mirror of
https://github.com/gabehf/Fladder.git
synced 2026-03-07 21:48:14 -08:00
Init repo
This commit is contained in:
commit
764b6034e3
566 changed files with 212335 additions and 0 deletions
108
lib/widgets/gapped_container_shape.dart
Normal file
108
lib/widgets/gapped_container_shape.dart
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
class GappedContainerShape extends StatelessWidget {
|
||||
final Color? activeColor;
|
||||
final Color? inActiveColor;
|
||||
final double? trackGapWidth;
|
||||
final double thumbPosition;
|
||||
|
||||
const GappedContainerShape({
|
||||
this.activeColor,
|
||||
this.inActiveColor,
|
||||
this.trackGapWidth,
|
||||
required this.thumbPosition,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomPaint(
|
||||
painter: _GappedContainerPainter(
|
||||
activeColor:
|
||||
activeColor ?? Theme.of(context).sliderTheme.activeTrackColor ?? Theme.of(context).colorScheme.primary,
|
||||
inActiveColor: inActiveColor ??
|
||||
Theme.of(context).sliderTheme.inactiveTrackColor ??
|
||||
Theme.of(context).colorScheme.secondaryContainer,
|
||||
trackGapWidth: trackGapWidth ?? 18,
|
||||
thumbCenterDxFraction: thumbPosition,
|
||||
),
|
||||
child: Container(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GappedContainerPainter extends CustomPainter {
|
||||
final Color activeColor;
|
||||
final Color inActiveColor;
|
||||
final double trackGapWidth;
|
||||
final double thumbCenterDxFraction;
|
||||
|
||||
_GappedContainerPainter({
|
||||
required this.activeColor,
|
||||
required this.inActiveColor,
|
||||
required this.trackGapWidth,
|
||||
required this.thumbCenterDxFraction,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final Paint paint = Paint()
|
||||
..color = activeColor // Change this to the desired color
|
||||
..style = PaintingStyle.fill;
|
||||
final Paint inActive = Paint()
|
||||
..color = inActiveColor // Change this to the desired color
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final Rect trackRect = Offset.zero & size;
|
||||
|
||||
// Ensure thumbCenterDxFraction is not NaN or Infinity
|
||||
final double thumbCenterDx =
|
||||
thumbCenterDxFraction.isFinite ? thumbCenterDxFraction * size.width : 0.0; // Default to 0 if invalid fraction
|
||||
|
||||
final Radius trackCornerRadius = Radius.circular(trackRect.shortestSide / 2);
|
||||
final Radius trackInsideCornerRadius = Radius.circular(2.0);
|
||||
|
||||
final RRect trackRRect = RRect.fromRectAndCorners(
|
||||
trackRect,
|
||||
topLeft: trackCornerRadius,
|
||||
bottomLeft: trackCornerRadius,
|
||||
topRight: trackCornerRadius,
|
||||
bottomRight: trackCornerRadius,
|
||||
);
|
||||
|
||||
final RRect leftRRect = RRect.fromLTRBAndCorners(
|
||||
trackRect.left,
|
||||
trackRect.top,
|
||||
math.max(trackRect.left, thumbCenterDx - trackGapWidth / 2),
|
||||
trackRect.bottom,
|
||||
topLeft: trackCornerRadius,
|
||||
bottomLeft: trackCornerRadius,
|
||||
topRight: trackInsideCornerRadius,
|
||||
bottomRight: trackInsideCornerRadius,
|
||||
);
|
||||
|
||||
final RRect rightRRect = RRect.fromLTRBAndCorners(
|
||||
thumbCenterDx + trackGapWidth / 2,
|
||||
trackRect.top,
|
||||
trackRect.right,
|
||||
trackRect.bottom,
|
||||
topRight: trackCornerRadius,
|
||||
bottomRight: trackCornerRadius,
|
||||
topLeft: trackInsideCornerRadius,
|
||||
bottomLeft: trackInsideCornerRadius,
|
||||
);
|
||||
|
||||
canvas
|
||||
..save()
|
||||
..clipRRect(trackRRect);
|
||||
canvas.drawRRect(leftRRect, paint);
|
||||
canvas.drawRRect(rightRRect, inActive);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
50
lib/widgets/navigation_scaffold/components/adaptive_fab.dart
Normal file
50
lib/widgets/navigation_scaffold/components/adaptive_fab.dart
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class AdaptiveFab {
|
||||
final BuildContext context;
|
||||
final String title;
|
||||
final Widget child;
|
||||
final Function() onPressed;
|
||||
final Key? key;
|
||||
AdaptiveFab({
|
||||
required this.context,
|
||||
this.title = '',
|
||||
required this.child,
|
||||
required this.onPressed,
|
||||
this.key,
|
||||
});
|
||||
|
||||
FloatingActionButton get normal {
|
||||
return FloatingActionButton(
|
||||
key: key,
|
||||
onPressed: onPressed,
|
||||
tooltip: title,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget get extended {
|
||||
return AnimatedContainer(
|
||||
key: key,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
height: 60,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
child,
|
||||
const Spacer(),
|
||||
Flexible(child: Text(title)),
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import 'package:fladder/routes/build_routes/route_builder.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;
|
||||
final Widget? icon;
|
||||
final Widget? selectedIcon;
|
||||
final CustomRoute? route;
|
||||
final Function()? action;
|
||||
final String? tooltip;
|
||||
final Badge? badge;
|
||||
final AdaptiveFab? floatingActionButton;
|
||||
// final FloatingActionButton? floatingActionButton;
|
||||
|
||||
DestinationModel({
|
||||
required this.label,
|
||||
this.icon,
|
||||
this.selectedIcon,
|
||||
this.route,
|
||||
this.action,
|
||||
this.tooltip,
|
||||
this.badge,
|
||||
this.floatingActionButton,
|
||||
}) : assert(
|
||||
badge == null || icon == null,
|
||||
'Only one of icon or badge should be provided, not both.',
|
||||
);
|
||||
|
||||
/// Converts this [DestinationModel] to a [NavigationRailDestination] used in a [NavigationRail].
|
||||
NavigationRailDestination toNavigationRailDestination({EdgeInsets? padding}) {
|
||||
if (badge != null) {
|
||||
return NavigationRailDestination(
|
||||
icon: badge!,
|
||||
label: Text(label),
|
||||
selectedIcon: badge!,
|
||||
padding: padding,
|
||||
);
|
||||
}
|
||||
return NavigationRailDestination(
|
||||
icon: icon!,
|
||||
label: Text(label),
|
||||
selectedIcon: selectedIcon,
|
||||
padding: padding,
|
||||
);
|
||||
}
|
||||
|
||||
/// Converts this [DestinationModel] to a [NavigationDrawerDestination] used in a [NavigationDrawer].
|
||||
NavigationDrawerDestination toNavigationDrawerDestination() {
|
||||
if (badge != null) {
|
||||
return NavigationDrawerDestination(
|
||||
icon: badge!,
|
||||
label: Text(label),
|
||||
selectedIcon: badge!,
|
||||
);
|
||||
}
|
||||
return NavigationDrawerDestination(
|
||||
icon: icon!,
|
||||
label: Text(label),
|
||||
selectedIcon: selectedIcon,
|
||||
);
|
||||
}
|
||||
|
||||
/// Converts this [DestinationModel] to a [NavigationDestination] used in a [BottomNavigationBar].
|
||||
NavigationDestination toNavigationDestination() {
|
||||
if (badge != null) {
|
||||
return NavigationDestination(
|
||||
icon: badge!,
|
||||
label: label,
|
||||
selectedIcon: badge!,
|
||||
);
|
||||
}
|
||||
return NavigationDestination(
|
||||
icon: icon!,
|
||||
label: label,
|
||||
selectedIcon: selectedIcon,
|
||||
tooltip: tooltip,
|
||||
);
|
||||
}
|
||||
|
||||
NavigationButton toNavigationButton(bool selected, bool expanded) {
|
||||
return NavigationButton(
|
||||
label: label,
|
||||
selected: selected,
|
||||
onPressed: action,
|
||||
horizontal: expanded,
|
||||
selectedIcon: selectedIcon!,
|
||||
icon: icon!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import 'package:fladder/screens/shared/animated_fade_size.dart';
|
||||
import 'package:fladder/util/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';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class DrawerListButton extends ConsumerStatefulWidget {
|
||||
final String label;
|
||||
final Widget selectedIcon;
|
||||
final Widget icon;
|
||||
final Function()? onPressed;
|
||||
final List<ItemAction> actions;
|
||||
final bool selected;
|
||||
final Duration duration;
|
||||
const DrawerListButton({
|
||||
required this.label,
|
||||
required this.selectedIcon,
|
||||
required this.icon,
|
||||
this.onPressed,
|
||||
this.actions = const [],
|
||||
this.selected = false,
|
||||
this.duration = const Duration(milliseconds: 125),
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _DrawerListButtonState();
|
||||
}
|
||||
|
||||
class _DrawerListButtonState extends ConsumerState<DrawerListButton> {
|
||||
bool showPopupButton = false;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (event) => setState(() => showPopupButton = true),
|
||||
onExit: (event) => setState(() => showPopupButton = false),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: ListTile(
|
||||
onTap: widget.onPressed,
|
||||
horizontalTitleGap: 15,
|
||||
selected: widget.selected,
|
||||
selectedTileColor: Theme.of(context).colorScheme.primary,
|
||||
selectedColor: Theme.of(context).colorScheme.onPrimary,
|
||||
onLongPress: widget.actions.isNotEmpty && AdaptiveLayout.of(context).inputDevice == InputDevice.touch
|
||||
? () => showBottomSheetPill(
|
||||
context: context,
|
||||
content: (context, scrollController) => ListView(
|
||||
shrinkWrap: true,
|
||||
controller: scrollController,
|
||||
children: widget.actions.listTileItems(context, useIcons: true),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 5),
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.all(3),
|
||||
child:
|
||||
AnimatedFadeSize(duration: widget.duration, child: widget.selected ? widget.selectedIcon : widget.icon),
|
||||
),
|
||||
trailing: widget.actions.isNotEmpty && AdaptiveLayout.of(context).inputDevice == InputDevice.pointer
|
||||
? AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 125),
|
||||
opacity: showPopupButton ? 1 : 0,
|
||||
child: PopupMenuButton(
|
||||
tooltip: "Options",
|
||||
itemBuilder: (context) => widget.actions.popupMenuItems(useIcons: true),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
title: Text(widget.label),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import 'package:fladder/screens/shared/default_titlebar.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
bool get _isDesktop {
|
||||
if (kIsWeb) return false;
|
||||
return [
|
||||
TargetPlatform.windows,
|
||||
TargetPlatform.linux,
|
||||
TargetPlatform.macOS,
|
||||
].contains(defaultTargetPlatform);
|
||||
}
|
||||
|
||||
class FladderAppbar extends StatelessWidget implements PreferredSize {
|
||||
final double height;
|
||||
final String? label;
|
||||
final bool automaticallyImplyLeading;
|
||||
const FladderAppbar({this.height = 35, this.automaticallyImplyLeading = false, this.label, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (AdaptiveLayout.of(context).isDesktop) {
|
||||
return PreferredSize(
|
||||
preferredSize: Size(double.infinity, height),
|
||||
child: SizedBox(
|
||||
height: height,
|
||||
child: Row(
|
||||
children: [
|
||||
if (automaticallyImplyLeading && context.canPop()) BackButton(),
|
||||
Expanded(
|
||||
child: DefaultTitleBar(
|
||||
label: label,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
));
|
||||
} else {
|
||||
return AppBar(
|
||||
toolbarHeight: 0,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface.withOpacity(0),
|
||||
scrolledUnderElevation: 0,
|
||||
elevation: 0,
|
||||
systemOverlayStyle: SystemUiOverlayStyle(),
|
||||
title: const Text(""),
|
||||
automaticallyImplyLeading: automaticallyImplyLeading,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget get child => Container();
|
||||
|
||||
@override
|
||||
Size get preferredSize => Size(double.infinity, _isDesktop ? height : 0);
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/media_playback_model.dart';
|
||||
import 'package:fladder/providers/settings/video_player_settings_provider.dart';
|
||||
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/duration_extensions.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/refresh_state.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
class FloatingPlayerBar extends ConsumerStatefulWidget {
|
||||
const FloatingPlayerBar({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _CurrentlyPlayingBarState();
|
||||
}
|
||||
|
||||
class _CurrentlyPlayingBarState extends ConsumerState<FloatingPlayerBar> {
|
||||
bool showExpandButton = false;
|
||||
|
||||
Future<void> openFullScreenPlayer() async {
|
||||
setState(() => showExpandButton = false);
|
||||
ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(state: VideoPlayerState.fullScreen));
|
||||
await Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const VideoPlayer(),
|
||||
),
|
||||
);
|
||||
if (AdaptiveLayout.of(context).isDesktop || kIsWeb) {
|
||||
final fullScreen = await windowManager.isFullScreen();
|
||||
if (fullScreen) {
|
||||
await windowManager.setFullScreen(false);
|
||||
}
|
||||
}
|
||||
if (context.mounted) {
|
||||
context.refreshData();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopPlayer() async {
|
||||
ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(state: VideoPlayerState.disposed));
|
||||
return ref.read(videoPlayerProvider).stop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final playbackInfo = ref.watch(mediaPlaybackProvider);
|
||||
final player = ref.watch(videoPlayerProvider);
|
||||
final playbackModel = ref.watch(playBackModel.select((value) => value?.item));
|
||||
final progress = playbackInfo.position.inMilliseconds / playbackInfo.duration.inMilliseconds;
|
||||
return Dismissible(
|
||||
key: Key("CurrentlyPlayingBar"),
|
||||
confirmDismiss: (direction) async {
|
||||
if (direction == DismissDirection.up) {
|
||||
await openFullScreenPlayer();
|
||||
} else {
|
||||
await stopPlayer();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
direction: DismissDirection.vertical,
|
||||
child: InkWell(
|
||||
onLongPress: () {
|
||||
fladderSnackbar(context, title: "Swipe up/down to open/close the player");
|
||||
},
|
||||
child: Card(
|
||||
elevation: 3,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: 50, maxHeight: 85),
|
||||
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: AspectRatio(
|
||||
aspectRatio: 1.67,
|
||||
child: MouseRegion(
|
||||
onEnter: (event) => setState(() => showExpandButton = true),
|
||||
onExit: (event) => setState(() => showExpandButton = false),
|
||||
child: Stack(
|
||||
children: [
|
||||
Hero(
|
||||
tag: "HeroPlayer",
|
||||
child: Video(
|
||||
controller: player.controller!,
|
||||
fit: BoxFit.fitHeight,
|
||||
controls: NoVideoControls,
|
||||
wakelock: playbackInfo.playing,
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Tooltip(
|
||||
message: "Expand player",
|
||||
waitDuration: Duration(milliseconds: 500),
|
||||
child: AnimatedOpacity(
|
||||
opacity: showExpandButton ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 125),
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
child: FlatButton(
|
||||
onTap: () async => openFullScreenPlayer(),
|
||||
child: Icon(Icons.keyboard_arrow_up_rounded),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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)
|
||||
Flexible(
|
||||
child: Text(
|
||||
playbackModel?.detailedName(context) ?? "",
|
||||
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
|
||||
? Icon(Icons.pause_rounded)
|
||||
: Icon(Icons.play_arrow_rounded),
|
||||
),
|
||||
),
|
||||
if (constraints.maxWidth > 500) ...{
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
final volume = player.player?.state.volume == 0 ? 100.0 : 0.0;
|
||||
ref.read(videoPlayerSettingsProvider.notifier).setVolume(volume);
|
||||
player.setVolume(volume);
|
||||
},
|
||||
icon: Icon(
|
||||
ref.watch(videoPlayerSettingsProvider.select((value) => value.volume)) <= 0
|
||||
? IconsaxBold.volume_cross
|
||||
: IconsaxBold.volume_high,
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Stop playback",
|
||||
waitDuration: Duration(milliseconds: 500),
|
||||
child: IconButton(
|
||||
onPressed: () async => stopPlayer(),
|
||||
icon: Icon(IconsaxBold.stop),
|
||||
),
|
||||
),
|
||||
},
|
||||
].addInBetween(SizedBox(width: 8)),
|
||||
),
|
||||
),
|
||||
),
|
||||
LinearProgressIndicator(
|
||||
minHeight: 6,
|
||||
backgroundColor: Colors.black.withOpacity(0.25),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
value: progress.clamp(0, 1),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
170
lib/widgets/navigation_scaffold/components/navigation_body.dart
Normal file
170
lib/widgets/navigation_scaffold/components/navigation_body.dart
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/providers/views_provider.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/navigation_drawer.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:fladder/routes/build_routes/settings_routes.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/widgets/navigation_scaffold/components/destination_model.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/settings_user_icon.dart';
|
||||
|
||||
class NavigationBody extends ConsumerStatefulWidget {
|
||||
final BuildContext parentContext;
|
||||
final Widget child;
|
||||
final int currentIndex;
|
||||
final List<DestinationModel> destinations;
|
||||
final String currentLocation;
|
||||
final GlobalKey<ScaffoldState> drawerKey;
|
||||
const NavigationBody({
|
||||
required this.parentContext,
|
||||
required this.child,
|
||||
required this.currentIndex,
|
||||
required this.destinations,
|
||||
required this.currentLocation,
|
||||
required this.drawerKey,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _NavigationBodyState();
|
||||
}
|
||||
|
||||
class _NavigationBodyState extends ConsumerState<NavigationBody> {
|
||||
bool expandedSideBar = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((value) {
|
||||
ref.read(viewsProvider.notifier).fetchViews();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final views = ref.watch(viewsProvider.select((value) => value.views));
|
||||
return switch (AdaptiveLayout.layoutOf(context)) {
|
||||
LayoutState.phone => MediaQuery.removePadding(
|
||||
context: widget.parentContext,
|
||||
child: widget.child,
|
||||
),
|
||||
LayoutState.tablet => Row(
|
||||
children: [
|
||||
navigationRail(context),
|
||||
Flexible(
|
||||
child: widget.child,
|
||||
)
|
||||
],
|
||||
),
|
||||
LayoutState.desktop => Row(
|
||||
children: [
|
||||
AnimatedFadeSize(
|
||||
duration: const Duration(milliseconds: 125),
|
||||
child: 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),
|
||||
),
|
||||
Flexible(
|
||||
child: widget.child,
|
||||
),
|
||||
],
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
AdaptiveFab? actionButton() {
|
||||
return (widget.currentIndex >= 0 && widget.currentIndex < widget.destinations.length)
|
||||
? widget.destinations[widget.currentIndex].floatingActionButton
|
||||
: null;
|
||||
}
|
||||
|
||||
Widget navigationRail(BuildContext context) {
|
||||
return Padding(
|
||||
key: const Key('navigation_rail'),
|
||||
padding: AdaptiveLayout.of(context).isDesktop ? EdgeInsets.zero : MediaQuery.of(context).padding,
|
||||
child: 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,
|
||||
),
|
||||
},
|
||||
const SizedBox(height: 8),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
if (AdaptiveLayout.layoutOf(context) != LayoutState.desktop) {
|
||||
widget.drawerKey.currentState?.openDrawer();
|
||||
} else {
|
||||
setState(() {
|
||||
expandedSideBar = true;
|
||||
});
|
||||
}
|
||||
},
|
||||
icon: const Icon(IconsaxBold.menu),
|
||||
),
|
||||
if (AdaptiveLayout.of(context).isDesktop) ...[
|
||||
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: 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(SettingsRoute().route)
|
||||
? Card(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Icon(IconsaxBold.setting_3),
|
||||
),
|
||||
)
|
||||
: const SettingsUserIcon()),
|
||||
),
|
||||
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import 'package:fladder/util/widget_extensions.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class NavigationButton extends ConsumerStatefulWidget {
|
||||
final String? label;
|
||||
final Widget selectedIcon;
|
||||
final Widget icon;
|
||||
final bool horizontal;
|
||||
final Function()? onPressed;
|
||||
final bool selected;
|
||||
final Duration duration;
|
||||
const NavigationButton({
|
||||
required this.label,
|
||||
required this.selectedIcon,
|
||||
required this.icon,
|
||||
this.horizontal = false,
|
||||
this.onPressed,
|
||||
this.selected = false,
|
||||
this.duration = const Duration(milliseconds: 125),
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _NavigationButtonState();
|
||||
}
|
||||
|
||||
class _NavigationButtonState extends ConsumerState<NavigationButton> {
|
||||
@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),
|
||||
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: WidgetStatePropertyAll(0),
|
||||
padding: WidgetStatePropertyAll(EdgeInsets.zero),
|
||||
backgroundColor: WidgetStatePropertyAll(Colors.transparent),
|
||||
foregroundColor: WidgetStateProperty.resolveWith((states) {
|
||||
return widget.selected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurface.withOpacity(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(
|
||||
children: [
|
||||
AnimatedSwitcher(
|
||||
duration: widget.duration,
|
||||
child: widget.selected
|
||||
? widget.selectedIcon.setKey(Key("${widget.label}+selected"))
|
||||
: widget.icon.setKey(Key("${widget.label}+normal")),
|
||||
),
|
||||
if (widget.horizontal && widget.label != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: _Label(widget: widget),
|
||||
)
|
||||
],
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: 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.withOpacity(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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/collection_types.dart';
|
||||
import 'package:fladder/models/view_model.dart';
|
||||
import 'package:fladder/routes/build_routes/home_routes.dart';
|
||||
import 'package:fladder/routes/build_routes/route_builder.dart';
|
||||
import 'package:fladder/routes/build_routes/settings_routes.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/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/drawer_list_button.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/settings_user_icon.dart';
|
||||
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class NestedNavigationDrawer extends ConsumerWidget {
|
||||
final bool isExpanded;
|
||||
final Function(bool expanded) toggleExpanded;
|
||||
final List<DestinationModel> destinations;
|
||||
final AdaptiveFab? actionButton;
|
||||
final List<ViewModel> views;
|
||||
final String currentLocation;
|
||||
const NestedNavigationDrawer(
|
||||
{this.isExpanded = false,
|
||||
required this.toggleExpanded,
|
||||
required this.actionButton,
|
||||
required this.destinations,
|
||||
required this.views,
|
||||
required this.currentLocation,
|
||||
super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return NavigationDrawer(
|
||||
key: const Key('navigation_drawer'),
|
||||
backgroundColor: isExpanded ? Colors.transparent : null,
|
||||
surfaceTintColor: isExpanded ? Colors.transparent : null,
|
||||
children: [
|
||||
if (AdaptiveLayout.of(context).isDesktop || kIsWeb) const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 0, 16, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.localized.navigation,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => toggleExpanded(false),
|
||||
icon: const Icon(IconsaxOutline.menu),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: actionButton != null ? 8 : 0),
|
||||
child: AnimatedFadeSize(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||
return ScaleTransition(scale: animation, child: child);
|
||||
},
|
||||
child: actionButton?.extended,
|
||||
),
|
||||
),
|
||||
),
|
||||
...destinations.map((destination) => DrawerListButton(
|
||||
label: destination.label,
|
||||
selected: destination.route?.route == currentLocation,
|
||||
selectedIcon: destination.selectedIcon!,
|
||||
icon: destination.icon!,
|
||||
onPressed: () {
|
||||
destination.action!();
|
||||
Scaffold.of(context).closeDrawer();
|
||||
},
|
||||
)),
|
||||
if (views.isNotEmpty) ...{
|
||||
const Divider(indent: 28, endIndent: 28),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 16, 16, 10),
|
||||
child: Text(
|
||||
context.localized.library(2),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
...views.map((library) => DrawerListButton(
|
||||
label: library.name,
|
||||
selected: currentLocation.contains(library.id),
|
||||
actions: [
|
||||
ItemActionButton(
|
||||
label: Text(context.localized.scanLibrary),
|
||||
icon: Icon(IconsaxOutline.refresh),
|
||||
action: () => showRefreshPopup(context, library.id, library.name),
|
||||
),
|
||||
],
|
||||
onPressed: () {
|
||||
context.routePushOrGo(LibrarySearchRoute(id: library.id));
|
||||
Scaffold.of(context).closeDrawer();
|
||||
},
|
||||
selectedIcon: Icon(library.collectionType.icon),
|
||||
icon: Icon(library.collectionType.iconOutlined))),
|
||||
},
|
||||
const Divider(indent: 28, endIndent: 28),
|
||||
if (isExpanded)
|
||||
Transform.translate(
|
||||
offset: Offset(-8, 0),
|
||||
child: DrawerListButton(
|
||||
label: context.localized.settings,
|
||||
selectedIcon: const Icon(IconsaxBold.setting_3),
|
||||
selected: currentLocation.contains(SettingsRoute().basePath),
|
||||
icon: const SizedBox(width: 35, height: 35, child: SettingsUserIcon()),
|
||||
onPressed: () {
|
||||
switch (AdaptiveLayout.of(context).size) {
|
||||
case ScreenLayout.single:
|
||||
context.routePush(SettingsRoute());
|
||||
break;
|
||||
case ScreenLayout.dual:
|
||||
context.routeGo(ClientSettingsRoute());
|
||||
break;
|
||||
}
|
||||
Scaffold.of(context).closeDrawer();
|
||||
},
|
||||
),
|
||||
)
|
||||
else
|
||||
DrawerListButton(
|
||||
label: context.localized.settings,
|
||||
selectedIcon: Icon(IconsaxBold.setting_2),
|
||||
icon: Icon(IconsaxOutline.setting_2),
|
||||
selected: currentLocation.contains(SettingsRoute().basePath),
|
||||
onPressed: () {
|
||||
switch (AdaptiveLayout.of(context).size) {
|
||||
case ScreenLayout.single:
|
||||
context.routePush(SettingsRoute());
|
||||
break;
|
||||
case ScreenLayout.dual:
|
||||
context.routeGo(ClientSettingsRoute());
|
||||
break;
|
||||
}
|
||||
Scaffold.of(context).closeDrawer();
|
||||
},
|
||||
),
|
||||
if (AdaptiveLayout.of(context).isDesktop || kIsWeb) const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/routes/build_routes/route_builder.dart';
|
||||
import 'package:fladder/routes/build_routes/settings_routes.dart';
|
||||
import 'package:fladder/screens/shared/user_icon.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class SettingsUserIcon extends ConsumerWidget {
|
||||
const SettingsUserIcon({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final users = ref.watch(userProvider);
|
||||
return Tooltip(
|
||||
message: context.localized.settings,
|
||||
waitDuration: const Duration(seconds: 1),
|
||||
child: UserIcon(
|
||||
user: users,
|
||||
cornerRadius: 200,
|
||||
onLongPress: () => context.routePush(LockScreenRoute()),
|
||||
onTap: () => switch (AdaptiveLayout.of(context).size) {
|
||||
ScreenLayout.single => context.routePush(SettingsRoute()),
|
||||
ScreenLayout.dual => context.routePush(ClientSettingsRoute()),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
118
lib/widgets/navigation_scaffold/navigation_scaffold.dart
Normal file
118
lib/widgets/navigation_scaffold/navigation_scaffold.dart
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import 'package:fladder/models/media_playback_model.dart';
|
||||
import 'package:fladder/providers/video_player_provider.dart';
|
||||
import 'package:fladder/providers/views_provider.dart';
|
||||
import 'package:fladder/routes/app_routes.dart';
|
||||
import 'package:fladder/screens/shared/nested_bottom_appbar.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/floating_player_bar.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/destination_model.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/fladder_appbar.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/navigation_body.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/navigation_drawer.dart';
|
||||
import 'package:fladder/widgets/shared/hide_on_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class NavigationScaffold extends ConsumerStatefulWidget {
|
||||
final int? currentIndex;
|
||||
final String? location;
|
||||
final Widget? nestedChild;
|
||||
final List<DestinationModel> destinations;
|
||||
final GlobalKey<NavigatorState>? nestedNavigatorKey;
|
||||
const NavigationScaffold({
|
||||
this.currentIndex,
|
||||
this.location,
|
||||
this.nestedChild,
|
||||
required this.destinations,
|
||||
this.nestedNavigatorKey,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _NavigationScaffoldState();
|
||||
}
|
||||
|
||||
class _NavigationScaffoldState extends ConsumerState<NavigationScaffold> {
|
||||
final GlobalKey<ScaffoldState> _key = GlobalKey();
|
||||
|
||||
int get currentIndex => widget.destinations.indexWhere((element) => element.route?.route == widget.location);
|
||||
String get currentLocation => widget.location ?? "Nothing";
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((value) {
|
||||
ref.read(viewsProvider.notifier).fetchViews();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final playerState = ref.watch(mediaPlaybackProvider.select((value) => value.state));
|
||||
final views = ref.watch(viewsProvider.select((value) => value.views));
|
||||
return PopScope(
|
||||
canPop: currentIndex == 0,
|
||||
onPopInvoked: (didPop) {
|
||||
if (currentIndex != 0) {
|
||||
widget.destinations.first.action!();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
key: _key,
|
||||
appBar: const FladderAppbar(),
|
||||
extendBodyBehindAppBar: true,
|
||||
extendBody: true,
|
||||
floatingActionButtonLocation:
|
||||
playerState == VideoPlayerState.minimized ? FloatingActionButtonLocation.centerFloat : null,
|
||||
floatingActionButton: AdaptiveLayout.of(context).layout == LayoutState.phone
|
||||
? switch (playerState) {
|
||||
VideoPlayerState.minimized => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: FloatingPlayerBar(),
|
||||
),
|
||||
_ => widget.destinations.elementAtOrNull(currentIndex)?.floatingActionButton?.normal,
|
||||
}
|
||||
: null,
|
||||
drawer: NestedNavigationDrawer(
|
||||
actionButton: null,
|
||||
toggleExpanded: (value) {
|
||||
_key.currentState?.closeDrawer();
|
||||
},
|
||||
views: views,
|
||||
destinations: widget.destinations,
|
||||
currentLocation: currentLocation,
|
||||
),
|
||||
bottomNavigationBar: AdaptiveLayout.of(context).layout == LayoutState.phone
|
||||
? HideOnScroll(
|
||||
controller: AppRoutes.scrollController,
|
||||
child: NestedBottomAppBar(
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: widget.destinations
|
||||
.map(
|
||||
(destination) =>
|
||||
destination.toNavigationButton(widget.location == destination.route?.route, false),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
body: widget.nestedChild != null
|
||||
? NavigationBody(
|
||||
child: widget.nestedChild!,
|
||||
parentContext: context,
|
||||
currentIndex: currentIndex,
|
||||
destinations: widget.destinations,
|
||||
currentLocation: currentLocation,
|
||||
drawerKey: _key,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
39
lib/widgets/pop_up/delete_file.dart
Normal file
39
lib/widgets/pop_up/delete_file.dart
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import 'package:chopper/chopper.dart';
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/providers/api_provider.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/widgets/shared/filled_button_await.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
Future<Response<dynamic>?> showDeleteDialog(BuildContext context, ItemBaseModel item, WidgetRef ref) async {
|
||||
Response<dynamic>? response;
|
||||
await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog.adaptive(
|
||||
title: Text(context.localized.deleteItem(item.type.label(context))),
|
||||
content: Text(
|
||||
context.localized.deleteFileFromSystem(item.name),
|
||||
),
|
||||
scrollable: true,
|
||||
actions: [
|
||||
ElevatedButton(onPressed: () => Navigator.of(context).pop(), child: Text(context.localized.cancel)),
|
||||
FilledButtonAwait(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.errorContainer,
|
||||
foregroundColor: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
onPressed: () async {
|
||||
response = await ref.read(jellyApiProvider).deleteItem(item.id);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
context.localized.delete,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
return response;
|
||||
}
|
||||
57
lib/widgets/shared/adaptive_date_picker.dart
Normal file
57
lib/widgets/shared/adaptive_date_picker.dart
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Future<DateTime?> showAdaptiveDatePicker(
|
||||
BuildContext context, {
|
||||
DateTime? initialDateTime,
|
||||
}) async {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
if (theme.platform == TargetPlatform.iOS) {
|
||||
return _buildCupertinoDatePicker(
|
||||
context,
|
||||
initialDateTime: initialDateTime,
|
||||
);
|
||||
} else {
|
||||
return _buildMaterialDatePicker(
|
||||
context,
|
||||
initialDateTime: initialDateTime,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<DateTime?> _buildCupertinoDatePicker(
|
||||
BuildContext context, {
|
||||
DateTime? initialDateTime,
|
||||
}) async {
|
||||
DateTime? newDate;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (BuildContext builder) {
|
||||
return Container(
|
||||
height: MediaQuery.of(context).copyWith().size.height / 3,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: CupertinoDatePicker(
|
||||
onDateTimeChanged: (value) {
|
||||
newDate = value;
|
||||
},
|
||||
initialDateTime: initialDateTime,
|
||||
dateOrder: DatePickerDateOrder.ymd,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
return newDate;
|
||||
}
|
||||
|
||||
Future<DateTime?> _buildMaterialDatePicker(
|
||||
BuildContext context, {
|
||||
DateTime? initialDateTime,
|
||||
}) async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: initialDateTime ?? DateTime.now(),
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2025),
|
||||
);
|
||||
return picked;
|
||||
}
|
||||
92
lib/widgets/shared/animated_icon.dart
Normal file
92
lib/widgets/shared/animated_icon.dart
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AnimatedVisibilityIcon extends StatefulWidget {
|
||||
final bool isFilled;
|
||||
final IconData filledIcon;
|
||||
final Color filledColor;
|
||||
final IconData outlinedIcon;
|
||||
final Color? outlinedColor;
|
||||
final Duration displayDuration;
|
||||
|
||||
const AnimatedVisibilityIcon({
|
||||
super.key,
|
||||
required this.isFilled,
|
||||
required this.filledIcon,
|
||||
this.filledColor = Colors.redAccent,
|
||||
required this.outlinedIcon,
|
||||
this.outlinedColor,
|
||||
this.displayDuration = const Duration(seconds: 2),
|
||||
});
|
||||
|
||||
@override
|
||||
AnimatedVisibilityIconState createState() => AnimatedVisibilityIconState();
|
||||
}
|
||||
|
||||
class AnimatedVisibilityIconState extends State<AnimatedVisibilityIcon> {
|
||||
bool _isVisible = false;
|
||||
bool _currentFilledState = false;
|
||||
|
||||
Timer? timer;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AnimatedVisibilityIcon oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.isFilled != oldWidget.isFilled) {
|
||||
_animateIconChange();
|
||||
}
|
||||
}
|
||||
|
||||
void _animateIconChange() {
|
||||
timer?.cancel();
|
||||
setState(() {
|
||||
_isVisible = true;
|
||||
_currentFilledState = widget.isFilled;
|
||||
});
|
||||
|
||||
timer = Timer.periodic(
|
||||
widget.displayDuration,
|
||||
(timer) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isVisible = false;
|
||||
});
|
||||
}
|
||||
timer.cancel();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedScale(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
scale: _currentFilledState ? 1.2 : 1.0,
|
||||
curve: Curves.easeInOutCubic,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _isVisible ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
decoration: BoxDecoration(
|
||||
color: (_currentFilledState ? widget.filledColor : widget.outlinedColor)?.withOpacity(0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 12, right: 12, top: 8, bottom: 6),
|
||||
child: Icon(
|
||||
_currentFilledState ? widget.filledIcon : widget.outlinedIcon,
|
||||
size: 42,
|
||||
color: _currentFilledState ? widget.filledColor : widget.outlinedColor,
|
||||
key: ValueKey<bool>(_currentFilledState),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
61
lib/widgets/shared/clickable_text.dart
Normal file
61
lib/widgets/shared/clickable_text.dart
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class ClickableText extends ConsumerStatefulWidget {
|
||||
final String text;
|
||||
final double opacity;
|
||||
final int? maxLines;
|
||||
final TextOverflow? overflow;
|
||||
final TextStyle? style;
|
||||
final VoidCallback? onTap;
|
||||
const ClickableText(
|
||||
{required this.text,
|
||||
this.style,
|
||||
this.maxLines,
|
||||
this.overflow = TextOverflow.ellipsis,
|
||||
this.opacity = 1.0,
|
||||
this.onTap,
|
||||
super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _ClickableTextState();
|
||||
}
|
||||
|
||||
class _ClickableTextState extends ConsumerState<ClickableText> {
|
||||
bool hovering = false;
|
||||
|
||||
Widget _textWidget(bool showDecoration) {
|
||||
return Opacity(
|
||||
opacity: widget.opacity,
|
||||
child: Text(
|
||||
widget.text,
|
||||
maxLines: widget.maxLines,
|
||||
overflow: widget.overflow,
|
||||
style: widget.style?.copyWith(
|
||||
color: showDecoration ? Theme.of(context).colorScheme.primary : null,
|
||||
decoration: showDecoration ? TextDecoration.underline : TextDecoration.none,
|
||||
decorationColor: showDecoration ? Theme.of(context).colorScheme.primary : null,
|
||||
decorationThickness: 3,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildClickable() {
|
||||
final showDecoration = ((widget.onTap != null) && hovering);
|
||||
return MouseRegion(
|
||||
cursor: widget.onTap != null ? SystemMouseCursors.click : SystemMouseCursors.basic,
|
||||
onEnter: (event) => setState(() => hovering = true),
|
||||
onExit: (event) => setState(() => hovering = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Tooltip(message: widget.text, child: _textWidget(showDecoration)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.onTap != null ? _buildClickable() : _textWidget(false);
|
||||
}
|
||||
}
|
||||
85
lib/widgets/shared/elevated_icon.dart
Normal file
85
lib/widgets/shared/elevated_icon.dart
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
IconData getBackIcon(BuildContext context) {
|
||||
if (kIsWeb) {
|
||||
// Always use 'Icons.arrow_back' as a back_button icon in web.
|
||||
return Icons.arrow_back;
|
||||
}
|
||||
switch (Theme.of(context).platform) {
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.fuchsia:
|
||||
case TargetPlatform.linux:
|
||||
case TargetPlatform.windows:
|
||||
return Icons.arrow_back;
|
||||
case TargetPlatform.iOS:
|
||||
case TargetPlatform.macOS:
|
||||
return Icons.arrow_back_ios;
|
||||
}
|
||||
}
|
||||
|
||||
final _shadows = [
|
||||
BoxShadow(blurRadius: 1, spreadRadius: 1, color: Colors.black.withOpacity(0.2)),
|
||||
BoxShadow(blurRadius: 4, spreadRadius: 4, color: Colors.black.withOpacity(0.1)),
|
||||
BoxShadow(blurRadius: 16, spreadRadius: 6, color: Colors.black.withOpacity(0.2)),
|
||||
];
|
||||
|
||||
class ElevatedIconButton extends ConsumerWidget {
|
||||
final Function() onPressed;
|
||||
final IconData icon;
|
||||
final Color? color;
|
||||
const ElevatedIconButton({required this.onPressed, required this.icon, this.color, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return IconButton(
|
||||
onPressed: onPressed,
|
||||
style: IconButtonTheme.of(context).style?.copyWith(
|
||||
backgroundColor: WidgetStatePropertyAll(color?.withOpacity(0.15)),
|
||||
),
|
||||
color: color,
|
||||
icon: Icon(
|
||||
icon,
|
||||
shadows: _shadows,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ElevatedIconButtonLabel extends StatelessWidget {
|
||||
final Function() onPressed;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final Color? color;
|
||||
const ElevatedIconButtonLabel({
|
||||
required this.onPressed,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
this.color,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: label,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 65),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ElevatedIconButton(onPressed: onPressed, icon: icon),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
82
lib/widgets/shared/enum_selection.dart
Normal file
82
lib/widgets/shared/enum_selection.dart
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class EnumBox<T> extends StatelessWidget {
|
||||
final String current;
|
||||
final List<PopupMenuEntry<T>> Function(BuildContext context) itemBuilder;
|
||||
|
||||
const EnumBox({required this.current, required this.itemBuilder, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textStyle = Theme.of(context).textTheme.titleMedium;
|
||||
const padding = EdgeInsets.symmetric(horizontal: 12, vertical: 6);
|
||||
final itemList = itemBuilder(context);
|
||||
return Card(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
shadowColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
child: PopupMenuButton(
|
||||
tooltip: '',
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
enabled: itemList.length > 1,
|
||||
itemBuilder: itemBuilder,
|
||||
padding: padding,
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: Material(
|
||||
textStyle: textStyle?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: itemList.length > 1 ? Theme.of(context).colorScheme.onPrimaryContainer : null),
|
||||
color: Colors.transparent,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
current,
|
||||
textAlign: TextAlign.start,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
if (itemList.length > 1)
|
||||
Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EnumSelection<T> extends StatelessWidget {
|
||||
final Text label;
|
||||
final String current;
|
||||
final List<PopupMenuEntry<T>> Function(BuildContext context) itemBuilder;
|
||||
const EnumSelection({
|
||||
required this.label,
|
||||
required this.current,
|
||||
required this.itemBuilder,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
textStyle: Theme.of(context).textTheme.titleMedium,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
label,
|
||||
const Spacer(),
|
||||
EnumBox(current: current, itemBuilder: itemBuilder),
|
||||
].toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
61
lib/widgets/shared/filled_button_await.dart
Normal file
61
lib/widgets/shared/filled_button_await.dart
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:fladder/screens/shared/animated_fade_size.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FilledButtonAwait extends StatefulWidget {
|
||||
final FutureOr<dynamic> Function() onPressed;
|
||||
final ButtonStyle? style;
|
||||
final Widget child;
|
||||
|
||||
const FilledButtonAwait({
|
||||
required this.onPressed,
|
||||
this.style,
|
||||
required this.child,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FilledButtonAwait> createState() => FilledButtonAwaitState();
|
||||
}
|
||||
|
||||
class FilledButtonAwaitState extends State<FilledButtonAwait> {
|
||||
bool loading = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const duration = Duration(milliseconds: 250);
|
||||
const iconSize = 24.0;
|
||||
return FilledButton(
|
||||
style: widget.style,
|
||||
onPressed: loading
|
||||
? null
|
||||
: () async {
|
||||
setState(() => loading = true);
|
||||
try {
|
||||
await widget.onPressed();
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
} finally {
|
||||
setState(() => loading = false);
|
||||
}
|
||||
},
|
||||
child: AnimatedFadeSize(
|
||||
duration: duration,
|
||||
child: loading
|
||||
? Opacity(
|
||||
opacity: 0.75,
|
||||
child: SizedBox(
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
child: CircularProgressIndicator(
|
||||
strokeCap: StrokeCap.round,
|
||||
color: widget.style?.foregroundColor?.resolve({WidgetState.hovered}),
|
||||
),
|
||||
),
|
||||
)
|
||||
: widget.child,
|
||||
));
|
||||
}
|
||||
}
|
||||
39
lib/widgets/shared/fladder_scrollbar.dart
Normal file
39
lib/widgets/shared/fladder_scrollbar.dart
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import 'package:flexible_scrollbar/flexible_scrollbar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class FladderScrollbar extends ConsumerWidget {
|
||||
final ScrollController controller;
|
||||
final Widget child;
|
||||
final bool visible;
|
||||
const FladderScrollbar({
|
||||
required this.controller,
|
||||
required this.child,
|
||||
this.visible = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return visible
|
||||
? FlexibleScrollbar(
|
||||
child: child,
|
||||
controller: controller,
|
||||
alwaysVisible: false,
|
||||
scrollThumbBuilder: (ScrollbarInfo info) {
|
||||
return AnimatedContainer(
|
||||
width: info.isDragging ? 24 : 8,
|
||||
height: (info.thumbMainAxisSize / 2),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
color: info.isDragging
|
||||
? Theme.of(context).colorScheme.secondary
|
||||
: Theme.of(context).colorScheme.secondaryContainer.withOpacity(0.75),
|
||||
),
|
||||
duration: Duration(milliseconds: 250),
|
||||
);
|
||||
},
|
||||
)
|
||||
: child;
|
||||
}
|
||||
}
|
||||
202
lib/widgets/shared/fladder_slider.dart
Normal file
202
lib/widgets/shared/fladder_slider.dart
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import 'package:fladder/util/num_extension.dart';
|
||||
import 'package:fladder/widgets/gapped_container_shape.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
double normalize(double min, double max, double value) {
|
||||
return (value - min) / (max - min);
|
||||
}
|
||||
|
||||
class FladderSlider extends StatefulWidget {
|
||||
final double value;
|
||||
final double min;
|
||||
final double max;
|
||||
final int? divisions;
|
||||
final double thumbWidth;
|
||||
final bool showThumb;
|
||||
final Duration animation;
|
||||
final Function(double value)? onChanged;
|
||||
final Function(double value)? onChangeStart;
|
||||
final Function(double value)? onChangeEnd;
|
||||
|
||||
const FladderSlider({
|
||||
required this.value,
|
||||
this.min = 0.0,
|
||||
this.max = 1.0,
|
||||
this.divisions,
|
||||
this.onChanged,
|
||||
this.thumbWidth = 6.5,
|
||||
this.showThumb = true,
|
||||
this.animation = const Duration(milliseconds: 100),
|
||||
this.onChangeStart,
|
||||
this.onChangeEnd,
|
||||
super.key,
|
||||
}) : assert(value >= min || value <= max);
|
||||
|
||||
@override
|
||||
FladderSliderState createState() => FladderSliderState();
|
||||
}
|
||||
|
||||
class FladderSliderState extends State<FladderSlider> with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _animation;
|
||||
double _currentValue = 0.0;
|
||||
bool hovering = false;
|
||||
bool dragging = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentValue = widget.value;
|
||||
_controller = AnimationController(vsync: this, duration: widget.animation);
|
||||
_animation = Tween<double>(begin: widget.value, end: widget.value).animate(_controller);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FladderSlider oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (oldWidget.value != widget.value || oldWidget.divisions != widget.divisions) {
|
||||
double newValue = widget.value;
|
||||
|
||||
if (widget.divisions != null) {
|
||||
final stepSize = (widget.max - widget.min) / widget.divisions!;
|
||||
newValue = ((newValue - widget.min) / stepSize).round() * stepSize + widget.min;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_currentValue = newValue;
|
||||
});
|
||||
|
||||
_animation = Tween<double>(begin: _animation.value, end: _currentValue).animate(_controller);
|
||||
_controller.forward(from: 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double normalize(double min, double max, double value) {
|
||||
return (value - min) / (max - min);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final thumbWidth = widget.thumbWidth;
|
||||
final height = Theme.of(context).sliderTheme.trackHeight ?? 24.0;
|
||||
|
||||
double calculateChange(double offset, double width) {
|
||||
double relativeOffset = (offset / width).clamp(0.0, 1.0);
|
||||
double newValue = (widget.max - widget.min) * relativeOffset + widget.min;
|
||||
|
||||
if (widget.divisions != null) {
|
||||
final stepSize = (widget.max - widget.min) / widget.divisions!;
|
||||
newValue = ((newValue - widget.min) / stepSize).round() * stepSize + widget.min;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_currentValue = newValue.clamp(widget.min, widget.max);
|
||||
});
|
||||
|
||||
return _currentValue.roundTo(2);
|
||||
}
|
||||
|
||||
return Container(
|
||||
height: height * 4,
|
||||
color: Colors.transparent,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final divisionSize = 5.0 * 0.95;
|
||||
final stepSize = constraints.maxWidth / (widget.divisions ?? 1);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (event) => setState(() => hovering = true),
|
||||
onExit: (event) => setState(() => hovering = false),
|
||||
child: GestureDetector(
|
||||
onTapUp: (details) => widget.onChangeEnd?.call(calculateChange(details.localPosition.dx, width)),
|
||||
onTapDown: (details) => widget.onChanged?.call(calculateChange(details.localPosition.dx, width)),
|
||||
onHorizontalDragStart: (details) {
|
||||
setState(() {
|
||||
dragging = true;
|
||||
});
|
||||
widget.onChangeStart?.call(calculateChange(details.localPosition.dx, width));
|
||||
},
|
||||
onHorizontalDragEnd: (details) {
|
||||
setState(() {
|
||||
dragging = false;
|
||||
});
|
||||
widget.onChangeEnd?.call(calculateChange(details.localPosition.dx, width));
|
||||
},
|
||||
onHorizontalDragUpdate: (details) =>
|
||||
widget.onChanged?.call(calculateChange(details.localPosition.dx, width)),
|
||||
child: Container(
|
||||
color: Colors.transparent,
|
||||
child: AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
final relativeValue = normalize(widget.min, widget.max, _animation.value);
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: height,
|
||||
width: constraints.maxWidth,
|
||||
child: GappedContainerShape(
|
||||
thumbPosition: relativeValue,
|
||||
),
|
||||
),
|
||||
if (widget.divisions != null && stepSize > divisionSize * 3)
|
||||
...List.generate(
|
||||
widget.divisions! + 1,
|
||||
(index) {
|
||||
final offset = (stepSize * index)
|
||||
.clamp(divisionSize / 1.2, constraints.maxWidth - divisionSize / 1.2);
|
||||
final active = (1.0 / widget.divisions!) * index > relativeValue;
|
||||
return Positioned(
|
||||
left: offset - divisionSize / 2,
|
||||
child: Container(
|
||||
width: divisionSize,
|
||||
height: divisionSize,
|
||||
decoration: BoxDecoration(
|
||||
color: active
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onPrimary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Thumb
|
||||
if (widget.showThumb)
|
||||
Positioned(
|
||||
left:
|
||||
(width * relativeValue).clamp(thumbWidth / 2, width - thumbWidth / 2) - thumbWidth / 2,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 125),
|
||||
height: (hovering || dragging) ? height * 3 : height,
|
||||
width: thumbWidth,
|
||||
decoration: BoxDecoration(
|
||||
color: (hovering || dragging)
|
||||
? Theme.of(context).colorScheme.onSurface
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
77
lib/widgets/shared/hide_on_scroll.dart
Normal file
77
lib/widgets/shared/hide_on_scroll.dart
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class HideOnScroll extends ConsumerStatefulWidget {
|
||||
final Widget? child;
|
||||
final ScrollController? controller;
|
||||
final double height;
|
||||
final Widget? Function(bool visible)? visibleBuilder;
|
||||
final Duration duration;
|
||||
const HideOnScroll({
|
||||
this.child,
|
||||
this.controller,
|
||||
this.height = kBottomNavigationBarHeight,
|
||||
this.visibleBuilder,
|
||||
this.duration = const Duration(milliseconds: 200),
|
||||
super.key,
|
||||
}) : assert(child != null || visibleBuilder != null);
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _HideOnScrollState();
|
||||
}
|
||||
|
||||
class _HideOnScrollState extends ConsumerState<HideOnScroll> {
|
||||
late final scrollController = widget.controller ?? ScrollController();
|
||||
bool isVisible = true;
|
||||
bool atEdge = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
scrollController.addListener(listen);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
scrollController.removeListener(listen);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void listen() {
|
||||
final direction = scrollController.position.userScrollDirection;
|
||||
|
||||
if (scrollController.offset < scrollController.position.maxScrollExtent) {
|
||||
if (direction == ScrollDirection.forward) {
|
||||
if (!isVisible) {
|
||||
setState(() => isVisible = true);
|
||||
}
|
||||
} else if (direction == ScrollDirection.reverse) {
|
||||
if (isVisible) {
|
||||
setState(() => isVisible = false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
isVisible = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.visibleBuilder != null) return widget.visibleBuilder!(isVisible)!;
|
||||
if (widget.child == null) return const SizedBox();
|
||||
if (AdaptiveLayout.of(context).layout == LayoutState.desktop) {
|
||||
return widget.child!;
|
||||
} else {
|
||||
return AnimatedAlign(
|
||||
alignment: const Alignment(0, -1),
|
||||
heightFactor: isVisible ? 1.0 : 0,
|
||||
duration: widget.duration,
|
||||
child: Wrap(children: [widget.child!]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
190
lib/widgets/shared/horizontal_list.dart
Normal file
190
lib/widgets/shared/horizontal_list.dart
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/providers/settings/client_settings_provider.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/disable_keypad_focus.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/sticky_header_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
class HorizontalList extends ConsumerStatefulWidget {
|
||||
final String? label;
|
||||
final List<Widget> titleActions;
|
||||
final Function()? onLabelClick;
|
||||
final String? subtext;
|
||||
final List items;
|
||||
final int? startIndex;
|
||||
final Widget Function(BuildContext context, int index) itemBuilder;
|
||||
final bool scrollToEnd;
|
||||
final EdgeInsets contentPadding;
|
||||
final double? height;
|
||||
final bool shrinkWrap;
|
||||
const HorizontalList({
|
||||
required this.items,
|
||||
required this.itemBuilder,
|
||||
this.startIndex,
|
||||
this.height,
|
||||
this.label,
|
||||
this.titleActions = const [],
|
||||
this.onLabelClick,
|
||||
this.scrollToEnd = false,
|
||||
this.contentPadding = const EdgeInsets.symmetric(horizontal: 16),
|
||||
this.subtext,
|
||||
this.shrinkWrap = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _HorizontalListState();
|
||||
}
|
||||
|
||||
class _HorizontalListState extends ConsumerState<HorizontalList> {
|
||||
final itemScrollController = ItemScrollController();
|
||||
late final scrollOffsetController = ScrollOffsetController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() async {
|
||||
if (widget.startIndex != null) {
|
||||
itemScrollController.jumpTo(index: widget.startIndex!);
|
||||
scrollOffsetController.animateScroll(
|
||||
offset: -widget.contentPadding.left, duration: const Duration(milliseconds: 125));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollToStart() {
|
||||
itemScrollController.scrollTo(index: 0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut);
|
||||
}
|
||||
|
||||
void _scrollToEnd() {
|
||||
itemScrollController.scrollTo(
|
||||
index: widget.items.length, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasPointer = AdaptiveLayout.of(context).inputDevice == InputDevice.pointer;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DisableFocus(
|
||||
child: Padding(
|
||||
padding: widget.contentPadding,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (widget.label != null)
|
||||
Flexible(
|
||||
child: StickyHeaderText(
|
||||
label: widget.label ?? "",
|
||||
onClick: widget.onLabelClick,
|
||||
),
|
||||
),
|
||||
if (widget.subtext != null)
|
||||
Opacity(
|
||||
opacity: 0.5,
|
||||
child: Text(
|
||||
widget.subtext!,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
...widget.titleActions
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.items.length > 1)
|
||||
Card(
|
||||
elevation: 5,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (hasPointer)
|
||||
GestureDetector(
|
||||
onLongPress: () => _scrollToStart(),
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
scrollOffsetController.animateScroll(
|
||||
offset: -(MediaQuery.of(context).size.width / 1.75),
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOut);
|
||||
},
|
||||
icon: const Icon(
|
||||
IconsaxOutline.arrow_left_2,
|
||||
size: 20,
|
||||
)),
|
||||
),
|
||||
if (widget.startIndex != null)
|
||||
IconButton(
|
||||
tooltip: "Scroll to current",
|
||||
onPressed: () {
|
||||
if (widget.startIndex != null) {
|
||||
itemScrollController.jumpTo(index: widget.startIndex!);
|
||||
scrollOffsetController.animateScroll(
|
||||
offset: -widget.contentPadding.left,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOutQuad);
|
||||
}
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.circle,
|
||||
size: 16,
|
||||
)),
|
||||
if (hasPointer)
|
||||
GestureDetector(
|
||||
onLongPress: () => _scrollToEnd(),
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
scrollOffsetController.animateScroll(
|
||||
offset: (MediaQuery.of(context).size.width / 1.75),
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOut);
|
||||
},
|
||||
icon: const Icon(
|
||||
IconsaxOutline.arrow_right_3,
|
||||
size: 20,
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
].addPadding(const EdgeInsets.symmetric(horizontal: 6)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: widget.height ??
|
||||
AdaptiveLayout.poster(context).size *
|
||||
ref.watch(clientSettingsProvider.select((value) => value.posterSize)),
|
||||
child: ScrollablePositionedList.separated(
|
||||
shrinkWrap: widget.shrinkWrap,
|
||||
itemScrollController: itemScrollController,
|
||||
scrollOffsetController: scrollOffsetController,
|
||||
padding: widget.contentPadding,
|
||||
itemCount: widget.items.length,
|
||||
scrollDirection: Axis.horizontal,
|
||||
separatorBuilder: (context, index) => const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
itemBuilder: widget.itemBuilder,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
30
lib/widgets/shared/hover_widget.dart
Normal file
30
lib/widgets/shared/hover_widget.dart
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class HoverWidget extends ConsumerStatefulWidget {
|
||||
final Size size;
|
||||
final Widget Function(bool visible) child;
|
||||
const HoverWidget({
|
||||
this.size = Size.infinite,
|
||||
required this.child,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _HoverWidgetState();
|
||||
}
|
||||
|
||||
class _HoverWidgetState extends ConsumerState<HoverWidget> {
|
||||
bool hovering = false;
|
||||
|
||||
void setHovering(bool value) => setState(() => hovering = value);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (event) => setHovering(true),
|
||||
onExit: (event) => setHovering(false),
|
||||
child: widget.child(hovering),
|
||||
);
|
||||
}
|
||||
}
|
||||
56
lib/widgets/shared/icon_button_await.dart
Normal file
56
lib/widgets/shared/icon_button_await.dart
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:fladder/screens/shared/animated_fade_size.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class IconButtonAwait extends StatefulWidget {
|
||||
final FutureOr<dynamic> Function() onPressed;
|
||||
final Color? color;
|
||||
final Widget icon;
|
||||
|
||||
const IconButtonAwait({required this.onPressed, required this.icon, this.color, super.key});
|
||||
|
||||
@override
|
||||
State<IconButtonAwait> createState() => IconButtonAwaitState();
|
||||
}
|
||||
|
||||
class IconButtonAwaitState extends State<IconButtonAwait> {
|
||||
bool loading = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const duration = Duration(milliseconds: 250);
|
||||
const iconSize = 24.0;
|
||||
return IconButton(
|
||||
color: widget.color,
|
||||
onPressed: loading
|
||||
? null
|
||||
: () async {
|
||||
setState(() => loading = true);
|
||||
try {
|
||||
await widget.onPressed();
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
} finally {
|
||||
setState(() => loading = false);
|
||||
}
|
||||
},
|
||||
icon: AnimatedFadeSize(
|
||||
duration: duration,
|
||||
child: loading
|
||||
? Opacity(
|
||||
opacity: 0.75,
|
||||
child: SizedBox(
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
child: CircularProgressIndicator(
|
||||
strokeCap: StrokeCap.round,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
)
|
||||
: widget.icon,
|
||||
));
|
||||
}
|
||||
}
|
||||
119
lib/widgets/shared/item_actions.dart
Normal file
119
lib/widgets/shared/item_actions.dart
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
abstract class ItemAction {
|
||||
Widget toMenuItemButton();
|
||||
PopupMenuEntry toPopupMenuItem({bool useIcons = false});
|
||||
Widget toLabel();
|
||||
Widget toListItem(BuildContext context, {bool useIcons = false, bool shouldPop = true});
|
||||
}
|
||||
|
||||
class ItemActionDivider extends ItemAction {
|
||||
Widget toWidget() => Divider();
|
||||
|
||||
@override
|
||||
Divider toMenuItemButton() => Divider();
|
||||
|
||||
@override
|
||||
PopupMenuEntry toPopupMenuItem({bool useIcons = false}) => PopupMenuDivider(height: 3);
|
||||
|
||||
@override
|
||||
Widget toLabel() => Container();
|
||||
|
||||
@override
|
||||
Widget toListItem(BuildContext context, {bool useIcons = false, bool shouldPop = true}) => Divider();
|
||||
}
|
||||
|
||||
class ItemActionButton extends ItemAction {
|
||||
final Widget? icon;
|
||||
final Widget? label;
|
||||
final FutureOr<void> Function()? action;
|
||||
ItemActionButton({
|
||||
this.icon,
|
||||
this.label,
|
||||
this.action,
|
||||
});
|
||||
|
||||
ItemActionButton copyWith({
|
||||
Widget? icon,
|
||||
Widget? label,
|
||||
Future<void> Function()? action,
|
||||
}) {
|
||||
return ItemActionButton(
|
||||
icon: icon ?? this.icon,
|
||||
label: label ?? this.label,
|
||||
action: action ?? this.action,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
MenuItemButton toMenuItemButton() {
|
||||
return MenuItemButton(leadingIcon: icon, onPressed: action, child: label);
|
||||
}
|
||||
|
||||
@override
|
||||
PopupMenuItem toPopupMenuItem({bool useIcons = false}) {
|
||||
return PopupMenuItem(
|
||||
onTap: action,
|
||||
child: useIcons
|
||||
? Builder(builder: (context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: Theme(
|
||||
data: ThemeData(
|
||||
iconTheme: IconThemeData(color: Theme.of(context).colorScheme.onSurface),
|
||||
),
|
||||
child: Row(
|
||||
children: [if (icon != null) icon!, SizedBox(width: 8), if (label != null) Flexible(child: label!)],
|
||||
),
|
||||
),
|
||||
);
|
||||
})
|
||||
: label,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget toLabel() {
|
||||
return label ?? const Text("Empty");
|
||||
}
|
||||
|
||||
@override
|
||||
ListTile toListItem(BuildContext context, {bool useIcons = false, bool shouldPop = true}) {
|
||||
return ListTile(
|
||||
onTap: () {
|
||||
if (shouldPop) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
action?.call();
|
||||
},
|
||||
title: useIcons
|
||||
? Builder(builder: (context) {
|
||||
return Theme(
|
||||
data: ThemeData(
|
||||
iconTheme: IconThemeData(color: Theme.of(context).colorScheme.onSurface),
|
||||
),
|
||||
child: Row(
|
||||
children: [if (icon != null) icon!, SizedBox(width: 8), if (label != null) Flexible(child: label!)],
|
||||
),
|
||||
);
|
||||
})
|
||||
: label,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension ItemActionExtension on List<ItemAction> {
|
||||
List<PopupMenuEntry> popupMenuItems({bool useIcons = false}) => map((e) => e.toPopupMenuItem(useIcons: useIcons))
|
||||
.whereNotIndexed((index, element) => (index == 0 && element is PopupMenuDivider))
|
||||
.toList();
|
||||
List<Widget> menuItemButtonItems() =>
|
||||
map((e) => e.toMenuItemButton()).whereNotIndexed((index, element) => (index == 0 && element is Divider)).toList();
|
||||
List<Widget> listTileItems(BuildContext context, {bool useIcons = false, bool shouldPop = true}) =>
|
||||
map((e) => e.toListItem(context, useIcons: useIcons, shouldPop: shouldPop))
|
||||
.whereNotIndexed((index, element) => (index == 0 && element is Divider))
|
||||
.toList();
|
||||
}
|
||||
30
lib/widgets/shared/list_button.dart
Normal file
30
lib/widgets/shared/list_button.dart
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class ListButton extends ConsumerWidget {
|
||||
final String label;
|
||||
final Icon icon;
|
||||
final VoidCallback onTap;
|
||||
final double height;
|
||||
const ListButton({required this.label, required this.icon, required this.onTap, this.height = 56, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ListTile(
|
||||
onTap: onTap,
|
||||
horizontalTitleGap: 15,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 5),
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.all(3),
|
||||
child: icon,
|
||||
),
|
||||
title: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(28.0)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
101
lib/widgets/shared/modal_bottom_sheet.dart
Normal file
101
lib/widgets/shared/modal_bottom_sheet.dart
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
Future<void> showBottomSheetPill({
|
||||
ItemBaseModel? item,
|
||||
bool showPill = true,
|
||||
Function()? onDismiss,
|
||||
EdgeInsets padding = const EdgeInsets.all(16),
|
||||
required BuildContext context,
|
||||
required Widget Function(
|
||||
BuildContext context,
|
||||
ScrollController scrollController,
|
||||
) content,
|
||||
}) async {
|
||||
await showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
showDragHandle: true,
|
||||
enableDrag: true,
|
||||
context: context,
|
||||
constraints: AdaptiveLayout.of(context).layout == LayoutState.phone
|
||||
? BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.9)
|
||||
: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.75, maxHeight: MediaQuery.of(context).size.height * 0.85),
|
||||
builder: (context) {
|
||||
final controller = ScrollController();
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
controller: controller,
|
||||
children: [
|
||||
if (item != null) ...{
|
||||
ItemBottomSheetPreview(item: item),
|
||||
const Divider(),
|
||||
},
|
||||
content(context, controller),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
onDismiss?.call();
|
||||
}
|
||||
|
||||
class ItemBottomSheetPreview extends ConsumerWidget {
|
||||
final ItemBaseModel item;
|
||||
const ItemBottomSheetPreview({required this.item, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Card(
|
||||
child: SizedBox(
|
||||
height: 90,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: FladderImage(
|
||||
image: item.images?.primary,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Flexible(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
if (item.subText?.isNotEmpty ?? false)
|
||||
Opacity(
|
||||
opacity: 0.75,
|
||||
child: Text(
|
||||
item.subText!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
146
lib/widgets/shared/modal_side_sheet.dart
Normal file
146
lib/widgets/shared/modal_side_sheet.dart
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
Future<void> showModalSideSheet(
|
||||
BuildContext context, {
|
||||
required Widget content,
|
||||
Widget? header,
|
||||
bool barrierDismissible = true,
|
||||
bool backButton = false,
|
||||
bool closeButton = false,
|
||||
bool addDivider = true,
|
||||
List<Widget>? actions,
|
||||
Function()? onDismiss,
|
||||
Duration? transitionDuration,
|
||||
}) async {
|
||||
await showGeneralDialog(
|
||||
context: context,
|
||||
transitionDuration: transitionDuration ?? const Duration(milliseconds: 200),
|
||||
barrierDismissible: barrierDismissible,
|
||||
barrierColor: Theme.of(context).colorScheme.scrim.withOpacity(0.3),
|
||||
barrierLabel: 'Material 3 side sheet',
|
||||
useRootNavigator: false,
|
||||
transitionBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return SlideTransition(
|
||||
position: Tween(begin: const Offset(1, 0), end: const Offset(0, 0)).animate(
|
||||
animation,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
pageBuilder: (context, animation1, animation2) {
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Sheet(
|
||||
header: header,
|
||||
backButton: backButton,
|
||||
closeButton: closeButton,
|
||||
actions: actions,
|
||||
content: content,
|
||||
addDivider: addDivider,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
onDismiss?.call();
|
||||
}
|
||||
|
||||
class Sheet extends StatelessWidget {
|
||||
final Widget? header;
|
||||
final bool backButton;
|
||||
final bool closeButton;
|
||||
final Widget content;
|
||||
final bool addDivider;
|
||||
final List<Widget>? actions;
|
||||
|
||||
const Sheet({
|
||||
super.key,
|
||||
this.header,
|
||||
required this.backButton,
|
||||
required this.closeButton,
|
||||
required this.content,
|
||||
required this.addDivider,
|
||||
this.actions,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Material(
|
||||
elevation: 1,
|
||||
color: colorScheme.surface,
|
||||
surfaceTintColor: colorScheme.onSurface,
|
||||
borderRadius: const BorderRadius.horizontal(left: Radius.circular(20)),
|
||||
child: Padding(
|
||||
padding: MediaQuery.of(context).padding,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
minWidth: 256,
|
||||
maxWidth: size.width <= 600 ? size.width : 400,
|
||||
minHeight: size.height,
|
||||
maxHeight: size.height,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
Expanded(
|
||||
child: content,
|
||||
),
|
||||
if (actions?.isNotEmpty ?? false) _buildFooter(context)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Visibility(
|
||||
visible: backButton,
|
||||
child: const BackButton(),
|
||||
),
|
||||
if (header != null)
|
||||
Material(
|
||||
textStyle: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
color: Colors.transparent,
|
||||
child: header!,
|
||||
),
|
||||
const Spacer(),
|
||||
Visibility(
|
||||
visible: closeButton,
|
||||
child: const CloseButton(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Visibility(
|
||||
visible: addDivider,
|
||||
child: const Divider(
|
||||
indent: 24,
|
||||
endIndent: 24,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 16, 24, 24),
|
||||
child: Row(
|
||||
children: actions ?? [],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
33
lib/widgets/shared/pinch_poster_zoom.dart
Normal file
33
lib/widgets/shared/pinch_poster_zoom.dart
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import 'package:fladder/providers/settings/client_settings_provider.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class PinchPosterZoom extends ConsumerStatefulWidget {
|
||||
final Widget child;
|
||||
final Function(double difference)? scaleDifference;
|
||||
const PinchPosterZoom({required this.child, this.scaleDifference, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _PinchPosterZoomState();
|
||||
}
|
||||
|
||||
class _PinchPosterZoomState extends ConsumerState<PinchPosterZoom> {
|
||||
double lastScale = 1.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onScaleStart: (details) {
|
||||
lastScale = 1;
|
||||
},
|
||||
onScaleUpdate: (details) {
|
||||
final difference = details.scale - lastScale;
|
||||
if (ref.watch(clientSettingsProvider.select((value) => value.pinchPosterZoom))) {
|
||||
widget.scaleDifference?.call(difference);
|
||||
}
|
||||
lastScale = details.scale;
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
43
lib/widgets/shared/poster_size_slider.dart
Normal file
43
lib/widgets/shared/poster_size_slider.dart
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import 'package:fladder/providers/settings/client_settings_provider.dart';
|
||||
import 'package:fladder/widgets/shared/fladder_slider.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class PosterSizeWidget extends ConsumerWidget {
|
||||
final Color? iconColor;
|
||||
final double width;
|
||||
const PosterSizeWidget({this.width = 150, this.iconColor, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (ref.watch(clientSettingsProvider.select((value) => value.pinchPosterZoom))) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Tooltip(
|
||||
message: 'Set poster size',
|
||||
child: IconButton(
|
||||
onPressed: () =>
|
||||
ref.read(clientSettingsProvider.notifier).update((current) => current.copyWith(posterSize: 1)),
|
||||
icon: Icon(Icons.photo_size_select_large_rounded),
|
||||
color: iconColor ?? Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: width,
|
||||
child: FladderSlider(
|
||||
value: ref.watch(clientSettingsProvider.select((value) => value.posterSize)),
|
||||
min: 0.5,
|
||||
divisions: 12,
|
||||
max: 1.5,
|
||||
onChanged: (value) =>
|
||||
ref.read(clientSettingsProvider.notifier).update((current) => current.copyWith(posterSize: value)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
}
|
||||
}
|
||||
181
lib/widgets/shared/progress_floating_button.dart
Normal file
181
lib/widgets/shared/progress_floating_button.dart
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:async/async.dart';
|
||||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/providers/settings/photo_view_settings_provider.dart';
|
||||
import 'package:fladder/util/simple_duration_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:square_progress_indicator/square_progress_indicator.dart';
|
||||
|
||||
class RestarableTimerController {
|
||||
late Duration _steps = const Duration(milliseconds: 32);
|
||||
RestartableTimer? _timer;
|
||||
late Duration _duration = const Duration(seconds: 1);
|
||||
late Function() _onTimeout;
|
||||
|
||||
late Duration _timeLeft = _duration;
|
||||
set setTimeLeft(Duration value) {
|
||||
_timeLeftController.add(value);
|
||||
_timeLeft = value;
|
||||
}
|
||||
|
||||
final StreamController<Duration> _timeLeftController = StreamController<Duration>.broadcast();
|
||||
final StreamController<bool> _isActiveController = StreamController<bool>.broadcast();
|
||||
|
||||
RestarableTimerController(Duration duration, Duration steps, Function() onTimeout) {
|
||||
_steps = steps;
|
||||
_duration = duration;
|
||||
_onTimeout = onTimeout;
|
||||
}
|
||||
|
||||
void playPause() {
|
||||
if (_timer?.isActive == true) {
|
||||
cancel();
|
||||
} else {
|
||||
play();
|
||||
}
|
||||
}
|
||||
|
||||
void play() {
|
||||
_timer?.cancel();
|
||||
_timer = _startTimer();
|
||||
_isActiveController.add(_timer?.isActive ?? true);
|
||||
}
|
||||
|
||||
RestartableTimer _startTimer() {
|
||||
return RestartableTimer(
|
||||
_steps,
|
||||
() {
|
||||
if (_timeLeft < _steps) {
|
||||
setTimeLeft = _duration;
|
||||
_onTimeout.call();
|
||||
} else {
|
||||
setTimeLeft = _timeLeft - _steps;
|
||||
}
|
||||
_timer?.reset();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool get timerIsActive => _timer?.isActive ?? false;
|
||||
|
||||
Stream<bool> get isActive => _isActiveController.stream;
|
||||
|
||||
Stream<Duration> get timeLeft => _timeLeftController.stream;
|
||||
|
||||
void setDuration(Duration value) => {
|
||||
_duration = value,
|
||||
};
|
||||
|
||||
void cancel() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_isActiveController.add(false);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
setTimeLeft = _duration;
|
||||
_timer?.reset();
|
||||
}
|
||||
|
||||
void dispose() => _timer?.cancel();
|
||||
}
|
||||
|
||||
class ProgressFloatingButton extends ConsumerStatefulWidget {
|
||||
final RestarableTimerController? controller;
|
||||
final Function()? onTimeOut;
|
||||
const ProgressFloatingButton({this.controller, this.onTimeOut, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _ProgressFloatingButtonState();
|
||||
}
|
||||
|
||||
class _ProgressFloatingButtonState extends ConsumerState<ProgressFloatingButton> {
|
||||
late RestarableTimerController timer;
|
||||
late Duration timeLeft = timer._duration;
|
||||
late bool isActive = false;
|
||||
|
||||
List<StreamSubscription> subscriptions = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
timer = widget.controller ??
|
||||
RestarableTimerController(
|
||||
const Duration(seconds: 1),
|
||||
const Duration(milliseconds: 32),
|
||||
widget.onTimeOut ?? () {},
|
||||
);
|
||||
subscriptions.addAll([
|
||||
timer.timeLeft.listen((event) => setState(() => timeLeft = event)),
|
||||
timer.isActive.listen((event) => setState(() => isActive = event))
|
||||
]);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
timer.cancel();
|
||||
for (var element in subscriptions) {
|
||||
element.cancel();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onDoubleTap: () {
|
||||
HapticFeedback.vibrate();
|
||||
setState(() {
|
||||
timer.reset();
|
||||
});
|
||||
},
|
||||
onLongPress: () async {
|
||||
HapticFeedback.vibrate();
|
||||
final newTimer =
|
||||
await showSimpleDurationPicker(context: context, initialValue: timer._duration, showNever: false);
|
||||
if (newTimer != null) {
|
||||
setState(() {
|
||||
ref.read(photoViewSettingsProvider.notifier).update((state) => state.copyWith(timer: newTimer));
|
||||
timer.setDuration(newTimer);
|
||||
});
|
||||
}
|
||||
},
|
||||
child: FloatingActionButton(
|
||||
onPressed: isActive ? timer.cancel : timer.play,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SquareProgressIndicator(
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
borderRadius: 6,
|
||||
strokeWidth: 4,
|
||||
value: timeLeft.inMilliseconds / timer._duration.inMilliseconds,
|
||||
),
|
||||
Icon(isActive ? IconsaxBold.pause : IconsaxBold.play)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CustomTrackShape extends RoundedRectSliderTrackShape {
|
||||
@override
|
||||
Rect getPreferredRect({
|
||||
required RenderBox parentBox,
|
||||
Offset offset = Offset.zero,
|
||||
required SliderThemeData sliderTheme,
|
||||
bool isEnabled = false,
|
||||
bool isDiscrete = false,
|
||||
}) {
|
||||
final trackHeight = sliderTheme.trackHeight;
|
||||
final trackLeft = offset.dx;
|
||||
final trackTop = offset.dy + (parentBox.size.height - trackHeight!) / 2;
|
||||
final trackWidth = parentBox.size.width;
|
||||
return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight);
|
||||
}
|
||||
}
|
||||
83
lib/widgets/shared/pull_to_refresh.dart
Normal file
83
lib/widgets/shared/pull_to_refresh.dart
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/refresh_state.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class PullToRefresh extends ConsumerStatefulWidget {
|
||||
final GlobalKey<RefreshIndicatorState>? refreshKey;
|
||||
final double? displacement;
|
||||
final bool refreshOnStart;
|
||||
final bool autoFocus;
|
||||
final bool contextRefresh;
|
||||
final Future<void> Function()? onRefresh;
|
||||
final Widget child;
|
||||
const PullToRefresh({
|
||||
required this.child,
|
||||
this.displacement,
|
||||
this.autoFocus = true,
|
||||
this.refreshOnStart = true,
|
||||
this.contextRefresh = true,
|
||||
required this.onRefresh,
|
||||
this.refreshKey,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _PullToRefreshState();
|
||||
}
|
||||
|
||||
class _PullToRefreshState extends ConsumerState<PullToRefresh> {
|
||||
final GlobalKey<RefreshIndicatorState> _refreshIndicatorKey = GlobalKey<RefreshIndicatorState>();
|
||||
final FocusNode focusNode = FocusNode();
|
||||
|
||||
GlobalKey<RefreshIndicatorState> get refreshKey {
|
||||
return (widget.refreshKey ?? _refreshIndicatorKey);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.refreshOnStart) {
|
||||
Future.microtask(
|
||||
() => refreshKey.currentState?.show(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if ((AdaptiveLayout.of(context).isDesktop || kIsWeb) && widget.autoFocus) {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
return RefreshState(
|
||||
refreshKey: refreshKey,
|
||||
refreshAble: widget.contextRefresh,
|
||||
child: Focus(
|
||||
focusNode: focusNode,
|
||||
autofocus: true,
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is KeyDownEvent) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.f5) {
|
||||
refreshKey.currentState?.show();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: widget.onRefresh != null
|
||||
? RefreshIndicator.adaptive(
|
||||
displacement: widget.displacement ?? 80 + MediaQuery.of(context).viewPadding.top,
|
||||
key: refreshKey,
|
||||
onRefresh: widget.onRefresh!,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: widget.child,
|
||||
)
|
||||
: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
66
lib/widgets/shared/scroll_position.dart
Normal file
66
lib/widgets/shared/scroll_position.dart
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
enum ScrollState {
|
||||
top,
|
||||
middle,
|
||||
bottom,
|
||||
}
|
||||
|
||||
class ScrollStatePosition extends ConsumerStatefulWidget {
|
||||
final ScrollController? controller;
|
||||
final Widget Function(ScrollState state) positionBuilder;
|
||||
|
||||
const ScrollStatePosition({
|
||||
this.controller,
|
||||
required this.positionBuilder,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _ScrollStatePositionState();
|
||||
}
|
||||
|
||||
class _ScrollStatePositionState extends ConsumerState<ScrollStatePosition> {
|
||||
late final scrollController = widget.controller ?? ScrollController();
|
||||
ScrollState scrollState = ScrollState.top;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
scrollController.addListener(listen);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
scrollController.removeListener(listen);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void listen() {
|
||||
if (scrollController.offset < scrollController.position.maxScrollExtent) {
|
||||
if (scrollController.position.atEdge) {
|
||||
bool isTop = scrollController.position.pixels == 0;
|
||||
if (isTop) {
|
||||
setState(() {
|
||||
scrollState = ScrollState.top;
|
||||
});
|
||||
print('At the top');
|
||||
} else {
|
||||
setState(() {
|
||||
scrollState = ScrollState.bottom;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
scrollState = ScrollState.middle;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.positionBuilder(scrollState);
|
||||
}
|
||||
}
|
||||
103
lib/widgets/shared/selectable_icon_button.dart
Normal file
103
lib/widgets/shared/selectable_icon_button.dart
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:fladder/screens/shared/animated_fade_size.dart';
|
||||
import 'package:fladder/util/refresh_state.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class SelectableIconButton extends ConsumerStatefulWidget {
|
||||
final FutureOr<dynamic> Function() onPressed;
|
||||
final String? label;
|
||||
final IconData icon;
|
||||
final IconData? selectedIcon;
|
||||
final bool selected;
|
||||
const SelectableIconButton({
|
||||
required this.onPressed,
|
||||
required this.selected,
|
||||
required this.icon,
|
||||
this.selectedIcon,
|
||||
this.label,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _SelectableIconButtonState();
|
||||
}
|
||||
|
||||
class _SelectableIconButtonState extends ConsumerState<SelectableIconButton> {
|
||||
bool loading = false;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const duration = Duration(milliseconds: 250);
|
||||
const iconSize = 24.0;
|
||||
return Tooltip(
|
||||
message: widget.label ?? "",
|
||||
child: ElevatedButton(
|
||||
style: ButtonStyle(
|
||||
backgroundColor: widget.selected ? WidgetStatePropertyAll(Theme.of(context).colorScheme.primary) : null,
|
||||
foregroundColor: widget.selected ? WidgetStatePropertyAll(Theme.of(context).colorScheme.onPrimary) : null,
|
||||
padding: const WidgetStatePropertyAll(EdgeInsets.zero),
|
||||
),
|
||||
onPressed: loading
|
||||
? null
|
||||
: () async {
|
||||
setState(() => loading = true);
|
||||
try {
|
||||
await widget.onPressed();
|
||||
if (context.mounted) await context.refreshData();
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
} finally {
|
||||
setState(() => loading = false);
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 10, horizontal: widget.label != null ? 18 : 0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.label != null) ...{
|
||||
Text(
|
||||
widget.label.toString(),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
},
|
||||
AnimatedFadeSize(
|
||||
duration: duration,
|
||||
child: loading
|
||||
? Opacity(
|
||||
opacity: 0.75,
|
||||
child: SizedBox(
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
child: CircularProgressIndicator(
|
||||
strokeCap: StrokeCap.round,
|
||||
color: widget.selected
|
||||
? Theme.of(context).colorScheme.onPrimary
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
)
|
||||
: !widget.selected
|
||||
? Opacity(
|
||||
opacity: 0.65,
|
||||
child: Icon(
|
||||
key: const Key("selected-off"),
|
||||
widget.icon,
|
||||
size: iconSize,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
key: const Key("selected-on"),
|
||||
widget.selectedIcon,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
65
lib/widgets/shared/shapes.dart
Normal file
65
lib/widgets/shared/shapes.dart
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppBarShape extends OutlinedBorder {
|
||||
@override
|
||||
OutlinedBorder copyWith({BorderSide? side}) => this; //todo
|
||||
|
||||
@override
|
||||
Path getInnerPath(Rect rect, {TextDirection? textDirection}) {
|
||||
Path path = Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRect(rect)
|
||||
..addRRect(RRect.fromRectAndCorners(
|
||||
Rect.fromLTWH(rect.left, rect.bottom - 14, rect.width, 14),
|
||||
topLeft: Radius.circular(14),
|
||||
topRight: Radius.circular(14),
|
||||
));
|
||||
return path;
|
||||
}
|
||||
|
||||
@override
|
||||
Path getOuterPath(Rect rect, {TextDirection? textDirection}) {
|
||||
return getInnerPath(rect, textDirection: textDirection);
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {
|
||||
/// create shader linear gradient
|
||||
canvas.drawPath(getInnerPath(rect), Paint()..color = Colors.transparent);
|
||||
}
|
||||
|
||||
@override
|
||||
ShapeBorder scale(double t) => this;
|
||||
}
|
||||
|
||||
class BottomBarShape extends OutlinedBorder {
|
||||
@override
|
||||
OutlinedBorder copyWith({BorderSide? side}) => this; //todo
|
||||
|
||||
@override
|
||||
Path getInnerPath(Rect rect, {TextDirection? textDirection}) {
|
||||
Path path = Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRect(rect)
|
||||
..addRRect(RRect.fromRectAndCorners(
|
||||
Rect.fromLTWH(rect.left, rect.top, rect.width, 14),
|
||||
bottomLeft: Radius.circular(14),
|
||||
bottomRight: Radius.circular(14),
|
||||
));
|
||||
return path;
|
||||
}
|
||||
|
||||
@override
|
||||
Path getOuterPath(Rect rect, {TextDirection? textDirection}) {
|
||||
return getInnerPath(rect, textDirection: textDirection);
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {
|
||||
/// create shader linear gradient
|
||||
canvas.drawPath(getInnerPath(rect), Paint()..color = Colors.transparent);
|
||||
}
|
||||
|
||||
@override
|
||||
ShapeBorder scale(double t) => this;
|
||||
}
|
||||
27
lib/widgets/shared/spaced_list_tile.dart
Normal file
27
lib/widgets/shared/spaced_list_tile.dart
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class SpacedListTile extends StatelessWidget {
|
||||
final Widget title;
|
||||
final Widget? content;
|
||||
final Function()? onTap;
|
||||
const SpacedListTile({required this.title, this.content, this.onTap, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
title: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(flex: 1, child: title),
|
||||
if (content != null)
|
||||
Flexible(
|
||||
flex: 1,
|
||||
child: content!,
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
31
lib/widgets/shared/status_card.dart
Normal file
31
lib/widgets/shared/status_card.dart
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class StatusCard extends ConsumerWidget {
|
||||
final Color? color;
|
||||
final Widget child;
|
||||
|
||||
const StatusCard({this.color, required this.child, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.all(5),
|
||||
child: SizedBox(
|
||||
width: 33,
|
||||
height: 33,
|
||||
child: Card(
|
||||
elevation: 10,
|
||||
surfaceTintColor: color,
|
||||
shadowColor: color != null ? Colors.transparent : null,
|
||||
child: IconTheme(
|
||||
data: IconThemeData(
|
||||
color: color,
|
||||
),
|
||||
child: Center(child: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
126
lib/widgets/shared/trickplay_image.dart
Normal file
126
lib/widgets/shared/trickplay_image.dart
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'package:fladder/models/items/trick_play_model.dart';
|
||||
|
||||
class TrickplayImage extends ConsumerStatefulWidget {
|
||||
final TrickPlayModel trickplay;
|
||||
final Duration? position;
|
||||
|
||||
const TrickplayImage(this.trickplay, {this.position, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _TrickplayImageState();
|
||||
}
|
||||
|
||||
class _TrickplayImageState extends ConsumerState<TrickplayImage> {
|
||||
ui.Image? image;
|
||||
late TrickPlayModel model = widget.trickplay;
|
||||
late Duration time = widget.position ?? Duration.zero;
|
||||
late Offset currentOffset = Offset(0, 0);
|
||||
String? currentUrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
loadImage();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TrickplayImage oldWidget) {
|
||||
if (oldWidget.position?.inMilliseconds != widget.position?.inMilliseconds) {
|
||||
time = widget.position ?? Duration.zero;
|
||||
model = widget.trickplay;
|
||||
loadImage();
|
||||
}
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
child: image != null
|
||||
? CustomPaint(
|
||||
painter: TilledPainter(image!, currentOffset, widget.trickplay),
|
||||
)
|
||||
: Container(
|
||||
color: Colors.purple,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> loadImage() async {
|
||||
if (model.images.isEmpty) return;
|
||||
final newUrl = model.getTile(time);
|
||||
currentOffset = model.offset(time);
|
||||
if (newUrl != currentUrl) {
|
||||
currentUrl = newUrl;
|
||||
final tempUrl = currentUrl;
|
||||
if (tempUrl == null) return;
|
||||
if (tempUrl.startsWith('http')) {
|
||||
await loadNetworkImage(tempUrl);
|
||||
} else {
|
||||
await loadFileImage(tempUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadNetworkImage(String url) async {
|
||||
final http.Response response = await http.get(Uri.parse(url));
|
||||
if (response.statusCode == 200) {
|
||||
final Uint8List bytes = response.bodyBytes;
|
||||
final ui.Codec codec = await ui.instantiateImageCodec(bytes);
|
||||
final ui.FrameInfo frameInfo = await codec.getNextFrame();
|
||||
setState(() {
|
||||
image = frameInfo.image;
|
||||
});
|
||||
} else {
|
||||
throw Exception('Failed to load network image');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadFileImage(String path) async {
|
||||
final Uint8List bytes = await File(path).readAsBytes();
|
||||
final ui.Codec codec = await ui.instantiateImageCodec(bytes);
|
||||
final ui.FrameInfo frameInfo = await codec.getNextFrame();
|
||||
setState(() {
|
||||
image = frameInfo.image;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class TilledPainter extends CustomPainter {
|
||||
final ui.Image image;
|
||||
final Offset offset;
|
||||
final TrickPlayModel model;
|
||||
|
||||
TilledPainter(this.image, this.offset, this.model);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
// Define the source rectangle from the image
|
||||
Rect srcRect = Rect.fromLTWH(
|
||||
offset.dx,
|
||||
offset.dy,
|
||||
model.width.toDouble(),
|
||||
model.height.toDouble(),
|
||||
); // Adjust these values to control the part of the image to display
|
||||
|
||||
// Define the destination rectangle on the canvas
|
||||
Rect dstRect = Rect.fromLTWH(0, 0, size.width, size.height);
|
||||
|
||||
// Draw the image part onto the canvas
|
||||
canvas.drawImageRect(image, srcRect, dstRect, Paint());
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue