mirror of
https://github.com/gabehf/Fladder.git
synced 2026-03-08 23:18:16 -07:00
Init repo
This commit is contained in:
commit
764b6034e3
566 changed files with 212335 additions and 0 deletions
118
lib/screens/book_viewer/book_viewer_chapters.dart
Normal file
118
lib/screens/book_viewer/book_viewer_chapters.dart
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import 'package:fladder/models/book_model.dart';
|
||||
import 'package:fladder/providers/book_viewer_provider.dart';
|
||||
import 'package:fladder/providers/items/book_details_provider.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:fladder/widgets/shared/modal_side_sheet.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
Future<void> showBookViewerChapters(
|
||||
BuildContext context, AutoDisposeStateNotifierProvider<BookDetailsProviderNotifier, BookProviderModel> provider,
|
||||
{Function(BookModel book)? onPressed}) async {
|
||||
if (AdaptiveLayout.of(context).isDesktop) {
|
||||
return showModalSideSheet(context,
|
||||
content: BookViewerChapters(
|
||||
provider: provider,
|
||||
onPressed: onPressed,
|
||||
));
|
||||
} else {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
useSafeArea: true,
|
||||
builder: (context) => BookViewerChapters(
|
||||
provider: provider,
|
||||
onPressed: onPressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BookViewerChapters extends ConsumerWidget {
|
||||
final AutoDisposeStateNotifierProvider<BookDetailsProviderNotifier, BookProviderModel> provider;
|
||||
final Function(BookModel book)? onPressed;
|
||||
const BookViewerChapters({required this.provider, this.onPressed, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentBook = ref.watch(bookViewerProvider.select((value) => value.book));
|
||||
final chapters = ref.watch(provider.select((value) => value.chapters));
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
"Chapters",
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
...chapters.map(
|
||||
(book) {
|
||||
final bool current = currentBook == book;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Card(
|
||||
elevation: current ? 10 : 3,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 80),
|
||||
alignment: Alignment.center,
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
leading: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Card(
|
||||
child: FladderImage(
|
||||
image: book.getPosters?.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(book.name),
|
||||
trailing: current
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: Icon(
|
||||
Icons.visibility_rounded,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
)
|
||||
: FilledButton(
|
||||
onPressed: () => onPressed?.call(book),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.read_more_rounded),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
398
lib/screens/book_viewer/book_viewer_controls.dart
Normal file
398
lib/screens/book_viewer/book_viewer_controls.dart
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
import 'package:extended_image/extended_image.dart';
|
||||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/book_model.dart';
|
||||
import 'package:fladder/providers/book_viewer_provider.dart';
|
||||
import 'package:fladder/providers/items/book_details_provider.dart';
|
||||
import 'package:fladder/providers/settings/book_viewer_settings_provider.dart';
|
||||
import 'package:fladder/screens/book_viewer/book_viewer_chapters.dart';
|
||||
import 'package:fladder/screens/book_viewer/book_viewer_settings.dart';
|
||||
import 'package:fladder/screens/shared/default_titlebar.dart';
|
||||
import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/throttler.dart';
|
||||
import 'package:fladder/widgets/shared/fladder_slider.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:screen_brightness/screen_brightness.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
class BookViewController {
|
||||
bool controlsVisible = true;
|
||||
|
||||
late ValueNotifier<bool> visibilityChanged = ValueNotifier(controlsVisible);
|
||||
|
||||
void toggleControls({bool? value}) {
|
||||
controlsVisible = value ?? !controlsVisible;
|
||||
visibilityChanged.value = controlsVisible;
|
||||
}
|
||||
}
|
||||
|
||||
class BookViewerControls extends ConsumerStatefulWidget {
|
||||
final AutoDisposeStateNotifierProvider<BookDetailsProviderNotifier, BookProviderModel> provider;
|
||||
final BookViewController viewController;
|
||||
|
||||
final ExtendedPageController controller;
|
||||
const BookViewerControls({
|
||||
required this.provider,
|
||||
required this.controller,
|
||||
required this.viewController,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _BookViewerControlsState();
|
||||
}
|
||||
|
||||
class _BookViewerControlsState extends ConsumerState<BookViewerControls> {
|
||||
final FocusNode focusNode = FocusNode();
|
||||
final Throttler throttler = Throttler(duration: const Duration(milliseconds: 130));
|
||||
final Duration pageAnimDuration = const Duration(milliseconds: 125);
|
||||
final Curve pageAnimCurve = Curves.easeInCubic;
|
||||
late final BookViewController viewController = widget.viewController;
|
||||
|
||||
late final double topPadding = MediaQuery.of(context).viewPadding.top;
|
||||
late final double bottomPadding = MediaQuery.of(context).viewPadding.bottom;
|
||||
|
||||
bool showControls = true;
|
||||
void toggleControls({bool? value}) {
|
||||
setState(() {
|
||||
showControls = value ?? !showControls;
|
||||
});
|
||||
SystemChrome.setEnabledSystemUIMode(!showControls ? SystemUiMode.leanBack : SystemUiMode.edgeToEdge, overlays: []);
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WakelockPlus.enable();
|
||||
viewController.visibilityChanged.addListener(() {
|
||||
toggleControls(value: viewController.controlsVisible);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
WakelockPlus.disable();
|
||||
ScreenBrightness().resetScreenBrightness();
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge, overlays: []);
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final details = ref.watch(widget.provider);
|
||||
final bookViewerSettings = ref.watch(bookViewerSettingsProvider);
|
||||
final chapters = details.chapters;
|
||||
final bookViewerDetails = ref.watch(bookViewerProvider);
|
||||
final currentPage = bookViewerDetails.currentPage;
|
||||
const overlayColor = Colors.black;
|
||||
final previousChapter = details.previousChapter(bookViewerDetails.book);
|
||||
final nextChapter = details.nextChapter(bookViewerDetails.book);
|
||||
|
||||
if (AdaptiveLayout.of(context).isDesktop) {
|
||||
FocusScope.of(context).requestFocus(focusNode);
|
||||
}
|
||||
return MediaQuery.removePadding(
|
||||
context: context,
|
||||
child: KeyboardListener(
|
||||
focusNode: focusNode,
|
||||
autofocus: AdaptiveLayout.of(context).isDesktop,
|
||||
onKeyEvent: (value) {
|
||||
if (value is KeyDownEvent) {
|
||||
if (value.logicalKey == LogicalKeyboardKey.arrowLeft || value.logicalKey == LogicalKeyboardKey.keyA) {
|
||||
bookViewerSettings.readDirection == ReadDirection.leftToRight ? previousPage() : nextPage();
|
||||
}
|
||||
if (value.logicalKey == LogicalKeyboardKey.arrowRight || value.logicalKey == LogicalKeyboardKey.keyD) {
|
||||
bookViewerSettings.readDirection == ReadDirection.leftToRight ? nextPage() : previousPage();
|
||||
}
|
||||
if (value.logicalKey == LogicalKeyboardKey.space) {
|
||||
toggleControls();
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
IgnorePointer(
|
||||
ignoring: !showControls,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
opacity: showControls ? 1 : 0,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
overlayColor.withOpacity(1),
|
||||
overlayColor.withOpacity(0.65),
|
||||
overlayColor.withOpacity(0),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: topPadding).copyWith(bottom: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (AdaptiveLayout.of(context).isDesktop)
|
||||
const Flexible(
|
||||
child: DefaultTitleBar(
|
||||
height: 50,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const BackButton(),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
bookViewerDetails.book?.name ?? "None",
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!bookViewerDetails.loading) ...{
|
||||
if (bookViewerDetails.book != null && bookViewerDetails.pages.isNotEmpty) ...{
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
overlayColor.withOpacity(0),
|
||||
overlayColor.withOpacity(0.65),
|
||||
overlayColor.withOpacity(1),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: bottomPadding).copyWith(top: 16, bottom: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 30),
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
Tooltip(
|
||||
message: bookViewerSettings.readDirection == ReadDirection.leftToRight
|
||||
? previousChapter?.name != null
|
||||
? "Load ${previousChapter?.name}"
|
||||
: ""
|
||||
: nextChapter?.name != null
|
||||
? "Load ${nextChapter?.name}"
|
||||
: "",
|
||||
child: IconButton.filled(
|
||||
onPressed: bookViewerSettings.readDirection == ReadDirection.leftToRight
|
||||
? previousChapter != null
|
||||
? () async => await loadNextBook(previousChapter)
|
||||
: null
|
||||
: nextChapter != null
|
||||
? () async => await loadNextBook(nextChapter)
|
||||
: null,
|
||||
icon: const Icon(IconsaxOutline.backward),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(60),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
children: [
|
||||
if (bookViewerSettings.readDirection == ReadDirection.leftToRight)
|
||||
...controls(currentPage, bookViewerSettings, bookViewerDetails)
|
||||
else
|
||||
...controls(currentPage, bookViewerSettings, bookViewerDetails)
|
||||
.reversed,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Tooltip(
|
||||
message: bookViewerSettings.readDirection == ReadDirection.leftToRight
|
||||
? nextChapter?.name != null
|
||||
? "Load ${nextChapter?.name}"
|
||||
: ""
|
||||
: previousChapter?.name != null
|
||||
? "Load ${previousChapter?.name}"
|
||||
: "",
|
||||
child: IconButton.filled(
|
||||
onPressed: bookViewerSettings.readDirection == ReadDirection.leftToRight
|
||||
? nextChapter != null
|
||||
? () async => await loadNextBook(nextChapter)
|
||||
: null
|
||||
: previousChapter != null
|
||||
? () async => await loadNextBook(previousChapter)
|
||||
: null,
|
||||
icon: const Icon(IconsaxOutline.forward),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
Transform.flip(
|
||||
flipX: bookViewerSettings.readDirection == ReadDirection.rightToLeft,
|
||||
child: IconButton(
|
||||
onPressed: () => widget.controller
|
||||
.animateToPage(1, duration: pageAnimDuration, curve: pageAnimCurve),
|
||||
icon: const Icon(IconsaxOutline.backward)),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
showBookViewerSettings(context);
|
||||
},
|
||||
icon: const Icon(IconsaxOutline.setting_2),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: chapters.length > 1
|
||||
? () {
|
||||
showBookViewerChapters(
|
||||
context,
|
||||
widget.provider,
|
||||
onPressed: (book) async {
|
||||
Navigator.of(context).pop();
|
||||
loadNextBook(book);
|
||||
},
|
||||
);
|
||||
}
|
||||
: () => fladderSnackbar(context, title: "No other chapters"),
|
||||
icon: const Icon(IconsaxOutline.bookmark_2),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
} else
|
||||
const Center(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.menu_book_rounded),
|
||||
SizedBox(width: 8),
|
||||
Text("Unable to load book"),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (bookViewerDetails.loading)
|
||||
Center(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (bookViewerDetails.book != null) ...{
|
||||
Flexible(
|
||||
child: Text("Loading ${bookViewerDetails.book?.name}",
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
},
|
||||
const CircularProgressIndicator.adaptive(strokeCap: StrokeCap.round),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> controls(int currentPage, BookViewerSettingsModel bookViewerSettings, BookViewerModel details) {
|
||||
final clampedCurrentPage = currentPage.clamp(1, details.pages.length);
|
||||
return [
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
(currentPage.clamp(1, details.pages.length)).toInt().toString().padLeft(1).padRight(1),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Transform.flip(
|
||||
flipX: bookViewerSettings.readDirection == ReadDirection.rightToLeft,
|
||||
child: SizedBox(
|
||||
height: 40,
|
||||
child: FladderSlider(
|
||||
value: clampedCurrentPage.toDouble(),
|
||||
divisions: details.pages.length - 1,
|
||||
min: 1,
|
||||
max: details.pages.length.toDouble(),
|
||||
onChangeEnd: (value) => widget.controller.jumpToPage(value.toInt()),
|
||||
onChanged: (value) => ref.read(bookViewerProvider.notifier).setPage(value),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
details.pages.length.toString().padLeft(1).padRight(1),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> loadNextBook(BookModel? book) async {
|
||||
await ref.read(bookViewerProvider.notifier).fetchBook(book);
|
||||
widget.controller.jumpToPage(0);
|
||||
return;
|
||||
}
|
||||
|
||||
Future<void> nextPage() async =>
|
||||
throttler.run(() async => await widget.controller.nextPage(duration: pageAnimDuration, curve: pageAnimCurve));
|
||||
|
||||
Future<void> previousPage() async =>
|
||||
throttler.run(() async => await widget.controller.previousPage(duration: pageAnimDuration, curve: pageAnimCurve));
|
||||
}
|
||||
119
lib/screens/book_viewer/book_viewer_reader.dart
Normal file
119
lib/screens/book_viewer/book_viewer_reader.dart
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:extended_image/extended_image.dart';
|
||||
import 'package:fladder/providers/settings/book_viewer_settings_provider.dart';
|
||||
import 'package:fladder/screens/book_viewer/book_viewer_controls.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class BookViewerReader extends ConsumerWidget {
|
||||
final int index;
|
||||
final List<String> pages;
|
||||
final BookViewerSettingsModel bookViewSettings;
|
||||
final Function() previousPage;
|
||||
final Function() nextPage;
|
||||
final BookViewController viewController;
|
||||
final double lastScale;
|
||||
final Function(double value) newScale;
|
||||
const BookViewerReader({
|
||||
required this.index,
|
||||
required this.pages,
|
||||
required this.bookViewSettings,
|
||||
required this.previousPage,
|
||||
required this.nextPage,
|
||||
required this.viewController,
|
||||
required this.lastScale,
|
||||
required this.newScale,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
double? initScale({
|
||||
required Size imageSize,
|
||||
required Size size,
|
||||
double? initialScale,
|
||||
}) {
|
||||
final double n1 = imageSize.height / imageSize.width;
|
||||
final double n2 = size.height / size.width;
|
||||
if (n1 > n2) {
|
||||
final FittedSizes fittedSizes = applyBoxFit(BoxFit.cover, imageSize, size);
|
||||
//final Size sourceSize = fittedSizes.source;
|
||||
final Size destinationSize = fittedSizes.destination;
|
||||
return size.width / destinationSize.width;
|
||||
} else if (n1 / n2 < 1 / 4) {
|
||||
final FittedSizes fittedSizes = applyBoxFit(BoxFit.cover, imageSize, size);
|
||||
//final Size sourceSize = fittedSizes.source;
|
||||
final Size destinationSize = fittedSizes.destination;
|
||||
return size.height / destinationSize.height;
|
||||
}
|
||||
|
||||
return initialScale;
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTapUp: (tapDetails) {
|
||||
double screenWidth = MediaQuery.of(context).size.width;
|
||||
double tapPosition = tapDetails.globalPosition.dx;
|
||||
double tapPercentage = tapPosition / screenWidth;
|
||||
if (tapPercentage < 0.22) {
|
||||
bookViewSettings.readDirection == ReadDirection.leftToRight ? previousPage() : nextPage();
|
||||
} else if (tapPercentage < 0.88) {
|
||||
viewController.toggleControls();
|
||||
} else {
|
||||
bookViewSettings.readDirection == ReadDirection.leftToRight ? nextPage() : previousPage();
|
||||
}
|
||||
},
|
||||
child: ExtendedImage.file(
|
||||
fit: BoxFit.contain,
|
||||
imageCacheName: pages[index - 1],
|
||||
mode: ExtendedImageMode.gesture,
|
||||
initGestureConfigHandler: (state) {
|
||||
double? initialScale = !bookViewSettings.keepPageZoom
|
||||
? switch (bookViewSettings.initZoomState) {
|
||||
InitZoomState.contained => 1.0,
|
||||
InitZoomState.covered => 1.75,
|
||||
}
|
||||
: lastScale;
|
||||
|
||||
if (state.extendedImageInfo != null) {
|
||||
initialScale = initScale(
|
||||
size: MediaQuery.sizeOf(context),
|
||||
initialScale: initialScale,
|
||||
imageSize: Size(
|
||||
state.extendedImageInfo!.image.width.toDouble(), state.extendedImageInfo!.image.height.toDouble()));
|
||||
}
|
||||
|
||||
return GestureConfig(
|
||||
inertialSpeed: 300,
|
||||
inPageView: true,
|
||||
initialScale: initialScale!,
|
||||
initialAlignment: bookViewSettings.initZoomState == InitZoomState.contained && initialScale == 1.0
|
||||
? InitialAlignment.center
|
||||
: switch (bookViewSettings.readDirection) {
|
||||
ReadDirection.rightToLeft => InitialAlignment.topRight,
|
||||
ReadDirection.leftToRight => InitialAlignment.topLeft,
|
||||
},
|
||||
reverseMousePointerScrollDirection: true,
|
||||
maxScale: math.max(initialScale, 5.0),
|
||||
minScale: math.min(initialScale, 1),
|
||||
animationMaxScale: math.max(initialScale, 5.0),
|
||||
gestureDetailsIsChanged: (details) {
|
||||
if (bookViewSettings.keepPageZoom) {
|
||||
if (lastScale != (details?.totalScale ?? initialScale)) {
|
||||
newScale(details?.totalScale ?? 1.0);
|
||||
}
|
||||
}
|
||||
},
|
||||
cacheGesture: bookViewSettings.cachePageZoom,
|
||||
hitTestBehavior: HitTestBehavior.translucent,
|
||||
);
|
||||
},
|
||||
File(pages[index - 1]),
|
||||
enableMemoryCache: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
33
lib/screens/book_viewer/book_viewer_reader_web.dart
Normal file
33
lib/screens/book_viewer/book_viewer_reader_web.dart
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import 'package:fladder/providers/settings/book_viewer_settings_provider.dart';
|
||||
import 'package:fladder/screens/book_viewer/book_viewer_controls.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class BookViewerReader extends ConsumerWidget {
|
||||
final int index;
|
||||
final List<String> pages;
|
||||
final BookViewerSettingsModel bookViewSettings;
|
||||
final Function() previousPage;
|
||||
final Function() nextPage;
|
||||
final BookViewController viewController;
|
||||
final double lastScale;
|
||||
final Function(double value) newScale;
|
||||
const BookViewerReader({
|
||||
required this.index,
|
||||
required this.pages,
|
||||
required this.bookViewSettings,
|
||||
required this.previousPage,
|
||||
required this.nextPage,
|
||||
required this.viewController,
|
||||
required this.lastScale,
|
||||
required this.newScale,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Container(
|
||||
child: Text("Web not supported."),
|
||||
);
|
||||
}
|
||||
}
|
||||
270
lib/screens/book_viewer/book_viewer_screen.dart
Normal file
270
lib/screens/book_viewer/book_viewer_screen.dart
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import 'package:extended_image/extended_image.dart';
|
||||
import 'package:fladder/models/book_model.dart';
|
||||
import 'package:fladder/providers/book_viewer_provider.dart';
|
||||
import 'package:fladder/providers/items/book_details_provider.dart';
|
||||
import 'package:fladder/providers/settings/book_viewer_settings_provider.dart';
|
||||
import 'package:fladder/screens/book_viewer/book_viewer_controls.dart';
|
||||
import 'package:fladder/screens/book_viewer/book_viewer_reader.dart'
|
||||
if (dart.library.html) 'package:fladder/screens/book_viewer/book_viewer_reader_web.dart';
|
||||
import 'package:fladder/util/themes_data.dart';
|
||||
import 'package:fladder/util/throttler.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
Future<void> openBookViewer(
|
||||
BuildContext context,
|
||||
AutoDisposeStateNotifierProvider<BookDetailsProviderNotifier, BookProviderModel> provider, {
|
||||
int? initialPage,
|
||||
}) async {
|
||||
return showDialog(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
useSafeArea: false,
|
||||
builder: (context) => Dialog.fullscreen(
|
||||
child: BookViewerScreen(
|
||||
initialPage: initialPage ?? 0,
|
||||
provider: provider,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class BookViewerScreen extends ConsumerStatefulWidget {
|
||||
final int initialPage;
|
||||
final AutoDisposeStateNotifierProvider<BookDetailsProviderNotifier, BookProviderModel> provider;
|
||||
const BookViewerScreen({required this.provider, this.initialPage = 0, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _BookViewerScreenState();
|
||||
}
|
||||
|
||||
class _BookViewerScreenState extends ConsumerState<BookViewerScreen> {
|
||||
final Throttler throttler = Throttler(duration: const Duration(milliseconds: 130));
|
||||
final Duration pageAnimDuration = const Duration(milliseconds: 125);
|
||||
final Curve pageAnimCurve = Curves.easeInCubic;
|
||||
late final ExtendedPageController extendedController = ExtendedPageController(initialPage: widget.initialPage);
|
||||
late final BookViewController viewController = BookViewController();
|
||||
bool outOfRange = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() => ref.read(bookViewerSettingsProvider.notifier).setSavedBrightness());
|
||||
}
|
||||
|
||||
late double lastScale = switch (ref.read(bookViewerSettingsProvider).initZoomState) {
|
||||
InitZoomState.contained => 1.0,
|
||||
InitZoomState.covered => 1.75,
|
||||
};
|
||||
|
||||
late double lastPosition = 0.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bookViewerDetails = ref.watch(bookViewerProvider);
|
||||
final loading = bookViewerDetails.loading;
|
||||
final pages = bookViewerDetails.pages;
|
||||
final book = bookViewerDetails.book;
|
||||
final bookViewSettings = ref.watch(bookViewerSettingsProvider);
|
||||
ref.listen(
|
||||
bookViewerProvider.select((value) => value.loading),
|
||||
(previous, next) {
|
||||
if (previous == true && next == false) {
|
||||
ref.read(bookViewerProvider.notifier).updatePlayback((widget.initialPage.toDouble()).toInt());
|
||||
}
|
||||
},
|
||||
);
|
||||
return Theme(
|
||||
data: ThemesData.of(context).dark,
|
||||
child: PopScope(
|
||||
canPop: true,
|
||||
onPopInvoked: (didPop) async {
|
||||
await ref.read(bookViewerProvider.notifier).stopPlayback();
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (!loading)
|
||||
ExtendedImageGesturePageView.builder(
|
||||
itemCount: pages.length + 2,
|
||||
controller: extendedController,
|
||||
canScrollPage: (gestureDetails) {
|
||||
return bookViewSettings.disableScrollOnZoom
|
||||
? gestureDetails != null
|
||||
? !(gestureDetails.totalScale! > 1.0)
|
||||
: true
|
||||
: true;
|
||||
},
|
||||
onPageChanged: (value) {
|
||||
final newRange = pages.length + 1 == value || value == 0;
|
||||
if (outOfRange != newRange) {
|
||||
viewController.toggleControls(value: newRange);
|
||||
outOfRange = newRange;
|
||||
}
|
||||
ref.read(bookViewerProvider.notifier).updatePlayback(value);
|
||||
},
|
||||
reverse: bookViewSettings.readDirection == ReadDirection.rightToLeft,
|
||||
itemBuilder: (context, index) {
|
||||
if (pages.length + 1 == index || index == 0) {
|
||||
final atEnd = index >= pages.length;
|
||||
final details = ref.read(widget.provider);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: bookViewSettings.readDirection != ReadDirection.leftToRight
|
||||
? CrossAxisAlignment.start
|
||||
: CrossAxisAlignment.end,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (atEnd) ...{
|
||||
Flexible(
|
||||
child: Text(
|
||||
"End: \n${book?.name}",
|
||||
textAlign: TextAlign.start,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
if (details.nextChapter(bookViewerDetails.book) != null) ...{
|
||||
const SizedBox(height: 32),
|
||||
Flexible(
|
||||
child: Text(
|
||||
"Next: ",
|
||||
textAlign: TextAlign.start,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 8)),
|
||||
onPressed: () async =>
|
||||
await loadNextBook(details.nextChapter(bookViewerDetails.book)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.keyboard_arrow_left_rounded),
|
||||
Text(
|
||||
details.nextChapter(bookViewerDetails.book)!.name,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onPrimary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
} else ...{
|
||||
const SizedBox(height: 32),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.info_rounded),
|
||||
const SizedBox(width: 16),
|
||||
Text("No next chapter"),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
} else ...{
|
||||
Flexible(
|
||||
child: Text(
|
||||
"Start: \n${book?.name}",
|
||||
textAlign: TextAlign.start,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
if (details.previousChapter(bookViewerDetails.book) != null) ...{
|
||||
const SizedBox(height: 32),
|
||||
Flexible(
|
||||
child: Text(
|
||||
"Previous:",
|
||||
textAlign: TextAlign.start,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 8)),
|
||||
onPressed: () async =>
|
||||
await loadNextBook(details.previousChapter(bookViewerDetails.book)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
details.previousChapter(bookViewerDetails.book)!.name,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onPrimary),
|
||||
),
|
||||
const Icon(Icons.keyboard_arrow_right_rounded),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
} else ...{
|
||||
const SizedBox(height: 32),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.info_rounded),
|
||||
const SizedBox(width: 16),
|
||||
Text("First chapter"),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return BookViewerReader(
|
||||
index: index,
|
||||
pages: pages,
|
||||
bookViewSettings: bookViewSettings,
|
||||
previousPage: previousPage,
|
||||
nextPage: nextPage,
|
||||
viewController: viewController,
|
||||
lastScale: lastScale,
|
||||
newScale: (value) => lastScale = value,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
BookViewerControls(
|
||||
provider: widget.provider,
|
||||
viewController: viewController,
|
||||
controller: extendedController,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> nextPage() async =>
|
||||
throttler.run(() async => await extendedController.nextPage(duration: pageAnimDuration, curve: pageAnimCurve));
|
||||
|
||||
Future<void> previousPage() async => throttler
|
||||
.run(() async => await extendedController.previousPage(duration: pageAnimDuration, curve: pageAnimCurve));
|
||||
|
||||
Future<void> loadNextBook(BookModel? book) async {
|
||||
await ref.read(bookViewerProvider.notifier).fetchBook(book);
|
||||
extendedController.jumpToPage(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
187
lib/screens/book_viewer/book_viewer_settings.dart
Normal file
187
lib/screens/book_viewer/book_viewer_settings.dart
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import 'package:fladder/providers/settings/book_viewer_settings_provider.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
import 'package:fladder/widgets/shared/enum_selection.dart';
|
||||
import 'package:fladder/widgets/shared/fladder_slider.dart';
|
||||
import 'package:fladder/widgets/shared/modal_side_sheet.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
Future<void> showBookViewerSettings(
|
||||
BuildContext context,
|
||||
) async {
|
||||
if (AdaptiveLayout.of(context).isDesktop) {
|
||||
return showModalSideSheet(context, content: const BookViewerSettingsScreen());
|
||||
} else {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (context) => const BookViewerSettingsScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BookViewerSettingsScreen extends ConsumerWidget {
|
||||
const BookViewerSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(bookViewerSettingsProvider);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
"Reader settings",
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
if (!AdaptiveLayout.of(context).isDesktop) ...{
|
||||
ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
const Text("Screen Brightness"),
|
||||
Flexible(
|
||||
child: Opacity(
|
||||
opacity: settings.screenBrightness == null ? 0.5 : 1,
|
||||
child: FladderSlider(
|
||||
value: settings.screenBrightness ?? 1.0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
onChanged: (value) => ref.read(bookViewerSettingsProvider.notifier).setScreenBrightness(value),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => ref.read(bookViewerSettingsProvider.notifier).setScreenBrightness(null),
|
||||
icon: Opacity(
|
||||
opacity: settings.screenBrightness != null ? 0.5 : 1,
|
||||
child: Icon(
|
||||
Icons.brightness_auto_rounded,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
},
|
||||
ListTile(
|
||||
title: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: EnumSelection(
|
||||
label: const Text("Read direction"),
|
||||
current: settings.readDirection.name.toUpperCaseSplit(),
|
||||
itemBuilder: (context) => ReadDirection.values
|
||||
.map((value) => PopupMenuItem(
|
||||
value: value,
|
||||
child: Text(value.name.toUpperCaseSplit()),
|
||||
onTap: () => ref
|
||||
.read(bookViewerSettingsProvider.notifier)
|
||||
.update((state) => state.copyWith(readDirection: value)),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: EnumSelection(
|
||||
label: const Text("Init zoom"),
|
||||
current: settings.initZoomState.name.toUpperCaseSplit(),
|
||||
itemBuilder: (context) => InitZoomState.values
|
||||
.map((value) => PopupMenuItem(
|
||||
value: value,
|
||||
child: Text(value.name.toUpperCaseSplit()),
|
||||
onTap: () => ref
|
||||
.read(bookViewerSettingsProvider.notifier)
|
||||
.update((state) => state.copyWith(initZoomState: value)),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
onTap: () => ref
|
||||
.read(bookViewerSettingsProvider.notifier)
|
||||
.update((state) => state.copyWith(disableScrollOnZoom: !settings.disableScrollOnZoom)),
|
||||
title: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Expanded(
|
||||
flex: 3,
|
||||
child: Text("Disable slide page gestures when zoomed"),
|
||||
),
|
||||
const Spacer(),
|
||||
Switch.adaptive(
|
||||
value: settings.disableScrollOnZoom,
|
||||
onChanged: (value) => ref
|
||||
.read(bookViewerSettingsProvider.notifier)
|
||||
.update((state) => state.copyWith(disableScrollOnZoom: value)),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
onTap: () => ref
|
||||
.read(bookViewerSettingsProvider.notifier)
|
||||
.update((state) => state.copyWith(cachePageZoom: !settings.cachePageZoom)),
|
||||
title: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Expanded(
|
||||
flex: 3,
|
||||
child: Text("Cache page zoom state"),
|
||||
),
|
||||
const Spacer(),
|
||||
Switch.adaptive(
|
||||
value: settings.cachePageZoom,
|
||||
onChanged: (value) => ref
|
||||
.read(bookViewerSettingsProvider.notifier)
|
||||
.update((incoming) => incoming.copyWith(cachePageZoom: value)),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
onTap: () => ref
|
||||
.read(bookViewerSettingsProvider.notifier)
|
||||
.update((state) => state.copyWith(keepPageZoom: !settings.keepPageZoom)),
|
||||
title: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Expanded(
|
||||
flex: 3,
|
||||
child: Text("Keep page zoom"),
|
||||
),
|
||||
const Spacer(),
|
||||
Switch.adaptive(
|
||||
value: settings.keepPageZoom,
|
||||
onChanged: (value) => ref
|
||||
.read(bookViewerSettingsProvider.notifier)
|
||||
.update((incoming) => incoming.copyWith(keepPageZoom: value)),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).padding.bottom,
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
174
lib/screens/collections/add_to_collection.dart
Normal file
174
lib/screens/collections/add_to_collection.dart
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/providers/collections_provider.dart';
|
||||
import 'package:fladder/screens/shared/adaptive_dialog.dart';
|
||||
import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/screens/shared/outlined_text_field.dart';
|
||||
|
||||
Future<void> addItemToCollection(BuildContext context, List<ItemBaseModel> item) {
|
||||
return showDialogAdaptive(
|
||||
context: context,
|
||||
builder: (context) => AddToCollection(
|
||||
items: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class AddToCollection extends ConsumerStatefulWidget {
|
||||
final List<ItemBaseModel> items;
|
||||
const AddToCollection({required this.items, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _AddToCollectionState();
|
||||
}
|
||||
|
||||
class _AddToCollectionState extends ConsumerState<AddToCollection> {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
late final provider = collectionsProvider;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() => ref.read(provider.notifier).setItems(widget.items));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final collectonOptions = ref.watch(provider);
|
||||
return Card(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(height: MediaQuery.paddingOf(context).top),
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (widget.items.length == 1)
|
||||
Text(
|
||||
'Add to collection',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'Add ${widget.items.length} item(s) to collection',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => ref.read(provider.notifier).setItems(widget.items),
|
||||
icon: const Icon(IconsaxOutline.refresh),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.items.length == 1) ItemBottomSheetPreview(item: widget.items.first),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: OutlinedTextField(
|
||||
label: 'New collection',
|
||||
controller: controller,
|
||||
onChanged: (value) => setState(() {}),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
IconButton(
|
||||
onPressed: controller.text.isNotEmpty
|
||||
? () async {
|
||||
await ref.read(provider.notifier).addToNewCollection(
|
||||
name: controller.text,
|
||||
);
|
||||
setState(() => controller.text = '');
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.add_rounded)),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
...collectonOptions.collections.entries.map(
|
||||
(e) {
|
||||
if (e.value != null) {
|
||||
return CheckboxListTile.adaptive(
|
||||
title: Text(e.key.name),
|
||||
value: e.value,
|
||||
onChanged: (value) async {
|
||||
final response = await ref
|
||||
.read(provider.notifier)
|
||||
.toggleCollection(boxSet: e.key, value: value == true, item: widget.items.first);
|
||||
if (context.mounted) {
|
||||
fladderSnackbar(context,
|
||||
title: response.isSuccessful
|
||||
? "${value == true ? "Added to" : "Removed from"} ${e.key.name} collection"
|
||||
: 'Unable to ${value == true ? "add to" : "remove from"} ${e.key.name} collection - (${response.statusCode}) - ${response.base.reasonPhrase}');
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return ListTile(
|
||||
title: Text(e.key.name),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final response =
|
||||
await ref.read(provider.notifier).addToCollection(boxSet: e.key, add: true);
|
||||
if (context.mounted) {
|
||||
fladderSnackbar(context,
|
||||
title: response.isSuccessful
|
||||
? "Added to ${e.key.name} collection"
|
||||
: 'Unable to add to ${e.key.name} collection - (${response.statusCode}) - ${response.base.reasonPhrase}');
|
||||
}
|
||||
},
|
||||
child: Icon(Icons.add_rounded, color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(context.localized.close),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
203
lib/screens/dashboard/dashboard_screen.dart
Normal file
203
lib/screens/dashboard/dashboard_screen.dart
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fladder/jellyfin/jellyfin_open_api.enums.swagger.dart';
|
||||
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
|
||||
import 'package:fladder/models/library_search/library_search_options.dart';
|
||||
import 'package:fladder/models/settings/home_settings_model.dart';
|
||||
import 'package:fladder/providers/dashboard_provider.dart';
|
||||
import 'package:fladder/providers/settings/client_settings_provider.dart';
|
||||
import 'package:fladder/providers/settings/home_settings_provider.dart';
|
||||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/providers/views_provider.dart';
|
||||
import 'package:fladder/routes/build_routes/home_routes.dart';
|
||||
import 'package:fladder/routes/build_routes/route_builder.dart';
|
||||
import 'package:fladder/screens/shared/media/carousel_banner.dart';
|
||||
import 'package:fladder/screens/shared/media/poster_row.dart';
|
||||
import 'package:fladder/screens/shared/nested_scaffold.dart';
|
||||
import 'package:fladder/screens/shared/nested_sliver_appbar.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/sliver_list_padding.dart';
|
||||
import 'package:fladder/widgets/shared/pinch_poster_zoom.dart';
|
||||
import 'package:fladder/widgets/shared/poster_size_slider.dart';
|
||||
import 'package:fladder/widgets/shared/pull_to_refresh.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class DashboardScreen extends ConsumerStatefulWidget {
|
||||
final ScrollController navigationScrollController;
|
||||
const DashboardScreen({
|
||||
required this.navigationScrollController,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _DashboardScreenState();
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
late final Timer _timer;
|
||||
final GlobalKey<RefreshIndicatorState> _refreshIndicatorKey = GlobalKey<RefreshIndicatorState>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(const Duration(seconds: 120), (timer) {
|
||||
_refreshIndicatorKey.currentState?.show();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _refreshHome() async {
|
||||
if (mounted) {
|
||||
await ref.read(userProvider.notifier).updateInformation();
|
||||
await ref.read(viewsProvider.notifier).fetchViews();
|
||||
await ref.read(dashboardProvider.notifier).fetchNextUpAndResume();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dashboardData = ref.watch(dashboardProvider);
|
||||
final views = ref.watch(viewsProvider);
|
||||
final homeSettings = ref.watch(homeSettingsProvider);
|
||||
final resumeVideo = dashboardData.resumeVideo;
|
||||
final resumeAudio = dashboardData.resumeAudio;
|
||||
final resumeBooks = dashboardData.resumeBooks;
|
||||
|
||||
final allResume = [...resumeVideo, ...resumeAudio, ...resumeBooks].toList();
|
||||
|
||||
final homeCarouselItems = switch (homeSettings.carouselSettings) {
|
||||
HomeCarouselSettings.nextUp => dashboardData.nextUp,
|
||||
HomeCarouselSettings.combined => [...allResume, ...dashboardData.nextUp],
|
||||
HomeCarouselSettings.cont => allResume,
|
||||
_ => [...allResume, ...dashboardData.nextUp],
|
||||
};
|
||||
|
||||
return MediaQuery.removeViewInsets(
|
||||
context: context,
|
||||
child: NestedScaffold(
|
||||
body: PullToRefresh(
|
||||
refreshKey: _refreshIndicatorKey,
|
||||
displacement: 80 + MediaQuery.of(context).viewPadding.top,
|
||||
onRefresh: () async => await _refreshHome(),
|
||||
child: PinchPosterZoom(
|
||||
scaleDifference: (difference) => ref.read(clientSettingsProvider.notifier).addPosterSize(difference),
|
||||
child: CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
controller: widget.navigationScrollController,
|
||||
slivers: [
|
||||
if (AdaptiveLayout.of(context).layout == LayoutState.phone)
|
||||
NestedSliverAppBar(
|
||||
route: LibrarySearchRoute(),
|
||||
parent: context,
|
||||
),
|
||||
if (homeSettings.carouselSettings != HomeCarouselSettings.off && homeCarouselItems.isNotEmpty) ...{
|
||||
SliverToBoxAdapter(
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, AdaptiveLayout.layoutOf(context) == LayoutState.phone ? -14 : 0),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: AdaptiveLayout.of(context).isDesktop ? 350 : 275,
|
||||
maxHeight: (MediaQuery.sizeOf(context).height * 0.25).clamp(400, double.infinity)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1.6,
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: CarouselBanner(
|
||||
items: homeCarouselItems,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
} else if (AdaptiveLayout.of(context).isDesktop)
|
||||
DefaultSliverTopBadding(),
|
||||
if (AdaptiveLayout.of(context).isDesktop)
|
||||
SliverToBoxAdapter(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
PosterSizeWidget(),
|
||||
],
|
||||
),
|
||||
),
|
||||
...[
|
||||
if (resumeVideo.isNotEmpty &&
|
||||
(homeSettings.nextUp == HomeNextUp.cont || homeSettings.nextUp == HomeNextUp.separate))
|
||||
SliverToBoxAdapter(
|
||||
child: PosterRow(
|
||||
label: context.localized.dashboardContinueWatching,
|
||||
posters: resumeVideo,
|
||||
),
|
||||
),
|
||||
if (resumeAudio.isNotEmpty &&
|
||||
(homeSettings.nextUp == HomeNextUp.cont || homeSettings.nextUp == HomeNextUp.separate))
|
||||
SliverToBoxAdapter(
|
||||
child: PosterRow(
|
||||
label: context.localized.dashboardContinueListening,
|
||||
posters: resumeAudio,
|
||||
),
|
||||
),
|
||||
if (resumeBooks.isNotEmpty &&
|
||||
(homeSettings.nextUp == HomeNextUp.cont || homeSettings.nextUp == HomeNextUp.separate))
|
||||
SliverToBoxAdapter(
|
||||
child: PosterRow(
|
||||
label: context.localized.dashboardContinueReading,
|
||||
posters: resumeBooks,
|
||||
),
|
||||
),
|
||||
if (dashboardData.nextUp.isNotEmpty &&
|
||||
(homeSettings.nextUp == HomeNextUp.nextUp || homeSettings.nextUp == HomeNextUp.separate))
|
||||
SliverToBoxAdapter(
|
||||
child: PosterRow(
|
||||
label: context.localized.dashboardNextUp,
|
||||
posters: dashboardData.nextUp,
|
||||
),
|
||||
),
|
||||
if ([...allResume, ...dashboardData.nextUp].isNotEmpty && homeSettings.nextUp == HomeNextUp.combined)
|
||||
SliverToBoxAdapter(
|
||||
child: PosterRow(
|
||||
label: context.localized.dashboardContinue,
|
||||
posters: [...allResume, ...dashboardData.nextUp],
|
||||
),
|
||||
),
|
||||
...views.dashboardViews
|
||||
.where((element) => element.recentlyAdded.isNotEmpty)
|
||||
.map((view) => SliverToBoxAdapter(
|
||||
child: PosterRow(
|
||||
label: context.localized.dashboardRecentlyAdded(view.name),
|
||||
onLabelClick: () => context.routePushOrGo(LibrarySearchRoute(
|
||||
id: view.id,
|
||||
sortOptions: switch (view.collectionType) {
|
||||
CollectionType.tvshows ||
|
||||
CollectionType.books ||
|
||||
CollectionType.boxsets ||
|
||||
CollectionType.folders ||
|
||||
CollectionType.music =>
|
||||
SortingOptions.dateLastContentAdded,
|
||||
_ => SortingOptions.dateAdded,
|
||||
},
|
||||
sortOrder: SortOrder.descending,
|
||||
)),
|
||||
posters: view.recentlyAdded,
|
||||
),
|
||||
)),
|
||||
].whereNotNull().toList().addInBetween(SliverToBoxAdapter(child: SizedBox(height: 16))),
|
||||
const DefautlSliverBottomPadding(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
228
lib/screens/details_screens/book_detail_screen.dart
Normal file
228
lib/screens/details_screens/book_detail_screen.dart
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/book_model.dart';
|
||||
import 'package:fladder/providers/items/book_details_provider.dart';
|
||||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/screens/details_screens/components/overview_header.dart';
|
||||
import 'package:fladder/screens/shared/detail_scaffold.dart';
|
||||
import 'package:fladder/screens/shared/media/components/media_play_button.dart';
|
||||
import 'package:fladder/screens/shared/media/expanding_overview.dart';
|
||||
import 'package:fladder/screens/shared/media/poster_list_item.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||
import 'package:fladder/util/item_base_model/play_item_helpers.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/widget_extensions.dart';
|
||||
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
|
||||
import 'package:fladder/widgets/shared/selectable_icon_button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class BookDetailScreen extends ConsumerStatefulWidget {
|
||||
final BookModel item;
|
||||
const BookDetailScreen({required this.item, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _BookDetailScreenState();
|
||||
}
|
||||
|
||||
class _BookDetailScreenState extends ConsumerState<BookDetailScreen> {
|
||||
late final provider = bookDetailsProvider(widget.item.id);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final details = ref.watch(provider);
|
||||
return DetailScaffold(
|
||||
label: widget.item.name,
|
||||
item: details.book,
|
||||
actions: (context) => details.book?.generateActions(
|
||||
context,
|
||||
ref,
|
||||
exclude: {
|
||||
ItemActions.play,
|
||||
ItemActions.playFromStart,
|
||||
ItemActions.details,
|
||||
},
|
||||
onDeleteSuccesFully: (item) {
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
backgroundColor: Theme.of(context).colorScheme.surface.withOpacity(0.8),
|
||||
onRefresh: () async => await ref.read(provider.notifier).fetchDetails(widget.item),
|
||||
backDrops: details.cover,
|
||||
content: (padding) => details.book != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(bottom: 64),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(height: MediaQuery.of(context).size.height * 0.2),
|
||||
if (MediaQuery.sizeOf(context).width < 500)
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.75),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 0.76,
|
||||
child: Card(
|
||||
child: FladderImage(image: details.cover?.primary),
|
||||
),
|
||||
),
|
||||
).padding(padding),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
if (MediaQuery.sizeOf(context).width > 500) ...{
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.sizeOf(context).width * 0.3,
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.75),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 0.76,
|
||||
child: Card(
|
||||
child: FladderImage(image: details.cover?.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
},
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (details.nextUp != null)
|
||||
OverviewHeader(
|
||||
subTitle: details.book!.parentName ?? details.parentModel?.name,
|
||||
name: details.nextUp!.name,
|
||||
productionYear: details.nextUp!.overview.productionYear,
|
||||
runTime: details.nextUp!.overview.runTime,
|
||||
genres: details.nextUp!.overview.genreItems,
|
||||
studios: details.nextUp!.overview.studios,
|
||||
officialRating: details.nextUp!.overview.parentalRating,
|
||||
communityRating: details.nextUp!.overview.communityRating,
|
||||
externalUrls: details.nextUp!.overview.externalUrls,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
//Wrapped so the correct context is used for refreshing the pages
|
||||
Builder(
|
||||
builder: (context) {
|
||||
return MediaPlayButton(
|
||||
item: details.nextUp!,
|
||||
onPressed: () async => details.nextUp.play(context, ref, provider: provider));
|
||||
},
|
||||
),
|
||||
if (details.parentModel != null)
|
||||
SelectableIconButton(
|
||||
onPressed: () async => await details.parentModel?.navigateTo(context),
|
||||
selected: false,
|
||||
selectedIcon: IconsaxBold.book,
|
||||
icon: IconsaxOutline.book,
|
||||
),
|
||||
if (details.parentModel != null)
|
||||
SelectableIconButton(
|
||||
onPressed: () async => await ref.read(userProvider.notifier).setAsFavorite(
|
||||
!details.parentModel!.userData.isFavourite, details.parentModel!.id),
|
||||
selected: details.parentModel!.userData.isFavourite,
|
||||
selectedIcon: IconsaxBold.heart,
|
||||
icon: IconsaxOutline.heart,
|
||||
)
|
||||
else
|
||||
SelectableIconButton(
|
||||
onPressed: () async => await ref
|
||||
.read(userProvider.notifier)
|
||||
.setAsFavorite(!details.book!.userData.isFavourite, details.book!.id),
|
||||
selected: details.book!.userData.isFavourite,
|
||||
selectedIcon: IconsaxBold.heart,
|
||||
icon: IconsaxOutline.heart,
|
||||
),
|
||||
|
||||
//This one toggles all books in a collection
|
||||
Builder(builder: (context) {
|
||||
return Tooltip(
|
||||
message: "Mark all chapters as read",
|
||||
child: SelectableIconButton(
|
||||
onPressed: () async => await Future.forEach(
|
||||
details.allBooks,
|
||||
(element) async => await ref
|
||||
.read(userProvider.notifier)
|
||||
.markAsPlayed(!details.collectionPlayed, element.id)),
|
||||
selected: details.collectionPlayed,
|
||||
selectedIcon: Icons.check_circle_rounded,
|
||||
icon: Icons.check_circle_outline_rounded,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(padding),
|
||||
if (details.nextUp!.overview.summary.isNotEmpty == true)
|
||||
ExpandingOverview(
|
||||
text: details.nextUp!.overview.summary,
|
||||
).padding(padding),
|
||||
if (details.chapters.length > 1)
|
||||
Builder(builder: (context) {
|
||||
final parentContext = context;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.localized.chapter(details.chapters.length),
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Divider(),
|
||||
),
|
||||
...details.chapters.map(
|
||||
(e) {
|
||||
final current = e == details.nextUp;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Opacity(
|
||||
opacity: e.userData.played ? 0.65 : 1,
|
||||
child: Card(
|
||||
color: current ? Theme.of(context).colorScheme.surfaceContainerHighest : null,
|
||||
child: PosterListItem(
|
||||
poster: e,
|
||||
onPressed: (action, item) => showBottomSheetPill(
|
||||
context: context,
|
||||
item: item,
|
||||
content: (context, scrollController) => ListView(
|
||||
shrinkWrap: true,
|
||||
controller: scrollController,
|
||||
children: item
|
||||
.generateActions(
|
||||
parentContext,
|
||||
ref,
|
||||
)
|
||||
.listTileItems(context, useIcons: true),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
).padding(padding);
|
||||
})
|
||||
].addPadding(const EdgeInsets.symmetric(vertical: 16)),
|
||||
),
|
||||
)
|
||||
: Container(),
|
||||
);
|
||||
}
|
||||
}
|
||||
39
lib/screens/details_screens/components/label_title_item.dart
Normal file
39
lib/screens/details_screens/components/label_title_item.dart
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class LabelTitleItem extends ConsumerWidget {
|
||||
final Text? title;
|
||||
final String? label;
|
||||
final Widget? content;
|
||||
const LabelTitleItem({
|
||||
this.title,
|
||||
this.label,
|
||||
this.content,
|
||||
super.key,
|
||||
}) : assert(label != null || content != null);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
textStyle: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: 0.6,
|
||||
child: Material(
|
||||
color: Colors.transparent, textStyle: Theme.of(context).textTheme.titleMedium, child: title)),
|
||||
const SizedBox(width: 12),
|
||||
label != null
|
||||
? SelectableText(
|
||||
label!,
|
||||
)
|
||||
: content!,
|
||||
].whereNotNull().toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:fladder/models/items/media_streams_model.dart';
|
||||
import 'package:fladder/screens/details_screens/components/label_title_item.dart';
|
||||
|
||||
class MediaStreamInformation extends ConsumerWidget {
|
||||
final MediaStreamsModel mediaStream;
|
||||
final Function(int index)? onAudioIndexChanged;
|
||||
final Function(int index)? onSubIndexChanged;
|
||||
const MediaStreamInformation(
|
||||
{required this.mediaStream, this.onAudioIndexChanged, this.onSubIndexChanged, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (mediaStream.videoStreams.isNotEmpty)
|
||||
_StreamOptionSelect(
|
||||
label: Text(context.localized.video),
|
||||
current: (mediaStream.videoStreams.first).prettyName,
|
||||
itemBuilder: (context) => mediaStream.videoStreams
|
||||
.map(
|
||||
(e) => PopupMenuItem(
|
||||
value: e,
|
||||
child: Text(e.prettyName),
|
||||
onTap: () {},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
if (mediaStream.audioStreams.isNotEmpty)
|
||||
_StreamOptionSelect(
|
||||
label: Text(context.localized.audio),
|
||||
current: mediaStream.currentAudioStream?.displayTitle ?? "",
|
||||
itemBuilder: (context) => mediaStream.audioStreams
|
||||
.map(
|
||||
(e) => PopupMenuItem(
|
||||
value: e,
|
||||
padding: EdgeInsets.zero,
|
||||
child: textWidget(context, selected: mediaStream.currentAudioStream == e, label: e.displayTitle),
|
||||
onTap: () => onAudioIndexChanged?.call(e.index),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
if (mediaStream.subStreams.isNotEmpty)
|
||||
_StreamOptionSelect(
|
||||
label: Text(context.localized.subtitles),
|
||||
current: mediaStream.currentSubStream?.displayTitle ?? "",
|
||||
itemBuilder: (context) => [SubStreamModel.no(), ...mediaStream.subStreams]
|
||||
.map(
|
||||
(e) => PopupMenuItem(
|
||||
value: e,
|
||||
padding: EdgeInsets.zero,
|
||||
child: textWidget(context, selected: mediaStream.currentSubStream == e, label: e.displayTitle),
|
||||
onTap: () => onSubIndexChanged?.call(e.index),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget textWidget(BuildContext context, {required bool selected, required String label}) {
|
||||
return Container(
|
||||
height: kMinInteractiveDimension,
|
||||
width: double.maxFinite,
|
||||
color: selected ? Theme.of(context).colorScheme.primary : null,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: selected ? Theme.of(context).colorScheme.onPrimary : null,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StreamOptionSelect<T> extends StatelessWidget {
|
||||
final Text label;
|
||||
final String current;
|
||||
final List<PopupMenuEntry<T>> Function(BuildContext context) itemBuilder;
|
||||
const _StreamOptionSelect({
|
||||
required this.label,
|
||||
required this.current,
|
||||
required this.itemBuilder,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textStyle = Theme.of(context).textTheme.titleMedium;
|
||||
const padding = EdgeInsets.all(6.0);
|
||||
final itemList = itemBuilder(context);
|
||||
return LabelTitleItem(
|
||||
title: label,
|
||||
content: Flexible(
|
||||
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.primary : 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.primary,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
165
lib/screens/details_screens/components/overview_header.dart
Normal file
165
lib/screens/details_screens/components/overview_header.dart
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import 'package:fladder/models/items/item_shared_models.dart';
|
||||
import 'package:fladder/screens/shared/media/components/small_detail_widgets.dart';
|
||||
import 'package:fladder/screens/shared/media/external_urls.dart';
|
||||
import 'package:fladder/util/humanize_duration.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class OverviewHeader extends ConsumerWidget {
|
||||
final String name;
|
||||
final EdgeInsets? padding;
|
||||
final String? subTitle;
|
||||
final String? originalTitle;
|
||||
final Function()? onTitleClicked;
|
||||
final int? productionYear;
|
||||
final Duration? runTime;
|
||||
final String? officialRating;
|
||||
final double? communityRating;
|
||||
final List<Studio> studios;
|
||||
final List<GenreItems> genres;
|
||||
final List<ExternalUrls>? externalUrls;
|
||||
final List<Widget> actions;
|
||||
const OverviewHeader({
|
||||
required this.name,
|
||||
this.padding,
|
||||
this.subTitle,
|
||||
this.originalTitle,
|
||||
this.onTitleClicked,
|
||||
this.productionYear,
|
||||
this.runTime,
|
||||
this.officialRating,
|
||||
this.communityRating,
|
||||
this.externalUrls,
|
||||
this.genres = const [],
|
||||
this.studios = const [],
|
||||
this.actions = const [],
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mainStyle = Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
);
|
||||
final subStyle = Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontSize: 20,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: padding ?? EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 32),
|
||||
if (subTitle == null)
|
||||
Flexible(
|
||||
child: SelectableText(
|
||||
name,
|
||||
style: mainStyle,
|
||||
),
|
||||
)
|
||||
else ...{
|
||||
Flexible(
|
||||
child: SelectableText(
|
||||
subTitle ?? "",
|
||||
style: mainStyle,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Opacity(
|
||||
opacity: 0.75,
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: SelectableText(
|
||||
name,
|
||||
style: subStyle,
|
||||
onTap: onTitleClicked,
|
||||
),
|
||||
),
|
||||
if (onTitleClicked != null)
|
||||
IconButton(
|
||||
onPressed: onTitleClicked,
|
||||
icon: Transform.translate(offset: Offset(0, 1.5), child: Icon(Icons.read_more_rounded)))
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
if (name != originalTitle && originalTitle != null)
|
||||
SelectableText(
|
||||
originalTitle.toString(),
|
||||
style: subStyle,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.start,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
if (productionYear != null)
|
||||
SelectableText(
|
||||
productionYear.toString(),
|
||||
style: subStyle,
|
||||
),
|
||||
if (runTime != null && (runTime?.inSeconds ?? 0) > 1)
|
||||
SelectableText(
|
||||
runTime.humanize.toString(),
|
||||
style: subStyle,
|
||||
),
|
||||
if (officialRating != null)
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 8),
|
||||
child: SelectableText(
|
||||
officialRating.toString(),
|
||||
style: subStyle,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (communityRating != null)
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.star_rate_rounded,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
Text(
|
||||
communityRating?.toStringAsFixed(1) ?? "",
|
||||
style: subStyle,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
if (studios.isNotEmpty)
|
||||
Text(
|
||||
"${context.localized.watchOn} ${studios.map((e) => e.name).first}",
|
||||
style: subStyle?.copyWith(fontSize: 16, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
if (externalUrls?.isNotEmpty ?? false)
|
||||
ExternalUrlsRow(
|
||||
urls: externalUrls,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
if (genres.isNotEmpty)
|
||||
Genres(
|
||||
genres: genres.take(10).toList(),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: actions.addPadding(
|
||||
const EdgeInsets.symmetric(horizontal: 6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
4
lib/screens/details_screens/details_screens.dart
Normal file
4
lib/screens/details_screens/details_screens.dart
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export 'movie_detail_screen.dart';
|
||||
export 'series_detail_screen.dart';
|
||||
export 'person_detail_screen.dart';
|
||||
export 'empty_item.dart';
|
||||
19
lib/screens/details_screens/empty_item.dart
Normal file
19
lib/screens/details_screens/empty_item.dart
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/screens/shared/detail_scaffold.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class EmptyItem extends ConsumerWidget {
|
||||
final ItemBaseModel item;
|
||||
const EmptyItem({required this.item, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return DetailScaffold(
|
||||
label: "Empty",
|
||||
content: (padding) =>
|
||||
Center(child: Text("Type of (Jelly.${item.jellyType?.name.capitalize()}) has not been implemented yet.")),
|
||||
);
|
||||
}
|
||||
}
|
||||
176
lib/screens/details_screens/episode_detail_screen.dart
Normal file
176
lib/screens/details_screens/episode_detail_screen.dart
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/screens/details_screens/components/overview_header.dart';
|
||||
import 'package:fladder/screens/shared/media/components/media_play_button.dart';
|
||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||
import 'package:fladder/util/item_base_model/play_item_helpers.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/providers/items/episode_details_provider.dart';
|
||||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/screens/details_screens/components/media_stream_information.dart';
|
||||
import 'package:fladder/screens/shared/detail_scaffold.dart';
|
||||
import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
||||
import 'package:fladder/screens/shared/media/chapter_row.dart';
|
||||
import 'package:fladder/screens/shared/media/components/media_header.dart';
|
||||
import 'package:fladder/screens/shared/media/episode_posters.dart';
|
||||
import 'package:fladder/screens/shared/media/expanding_overview.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/widget_extensions.dart';
|
||||
import 'package:fladder/widgets/shared/selectable_icon_button.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class EpisodeDetailScreen extends ConsumerStatefulWidget {
|
||||
final ItemBaseModel item;
|
||||
const EpisodeDetailScreen({required this.item, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _ItemDetailScreenState();
|
||||
}
|
||||
|
||||
class _ItemDetailScreenState extends ConsumerState<EpisodeDetailScreen> {
|
||||
late final providerInstance = episodeDetailsProvider(widget.item.id);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final details = ref.watch(providerInstance);
|
||||
final seasonDetails = details.series;
|
||||
final episodeDetails = details.episode;
|
||||
|
||||
return DetailScaffold(
|
||||
label: widget.item.name,
|
||||
item: details.episode,
|
||||
actions: (context) => details.episode?.generateActions(
|
||||
context,
|
||||
ref,
|
||||
exclude: {
|
||||
if (details.series == null) ItemActions.openShow,
|
||||
ItemActions.details,
|
||||
},
|
||||
onDeleteSuccesFully: (item) {
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
onRefresh: () async => await ref.read(providerInstance.notifier).fetchDetails(widget.item),
|
||||
backDrops: details.episode?.images ?? details.series?.images,
|
||||
content: (padding) => seasonDetails != null && episodeDetails != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(bottom: 64),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(height: MediaQuery.of(context).size.height * 0.35),
|
||||
MediaHeader(
|
||||
name: details.series?.name ?? "",
|
||||
logo: seasonDetails.images?.logo,
|
||||
),
|
||||
OverviewHeader(
|
||||
name: details.series?.name ?? "",
|
||||
padding: padding,
|
||||
subTitle: details.episode?.name,
|
||||
originalTitle: details.series?.originalTitle,
|
||||
onTitleClicked: () => details.series?.navigateTo(context),
|
||||
productionYear: details.series?.overview.productionYear,
|
||||
runTime: details.episode?.overview.runTime,
|
||||
studios: details.series?.overview.studios ?? [],
|
||||
genres: details.series?.overview.genreItems ?? [],
|
||||
officialRating: details.series?.overview.parentalRating,
|
||||
communityRating: details.series?.overview.communityRating,
|
||||
externalUrls: details.series?.overview.externalUrls,
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
if (episodeDetails.playAble)
|
||||
MediaPlayButton(
|
||||
item: episodeDetails,
|
||||
onPressed: () async {
|
||||
await details.episode.play(context, ref);
|
||||
ref.read(providerInstance.notifier).fetchDetails(widget.item);
|
||||
},
|
||||
onLongPressed: () async {
|
||||
await details.episode.play(context, ref, showPlaybackOption: true);
|
||||
ref.read(providerInstance.notifier).fetchDetails(widget.item);
|
||||
},
|
||||
),
|
||||
SelectableIconButton(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(userProvider.notifier)
|
||||
.setAsFavorite(!(episodeDetails.userData.isFavourite), episodeDetails.id);
|
||||
},
|
||||
selected: episodeDetails.userData.isFavourite,
|
||||
selectedIcon: IconsaxBold.heart,
|
||||
icon: IconsaxOutline.heart,
|
||||
),
|
||||
SelectableIconButton(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(userProvider.notifier)
|
||||
.markAsPlayed(!(episodeDetails.userData.played), episodeDetails.id);
|
||||
},
|
||||
selected: episodeDetails.userData.played,
|
||||
selectedIcon: IconsaxBold.tick_circle,
|
||||
icon: IconsaxOutline.tick_circle,
|
||||
),
|
||||
].addPadding(const EdgeInsets.symmetric(horizontal: 6)),
|
||||
).padding(padding),
|
||||
if (details.episode?.mediaStreams != null)
|
||||
Padding(
|
||||
padding: padding,
|
||||
child: MediaStreamInformation(
|
||||
mediaStream: details.episode!.mediaStreams,
|
||||
onSubIndexChanged: (index) {
|
||||
ref.read(providerInstance.notifier).setSubIndex(index);
|
||||
},
|
||||
onAudioIndexChanged: (index) {
|
||||
ref.read(providerInstance.notifier).setAudioIndex(index);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (episodeDetails.overview.summary.isNotEmpty == true)
|
||||
ExpandingOverview(
|
||||
text: episodeDetails.overview.summary,
|
||||
).padding(padding),
|
||||
if (episodeDetails.chapters.isNotEmpty)
|
||||
ChapterRow(
|
||||
chapters: episodeDetails.chapters,
|
||||
contentPadding: padding,
|
||||
onPressed: (chapter) async {
|
||||
await details.episode?.play(context, ref, startPosition: chapter.startPosition);
|
||||
ref.read(providerInstance.notifier).fetchDetails(widget.item);
|
||||
},
|
||||
),
|
||||
if (details.episodes.length > 1)
|
||||
EpisodePosters(
|
||||
contentPadding: padding,
|
||||
label: context.localized
|
||||
.moreFrom("${context.localized.season(1).toLowerCase()} ${episodeDetails.season}"),
|
||||
onEpisodeTap: (action, episodeModel) {
|
||||
if (episodeModel.id == episodeDetails.id) {
|
||||
fladderSnackbar(context, title: context.localized.selectedWith(context.localized.episode(0)));
|
||||
} else {
|
||||
action();
|
||||
}
|
||||
},
|
||||
playEpisode: (episode) => episode.play(
|
||||
context,
|
||||
ref,
|
||||
),
|
||||
episodes: details.episodes.where((element) => element.season == episodeDetails.season).toList(),
|
||||
),
|
||||
].addPadding(const EdgeInsets.symmetric(vertical: 16)),
|
||||
),
|
||||
)
|
||||
: Container(),
|
||||
);
|
||||
}
|
||||
}
|
||||
59
lib/screens/details_screens/folder_detail_screen.dart
Normal file
59
lib/screens/details_screens/folder_detail_screen.dart
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/models/items/photos_model.dart';
|
||||
import 'package:fladder/providers/items/folder_details_provider.dart';
|
||||
import 'package:fladder/screens/photo_viewer/photo_viewer_screen.dart';
|
||||
import 'package:fladder/screens/shared/media/poster_grid.dart';
|
||||
import 'package:fladder/widgets/shared/pull_to_refresh.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:page_transition/page_transition.dart';
|
||||
|
||||
class FolderDetailScreen extends ConsumerWidget {
|
||||
final ItemBaseModel item;
|
||||
const FolderDetailScreen({required this.item, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final providerInstance = folderDetailsProvider(item.id);
|
||||
final details = ref.watch(providerInstance);
|
||||
|
||||
return PullToRefresh(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
details?.name ?? "",
|
||||
)),
|
||||
body: ListView(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: PosterGrid(
|
||||
posters: details?.items ?? [],
|
||||
onPressed: (action, item) async {
|
||||
switch (item) {
|
||||
case PhotoModel photoModel:
|
||||
final photoItems = details?.items.whereType<PhotoModel>().toList();
|
||||
await Navigator.of(context, rootNavigator: true).push(PageTransition(
|
||||
child: PhotoViewerScreen(
|
||||
items: photoItems,
|
||||
indexOfSelected: photoItems?.indexOf(photoModel) ?? 0,
|
||||
),
|
||||
type: PageTransitionType.fade));
|
||||
break;
|
||||
default:
|
||||
if (context.mounted) {
|
||||
await item.navigateTo(context);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
onRefresh: () async {
|
||||
await ref.read(providerInstance.notifier).fetchDetails(item.id);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
164
lib/screens/details_screens/movie_detail_screen.dart
Normal file
164
lib/screens/details_screens/movie_detail_screen.dart
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/providers/items/movies_details_provider.dart';
|
||||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/screens/details_screens/components/overview_header.dart';
|
||||
import 'package:fladder/screens/details_screens/components/media_stream_information.dart';
|
||||
import 'package:fladder/screens/shared/media/components/media_header.dart';
|
||||
import 'package:fladder/screens/shared/detail_scaffold.dart';
|
||||
import 'package:fladder/screens/shared/media/chapter_row.dart';
|
||||
import 'package:fladder/screens/shared/media/components/media_play_button.dart';
|
||||
import 'package:fladder/screens/shared/media/expanding_overview.dart';
|
||||
import 'package:fladder/screens/shared/media/people_row.dart';
|
||||
import 'package:fladder/screens/shared/media/poster_row.dart';
|
||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||
import 'package:fladder/util/item_base_model/play_item_helpers.dart';
|
||||
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/widget_extensions.dart';
|
||||
import 'package:fladder/widgets/shared/selectable_icon_button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class MovieDetailScreen extends ConsumerStatefulWidget {
|
||||
final ItemBaseModel item;
|
||||
const MovieDetailScreen({required this.item, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _ItemDetailScreenState();
|
||||
}
|
||||
|
||||
class _ItemDetailScreenState extends ConsumerState<MovieDetailScreen> {
|
||||
late final providerInstance = movieDetailsProvider(widget.item.id);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final details = ref.watch(providerInstance);
|
||||
|
||||
return DetailScaffold(
|
||||
label: widget.item.name,
|
||||
item: details,
|
||||
actions: (context) => details?.generateActions(
|
||||
context,
|
||||
ref,
|
||||
exclude: {
|
||||
ItemActions.play,
|
||||
ItemActions.playFromStart,
|
||||
ItemActions.details,
|
||||
},
|
||||
onDeleteSuccesFully: (item) {
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
onRefresh: () async => await ref.read(providerInstance.notifier).fetchDetails(widget.item),
|
||||
backDrops: details?.images,
|
||||
content: (padding) => details != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(bottom: 64),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(height: MediaQuery.of(context).size.height * 0.25),
|
||||
MediaHeader(
|
||||
name: details.name,
|
||||
logo: details.images?.logo,
|
||||
),
|
||||
OverviewHeader(
|
||||
name: details.name,
|
||||
padding: padding,
|
||||
originalTitle: details.originalTitle,
|
||||
productionYear: details.overview.productionYear,
|
||||
runTime: details.overview.runTime,
|
||||
genres: details.overview.genreItems,
|
||||
studios: details.overview.studios,
|
||||
officialRating: details.overview.parentalRating,
|
||||
communityRating: details.overview.communityRating,
|
||||
externalUrls: details.overview.externalUrls,
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
MediaPlayButton(
|
||||
item: details,
|
||||
onLongPressed: () async {
|
||||
await details.play(
|
||||
context,
|
||||
ref,
|
||||
showPlaybackOption: true,
|
||||
);
|
||||
ref.read(providerInstance.notifier).fetchDetails(widget.item);
|
||||
},
|
||||
onPressed: () async {
|
||||
await details.play(
|
||||
context,
|
||||
ref,
|
||||
);
|
||||
ref.read(providerInstance.notifier).fetchDetails(widget.item);
|
||||
},
|
||||
),
|
||||
SelectableIconButton(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(userProvider.notifier)
|
||||
.setAsFavorite(!details.userData.isFavourite, details.id);
|
||||
},
|
||||
selected: details.userData.isFavourite,
|
||||
selectedIcon: IconsaxBold.heart,
|
||||
icon: IconsaxOutline.heart,
|
||||
),
|
||||
SelectableIconButton(
|
||||
onPressed: () async {
|
||||
await ref.read(userProvider.notifier).markAsPlayed(!details.userData.played, details.id);
|
||||
},
|
||||
selected: details.userData.played,
|
||||
selectedIcon: IconsaxBold.tick_circle,
|
||||
icon: IconsaxOutline.tick_circle,
|
||||
),
|
||||
],
|
||||
).padding(padding),
|
||||
if (details.mediaStreams.isNotEmpty)
|
||||
MediaStreamInformation(
|
||||
onSubIndexChanged: (index) {
|
||||
ref.read(providerInstance.notifier).setSubIndex(index);
|
||||
},
|
||||
onAudioIndexChanged: (index) {
|
||||
ref.read(providerInstance.notifier).setAudioIndex(index);
|
||||
},
|
||||
mediaStream: details.mediaStreams,
|
||||
).padding(padding),
|
||||
if (details.overview.summary.isNotEmpty == true)
|
||||
ExpandingOverview(
|
||||
text: details.overview.summary,
|
||||
).padding(padding),
|
||||
if (details.chapters.isNotEmpty)
|
||||
ChapterRow(
|
||||
chapters: details.chapters,
|
||||
contentPadding: padding,
|
||||
onPressed: (chapter) {
|
||||
details.play(
|
||||
context,
|
||||
ref,
|
||||
startPosition: chapter.startPosition,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (details.overview.people.isNotEmpty)
|
||||
PeopleRow(
|
||||
people: details.overview.people,
|
||||
contentPadding: padding,
|
||||
),
|
||||
if (details.related.isNotEmpty)
|
||||
PosterRow(posters: details.related, contentPadding: padding, label: "Related"),
|
||||
].addPadding(const EdgeInsets.symmetric(vertical: 16)),
|
||||
),
|
||||
)
|
||||
: Container(),
|
||||
);
|
||||
}
|
||||
}
|
||||
127
lib/screens/details_screens/person_detail_screen.dart
Normal file
127
lib/screens/details_screens/person_detail_screen.dart
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:fladder/models/items/item_shared_models.dart';
|
||||
import 'package:fladder/providers/items/person_details_provider.dart';
|
||||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/screens/shared/detail_scaffold.dart';
|
||||
import 'package:fladder/screens/shared/media/external_urls.dart';
|
||||
import 'package:fladder/screens/shared/media/poster_row.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:fladder/util/list_extensions.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
import 'package:fladder/util/widget_extensions.dart';
|
||||
import 'package:fladder/widgets/shared/selectable_icon_button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class PersonDetailScreen extends ConsumerStatefulWidget {
|
||||
final Person person;
|
||||
const PersonDetailScreen({required this.person, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _PersonDetailScreenState();
|
||||
}
|
||||
|
||||
class _PersonDetailScreenState extends ConsumerState<PersonDetailScreen> {
|
||||
late final providerID = personDetailsProvider(widget.person.id);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final details = ref.watch(providerID);
|
||||
return DetailScaffold(
|
||||
label: details?.name ?? "",
|
||||
onRefresh: () async {
|
||||
await ref.read(providerID.notifier).fetchPerson(widget.person);
|
||||
},
|
||||
backDrops: [...?details?.movies, ...?details?.series].random().firstOrNull?.images,
|
||||
content: (padding) => Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
SizedBox(height: MediaQuery.of(context).size.height / 6),
|
||||
Padding(
|
||||
padding: padding,
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
runAlignment: WrapAlignment.spaceEvenly,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
runSpacing: 32,
|
||||
spacing: 32,
|
||||
children: [
|
||||
Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
width: AdaptiveLayout.of(context).layout == LayoutState.phone
|
||||
? MediaQuery.of(context).size.width
|
||||
: MediaQuery.of(context).size.width / 3.5,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 0.70,
|
||||
child: FladderImage(
|
||||
fit: BoxFit.cover,
|
||||
placeHolder: placeHolder(details?.name ?? ""),
|
||||
image: details?.images?.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(child: Text(details?.name ?? "", style: Theme.of(context).textTheme.displaySmall)),
|
||||
const SizedBox(width: 15),
|
||||
SelectableIconButton(
|
||||
onPressed: () async => await ref
|
||||
.read(userProvider.notifier)
|
||||
.setAsFavorite(!(details?.userData.isFavourite ?? false), details?.id ?? ""),
|
||||
selected: (details?.userData.isFavourite ?? false),
|
||||
selectedIcon: Icons.favorite_rounded,
|
||||
icon: Icons.favorite_border_rounded,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (details?.dateOfBirth != null)
|
||||
Text("Birthday: ${DateFormat.yMEd().format(details?.dateOfBirth ?? DateTime.now()).toString()}"),
|
||||
if (details?.age != null) Text("Age: ${details?.age}"),
|
||||
if (details?.birthPlace.isEmpty == false) Text("Born in ${details?.birthPlace.join(",")}"),
|
||||
if (details?.overview.externalUrls?.isNotEmpty ?? false)
|
||||
ExternalUrlsRow(
|
||||
urls: details?.overview.externalUrls,
|
||||
).padding(padding),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
if (details?.movies.isNotEmpty ?? false)
|
||||
PosterRow(contentPadding: padding, posters: details?.movies ?? [], label: "Movies"),
|
||||
if (details?.series.isNotEmpty ?? false)
|
||||
PosterRow(contentPadding: padding, posters: details?.series ?? [], label: "Series")
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget placeHolder(String name) {
|
||||
return Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: 0.4,
|
||||
child: Card(
|
||||
shape: const CircleBorder(),
|
||||
child: Center(
|
||||
child: Text(
|
||||
name.getInitials(),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
185
lib/screens/details_screens/season_detail_screen.dart
Normal file
185
lib/screens/details_screens/season_detail_screen.dart
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/providers/items/season_details_provider.dart';
|
||||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/screens/details_screens/components/overview_header.dart';
|
||||
import 'package:fladder/screens/shared/detail_scaffold.dart';
|
||||
import 'package:fladder/screens/shared/media/components/media_header.dart';
|
||||
import 'package:fladder/screens/shared/media/episode_details_list.dart';
|
||||
import 'package:fladder/screens/shared/media/expanding_overview.dart';
|
||||
import 'package:fladder/screens/shared/media/people_row.dart';
|
||||
import 'package:fladder/screens/shared/media/person_list_.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
import 'package:fladder/util/widget_extensions.dart';
|
||||
import 'package:fladder/widgets/shared/selectable_icon_button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class SeasonDetailScreen extends ConsumerStatefulWidget {
|
||||
final ItemBaseModel item;
|
||||
const SeasonDetailScreen({required this.item, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _SeasonDetailScreenState();
|
||||
}
|
||||
|
||||
class _SeasonDetailScreenState extends ConsumerState<SeasonDetailScreen> {
|
||||
Set<EpisodeDetailsViewType> viewOptions = {EpisodeDetailsViewType.grid};
|
||||
late final providerId = seasonDetailsProvider(widget.item.id);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final details = ref.watch(providerId);
|
||||
|
||||
return DetailScaffold(
|
||||
label: details?.localizedName(context) ?? "",
|
||||
item: details,
|
||||
actions: (context) => details?.generateActions(context, ref, exclude: {
|
||||
ItemActions.details,
|
||||
}),
|
||||
onRefresh: () async {
|
||||
await ref.read(providerId.notifier).fetchDetails(widget.item.id);
|
||||
},
|
||||
backDrops: details?.parentImages,
|
||||
content: (padding) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 64),
|
||||
child: details != null
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(height: MediaQuery.of(context).size.height * 0.35),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.spaceAround,
|
||||
runAlignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 600,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
MediaHeader(
|
||||
name: "${details.seriesName} - ${details.name}",
|
||||
logo: details.parentImages?.logo,
|
||||
),
|
||||
OverviewHeader(
|
||||
name: details.seriesName,
|
||||
padding: padding,
|
||||
subTitle: details.localizedName(context),
|
||||
onTitleClicked: () => details.parentBaseModel.navigateTo(context),
|
||||
originalTitle: details.seriesName,
|
||||
productionYear: details.overview.productionYear,
|
||||
runTime: details.overview.runTime,
|
||||
studios: details.overview.studios,
|
||||
officialRating: details.overview.parentalRating,
|
||||
genres: details.overview.genreItems,
|
||||
communityRating: details.overview.communityRating,
|
||||
externalUrls: details.overview.externalUrls,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 300),
|
||||
child: Card(child: FladderImage(image: details.getPosters?.primary))),
|
||||
],
|
||||
).padding(padding),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
SelectableIconButton(
|
||||
onPressed: () async => await ref
|
||||
.read(userProvider.notifier)
|
||||
.setAsFavorite(!details.userData.isFavourite, details.id),
|
||||
selected: details.userData.isFavourite,
|
||||
selectedIcon: IconsaxBold.heart,
|
||||
icon: IconsaxOutline.heart,
|
||||
),
|
||||
SelectableIconButton(
|
||||
onPressed: () async => await ref
|
||||
.read(userProvider.notifier)
|
||||
.markAsPlayed(!details.userData.played, details.id),
|
||||
selected: details.userData.played,
|
||||
selectedIcon: IconsaxBold.tick_circle,
|
||||
icon: IconsaxOutline.tick_circle,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Card(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(200)),
|
||||
child: SegmentedButton(
|
||||
style: ButtonStyle(
|
||||
elevation: WidgetStatePropertyAll(5),
|
||||
side: WidgetStatePropertyAll(BorderSide.none),
|
||||
),
|
||||
showSelectedIcon: true,
|
||||
segments: EpisodeDetailsViewType.values
|
||||
.map(
|
||||
(e) => ButtonSegment(
|
||||
value: e,
|
||||
icon: Icon(e.icon),
|
||||
label: SizedBox(
|
||||
height: 50,
|
||||
child: Center(
|
||||
child: Text(
|
||||
e.name.capitalize(),
|
||||
),
|
||||
)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
selected: viewOptions,
|
||||
onSelectionChanged: (newOptions) {
|
||||
setState(() {
|
||||
viewOptions = newOptions;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
).padding(padding),
|
||||
if (details.overview.summary.isNotEmpty)
|
||||
ExpandingOverview(
|
||||
text: details.overview.summary,
|
||||
).padding(padding),
|
||||
if (details.overview.directors.isNotEmpty)
|
||||
PersonList(
|
||||
label: context.localized.director(2),
|
||||
people: details.overview.directors,
|
||||
).padding(padding),
|
||||
if (details.overview.writers.isNotEmpty)
|
||||
PersonList(label: context.localized.writer(2), people: details.overview.writers).padding(padding),
|
||||
if (details.episodes.isNotEmpty)
|
||||
EpisodeDetailsList(
|
||||
viewType: viewOptions.first,
|
||||
episodes: details.episodes,
|
||||
padding: padding,
|
||||
),
|
||||
if (details.overview.people.isNotEmpty)
|
||||
PeopleRow(
|
||||
people: details.overview.people,
|
||||
contentPadding: padding,
|
||||
),
|
||||
].addPadding(const EdgeInsets.symmetric(vertical: 16)),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
165
lib/screens/details_screens/series_detail_screen.dart
Normal file
165
lib/screens/details_screens/series_detail_screen.dart
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/screens/details_screens/components/overview_header.dart';
|
||||
import 'package:fladder/screens/shared/media/components/media_play_button.dart';
|
||||
import 'package:fladder/screens/shared/media/components/next_up_episode.dart';
|
||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||
import 'package:fladder/util/item_base_model/play_item_helpers.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/providers/items/series_details_provider.dart';
|
||||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/screens/shared/detail_scaffold.dart';
|
||||
import 'package:fladder/screens/shared/media/components/media_header.dart';
|
||||
import 'package:fladder/screens/shared/media/episode_posters.dart';
|
||||
import 'package:fladder/screens/shared/media/expanding_overview.dart';
|
||||
import 'package:fladder/screens/shared/media/people_row.dart';
|
||||
import 'package:fladder/screens/shared/media/poster_row.dart';
|
||||
import 'package:fladder/screens/shared/media/season_row.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/widget_extensions.dart';
|
||||
import 'package:fladder/widgets/shared/selectable_icon_button.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class SeriesDetailScreen extends ConsumerStatefulWidget {
|
||||
final ItemBaseModel item;
|
||||
const SeriesDetailScreen({required this.item, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _SeriesDetailScreenState();
|
||||
}
|
||||
|
||||
class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
|
||||
late final providerId = seriesDetailsProvider(widget.item.id);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final details = ref.watch(providerId);
|
||||
return DetailScaffold(
|
||||
label: details?.name ?? "",
|
||||
item: details,
|
||||
actions: (context) => details?.generateActions(
|
||||
context,
|
||||
ref,
|
||||
exclude: {
|
||||
ItemActions.play,
|
||||
ItemActions.playFromStart,
|
||||
ItemActions.details,
|
||||
},
|
||||
onDeleteSuccesFully: (item) {
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
onRefresh: () => ref.read(providerId.notifier).fetchDetails(widget.item),
|
||||
backDrops: details?.images,
|
||||
content: (padding) => details != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(bottom: 64),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(height: MediaQuery.of(context).size.height * 0.35),
|
||||
MediaHeader(
|
||||
name: details.name,
|
||||
logo: details.images?.logo,
|
||||
),
|
||||
OverviewHeader(
|
||||
name: details.name,
|
||||
padding: padding,
|
||||
originalTitle: details.originalTitle,
|
||||
productionYear: details.overview.productionYear,
|
||||
runTime: details.overview.runTime,
|
||||
studios: details.overview.studios,
|
||||
officialRating: details.overview.parentalRating,
|
||||
genres: details.overview.genreItems,
|
||||
communityRating: details.overview.communityRating,
|
||||
externalUrls: details.overview.externalUrls,
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
MediaPlayButton(
|
||||
item: details.nextUp,
|
||||
onPressed: details.nextUp != null
|
||||
? () async {
|
||||
await details.nextUp.play(context, ref);
|
||||
ref.read(providerId.notifier).fetchDetails(widget.item);
|
||||
}
|
||||
: null,
|
||||
onLongPressed: details.nextUp != null
|
||||
? () async {
|
||||
await details.nextUp.play(context, ref, showPlaybackOption: true);
|
||||
ref.read(providerId.notifier).fetchDetails(widget.item);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
SelectableIconButton(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(userProvider.notifier)
|
||||
.setAsFavorite(!details.userData.isFavourite, details.id);
|
||||
},
|
||||
selected: details.userData.isFavourite,
|
||||
selectedIcon: IconsaxBold.heart,
|
||||
icon: IconsaxOutline.heart,
|
||||
),
|
||||
SelectableIconButton(
|
||||
onPressed: () async {
|
||||
await ref.read(userProvider.notifier).markAsPlayed(!details.userData.played, details.id);
|
||||
},
|
||||
selected: details.userData.played,
|
||||
selectedIcon: IconsaxBold.tick_circle,
|
||||
icon: IconsaxOutline.tick_circle,
|
||||
),
|
||||
],
|
||||
).padding(padding),
|
||||
if (details.nextUp != null)
|
||||
NextUpEpisode(
|
||||
nextEpisode: details.nextUp!,
|
||||
onChanged: (episode) => ref.read(providerId.notifier).updateEpisodeInfo(episode),
|
||||
).padding(padding),
|
||||
if (details.overview.summary.isNotEmpty)
|
||||
ExpandingOverview(
|
||||
text: details.overview.summary,
|
||||
).padding(padding),
|
||||
if (details.availableEpisodes?.isNotEmpty ?? false)
|
||||
EpisodePosters(
|
||||
contentPadding: padding,
|
||||
label: context.localized.episode(details.availableEpisodes?.length ?? 2),
|
||||
playEpisode: (episode) async {
|
||||
await episode.play(
|
||||
context,
|
||||
ref,
|
||||
);
|
||||
ref.read(providerId.notifier).fetchDetails(widget.item);
|
||||
},
|
||||
episodes: details.availableEpisodes ?? [],
|
||||
),
|
||||
if (details.seasons?.isNotEmpty ?? false)
|
||||
SeasonsRow(
|
||||
contentPadding: padding,
|
||||
seasons: details.seasons,
|
||||
onSeasonPressed: (season) => season.navigateTo(context),
|
||||
),
|
||||
if (details.overview.people.isNotEmpty)
|
||||
PeopleRow(
|
||||
people: details.overview.people,
|
||||
contentPadding: padding,
|
||||
),
|
||||
if (details.related.isNotEmpty)
|
||||
PosterRow(posters: details.related, contentPadding: padding, label: context.localized.related),
|
||||
].addPadding(const EdgeInsets.symmetric(vertical: 16)),
|
||||
),
|
||||
)
|
||||
: Container(),
|
||||
);
|
||||
}
|
||||
}
|
||||
82
lib/screens/favourites/favourites_screen.dart
Normal file
82
lib/screens/favourites/favourites_screen.dart
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import 'package:fladder/providers/settings/client_settings_provider.dart';
|
||||
import 'package:fladder/routes/build_routes/home_routes.dart';
|
||||
import 'package:fladder/screens/shared/nested_scaffold.dart';
|
||||
import 'package:fladder/screens/shared/nested_sliver_appbar.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/widgets/shared/pinch_poster_zoom.dart';
|
||||
import 'package:fladder/widgets/shared/poster_size_slider.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:fladder/providers/favourites_provider.dart';
|
||||
import 'package:fladder/screens/shared/media/poster_grid.dart';
|
||||
import 'package:fladder/util/sliver_list_padding.dart';
|
||||
import 'package:fladder/widgets/shared/pull_to_refresh.dart';
|
||||
|
||||
class FavouritesScreen extends ConsumerWidget {
|
||||
final ScrollController navigationScrollController;
|
||||
|
||||
const FavouritesScreen({required this.navigationScrollController, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final favourites = ref.watch(favouritesProvider);
|
||||
|
||||
return PullToRefresh(
|
||||
onRefresh: () async => await ref.read(favouritesProvider.notifier).fetchFavourites(),
|
||||
child: NestedScaffold(
|
||||
body: PinchPosterZoom(
|
||||
scaleDifference: (difference) => ref.read(clientSettingsProvider.notifier).addPosterSize(difference / 2),
|
||||
child: CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
controller: navigationScrollController,
|
||||
slivers: [
|
||||
if (AdaptiveLayout.of(context).layout == LayoutState.phone)
|
||||
NestedSliverAppBar(
|
||||
searchTitle: "${context.localized.search} ${context.localized.favorites.toLowerCase()}...",
|
||||
parent: context,
|
||||
route: LibrarySearchRoute(favorites: true),
|
||||
)
|
||||
else
|
||||
const DefaultSliverTopBadding(),
|
||||
if (AdaptiveLayout.of(context).isDesktop)
|
||||
SliverToBoxAdapter(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
PosterSizeWidget(),
|
||||
],
|
||||
),
|
||||
),
|
||||
...favourites.favourites.entries.where((element) => element.value.isNotEmpty).map(
|
||||
(e) => SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: PosterGrid(
|
||||
stickyHeader: true,
|
||||
name: e.key.label(context),
|
||||
posters: e.value,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (favourites.people.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: PosterGrid(
|
||||
stickyHeader: true,
|
||||
name: "People",
|
||||
posters: favourites.people,
|
||||
),
|
||||
),
|
||||
),
|
||||
const DefautlSliverBottomPadding(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
86
lib/screens/home.dart
Normal file
86
lib/screens/home.dart
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:fladder/routes/build_routes/home_routes.dart';
|
||||
import 'package:fladder/routes/build_routes/route_builder.dart';
|
||||
import 'package:fladder/util/string_extensions.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/navigation_scaffold.dart';
|
||||
|
||||
enum HomeTabs {
|
||||
dashboard,
|
||||
favorites,
|
||||
sync;
|
||||
}
|
||||
|
||||
class Home extends ConsumerWidget {
|
||||
final HomeTabs? currentTab;
|
||||
final Widget? nestedChild;
|
||||
final String? location;
|
||||
const Home({this.currentTab, this.nestedChild, this.location, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final canDownload = ref.watch(showSyncButtonProviderProvider);
|
||||
final destinations = HomeTabs.values.map((e) {
|
||||
switch (e) {
|
||||
case HomeTabs.dashboard:
|
||||
return DestinationModel(
|
||||
label: context.localized.navigationDashboard,
|
||||
icon: const Icon(IconsaxOutline.home),
|
||||
selectedIcon: const Icon(IconsaxBold.home),
|
||||
route: DashboardRoute(),
|
||||
action: () => context.routeGo(DashboardRoute()),
|
||||
floatingActionButton: AdaptiveFab(
|
||||
context: context,
|
||||
title: context.localized.search,
|
||||
key: Key(e.name.capitalize()),
|
||||
onPressed: () => context.routePushOrGo(LibrarySearchRoute()),
|
||||
child: const Icon(IconsaxOutline.search_normal_1),
|
||||
),
|
||||
);
|
||||
case HomeTabs.favorites:
|
||||
return DestinationModel(
|
||||
label: context.localized.navigationFavorites,
|
||||
icon: const Icon(IconsaxOutline.heart),
|
||||
selectedIcon: const Icon(IconsaxBold.heart),
|
||||
route: FavouritesRoute(),
|
||||
floatingActionButton: AdaptiveFab(
|
||||
context: context,
|
||||
title: context.localized.filter(0),
|
||||
key: Key(e.name.capitalize()),
|
||||
onPressed: () => context.routePushOrGo(LibrarySearchRoute(favorites: true)),
|
||||
child: const Icon(IconsaxOutline.heart_search),
|
||||
),
|
||||
action: () => context.routeGo(FavouritesRoute()),
|
||||
);
|
||||
case HomeTabs.sync:
|
||||
if (canDownload) {
|
||||
return DestinationModel(
|
||||
label: context.localized.navigationSync,
|
||||
icon: const Icon(IconsaxOutline.cloud),
|
||||
selectedIcon: const Icon(IconsaxBold.cloud),
|
||||
route: SyncRoute(),
|
||||
action: () => context.routeGo(SyncRoute()),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
return NavigationScaffold(
|
||||
currentIndex: currentTab?.index ?? 0,
|
||||
location: location,
|
||||
nestedChild: nestedChild,
|
||||
destinations: destinations.whereNotNull().toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
83
lib/screens/library/components/library_tabs.dart
Normal file
83
lib/screens/library/components/library_tabs.dart
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
|
||||
import 'package:fladder/jellyfin/jellyfin_open_api.enums.swagger.dart';
|
||||
import 'package:fladder/screens/library/tabs/favourites_tab.dart';
|
||||
import 'package:fladder/screens/library/tabs/library_tab.dart';
|
||||
import 'package:fladder/screens/library/tabs/timeline_tab.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fladder/models/view_model.dart';
|
||||
import 'package:fladder/screens/library/tabs/recommendations_tab.dart';
|
||||
|
||||
class LibraryTabs {
|
||||
final String name;
|
||||
final Icon icon;
|
||||
final Widget page;
|
||||
final FloatingActionButton? floatingActionButton;
|
||||
LibraryTabs({
|
||||
required this.name,
|
||||
required this.icon,
|
||||
required this.page,
|
||||
this.floatingActionButton,
|
||||
});
|
||||
|
||||
static List<LibraryTabs> getLibraryForType(ViewModel viewModel, CollectionType type) {
|
||||
LibraryTabs recommendTab() {
|
||||
return LibraryTabs(
|
||||
name: "Recommended",
|
||||
icon: const Icon(Icons.recommend_rounded),
|
||||
page: RecommendationsTab(viewModel: viewModel),
|
||||
);
|
||||
}
|
||||
|
||||
LibraryTabs timelineTab() {
|
||||
return LibraryTabs(
|
||||
name: "Timeline",
|
||||
icon: const Icon(Icons.timeline),
|
||||
page: TimelineTab(viewModel: viewModel),
|
||||
);
|
||||
}
|
||||
|
||||
LibraryTabs favouritesTab() {
|
||||
return LibraryTabs(
|
||||
name: "Favourites",
|
||||
icon: const Icon(Icons.favorite_rounded),
|
||||
page: FavouritesTab(viewModel: viewModel),
|
||||
);
|
||||
}
|
||||
|
||||
LibraryTabs libraryTab() {
|
||||
return LibraryTabs(
|
||||
name: "Library",
|
||||
icon: const Icon(Icons.book_rounded),
|
||||
page: LibraryTab(viewModel: viewModel),
|
||||
);
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case CollectionType.tvshows:
|
||||
case CollectionType.movies:
|
||||
return [
|
||||
libraryTab(),
|
||||
recommendTab(),
|
||||
favouritesTab(),
|
||||
];
|
||||
case CollectionType.books:
|
||||
case CollectionType.homevideos:
|
||||
return [
|
||||
libraryTab(),
|
||||
timelineTab(),
|
||||
recommendTab(),
|
||||
favouritesTab(),
|
||||
];
|
||||
case CollectionType.boxsets:
|
||||
case CollectionType.playlists:
|
||||
case CollectionType.folders:
|
||||
return [
|
||||
libraryTab(),
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
92
lib/screens/library/library_screen.dart
Normal file
92
lib/screens/library/library_screen.dart
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import 'package:fladder/models/view_model.dart';
|
||||
import 'package:fladder/providers/library_provider.dart';
|
||||
import 'package:fladder/screens/library/components/library_tabs.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class LibraryScreen extends ConsumerStatefulWidget {
|
||||
final ViewModel viewModel;
|
||||
const LibraryScreen({
|
||||
required this.viewModel,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _LibraryScreenState();
|
||||
}
|
||||
|
||||
class _LibraryScreenState extends ConsumerState<LibraryScreen> with SingleTickerProviderStateMixin {
|
||||
late final List<LibraryTabs> tabs = LibraryTabs.getLibraryForType(widget.viewModel, widget.viewModel.collectionType);
|
||||
late final TabController tabController = TabController(length: tabs.length, vsync: this);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() {
|
||||
ref.read(libraryProvider(widget.viewModel.id).notifier).setupLibrary(widget.viewModel);
|
||||
});
|
||||
|
||||
tabController.addListener(() {
|
||||
if (tabController.previousIndex != tabController.index) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final PreferredSizeWidget tabBar = TabBar(
|
||||
isScrollable: AdaptiveLayout.of(context).isDesktop ? true : false,
|
||||
indicatorWeight: 3,
|
||||
controller: tabController,
|
||||
tabs: tabs
|
||||
.map((e) => Tab(
|
||||
text: e.name,
|
||||
icon: e.icon,
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: AdaptiveLayout.of(context).isDesktop
|
||||
? EdgeInsets.only(top: MediaQuery.of(context).padding.top)
|
||||
: EdgeInsets.zero,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AdaptiveLayout.of(context).isDesktop ? 15 : 0),
|
||||
child: Card(
|
||||
margin: AdaptiveLayout.of(context).isDesktop ? null : EdgeInsets.zero,
|
||||
elevation: 2,
|
||||
child: Scaffold(
|
||||
backgroundColor: AdaptiveLayout.of(context).isDesktop ? Colors.transparent : null,
|
||||
floatingActionButton: tabs[tabController.index].floatingActionButton,
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.endContained,
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
backgroundColor: AdaptiveLayout.of(context).isDesktop ? Colors.transparent : null,
|
||||
title: tabs.length > 1 ? (!AdaptiveLayout.of(context).isDesktop ? null : tabBar) : null,
|
||||
toolbarHeight: AdaptiveLayout.of(context).isDesktop ? 75 : 40,
|
||||
bottom: tabs.length > 1 ? (AdaptiveLayout.of(context).isDesktop ? null : tabBar) : null,
|
||||
),
|
||||
extendBody: true,
|
||||
body: Padding(
|
||||
padding: !AdaptiveLayout.of(context).isDesktop
|
||||
? EdgeInsets.only(
|
||||
left: MediaQuery.of(context).padding.left, right: MediaQuery.of(context).padding.right)
|
||||
: EdgeInsets.zero,
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
children: tabs
|
||||
.map((e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: e.page,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
37
lib/screens/library/tabs/favourites_tab.dart
Normal file
37
lib/screens/library/tabs/favourites_tab.dart
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import 'package:fladder/models/view_model.dart';
|
||||
import 'package:fladder/providers/library_provider.dart';
|
||||
import 'package:fladder/screens/shared/media/poster_grid.dart';
|
||||
import 'package:fladder/widgets/shared/pull_to_refresh.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class FavouritesTab extends ConsumerStatefulWidget {
|
||||
final ViewModel viewModel;
|
||||
const FavouritesTab({required this.viewModel, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _FavouritesTabState();
|
||||
}
|
||||
|
||||
class _FavouritesTabState extends ConsumerState<FavouritesTab> with AutomaticKeepAliveClientMixin {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final favourites = ref.watch(libraryProvider(widget.viewModel.id))?.favourites ?? [];
|
||||
super.build(context);
|
||||
return PullToRefresh(
|
||||
onRefresh: () async {
|
||||
await ref.read(libraryProvider(widget.viewModel.id).notifier).loadFavourites(widget.viewModel);
|
||||
},
|
||||
child: favourites.isNotEmpty
|
||||
? ListView(
|
||||
children: [
|
||||
PosterGrid(posters: favourites),
|
||||
],
|
||||
)
|
||||
: const Center(child: Text("No favourites, add some using the heart icon.")),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
40
lib/screens/library/tabs/library_tab.dart
Normal file
40
lib/screens/library/tabs/library_tab.dart
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import 'package:fladder/models/view_model.dart';
|
||||
import 'package:fladder/providers/library_provider.dart';
|
||||
import 'package:fladder/screens/shared/media/poster_grid.dart';
|
||||
import 'package:fladder/util/grouping.dart';
|
||||
import 'package:fladder/util/keyed_list_view.dart';
|
||||
import 'package:fladder/widgets/shared/pull_to_refresh.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class LibraryTab extends ConsumerStatefulWidget {
|
||||
final ViewModel viewModel;
|
||||
const LibraryTab({required this.viewModel, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _LibraryTabState();
|
||||
}
|
||||
|
||||
class _LibraryTabState extends ConsumerState<LibraryTab> with AutomaticKeepAliveClientMixin {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
final library = ref.watch(libraryProvider(widget.viewModel.id).select((value) => value?.posters)) ?? [];
|
||||
final items = groupByName(library);
|
||||
return PullToRefresh(
|
||||
onRefresh: () async {
|
||||
await ref.read(libraryProvider(widget.viewModel.id).notifier).loadLibrary(widget.viewModel);
|
||||
},
|
||||
child: KeyedListView(
|
||||
map: items,
|
||||
itemBuilder: (context, index) {
|
||||
final currentIndex = items.entries.elementAt(index);
|
||||
return PosterGrid(name: currentIndex.key, posters: currentIndex.value);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
49
lib/screens/library/tabs/recommendations_tab.dart
Normal file
49
lib/screens/library/tabs/recommendations_tab.dart
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import 'package:fladder/models/view_model.dart';
|
||||
import 'package:fladder/providers/library_provider.dart';
|
||||
import 'package:fladder/screens/shared/media/poster_grid.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/widgets/shared/pull_to_refresh.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class RecommendationsTab extends ConsumerStatefulWidget {
|
||||
final ViewModel viewModel;
|
||||
|
||||
const RecommendationsTab({required this.viewModel, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _RecommendationsTabState();
|
||||
}
|
||||
|
||||
class _RecommendationsTabState extends ConsumerState<RecommendationsTab> with AutomaticKeepAliveClientMixin {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
final recommendations = ref.watch(libraryProvider(widget.viewModel.id)
|
||||
.select((value) => value?.recommendations.where((element) => element.posters.isNotEmpty))) ??
|
||||
[];
|
||||
return PullToRefresh(
|
||||
onRefresh: () async {
|
||||
await ref.read(libraryProvider(widget.viewModel.id).notifier).loadRecommendations(widget.viewModel);
|
||||
},
|
||||
child: recommendations.isNotEmpty
|
||||
? ListView(
|
||||
children: recommendations
|
||||
.map(
|
||||
(e) => PosterGrid(name: e.name, posters: e.posters),
|
||||
)
|
||||
.toList()
|
||||
.addPadding(
|
||||
const EdgeInsets.only(
|
||||
bottom: 32,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Center(
|
||||
child: Text("No recommendations, add more movies and or shows to receive more recomendations")),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
132
lib/screens/library/tabs/timeline_tab.dart
Normal file
132
lib/screens/library/tabs/timeline_tab.dart
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import 'package:fladder/models/items/photos_model.dart';
|
||||
import 'package:fladder/models/view_model.dart';
|
||||
import 'package:fladder/providers/library_provider.dart';
|
||||
import 'package:fladder/screens/photo_viewer/photo_viewer_screen.dart';
|
||||
import 'package:fladder/screens/shared/flat_button.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:fladder/util/sticky_header_text.dart';
|
||||
import 'package:fladder/widgets/shared/pull_to_refresh.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:page_transition/page_transition.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
import 'package:sticky_headers/sticky_headers.dart';
|
||||
|
||||
class TimelineTab extends ConsumerStatefulWidget {
|
||||
final ViewModel viewModel;
|
||||
|
||||
const TimelineTab({required this.viewModel, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _TimelineTabState();
|
||||
}
|
||||
|
||||
class _TimelineTabState extends ConsumerState<TimelineTab> with AutomaticKeepAliveClientMixin {
|
||||
final itemScrollController = ItemScrollController();
|
||||
double get posterCount {
|
||||
if (AdaptiveLayout.of(context).layout == LayoutState.desktop) {
|
||||
return 200;
|
||||
}
|
||||
return 125;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
final timeLine = ref.watch(libraryProvider(widget.viewModel.id))?.timelinePhotos ?? [];
|
||||
final items = groupedItems(timeLine);
|
||||
|
||||
return PullToRefresh(
|
||||
onRefresh: () async {
|
||||
await ref.read(libraryProvider(widget.viewModel.id).notifier).loadTimeline(widget.viewModel);
|
||||
},
|
||||
child: ScrollablePositionedList.builder(
|
||||
itemScrollController: itemScrollController,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items.entries.elementAt(index);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 64.0),
|
||||
child: StickyHeader(
|
||||
header: StickyHeaderText(
|
||||
label: item.key.year != DateTime.now().year
|
||||
? DateFormat('E dd MMM. y').format(item.key)
|
||||
: DateFormat('E dd MMM.').format(item.key)),
|
||||
content: StaggeredGrid.count(
|
||||
crossAxisCount: MediaQuery.of(context).size.width ~/ posterCount,
|
||||
mainAxisSpacing: 0,
|
||||
crossAxisSpacing: 0,
|
||||
axisDirection: AxisDirection.down,
|
||||
children: item.value
|
||||
.map(
|
||||
(e) => Hero(
|
||||
tag: e.id,
|
||||
child: AspectRatio(
|
||||
aspectRatio: e.primaryRatio ?? 0.0,
|
||||
child: Card(
|
||||
margin: const EdgeInsets.all(4),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
children: [
|
||||
FladderImage(image: e.thumbnail?.primary),
|
||||
FlatButton(
|
||||
onLongPress: () {},
|
||||
onTap: () async {
|
||||
final position = await Navigator.of(context, rootNavigator: true).push(
|
||||
PageTransition(
|
||||
child: PhotoViewerScreen(
|
||||
items: timeLine,
|
||||
indexOfSelected: timeLine.indexOf(e),
|
||||
),
|
||||
type: PageTransitionType.fade),
|
||||
);
|
||||
getParentPosition(items, timeLine, position);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void getParentPosition(Map<DateTime, List<PhotoModel>> items, List<PhotoModel> timeLine, int position) {
|
||||
items.forEach(
|
||||
(key, value) {
|
||||
if (value.contains(timeLine[position])) {
|
||||
itemScrollController.scrollTo(
|
||||
index: items.keys.toList().indexOf(key), duration: const Duration(milliseconds: 250));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Map<DateTime, List<PhotoModel>> groupedItems(List<PhotoModel> items) {
|
||||
Map<DateTime, List<PhotoModel>> groupedItems = {};
|
||||
for (int i = 0; i < items.length; i++) {
|
||||
DateTime curretDate = items[i].dateTaken ?? DateTime.now();
|
||||
DateTime key = DateTime(curretDate.year, curretDate.month, curretDate.day);
|
||||
if (!groupedItems.containsKey(key)) {
|
||||
groupedItems[key] = [items[i]];
|
||||
} else {
|
||||
groupedItems[key]?.add(items[i]);
|
||||
}
|
||||
}
|
||||
return groupedItems;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
790
lib/screens/library_search/library_search_screen.dart
Normal file
790
lib/screens/library_search/library_search_screen.dart
Normal file
|
|
@ -0,0 +1,790 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/boxset_model.dart';
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/models/items/photos_model.dart';
|
||||
import 'package:fladder/models/library_search/library_search_options.dart';
|
||||
import 'package:fladder/models/media_playback_model.dart';
|
||||
import 'package:fladder/models/playlist_model.dart';
|
||||
import 'package:fladder/providers/settings/client_settings_provider.dart';
|
||||
import 'package:fladder/providers/video_player_provider.dart';
|
||||
import 'package:fladder/screens/collections/add_to_collection.dart';
|
||||
import 'package:fladder/screens/library_search/widgets/library_sort_dialogue.dart';
|
||||
import 'package:fladder/screens/playlists/add_to_playlists.dart';
|
||||
import 'package:fladder/screens/shared/animated_fade_size.dart';
|
||||
import 'package:fladder/screens/shared/flat_button.dart';
|
||||
import 'package:fladder/screens/shared/nested_bottom_appbar.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/fab_extended_anim.dart';
|
||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/refresh_state.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/floating_player_bar.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/settings_user_icon.dart';
|
||||
import 'package:fladder/widgets/shared/fladder_scrollbar.dart';
|
||||
import 'package:fladder/widgets/shared/hide_on_scroll.dart';
|
||||
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
|
||||
import 'package:fladder/widgets/shared/pinch_poster_zoom.dart';
|
||||
import 'package:fladder/widgets/shared/poster_size_slider.dart';
|
||||
import 'package:fladder/widgets/shared/scroll_position.dart';
|
||||
import 'package:fladder/widgets/shared/shapes.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:fladder/models/library_search/library_search_model.dart';
|
||||
import 'package:fladder/providers/library_search_provider.dart';
|
||||
import 'package:fladder/screens/library_search/widgets/library_filter_chips.dart';
|
||||
import 'package:fladder/screens/library_search/widgets/library_views.dart';
|
||||
import 'package:fladder/screens/library_search/widgets/suggestion_search_bar.dart';
|
||||
import 'package:fladder/util/debouncer.dart';
|
||||
import 'package:fladder/util/sliver_list_padding.dart';
|
||||
import 'package:fladder/widgets/shared/pull_to_refresh.dart';
|
||||
|
||||
class LibrarySearchScreen extends ConsumerStatefulWidget {
|
||||
final String? viewModelId;
|
||||
final bool? favourites;
|
||||
final List<String>? folderId;
|
||||
final SortingOrder? sortOrder;
|
||||
final SortingOptions? sortingOptions;
|
||||
final PhotoModel? photoToView;
|
||||
const LibrarySearchScreen({
|
||||
this.viewModelId,
|
||||
this.folderId,
|
||||
this.favourites,
|
||||
this.sortOrder,
|
||||
this.sortingOptions,
|
||||
this.photoToView,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _LibrarySearchScreenState();
|
||||
}
|
||||
|
||||
class _LibrarySearchScreenState extends ConsumerState<LibrarySearchScreen> {
|
||||
late final Key uniqueKey = Key(widget.folderId?.join(',').toString() ?? widget.viewModelId ?? UniqueKey().toString());
|
||||
late final providerKey = librarySearchProvider(uniqueKey);
|
||||
late final libraryProvider = ref.read(providerKey.notifier);
|
||||
final SearchController searchController = SearchController();
|
||||
final Debouncer debouncer = Debouncer(const Duration(seconds: 1));
|
||||
final GlobalKey<RefreshIndicatorState> refreshKey = GlobalKey<RefreshIndicatorState>();
|
||||
final ScrollController scrollController = ScrollController();
|
||||
late double lastScale = 0;
|
||||
|
||||
bool loadOnStart = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
searchController.addListener(() {
|
||||
debouncer.run(() {
|
||||
ref.read(providerKey.notifier).setSearch(searchController.text);
|
||||
});
|
||||
});
|
||||
|
||||
Future.microtask(
|
||||
() async {
|
||||
libraryProvider.setDefaultOptions(widget.sortOrder, widget.sortingOptions);
|
||||
await refreshKey.currentState?.show();
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.edgeToEdge,
|
||||
overlays: [],
|
||||
);
|
||||
if (context.mounted && widget.photoToView != null) {
|
||||
libraryProvider.viewGallery(context, selected: widget.photoToView);
|
||||
}
|
||||
scrollController.addListener(() {
|
||||
scrollPosition();
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void scrollPosition() {
|
||||
if (scrollController.position.pixels > scrollController.position.maxScrollExtent * 0.65) {
|
||||
libraryProvider.loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isEmptySearchScreen = widget.viewModelId == null && widget.favourites == null && widget.folderId == null;
|
||||
final librarySearchResults = ref.watch(providerKey);
|
||||
final libraryProvider = ref.read(providerKey.notifier);
|
||||
final postersList = librarySearchResults.posters.hideEmptyChildren(librarySearchResults.hideEmtpyShows);
|
||||
final playerState = ref.watch(mediaPlaybackProvider.select((value) => value.state));
|
||||
|
||||
final libraryViewType = ref.watch(libraryViewTypeProvider);
|
||||
|
||||
ref.listen(
|
||||
providerKey,
|
||||
(previous, next) {
|
||||
if (previous != next) {
|
||||
refreshKey.currentState?.show();
|
||||
scrollController.jumpTo(0);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return PopScope(
|
||||
canPop: !librarySearchResults.selecteMode,
|
||||
onPopInvoked: (popped) async {
|
||||
if (librarySearchResults.selecteMode) {
|
||||
libraryProvider.toggleSelectMode();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
floatingActionButtonLocation:
|
||||
playerState == VideoPlayerState.minimized ? FloatingActionButtonLocation.centerFloat : null,
|
||||
floatingActionButton: switch (playerState) {
|
||||
VideoPlayerState.minimized => Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: FloatingPlayerBar(),
|
||||
),
|
||||
_ => HideOnScroll(
|
||||
controller: scrollController,
|
||||
visibleBuilder: (visible) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (librarySearchResults.showPlayButtons)
|
||||
FloatingActionButtonAnimated(
|
||||
key: Key(context.localized.playLabel),
|
||||
isExtended: visible,
|
||||
tooltip: context.localized.playVideos,
|
||||
onPressed: () async => await libraryProvider.playLibraryItems(context, ref),
|
||||
label: Text(context.localized.playLabel),
|
||||
icon: const Icon(IconsaxBold.play),
|
||||
),
|
||||
if (librarySearchResults.showGalleryButtons)
|
||||
FloatingActionButtonAnimated(
|
||||
key: Key(context.localized.viewPhotos),
|
||||
isExtended: visible,
|
||||
alternate: true,
|
||||
tooltip: context.localized.viewPhotos,
|
||||
onPressed: () async => await libraryProvider.viewGallery(context),
|
||||
label: Text(context.localized.viewPhotos),
|
||||
icon: const Icon(IconsaxBold.gallery),
|
||||
)
|
||||
].addInBetween(SizedBox(height: 10)),
|
||||
),
|
||||
),
|
||||
},
|
||||
bottomNavigationBar: HideOnScroll(
|
||||
controller: AdaptiveLayout.of(context).isDesktop ? null : scrollController,
|
||||
child: IgnorePointer(
|
||||
ignoring: librarySearchResults.fetchingItems,
|
||||
child: _LibrarySearchBottomBar(
|
||||
uniqueKey: uniqueKey,
|
||||
refreshKey: refreshKey,
|
||||
scrollController: scrollController,
|
||||
libraryProvider: libraryProvider,
|
||||
postersList: postersList,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Card(
|
||||
elevation: 1,
|
||||
child: PinchPosterZoom(
|
||||
scaleDifference: (difference) => ref.read(clientSettingsProvider.notifier).addPosterSize(difference),
|
||||
child: MediaQuery.removeViewInsets(
|
||||
context: context,
|
||||
child: ClipRRect(
|
||||
borderRadius: AdaptiveLayout.of(context).layout == LayoutState.desktop
|
||||
? BorderRadius.circular(15)
|
||||
: BorderRadius.circular(0),
|
||||
child: FladderScrollbar(
|
||||
visible: AdaptiveLayout.of(context).inputDevice != InputDevice.pointer,
|
||||
controller: scrollController,
|
||||
child: PullToRefresh(
|
||||
refreshKey: refreshKey,
|
||||
autoFocus: false,
|
||||
contextRefresh: false,
|
||||
onRefresh: () async =>
|
||||
libraryProvider.initRefresh(widget.folderId, widget.viewModelId, widget.favourites),
|
||||
refreshOnStart: false,
|
||||
child: CustomScrollView(
|
||||
physics: const AlwaysScrollableNoImplicitScrollPhysics(),
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
floating: !AdaptiveLayout.of(context).isDesktop,
|
||||
collapsedHeight: 80,
|
||||
automaticallyImplyLeading: true,
|
||||
pinned: AdaptiveLayout.of(context).isDesktop,
|
||||
primary: true,
|
||||
elevation: 5,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
shape: AppBarShape(),
|
||||
titleSpacing: 4,
|
||||
leadingWidth: 48,
|
||||
actions: [
|
||||
const SizedBox(width: 4),
|
||||
Builder(builder: (context) {
|
||||
final isFavorite =
|
||||
librarySearchResults.nestedCurrentItem?.userData.isFavourite == true;
|
||||
final itemActions = librarySearchResults.nestedCurrentItem?.generateActions(
|
||||
context,
|
||||
ref,
|
||||
exclude: {
|
||||
ItemActions.details,
|
||||
ItemActions.markPlayed,
|
||||
ItemActions.markUnplayed,
|
||||
},
|
||||
onItemUpdated: (item) {
|
||||
libraryProvider.updateParentItem(item);
|
||||
},
|
||||
onUserDataChanged: (userData) {
|
||||
libraryProvider.updateUserDataMain(userData);
|
||||
},
|
||||
) ??
|
||||
[];
|
||||
final itemCountWidget = ItemActionButton(
|
||||
label: Text(context.localized.itemCount(librarySearchResults.totalItemCount)),
|
||||
icon: Icon(IconsaxBold.document_1),
|
||||
);
|
||||
final refreshAction = ItemActionButton(
|
||||
label: Text(context.localized.forceRefresh),
|
||||
action: () => refreshKey.currentState?.show(),
|
||||
icon: Icon(IconsaxOutline.refresh),
|
||||
);
|
||||
final itemViewAction = ItemActionButton(
|
||||
label: Text(context.localized.selectViewType),
|
||||
icon: Icon(libraryViewType.icon),
|
||||
action: () {
|
||||
showAdaptiveDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog.adaptive(
|
||||
content: Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final currentType = ref.watch(libraryViewTypeProvider);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(context.localized.selectViewType,
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
...LibraryViewTypes.values
|
||||
.map(
|
||||
(e) => FilledButton.tonal(
|
||||
style: FilledButtonTheme.of(context).style?.copyWith(
|
||||
padding: WidgetStatePropertyAll(
|
||||
EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 24)),
|
||||
backgroundColor: WidgetStateProperty.resolveWith(
|
||||
(states) {
|
||||
if (e != currentType) {
|
||||
return Colors.transparent;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
ref.read(libraryViewTypeProvider.notifier).state = e;
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(e.icon),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
e.label(context),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
.addInBetween(const SizedBox(height: 12)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
return Card(
|
||||
elevation: 0,
|
||||
child: Tooltip(
|
||||
message: librarySearchResults.nestedCurrentItem?.type.label(context) ??
|
||||
context.localized.library(1),
|
||||
child: InkWell(
|
||||
onTapUp: (details) async {
|
||||
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) {
|
||||
double left = details.globalPosition.dx;
|
||||
double top = details.globalPosition.dy + 20;
|
||||
await showMenu(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(left, top, 40, 100),
|
||||
items: <PopupMenuEntry>[
|
||||
PopupMenuItem(
|
||||
child: Text(
|
||||
librarySearchResults.nestedCurrentItem?.type.label(context) ??
|
||||
context.localized.library(0))),
|
||||
itemCountWidget.toPopupMenuItem(useIcons: true),
|
||||
refreshAction.toPopupMenuItem(useIcons: true),
|
||||
itemViewAction.toPopupMenuItem(useIcons: true),
|
||||
if (itemActions.isNotEmpty) ItemActionDivider().toPopupMenuItem(),
|
||||
...itemActions.popupMenuItems(useIcons: true),
|
||||
],
|
||||
elevation: 8.0,
|
||||
);
|
||||
} else {
|
||||
await showBottomSheetPill(
|
||||
context: context,
|
||||
content: (context, scrollController) => ListView(
|
||||
shrinkWrap: true,
|
||||
controller: scrollController,
|
||||
children: [
|
||||
itemCountWidget.toListItem(context, useIcons: true),
|
||||
refreshAction.toListItem(context, useIcons: true),
|
||||
itemViewAction.toListItem(context, useIcons: true),
|
||||
if (itemActions.isNotEmpty) ItemActionDivider().toListItem(context),
|
||||
...itemActions.listTileItems(context, useIcons: true),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Icon(
|
||||
isFavorite
|
||||
? librarySearchResults.nestedCurrentItem?.type.selectedicon
|
||||
: librarySearchResults.nestedCurrentItem?.type.icon ??
|
||||
IconsaxOutline.document,
|
||||
color: isFavorite ? Theme.of(context).colorScheme.primary : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
if (AdaptiveLayout.of(context).layout == LayoutState.phone) ...[
|
||||
const SizedBox(width: 6),
|
||||
SizedBox.square(dimension: 46, child: SettingsUserIcon()),
|
||||
],
|
||||
const SizedBox(width: 12)
|
||||
],
|
||||
title: Hero(
|
||||
tag: "PrimarySearch",
|
||||
child: SuggestionSearchBar(
|
||||
autoFocus: isEmptySearchScreen,
|
||||
key: uniqueKey,
|
||||
title: librarySearchResults.searchBarTitle(context),
|
||||
debounceDuration: const Duration(seconds: 1),
|
||||
onItem: (value) async {
|
||||
await value.navigateTo(context);
|
||||
refreshKey.currentState?.show();
|
||||
},
|
||||
onSubmited: (value) async {
|
||||
if (librarySearchResults.searchQuery != value) {
|
||||
libraryProvider.setSearch(value);
|
||||
refreshKey.currentState?.show();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size(0, 50),
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, AdaptiveLayout.of(context).isDesktop ? -20 : -15),
|
||||
child: IgnorePointer(
|
||||
ignoring: librarySearchResults.loading,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: librarySearchResults.loading ? 0.5 : 1,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(8),
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: LibraryFilterChips(
|
||||
controller: scrollController,
|
||||
libraryProvider: libraryProvider,
|
||||
librarySearchResults: librarySearchResults,
|
||||
uniqueKey: uniqueKey,
|
||||
postersList: postersList,
|
||||
libraryViewType: libraryViewType,
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (AdaptiveLayout.of(context).isDesktop)
|
||||
SliverToBoxAdapter(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
PosterSizeWidget(),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (postersList.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
left: MediaQuery.of(context).padding.left,
|
||||
right: MediaQuery.of(context).padding.right),
|
||||
sliver: LibraryViews(
|
||||
key: uniqueKey,
|
||||
items: postersList,
|
||||
groupByType: librarySearchResults.groupBy,
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: Text(context.localized.noItemsToShow),
|
||||
),
|
||||
),
|
||||
const DefautlSliverBottomPadding(),
|
||||
const SliverPadding(padding: EdgeInsets.only(bottom: 80))
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (librarySearchResults.fetchingItems) ...[
|
||||
Container(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
),
|
||||
Center(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator.adaptive(),
|
||||
Text(context.localized.fetchingLibrary, style: Theme.of(context).textTheme.titleMedium),
|
||||
IconButton(
|
||||
onPressed: () => libraryProvider.cancelFetch(),
|
||||
icon: Icon(IconsaxOutline.close_square),
|
||||
)
|
||||
].addInBetween(const SizedBox(width: 16)),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AlwaysScrollableNoImplicitScrollPhysics extends ScrollPhysics {
|
||||
/// Creates scroll physics that always lets the user scroll.
|
||||
const AlwaysScrollableNoImplicitScrollPhysics({super.parent});
|
||||
|
||||
@override
|
||||
AlwaysScrollableNoImplicitScrollPhysics applyTo(ScrollPhysics? ancestor) {
|
||||
return AlwaysScrollableNoImplicitScrollPhysics(parent: buildParent(ancestor));
|
||||
}
|
||||
|
||||
@override
|
||||
bool get allowImplicitScrolling => false;
|
||||
|
||||
@override
|
||||
bool shouldAcceptUserOffset(ScrollMetrics position) => true;
|
||||
|
||||
@override
|
||||
bool recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) => false;
|
||||
}
|
||||
|
||||
class _LibrarySearchBottomBar extends ConsumerWidget {
|
||||
final Key uniqueKey;
|
||||
final ScrollController scrollController;
|
||||
final LibrarySearchNotifier libraryProvider;
|
||||
final List<ItemBaseModel> postersList;
|
||||
final GlobalKey<RefreshIndicatorState> refreshKey;
|
||||
const _LibrarySearchBottomBar({
|
||||
required this.uniqueKey,
|
||||
required this.scrollController,
|
||||
required this.libraryProvider,
|
||||
required this.postersList,
|
||||
required this.refreshKey,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final librarySearchResults = ref.watch(librarySearchProvider(uniqueKey));
|
||||
final actions = [
|
||||
ItemActionButton(
|
||||
action: () async {
|
||||
await libraryProvider.setSelectedAsFavorite(true);
|
||||
if (context.mounted) context.refreshData();
|
||||
},
|
||||
label: Text(context.localized.addAsFavorite),
|
||||
icon: const Icon(IconsaxOutline.heart_add),
|
||||
),
|
||||
ItemActionButton(
|
||||
action: () async {
|
||||
await libraryProvider.setSelectedAsFavorite(false);
|
||||
if (context.mounted) context.refreshData();
|
||||
},
|
||||
label: Text(context.localized.removeAsFavorite),
|
||||
icon: const Icon(IconsaxOutline.heart_remove),
|
||||
),
|
||||
ItemActionButton(
|
||||
action: () async {
|
||||
await libraryProvider.setSelectedAsWatched(true);
|
||||
if (context.mounted) context.refreshData();
|
||||
},
|
||||
label: Text(context.localized.markAsWatched),
|
||||
icon: const Icon(IconsaxOutline.eye),
|
||||
),
|
||||
ItemActionButton(
|
||||
action: () async {
|
||||
await libraryProvider.setSelectedAsWatched(false);
|
||||
if (context.mounted) context.refreshData();
|
||||
},
|
||||
label: Text(context.localized.markAsUnwatched),
|
||||
icon: const Icon(IconsaxOutline.eye_slash),
|
||||
),
|
||||
if (librarySearchResults.nestedCurrentItem is BoxSetModel)
|
||||
ItemActionButton(
|
||||
action: () async {
|
||||
await libraryProvider.removeSelectedFromCollection();
|
||||
if (context.mounted) context.refreshData();
|
||||
},
|
||||
label: Text(context.localized.removeFromCollection),
|
||||
icon: Container(
|
||||
decoration:
|
||||
BoxDecoration(color: Theme.of(context).colorScheme.onPrimary, borderRadius: BorderRadius.circular(6)),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(3.0),
|
||||
child: Icon(IconsaxOutline.save_remove, size: 20),
|
||||
),
|
||||
)),
|
||||
if (librarySearchResults.nestedCurrentItem is PlaylistModel)
|
||||
ItemActionButton(
|
||||
action: () async {
|
||||
await libraryProvider.removeSelectedFromPlaylist();
|
||||
if (context.mounted) context.refreshData();
|
||||
},
|
||||
label: Text(context.localized.removeFromPlaylist),
|
||||
icon: const Icon(IconsaxOutline.save_remove),
|
||||
),
|
||||
ItemActionButton(
|
||||
action: () async {
|
||||
await addItemToCollection(context, librarySearchResults.selectedPosters);
|
||||
if (context.mounted) context.refreshData();
|
||||
},
|
||||
label: Text(context.localized.addToCollection),
|
||||
icon: Icon(
|
||||
IconsaxOutline.save_add,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
ItemActionButton(
|
||||
action: () async {
|
||||
await addItemToPlaylist(context, librarySearchResults.selectedPosters);
|
||||
if (context.mounted) context.refreshData();
|
||||
},
|
||||
label: Text(context.localized.addToPlaylist),
|
||||
icon: const Icon(IconsaxOutline.save_add),
|
||||
),
|
||||
];
|
||||
return NestedBottomAppBar(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ScrollStatePosition(
|
||||
controller: scrollController,
|
||||
positionBuilder: (state) => AnimatedFadeSize(
|
||||
child: state != ScrollState.top
|
||||
? Tooltip(
|
||||
message: context.localized.scrollToTop,
|
||||
child: FlatButton(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
elevation: 0,
|
||||
borderRadiusGeometry: BorderRadius.circular(6),
|
||||
onTap: () => scrollController.animateTo(0,
|
||||
duration: const Duration(milliseconds: 500), curve: Curves.easeInOutCubic),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Icon(
|
||||
IconsaxOutline.arrow_up_3,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
if (!librarySearchResults.selecteMode) ...{
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
tooltip: context.localized.sortBy,
|
||||
onPressed: () async {
|
||||
final newOptions = await openSortByDialogue(
|
||||
context,
|
||||
libraryProvider: libraryProvider,
|
||||
uniqueKey: uniqueKey,
|
||||
options: (librarySearchResults.sortingOption, librarySearchResults.sortOrder),
|
||||
);
|
||||
if (newOptions != null) {
|
||||
if (newOptions.$1 != null) {
|
||||
libraryProvider.setSortBy(newOptions.$1!);
|
||||
}
|
||||
if (newOptions.$2 != null) {
|
||||
libraryProvider.setSortOrder(newOptions.$2!);
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(IconsaxOutline.sort),
|
||||
),
|
||||
if (librarySearchResults.hasActiveFilters) ...{
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
tooltip: context.localized.disableFilters,
|
||||
onPressed: disableFilters(librarySearchResults, libraryProvider),
|
||||
icon: const Icon(IconsaxOutline.filter_remove),
|
||||
),
|
||||
},
|
||||
},
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
onPressed: () => libraryProvider.toggleSelectMode(),
|
||||
color: librarySearchResults.selecteMode ? Theme.of(context).colorScheme.primary : null,
|
||||
icon: const Icon(IconsaxOutline.category_2),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
AnimatedFadeSize(
|
||||
child: librarySearchResults.selecteMode
|
||||
? Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
child: Row(
|
||||
children: [
|
||||
Tooltip(
|
||||
message: context.localized.selectAll,
|
||||
child: IconButton(
|
||||
onPressed: () => libraryProvider.selectAll(true),
|
||||
icon: const Icon(IconsaxOutline.box_add),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Tooltip(
|
||||
message: context.localized.clearSelection,
|
||||
child: IconButton(
|
||||
onPressed: () => libraryProvider.selectAll(false),
|
||||
icon: const Icon(IconsaxOutline.box_remove),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
if (librarySearchResults.selectedPosters.isNotEmpty) ...{
|
||||
if (AdaptiveLayout.of(context).isDesktop)
|
||||
PopupMenuButton(
|
||||
itemBuilder: (context) => actions.popupMenuItems(useIcons: true),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
showBottomSheetPill(
|
||||
context: context,
|
||||
content: (context, scrollController) => ListView(
|
||||
shrinkWrap: true,
|
||||
controller: scrollController,
|
||||
children: actions.listTileItems(context, useIcons: true),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: Icon(IconsaxOutline.more))
|
||||
},
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox(),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
tooltip: context.localized.random,
|
||||
onPressed: () => libraryProvider.openRandom(context),
|
||||
icon: Card(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
child: Icon(
|
||||
IconsaxBold.arrow_up_1,
|
||||
color: Theme.of(context).colorScheme.onSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (librarySearchResults.showGalleryButtons)
|
||||
IconButton(
|
||||
tooltip: context.localized.shuffleGallery,
|
||||
onPressed: () => libraryProvider.viewGallery(context, shuffle: true),
|
||||
icon: Card(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
child: Icon(
|
||||
IconsaxBold.shuffle,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (librarySearchResults.showPlayButtons)
|
||||
IconButton(
|
||||
tooltip: context.localized.shuffleVideos,
|
||||
onPressed: librarySearchResults.activePosters.isNotEmpty
|
||||
? () async {
|
||||
await libraryProvider.playLibraryItems(context, ref, shuffle: true);
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(IconsaxOutline.shuffle),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (AdaptiveLayout.of(context).isDesktop) SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void Function()? disableFilters(LibrarySearchModel librarySearchResults, LibrarySearchNotifier libraryProvider) {
|
||||
return () {
|
||||
libraryProvider.clearAllFilters();
|
||||
refreshKey.currentState?.show();
|
||||
};
|
||||
}
|
||||
}
|
||||
219
lib/screens/library_search/widgets/library_filter_chips.dart
Normal file
219
lib/screens/library_search/widgets/library_filter_chips.dart
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/jellyfin/jellyfin_open_api.enums.swagger.dart';
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/models/items/item_shared_models.dart';
|
||||
import 'package:fladder/models/library_search/library_search_model.dart';
|
||||
import 'package:fladder/models/library_search/library_search_options.dart';
|
||||
import 'package:fladder/providers/library_search_provider.dart';
|
||||
import 'package:fladder/screens/library_search/widgets/library_views.dart';
|
||||
import 'package:fladder/screens/shared/chips/category_chip.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/map_bool_helper.dart';
|
||||
import 'package:fladder/util/refresh_state.dart';
|
||||
import 'package:fladder/widgets/shared/scroll_position.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class LibraryFilterChips extends ConsumerWidget {
|
||||
final Key uniqueKey;
|
||||
final ScrollController controller;
|
||||
final LibrarySearchModel librarySearchResults;
|
||||
final LibrarySearchNotifier libraryProvider;
|
||||
final List<ItemBaseModel> postersList;
|
||||
final LibraryViewTypes libraryViewType;
|
||||
const LibraryFilterChips({
|
||||
required this.uniqueKey,
|
||||
required this.controller,
|
||||
required this.librarySearchResults,
|
||||
required this.libraryProvider,
|
||||
required this.postersList,
|
||||
required this.libraryViewType,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ScrollStatePosition(
|
||||
controller: controller,
|
||||
positionBuilder: (state) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: libraryFilterChips(
|
||||
context,
|
||||
ref,
|
||||
uniqueKey,
|
||||
librarySearchResults: librarySearchResults,
|
||||
libraryProvider: libraryProvider,
|
||||
postersList: postersList,
|
||||
libraryViewType: libraryViewType,
|
||||
).addPadding(const EdgeInsets.symmetric(horizontal: 8)),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<Widget> libraryFilterChips(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Key uniqueKey, {
|
||||
required LibrarySearchModel librarySearchResults,
|
||||
required LibrarySearchNotifier libraryProvider,
|
||||
required List<ItemBaseModel> postersList,
|
||||
required LibraryViewTypes libraryViewType,
|
||||
}) {
|
||||
Future<dynamic> openGroupDialogue() {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
return AlertDialog.adaptive(
|
||||
content: SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.65,
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
Text(context.localized.groupBy),
|
||||
...GroupBy.values.map((groupBy) => RadioListTile.adaptive(
|
||||
value: groupBy,
|
||||
title: Text(groupBy.value(context)),
|
||||
groupValue: ref.watch(librarySearchProvider(uniqueKey).select((value) => value.groupBy)),
|
||||
onChanged: (value) {
|
||||
libraryProvider.setGroupBy(groupBy);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
if (librarySearchResults.folderOverwrite.isEmpty)
|
||||
CategoryChip(
|
||||
label: Text(context.localized.library(2)),
|
||||
items: librarySearchResults.views,
|
||||
labelBuilder: (item) => Text(item.name),
|
||||
onSave: (value) => libraryProvider.setViews(value),
|
||||
onCancel: () => libraryProvider.setViews(librarySearchResults.views),
|
||||
onClear: () => libraryProvider.setViews(librarySearchResults.views.setAll(false)),
|
||||
),
|
||||
CategoryChip<FladderItemType>(
|
||||
label: Text(context.localized.type(librarySearchResults.types.length)),
|
||||
items: librarySearchResults.types,
|
||||
labelBuilder: (item) => Row(
|
||||
children: [
|
||||
Icon(item.icon),
|
||||
const SizedBox(width: 12),
|
||||
Text(item.label(context)),
|
||||
],
|
||||
),
|
||||
onSave: (value) => libraryProvider.setTypes(value),
|
||||
onClear: () => libraryProvider.setTypes(librarySearchResults.types.setAll(false)),
|
||||
),
|
||||
FilterChip(
|
||||
label: Text(context.localized.favorites),
|
||||
avatar: Icon(
|
||||
librarySearchResults.favourites ? IconsaxBold.heart : IconsaxOutline.heart,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
selected: librarySearchResults.favourites,
|
||||
showCheckmark: false,
|
||||
onSelected: (value) {
|
||||
libraryProvider.toggleFavourite();
|
||||
context.refreshData();
|
||||
},
|
||||
),
|
||||
FilterChip(
|
||||
label: Text(context.localized.recursive),
|
||||
selected: librarySearchResults.recursive,
|
||||
onSelected: (value) {
|
||||
libraryProvider.toggleRecursive();
|
||||
context.refreshData();
|
||||
},
|
||||
),
|
||||
if (librarySearchResults.genres.isNotEmpty)
|
||||
CategoryChip<String>(
|
||||
label: Text(context.localized.genre(librarySearchResults.genres.length)),
|
||||
activeIcon: IconsaxBold.hierarchy_2,
|
||||
items: librarySearchResults.genres,
|
||||
labelBuilder: (item) => Text(item),
|
||||
onSave: (value) => libraryProvider.setGenres(value),
|
||||
onCancel: () => libraryProvider.setGenres(librarySearchResults.genres),
|
||||
onClear: () => libraryProvider.setGenres(librarySearchResults.genres.setAll(false)),
|
||||
),
|
||||
if (librarySearchResults.studios.isNotEmpty)
|
||||
CategoryChip<Studio>(
|
||||
label: Text(context.localized.studio(librarySearchResults.studios.length)),
|
||||
activeIcon: IconsaxBold.airdrop,
|
||||
items: librarySearchResults.studios,
|
||||
labelBuilder: (item) => Text(item.name),
|
||||
onSave: (value) => libraryProvider.setStudios(value),
|
||||
onCancel: () => libraryProvider.setStudios(librarySearchResults.studios),
|
||||
onClear: () => libraryProvider.setStudios(librarySearchResults.studios.setAll(false)),
|
||||
),
|
||||
if (librarySearchResults.tags.isNotEmpty)
|
||||
CategoryChip<String>(
|
||||
label: Text(context.localized.label(librarySearchResults.tags.length)),
|
||||
activeIcon: Icons.label_rounded,
|
||||
items: librarySearchResults.tags,
|
||||
labelBuilder: (item) => Text(item),
|
||||
onSave: (value) => libraryProvider.setTags(value),
|
||||
onCancel: () => libraryProvider.setTags(librarySearchResults.tags),
|
||||
onClear: () => libraryProvider.setTags(librarySearchResults.tags.setAll(false)),
|
||||
),
|
||||
FilterChip(
|
||||
label: Text(context.localized.group),
|
||||
selected: librarySearchResults.groupBy != GroupBy.none,
|
||||
onSelected: (value) {
|
||||
openGroupDialogue();
|
||||
},
|
||||
),
|
||||
CategoryChip<ItemFilter>(
|
||||
label: Text(context.localized.filter(librarySearchResults.filters.length)),
|
||||
items: librarySearchResults.filters,
|
||||
labelBuilder: (item) => Text(item.label(context)),
|
||||
onSave: (value) => libraryProvider.setFilters(value),
|
||||
onClear: () => libraryProvider.setFilters(librarySearchResults.filters.setAll(false)),
|
||||
),
|
||||
if (librarySearchResults.types[FladderItemType.series] == true)
|
||||
FilterChip(
|
||||
avatar: Icon(
|
||||
librarySearchResults.hideEmtpyShows ? Icons.visibility_rounded : Icons.visibility_off_rounded,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
selected: librarySearchResults.hideEmtpyShows,
|
||||
showCheckmark: false,
|
||||
label: Text(librarySearchResults.hideEmtpyShows ? context.localized.showEmpty : context.localized.hideEmpty),
|
||||
onSelected: libraryProvider.setHideEmpty,
|
||||
),
|
||||
if (librarySearchResults.officialRatings.isNotEmpty)
|
||||
CategoryChip<String>(
|
||||
label: Text(context.localized.rating(librarySearchResults.officialRatings.length)),
|
||||
activeIcon: Icons.star_rate_rounded,
|
||||
items: librarySearchResults.officialRatings,
|
||||
labelBuilder: (item) => Text(item),
|
||||
onSave: (value) => libraryProvider.setRatings(value),
|
||||
onCancel: () => libraryProvider.setRatings(librarySearchResults.officialRatings),
|
||||
onClear: () => libraryProvider.setRatings(librarySearchResults.officialRatings.setAll(false)),
|
||||
),
|
||||
if (librarySearchResults.years.isNotEmpty)
|
||||
CategoryChip<int>(
|
||||
label: Text(context.localized.year(librarySearchResults.years.length)),
|
||||
items: librarySearchResults.years,
|
||||
labelBuilder: (item) => Text(item.toString()),
|
||||
onSave: (value) => libraryProvider.setYears(value),
|
||||
onCancel: () => libraryProvider.setYears(librarySearchResults.years),
|
||||
onClear: () => libraryProvider.setYears(librarySearchResults.years.setAll(false)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import 'package:fladder/models/library_search/library_search_options.dart';
|
||||
import 'package:fladder/providers/library_search_provider.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Future<(SortingOptions? sortOptions, SortingOrder? sortingOrder)?> openSortByDialogue(
|
||||
BuildContext context, {
|
||||
required (SortingOptions sortOptions, SortingOrder sortingOrder) options,
|
||||
required LibrarySearchNotifier libraryProvider,
|
||||
required Key uniqueKey,
|
||||
}) async {
|
||||
SortingOptions? newSortingOptions = options.$1;
|
||||
SortingOrder? newSortOrder = options.$2;
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, state) {
|
||||
return AlertDialog.adaptive(
|
||||
content: SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.65,
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Text(context.localized.sortBy, style: Theme.of(context).textTheme.titleLarge),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...SortingOptions.values.map((e) => RadioListTile.adaptive(
|
||||
value: e,
|
||||
title: Text(e.label(context)),
|
||||
groupValue: newSortingOptions,
|
||||
onChanged: (value) {
|
||||
state(
|
||||
() {
|
||||
newSortingOptions = value;
|
||||
},
|
||||
);
|
||||
},
|
||||
)),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Divider(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Text(context.localized.sortOrder, style: Theme.of(context).textTheme.titleLarge),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...SortingOrder.values.map(
|
||||
(e) => RadioListTile.adaptive(
|
||||
value: e,
|
||||
title: Text(e.label(context)),
|
||||
groupValue: newSortOrder,
|
||||
onChanged: (value) {
|
||||
state(
|
||||
() {
|
||||
newSortOrder = value;
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
if (newSortingOptions == null && newSortOrder == null) {
|
||||
return null;
|
||||
} else {
|
||||
return (newSortingOptions, newSortOrder);
|
||||
}
|
||||
}
|
||||
387
lib/screens/library_search/widgets/library_views.dart
Normal file
387
lib/screens/library_search/widgets/library_views.dart
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/boxset_model.dart';
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/models/items/photos_model.dart';
|
||||
import 'package:fladder/models/library_search/library_search_options.dart';
|
||||
import 'package:fladder/models/playlist_model.dart';
|
||||
import 'package:fladder/providers/library_search_provider.dart';
|
||||
import 'package:fladder/providers/settings/client_settings_provider.dart';
|
||||
import 'package:fladder/screens/photo_viewer/photo_viewer_screen.dart';
|
||||
import 'package:fladder/screens/shared/media/poster_grid.dart';
|
||||
import 'package:fladder/screens/shared/media/poster_list_item.dart';
|
||||
import 'package:fladder/screens/shared/media/poster_widget.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/refresh_state.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:page_transition/page_transition.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
import 'package:sticky_headers/sticky_headers/widget.dart';
|
||||
|
||||
final libraryViewTypeProvider = StateProvider<LibraryViewTypes>((ref) {
|
||||
return LibraryViewTypes.grid;
|
||||
});
|
||||
|
||||
enum LibraryViewTypes {
|
||||
grid(icon: IconsaxOutline.grid_2),
|
||||
list(icon: IconsaxOutline.grid_6),
|
||||
masonry(icon: IconsaxOutline.grid_3);
|
||||
|
||||
const LibraryViewTypes({required this.icon});
|
||||
|
||||
String label(BuildContext context) => switch (this) {
|
||||
LibraryViewTypes.grid => context.localized.grid,
|
||||
LibraryViewTypes.list => context.localized.list,
|
||||
LibraryViewTypes.masonry => context.localized.masonry,
|
||||
};
|
||||
|
||||
final IconData icon;
|
||||
}
|
||||
|
||||
class LibraryViews extends ConsumerWidget {
|
||||
final List<ItemBaseModel> items;
|
||||
final GroupBy groupByType;
|
||||
final Function(ItemBaseModel)? onPressed;
|
||||
final Set<ItemActions> excludeActions = const {ItemActions.openParent};
|
||||
const LibraryViews({required this.items, required this.groupByType, this.onPressed, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
sliver: SliverAnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: _getWidget(ref, context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getWidget(WidgetRef ref, BuildContext context) {
|
||||
final selected = ref.watch(librarySearchProvider(key!).select((value) => value.selectedPosters));
|
||||
final posterSizeMultiplier = ref.watch(clientSettingsProvider.select((value) => value.posterSize));
|
||||
final libraryProvider = ref.read(librarySearchProvider(key!).notifier);
|
||||
final posterSize = MediaQuery.sizeOf(context).width /
|
||||
(AdaptiveLayout.poster(context).gridRatio *
|
||||
ref.watch(clientSettingsProvider.select((value) => value.posterSize)));
|
||||
final decimal = posterSize - posterSize.toInt();
|
||||
|
||||
final sortingOptions = ref.watch(librarySearchProvider(key!).select((value) => value.sortingOption));
|
||||
|
||||
List<ItemAction> otherActions(ItemBaseModel item) {
|
||||
return [
|
||||
if (ref.watch(librarySearchProvider(key!).select((value) => value.nestedCurrentItem is BoxSetModel))) ...{
|
||||
ItemActionButton(
|
||||
label: Text(context.localized.removeFromCollection),
|
||||
icon: Icon(IconsaxOutline.archive_slash),
|
||||
action: () async {
|
||||
await libraryProvider.removeFromCollection(items: [item]);
|
||||
if (context.mounted) {
|
||||
context.refreshData();
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
if (ref.watch(librarySearchProvider(key!).select((value) => value.nestedCurrentItem is PlaylistModel))) ...{
|
||||
ItemActionButton(
|
||||
label: Text(context.localized.removeFromPlaylist),
|
||||
icon: Icon(IconsaxOutline.archive_minus),
|
||||
action: () async {
|
||||
await libraryProvider.removeFromPlaylist(items: [item]);
|
||||
if (context.mounted) {
|
||||
context.refreshData();
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
switch (ref.watch(libraryViewTypeProvider)) {
|
||||
case LibraryViewTypes.grid:
|
||||
if (groupByType != GroupBy.none) {
|
||||
final groupedItems = groupItemsBy(context, items, groupByType);
|
||||
return SliverList.builder(
|
||||
itemCount: groupedItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final name = groupedItems.keys.elementAt(index);
|
||||
final group = groupedItems[name];
|
||||
if (group?.isEmpty ?? false || group == null) {
|
||||
return Text(context.localized.empty);
|
||||
}
|
||||
return PosterGrid(
|
||||
posters: group!,
|
||||
name: name,
|
||||
itemBuilder: (context, index) {
|
||||
final item = group[index];
|
||||
return PosterWidget(
|
||||
key: Key(item.id),
|
||||
poster: group[index],
|
||||
maxLines: 2,
|
||||
heroTag: true,
|
||||
subTitle: item.subTitle(sortingOptions),
|
||||
excludeActions: excludeActions,
|
||||
otherActions: otherActions(item),
|
||||
selected: selected.contains(item),
|
||||
onUserDataChanged: (id, newData) => libraryProvider.updateUserData(id, newData),
|
||||
onItemRemoved: (oldItem) => libraryProvider.removeFromPosters([oldItem.id]),
|
||||
onItemUpdated: (newItem) => libraryProvider.updateItem(newItem),
|
||||
onPressed: (action, item) async => onItemPressed(action, key, item, ref, context),
|
||||
);
|
||||
},
|
||||
onPressed: (action, item) async => onItemPressed(action, key, item, ref, context),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
sliver: SliverGrid.builder(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: posterSize.toInt(),
|
||||
mainAxisSpacing: (8 * decimal) + 8,
|
||||
crossAxisSpacing: (8 * decimal) + 8,
|
||||
childAspectRatio: AdaptiveLayout.poster(context).ratio,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return PosterWidget(
|
||||
key: Key(item.id),
|
||||
poster: item,
|
||||
maxLines: 2,
|
||||
heroTag: true,
|
||||
subTitle: item.subTitle(sortingOptions),
|
||||
excludeActions: excludeActions,
|
||||
otherActions: otherActions(item),
|
||||
selected: selected.contains(item),
|
||||
onUserDataChanged: (id, newData) => libraryProvider.updateUserData(id, newData),
|
||||
onItemRemoved: (oldItem) => libraryProvider.removeFromPosters([oldItem.id]),
|
||||
onItemUpdated: (newItem) => libraryProvider.updateItem(newItem),
|
||||
onPressed: (action, item) async => onItemPressed(action, key, item, ref, context),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
case LibraryViewTypes.list:
|
||||
if (groupByType != GroupBy.none) {
|
||||
final groupedItems = groupItemsBy(context, items, groupByType);
|
||||
return SliverList.builder(
|
||||
itemCount: groupedItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final name = groupedItems.keys.elementAt(index);
|
||||
final group = groupedItems[name];
|
||||
if (group?.isEmpty ?? false) {
|
||||
return Text(context.localized.empty);
|
||||
}
|
||||
return StickyHeader(
|
||||
header: Text(name, style: Theme.of(context).textTheme.headlineSmall),
|
||||
content: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: group?.length,
|
||||
itemBuilder: (context, index) {
|
||||
final poster = group![index];
|
||||
return PosterListItem(
|
||||
key: Key(poster.id),
|
||||
poster: poster,
|
||||
subTitle: poster.subTitle(sortingOptions),
|
||||
excludeActions: excludeActions,
|
||||
otherActions: otherActions(poster),
|
||||
selected: selected.contains(poster),
|
||||
onUserDataChanged: (id, newData) => libraryProvider.updateUserData(id, newData),
|
||||
onItemRemoved: (oldItem) => libraryProvider.removeFromPosters([oldItem.id]),
|
||||
onItemUpdated: (newItem) => libraryProvider.updateItem(newItem),
|
||||
onPressed: (action, item) async => onItemPressed(action, key, item, ref, context),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
return SliverList.builder(
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final poster = items[index];
|
||||
return PosterListItem(
|
||||
poster: poster,
|
||||
selected: selected.contains(poster),
|
||||
excludeActions: excludeActions,
|
||||
otherActions: otherActions(poster),
|
||||
subTitle: poster.subTitle(sortingOptions),
|
||||
onUserDataChanged: (id, newData) => libraryProvider.updateUserData(id, newData),
|
||||
onItemRemoved: (oldItem) => libraryProvider.removeFromPosters([oldItem.id]),
|
||||
onItemUpdated: (newItem) => libraryProvider.updateItem(newItem),
|
||||
onPressed: (action, item) async => onItemPressed(action, key, item, ref, context),
|
||||
);
|
||||
},
|
||||
);
|
||||
case LibraryViewTypes.masonry:
|
||||
if (groupByType != GroupBy.none) {
|
||||
final groupedItems = groupItemsBy(context, items, groupByType);
|
||||
return SliverList.builder(
|
||||
itemCount: groupedItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final name = groupedItems.keys.elementAt(index);
|
||||
final group = groupedItems[name];
|
||||
if (group?.isEmpty ?? false) {
|
||||
return Text(context.localized.empty);
|
||||
}
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: index == 0 ? 0 : 64.0),
|
||||
child: StickyHeader(
|
||||
header: Text(name, style: Theme.of(context).textTheme.headlineMedium),
|
||||
overlapHeaders: true,
|
||||
content: Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: MasonryGridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: (8 * decimal) + 8,
|
||||
crossAxisSpacing: (8 * decimal) + 8,
|
||||
gridDelegate: SliverSimpleGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent:
|
||||
(MediaQuery.sizeOf(context).width ~/ (lerpDouble(250, 75, posterSizeMultiplier) ?? 1.0))
|
||||
.toDouble() *
|
||||
20,
|
||||
),
|
||||
itemCount: group!.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = group[index];
|
||||
return PosterWidget(
|
||||
key: Key(item.id),
|
||||
poster: item,
|
||||
aspectRatio: item.primaryRatio,
|
||||
selected: selected.contains(item),
|
||||
inlineTitle: true,
|
||||
heroTag: true,
|
||||
subTitle: item.subTitle(sortingOptions),
|
||||
excludeActions: excludeActions,
|
||||
otherActions: otherActions(group[index]),
|
||||
onUserDataChanged: (id, newData) => libraryProvider.updateUserData(id, newData),
|
||||
onItemRemoved: (oldItem) => libraryProvider.removeFromPosters([oldItem.id]),
|
||||
onItemUpdated: (newItem) => libraryProvider.updateItem(newItem),
|
||||
onPressed: (action, item) async => onItemPressed(action, key, item, ref, context),
|
||||
);
|
||||
},
|
||||
),
|
||||
)),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return SliverMasonryGrid.count(
|
||||
mainAxisSpacing: (8 * decimal) + 8,
|
||||
crossAxisSpacing: (8 * decimal) + 8,
|
||||
crossAxisCount: posterSize.toInt(),
|
||||
childCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return PosterWidget(
|
||||
poster: item,
|
||||
key: Key(item.id),
|
||||
aspectRatio: item.primaryRatio,
|
||||
selected: selected.contains(item),
|
||||
inlineTitle: true,
|
||||
heroTag: true,
|
||||
excludeActions: excludeActions,
|
||||
otherActions: otherActions(item),
|
||||
subTitle: item.subTitle(sortingOptions),
|
||||
onUserDataChanged: (id, newData) => libraryProvider.updateUserData(id, newData),
|
||||
onItemRemoved: (oldItem) => libraryProvider.removeFromPosters([oldItem.id]),
|
||||
onItemUpdated: (newItem) => libraryProvider.updateItem(newItem),
|
||||
onPressed: (action, item) async => onItemPressed(action, key, item, ref, context),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, List<ItemBaseModel>> groupItemsBy(BuildContext context, List<ItemBaseModel> list, GroupBy groupOption) {
|
||||
switch (groupOption) {
|
||||
case GroupBy.dateAdded:
|
||||
return groupBy(
|
||||
items,
|
||||
(poster) => DateFormat.yMMMMd().format(DateTime(
|
||||
poster.overview.dateAdded!.year, poster.overview.dateAdded!.month, poster.overview.dateAdded!.day)));
|
||||
case GroupBy.releaseDate:
|
||||
return groupBy(list, (poster) => poster.overview.yearAired?.toString() ?? context.localized.unknown);
|
||||
case GroupBy.rating:
|
||||
return groupBy(list, (poster) => poster.overview.parentalRating ?? context.localized.noRating);
|
||||
case GroupBy.tags:
|
||||
return groupByList(context, list, true);
|
||||
case GroupBy.genres:
|
||||
return groupByList(context, list, false);
|
||||
case GroupBy.name:
|
||||
return groupBy(list, (poster) => poster.name[0].capitalize());
|
||||
case GroupBy.type:
|
||||
return groupBy(list, (poster) => poster.type.label(context));
|
||||
case GroupBy.none:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onItemPressed(
|
||||
Function() action, Key? key, ItemBaseModel item, WidgetRef ref, BuildContext context) async {
|
||||
final selectMode = ref.read(librarySearchProvider(key!).select((value) => value.selecteMode));
|
||||
if (selectMode) {
|
||||
ref.read(librarySearchProvider(key).notifier).toggleSelection(item);
|
||||
return;
|
||||
}
|
||||
switch (item) {
|
||||
case PhotoModel _:
|
||||
final photoList = items.whereType<PhotoModel>().toList();
|
||||
if (context.mounted) {
|
||||
await Navigator.of(context, rootNavigator: true).push(
|
||||
PageTransition(
|
||||
child: PhotoViewerScreen(
|
||||
items: photoList,
|
||||
loadingItems: ref.read(librarySearchProvider(key).notifier).fetchGallery(),
|
||||
indexOfSelected: photoList.indexWhere((element) => element.id == item.id),
|
||||
),
|
||||
type: PageTransitionType.fade),
|
||||
);
|
||||
}
|
||||
if (context.mounted) context.refreshData();
|
||||
break;
|
||||
default:
|
||||
action.call();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, List<ItemBaseModel>> groupByList(BuildContext context, List<ItemBaseModel> items, bool tags) {
|
||||
Map<String, int> tagsCount = {};
|
||||
for (var item in items) {
|
||||
for (var tag in (tags ? item.overview.tags : item.overview.genres)) {
|
||||
tagsCount[tag] = (tagsCount[tag] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
List<String> sortedTags = tagsCount.keys.toList()..sort((a, b) => tagsCount[a]!.compareTo(tagsCount[b]!));
|
||||
|
||||
Map<String, List<ItemBaseModel>> groupedItems = {};
|
||||
|
||||
for (var item in items) {
|
||||
List<String> itemTags = (tags ? item.overview.tags : item.overview.genres);
|
||||
itemTags.sort((a, b) => sortedTags.indexOf(a).compareTo(sortedTags.indexOf(b)));
|
||||
String key = itemTags.take(2).join(', ');
|
||||
key = key.isNotEmpty ? key : context.localized.none;
|
||||
groupedItems[key] = [...(groupedItems[key] ?? []), item];
|
||||
}
|
||||
|
||||
return groupedItems;
|
||||
}
|
||||
184
lib/screens/library_search/widgets/suggestion_search_bar.dart
Normal file
184
lib/screens/library_search/widgets/suggestion_search_bar.dart
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/main.dart';
|
||||
import 'package:fladder/theme.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||
import 'package:page_transition/page_transition.dart';
|
||||
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/providers/library_search_provider.dart';
|
||||
import 'package:fladder/util/debouncer.dart';
|
||||
|
||||
class SuggestionSearchBar extends ConsumerStatefulWidget {
|
||||
final String? title;
|
||||
final bool autoFocus;
|
||||
final TextEditingController? textEditingController;
|
||||
final Duration debounceDuration;
|
||||
final SuggestionsController<ItemBaseModel>? suggestionsBoxController;
|
||||
final Function(String value)? onSubmited;
|
||||
final Function(String value)? onChanged;
|
||||
final Function(ItemBaseModel value)? onItem;
|
||||
const SuggestionSearchBar({
|
||||
this.title,
|
||||
this.autoFocus = false,
|
||||
this.textEditingController,
|
||||
this.debounceDuration = const Duration(milliseconds: 250),
|
||||
this.suggestionsBoxController,
|
||||
this.onSubmited,
|
||||
this.onChanged,
|
||||
this.onItem,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _SearchBarState();
|
||||
}
|
||||
|
||||
class _SearchBarState extends ConsumerState<SuggestionSearchBar> {
|
||||
late final Debouncer debouncer = Debouncer(widget.debounceDuration);
|
||||
late final SuggestionsController<ItemBaseModel> suggestionsBoxController =
|
||||
widget.suggestionsBoxController ?? SuggestionsController<ItemBaseModel>();
|
||||
late final TextEditingController textEditingController = widget.textEditingController ?? TextEditingController();
|
||||
bool isEmpty = true;
|
||||
final FocusNode focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.autoFocus) {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ref.listen(librarySearchProvider(widget.key!).select((value) => value.searchQuery), (previous, next) {
|
||||
if (textEditingController.text != next) {
|
||||
setState(() {
|
||||
textEditingController.text = next;
|
||||
});
|
||||
}
|
||||
});
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shadowColor: Colors.transparent,
|
||||
child: TypeAheadField<ItemBaseModel>(
|
||||
focusNode: focusNode,
|
||||
hideOnEmpty: isEmpty,
|
||||
emptyBuilder: (context) => Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
"${context.localized.noSuggestionsFound}...",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
suggestionsController: suggestionsBoxController,
|
||||
decorationBuilder: (context, child) => DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
borderRadius: FladderTheme.defaultShape.borderRadius,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
builder: (context, controller, focusNode) => TextField(
|
||||
focusNode: focusNode,
|
||||
controller: controller,
|
||||
onSubmitted: (value) {
|
||||
widget.onSubmited!(value);
|
||||
suggestionsBoxController.close();
|
||||
},
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
isEmpty = value.isEmpty;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.title ?? "${context.localized.search}...",
|
||||
prefixIcon: Icon(IconsaxOutline.search_normal),
|
||||
contentPadding: EdgeInsets.only(top: 13),
|
||||
suffixIcon: controller.text.isNotEmpty
|
||||
? IconButton(
|
||||
onPressed: () {
|
||||
widget.onSubmited?.call('');
|
||||
controller.text = '';
|
||||
suggestionsBoxController.close();
|
||||
setState(() {
|
||||
isEmpty = true;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.clear))
|
||||
: null,
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
loadingBuilder: (context) => const SizedBox(
|
||||
height: 50,
|
||||
child: Center(child: CircularProgressIndicator(strokeCap: StrokeCap.round)),
|
||||
),
|
||||
onSelected: (suggestion) {
|
||||
suggestionsBoxController.close();
|
||||
},
|
||||
itemBuilder: (context, suggestion) {
|
||||
return ListTile(
|
||||
onTap: () {
|
||||
if (widget.onItem != null) {
|
||||
widget.onItem?.call(suggestion);
|
||||
} else {
|
||||
Navigator.of(context)
|
||||
.push(PageTransition(child: suggestion.detailScreenWidget, type: PageTransitionType.fade));
|
||||
}
|
||||
},
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
title: SizedBox(
|
||||
height: 50,
|
||||
child: Row(
|
||||
children: [
|
||||
Card(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 0.8,
|
||||
child: CachedNetworkImage(
|
||||
cacheManager: CustomCacheManager.instance,
|
||||
imageUrl: suggestion.images?.primary?.path ?? "",
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
suggestion.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)),
|
||||
if (suggestion.overview.yearAired.toString().isNotEmpty)
|
||||
Flexible(
|
||||
child:
|
||||
Opacity(opacity: 0.45, child: Text(suggestion.overview.yearAired?.toString() ?? ""))),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
suggestionsCallback: (pattern) async {
|
||||
if (pattern.isEmpty) return [];
|
||||
if (widget.key != null) {
|
||||
return (await ref.read(librarySearchProvider(widget.key!).notifier).fetchSuggestions(pattern));
|
||||
}
|
||||
return [];
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
145
lib/screens/login/lock_screen.dart
Normal file
145
lib/screens/login/lock_screen.dart
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:fladder/models/account_model.dart';
|
||||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/routes/build_routes/route_builder.dart';
|
||||
import 'package:fladder/screens/login/widgets/login_icon.dart';
|
||||
import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
||||
import 'package:fladder/screens/shared/passcode_input.dart';
|
||||
import 'package:fladder/util/auth_service.dart';
|
||||
|
||||
final lockScreenActiveProvider = StateProvider<bool>((ref) => false);
|
||||
|
||||
class LockScreen extends ConsumerStatefulWidget {
|
||||
final bool selfLock;
|
||||
const LockScreen({this.selfLock = false, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _LockScreenState();
|
||||
}
|
||||
|
||||
class _LockScreenState extends ConsumerState<LockScreen> with WidgetsBindingObserver {
|
||||
bool poppingLockScreen = false;
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) async {
|
||||
switch (state) {
|
||||
case AppLifecycleState.resumed:
|
||||
hackyFixForBlackNavbar();
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void hackyFixForBlackNavbar() {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge, overlays: []);
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
Future.microtask(() {
|
||||
ref.read(lockScreenActiveProvider.notifier).update((state) => true);
|
||||
final user = ref.read(userProvider);
|
||||
if (user != null && !widget.selfLock) {
|
||||
tapLoggedInAccount(user);
|
||||
}
|
||||
});
|
||||
hackyFixForBlackNavbar();
|
||||
}
|
||||
|
||||
void handleLogin(AccountModel user) {
|
||||
ref.read(lockScreenActiveProvider.notifier).update((state) => false);
|
||||
poppingLockScreen = true;
|
||||
context.pop();
|
||||
}
|
||||
|
||||
void tapLoggedInAccount(AccountModel user) async {
|
||||
switch (user.authMethod) {
|
||||
case Authentication.autoLogin:
|
||||
handleLogin(user);
|
||||
break;
|
||||
case Authentication.biometrics:
|
||||
final authenticated = await AuthService.authenticateUser(context, user);
|
||||
if (authenticated && context.mounted) {
|
||||
handleLogin(user);
|
||||
}
|
||||
break;
|
||||
case Authentication.passcode:
|
||||
if (context.mounted) {
|
||||
showPassCodeDialog(context, (newPin) {
|
||||
if (newPin == user.localPin) {
|
||||
handleLogin(user);
|
||||
} else {
|
||||
fladderSnackbar(context, title: context.localized.incorrectPinTryAgain);
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
case Authentication.none:
|
||||
handleLogin(user);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = ref.watch(userProvider);
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvoked: (didPop) {
|
||||
if (!poppingLockScreen) {
|
||||
SystemNavigator.pop();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: Center(
|
||||
child: Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
alignment: WrapAlignment.center,
|
||||
runAlignment: WrapAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
IconsaxOutline.lock_1,
|
||||
size: 38,
|
||||
),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: 400,
|
||||
maxWidth: 400,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(64.0),
|
||||
child: LoginIcon(
|
||||
user: user!,
|
||||
onPressed: () => tapLoggedInAccount(user),
|
||||
),
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
ref.read(lockScreenActiveProvider.notifier).update((state) => false);
|
||||
context.routeGo(LoginRoute());
|
||||
},
|
||||
icon: const Icon(Icons.login_rounded),
|
||||
label: Text(context.localized.login),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
99
lib/screens/login/login_edit_user.dart
Normal file
99
lib/screens/login/login_edit_user.dart
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/account_model.dart';
|
||||
import 'package:fladder/providers/auth_provider.dart';
|
||||
import 'package:fladder/providers/shared_provider.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class LoginEditUser extends ConsumerWidget {
|
||||
final AccountModel user;
|
||||
final ValueChanged<String>? onTapServer;
|
||||
const LoginEditUser({required this.user, this.onTapServer, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return AlertDialog.adaptive(
|
||||
title: Center(child: Text(user.name)),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Divider(),
|
||||
if (user.credentials.serverName.isNotEmpty)
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.dns_rounded),
|
||||
const SizedBox(width: 8),
|
||||
Text(user.credentials.serverName),
|
||||
],
|
||||
),
|
||||
if (user.credentials.server.isNotEmpty)
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.http_rounded),
|
||||
const SizedBox(width: 8),
|
||||
Text(user.credentials.server),
|
||||
if (onTapServer != null) ...{
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filledTonal(
|
||||
onPressed: () {
|
||||
onTapServer?.call(user.credentials.server);
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.send_rounded,
|
||||
),
|
||||
)
|
||||
}
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Icon(user.authMethod.icon),
|
||||
const SizedBox(width: 8),
|
||||
Text(user.authMethod.name(context)),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Icon(IconsaxBold.clock),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(DateFormat.yMMMEd().format(user.lastUsed)),
|
||||
Text(DateFormat.Hms().format(user.lastUsed)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
Tooltip(
|
||||
message: "Removes the user and forces a logout",
|
||||
waitDuration: const Duration(milliseconds: 500),
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () async {
|
||||
await ref.read(sharedUtilityProvider).removeAccount(user);
|
||||
ref.read(authProvider.notifier).getSavedAccounts();
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.remove_rounded),
|
||||
label: const Text("Remove user"),
|
||||
),
|
||||
),
|
||||
),
|
||||
].addPadding(const EdgeInsets.symmetric(vertical: 8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
393
lib/screens/login/login_screen.dart
Normal file
393
lib/screens/login/login_screen.dart
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/screens/login/lock_screen.dart';
|
||||
import 'package:fladder/screens/login/widgets/discover_servers_widget.dart';
|
||||
import 'package:fladder/screens/shared/fladder_logo.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:fladder/models/account_model.dart';
|
||||
import 'package:fladder/providers/auth_provider.dart';
|
||||
import 'package:fladder/providers/shared_provider.dart';
|
||||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/routes/build_routes/home_routes.dart';
|
||||
import 'package:fladder/routes/build_routes/route_builder.dart';
|
||||
import 'package:fladder/screens/login/login_edit_user.dart';
|
||||
import 'package:fladder/screens/login/login_user_grid.dart';
|
||||
import 'package:fladder/screens/shared/animated_fade_size.dart';
|
||||
import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
||||
import 'package:fladder/screens/shared/outlined_text_field.dart';
|
||||
import 'package:fladder/screens/shared/passcode_input.dart';
|
||||
import 'package:fladder/util/auth_service.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/fladder_appbar.dart';
|
||||
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginScreen> {
|
||||
List<AccountModel> users = const [];
|
||||
bool loading = false;
|
||||
String? invalidUrl = "";
|
||||
bool startCheckingForErrors = false;
|
||||
bool addingNewUser = false;
|
||||
bool editingUsers = false;
|
||||
late final TextEditingController serverTextController = TextEditingController(text: "");
|
||||
|
||||
final usernameController = TextEditingController();
|
||||
final passwordController = TextEditingController();
|
||||
final FocusNode focusNode = FocusNode();
|
||||
|
||||
void startAddingNewUser() {
|
||||
setState(() {
|
||||
addingNewUser = true;
|
||||
editingUsers = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() {
|
||||
ref.read(userProvider.notifier).clear();
|
||||
final currentAccounts = ref.read(authProvider.notifier).getSavedAccounts();
|
||||
addingNewUser = currentAccounts.isEmpty;
|
||||
ref.read(lockScreenActiveProvider.notifier).update((state) => true);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loggedInUsers = ref.watch(authProvider.select((value) => value.accounts));
|
||||
final authLoading = ref.watch(authProvider.select((value) => value.loading));
|
||||
return Scaffold(
|
||||
appBar: const FladderAppbar(),
|
||||
floatingActionButton: !addingNewUser
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (!AdaptiveLayout.of(context).isDesktop)
|
||||
FloatingActionButton(
|
||||
key: Key("edit_button"),
|
||||
child: Icon(IconsaxOutline.edit_2),
|
||||
onPressed: () => setState(() => editingUsers = !editingUsers),
|
||||
),
|
||||
FloatingActionButton(
|
||||
key: Key("new_button"),
|
||||
child: Icon(IconsaxOutline.add_square),
|
||||
onPressed: startAddingNewUser,
|
||||
),
|
||||
].addInBetween(const SizedBox(width: 16)),
|
||||
)
|
||||
: null,
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 900),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32),
|
||||
children: [
|
||||
Center(
|
||||
child: FladderLogo(),
|
||||
),
|
||||
AnimatedFadeSize(
|
||||
child: addingNewUser
|
||||
? addUserFields(loggedInUsers, authLoading)
|
||||
: Column(
|
||||
key: UniqueKey(),
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
LoginUserGrid(
|
||||
users: loggedInUsers,
|
||||
editMode: editingUsers,
|
||||
onPressed: (user) async => tapLoggedInAccount(user),
|
||||
onLongPress: (user) => openUserEditDialogue(context, user),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
].addPadding(const EdgeInsets.symmetric(vertical: 16)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _parseUrl(String url) {
|
||||
setState(() {
|
||||
ref.read(authProvider.notifier).setServer("");
|
||||
users = [];
|
||||
|
||||
if (url.isEmpty) {
|
||||
invalidUrl = "";
|
||||
return;
|
||||
}
|
||||
if (!Uri.parse(url).isAbsolute) {
|
||||
invalidUrl = context.localized.invalidUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!url.startsWith('https://') && !url.startsWith('http://')) {
|
||||
invalidUrl = context.localized.invalidUrlDesc;
|
||||
return;
|
||||
}
|
||||
|
||||
invalidUrl = null;
|
||||
|
||||
if (invalidUrl == null) {
|
||||
ref.read(authProvider.notifier).setServer(url.rtrim('/'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void openUserEditDialogue(BuildContext context, AccountModel user) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => LoginEditUser(
|
||||
user: user,
|
||||
onTapServer: (value) {
|
||||
setState(() {
|
||||
_parseUrl(value);
|
||||
serverTextController.text = value;
|
||||
startAddingNewUser();
|
||||
});
|
||||
context.pop();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void tapLoggedInAccount(AccountModel user) async {
|
||||
switch (user.authMethod) {
|
||||
case Authentication.autoLogin:
|
||||
handleLogin(user);
|
||||
break;
|
||||
case Authentication.biometrics:
|
||||
final authenticated = await AuthService.authenticateUser(context, user);
|
||||
if (authenticated) {
|
||||
handleLogin(user);
|
||||
}
|
||||
break;
|
||||
case Authentication.passcode:
|
||||
if (context.mounted) {
|
||||
showPassCodeDialog(context, (newPin) {
|
||||
if (newPin == user.localPin) {
|
||||
handleLogin(user);
|
||||
} else {
|
||||
fladderSnackbar(context, title: context.localized.incorrectPinTryAgain);
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
case Authentication.none:
|
||||
handleLogin(user);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleLogin(AccountModel user) async {
|
||||
await ref.read(authProvider.notifier).switchUser();
|
||||
await ref.read(sharedUtilityProvider).updateAccountInfo(user.copyWith(
|
||||
lastUsed: DateTime.now(),
|
||||
));
|
||||
ref.read(userProvider.notifier).updateUser(user.copyWith(lastUsed: DateTime.now()));
|
||||
|
||||
loggedInGoToHome();
|
||||
}
|
||||
|
||||
void loggedInGoToHome() {
|
||||
ref.read(lockScreenActiveProvider.notifier).update((state) => false);
|
||||
if (context.mounted) {
|
||||
context.routeGo(DashboardRoute());
|
||||
}
|
||||
}
|
||||
|
||||
Future<Null> Function()? get enterCredentialsTryLogin => emptyFields()
|
||||
? null
|
||||
: () async {
|
||||
log('try login');
|
||||
serverTextController.text = serverTextController.text.rtrim('/');
|
||||
ref.read(authProvider.notifier).setServer(serverTextController.text.rtrim('/'));
|
||||
final response = await ref.read(authProvider.notifier).authenticateByName(
|
||||
usernameController.text,
|
||||
passwordController.text,
|
||||
);
|
||||
if (response?.isSuccessful == false) {
|
||||
fladderSnackbar(context,
|
||||
title:
|
||||
"(${response?.base.statusCode}) ${response?.base.reasonPhrase ?? context.localized.somethingWentWrongPasswordCheck}");
|
||||
} else if (response?.body != null) {
|
||||
loggedInGoToHome();
|
||||
}
|
||||
};
|
||||
|
||||
bool emptyFields() {
|
||||
return usernameController.text.isEmpty || passwordController.text.isEmpty;
|
||||
}
|
||||
|
||||
void retrieveListOfUsers() async {
|
||||
serverTextController.text = serverTextController.text.rtrim('/');
|
||||
ref.read(authProvider.notifier).setServer(serverTextController.text);
|
||||
setState(() => loading = true);
|
||||
final response = await ref.read(authProvider.notifier).getPublicUsers();
|
||||
if ((response == null || response.isSuccessful == false) && context.mounted) {
|
||||
fladderSnackbar(context, title: response?.base.reasonPhrase ?? context.localized.unableToConnectHost);
|
||||
setState(() => startCheckingForErrors = true);
|
||||
}
|
||||
if (response?.body?.isEmpty == true) {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
setState(() {
|
||||
users = response?.body ?? [];
|
||||
loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Widget addUserFields(List<AccountModel> accounts, bool authLoading) {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (accounts.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: IconButton.filledTonal(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
addingNewUser = false;
|
||||
loading = false;
|
||||
startCheckingForErrors = false;
|
||||
serverTextController.text = "";
|
||||
usernameController.text = "";
|
||||
passwordController.text = "";
|
||||
invalidUrl = "";
|
||||
});
|
||||
ref.read(authProvider.notifier).setServer("");
|
||||
},
|
||||
icon: const Icon(
|
||||
IconsaxOutline.arrow_left_2,
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: OutlinedTextField(
|
||||
controller: serverTextController,
|
||||
onChanged: _parseUrl,
|
||||
onSubmitted: (value) => retrieveListOfUsers(),
|
||||
autoFillHints: const [AutofillHints.url],
|
||||
keyboardType: TextInputType.url,
|
||||
textInputAction: TextInputAction.go,
|
||||
label: context.localized.server,
|
||||
errorText: (invalidUrl == null || serverTextController.text.isEmpty || !startCheckingForErrors)
|
||||
? null
|
||||
: invalidUrl,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Tooltip(
|
||||
message: context.localized.retrievePublicListOfUsers,
|
||||
waitDuration: const Duration(seconds: 1),
|
||||
child: IconButton.filled(
|
||||
onPressed: () => retrieveListOfUsers(),
|
||||
icon: const Icon(
|
||||
IconsaxOutline.refresh,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
AnimatedFadeSize(
|
||||
child: invalidUrl == null
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (loading || users.isNotEmpty)
|
||||
AnimatedFadeSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: loading
|
||||
? CircularProgressIndicator(key: UniqueKey(), strokeCap: StrokeCap.round)
|
||||
: LoginUserGrid(
|
||||
users: users,
|
||||
onPressed: (value) {
|
||||
usernameController.text = value.name;
|
||||
passwordController.text = "";
|
||||
focusNode.requestFocus();
|
||||
},
|
||||
),
|
||||
),
|
||||
AutofillGroup(
|
||||
child: Column(
|
||||
children: [
|
||||
OutlinedTextField(
|
||||
controller: usernameController,
|
||||
autoFillHints: const [AutofillHints.username],
|
||||
textInputAction: TextInputAction.next,
|
||||
onChanged: (value) => setState(() {}),
|
||||
label: context.localized.userName,
|
||||
),
|
||||
OutlinedTextField(
|
||||
controller: passwordController,
|
||||
autoFillHints: const [AutofillHints.password],
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
focusNode: focusNode,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (value) => enterCredentialsTryLogin?.call(),
|
||||
onChanged: (value) => setState(() {}),
|
||||
label: context.localized.password,
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: enterCredentialsTryLogin,
|
||||
child: authLoading
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
color: Theme.of(context).colorScheme.inversePrimary,
|
||||
strokeCap: StrokeCap.round),
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(context.localized.login),
|
||||
const SizedBox(width: 8),
|
||||
Icon(IconsaxBold.send_1),
|
||||
],
|
||||
),
|
||||
),
|
||||
].addPadding(const EdgeInsets.symmetric(vertical: 4)),
|
||||
),
|
||||
),
|
||||
].addPadding(const EdgeInsets.symmetric(vertical: 10)),
|
||||
)
|
||||
: DiscoverServersWidget(
|
||||
serverCredentials: accounts.map((e) => e.credentials).toList(),
|
||||
onPressed: (server) {
|
||||
serverTextController.text = server.address;
|
||||
_parseUrl(server.address);
|
||||
retrieveListOfUsers();
|
||||
},
|
||||
),
|
||||
)
|
||||
].addPadding(const EdgeInsets.symmetric(vertical: 8)),
|
||||
);
|
||||
}
|
||||
}
|
||||
149
lib/screens/login/login_user_grid.dart
Normal file
149
lib/screens/login/login_user_grid.dart
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/account_model.dart';
|
||||
import 'package:fladder/providers/auth_provider.dart';
|
||||
import 'package:fladder/screens/shared/user_icon.dart';
|
||||
import 'package:fladder/screens/shared/flat_button.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:reorderable_grid/reorderable_grid.dart';
|
||||
|
||||
class LoginUserGrid extends ConsumerWidget {
|
||||
final List<AccountModel> users;
|
||||
final bool editMode;
|
||||
final ValueChanged<AccountModel>? onPressed;
|
||||
final ValueChanged<AccountModel>? onLongPress;
|
||||
const LoginUserGrid({this.users = const [], this.onPressed, this.editMode = false, this.onLongPress, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mainAxisExtent = 175.0;
|
||||
final maxCount = (MediaQuery.of(context).size.width ~/ mainAxisExtent).clamp(1, 3);
|
||||
|
||||
return ReorderableGridView.builder(
|
||||
onReorder: (oldIndex, newIndex) => ref.read(authProvider.notifier).reOrderUsers(oldIndex, newIndex),
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
autoScroll: true,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: users.length == 1 ? 1 : maxCount,
|
||||
mainAxisSpacing: 24,
|
||||
crossAxisSpacing: 24,
|
||||
mainAxisExtent: mainAxisExtent,
|
||||
),
|
||||
itemCount: users.length,
|
||||
itemBuilder: (context, index) {
|
||||
final user = users[index];
|
||||
return _CardHolder(
|
||||
key: Key(user.id),
|
||||
content: Stack(
|
||||
children: [
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Flexible(
|
||||
child: UserIcon(
|
||||
labelStyle: Theme.of(context).textTheme.headlineMedium,
|
||||
user: user,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Icon(
|
||||
user.authMethod.icon,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
user.name,
|
||||
maxLines: 2,
|
||||
softWrap: true,
|
||||
)),
|
||||
],
|
||||
),
|
||||
if (user.credentials.serverName.isNotEmpty)
|
||||
Opacity(
|
||||
opacity: 0.75,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
const Icon(
|
||||
IconsaxBold.driver_2,
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
user.credentials.serverName,
|
||||
maxLines: 2,
|
||||
softWrap: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
].addInBetween(SizedBox(width: 4, height: 4)),
|
||||
),
|
||||
if (editMode)
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Card(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: const Icon(
|
||||
IconsaxBold.edit_2,
|
||||
size: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
onTap: () => editMode ? onLongPress?.call(user) : onPressed?.call(user),
|
||||
onLongPress: () => onLongPress?.call(user),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CardHolder extends StatelessWidget {
|
||||
final Widget content;
|
||||
final Function() onTap;
|
||||
final Function() onLongPress;
|
||||
|
||||
const _CardHolder({
|
||||
required this.content,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 1,
|
||||
shadowColor: Colors.transparent,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
margin: EdgeInsets.zero,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 150, maxWidth: 150),
|
||||
child: FlatButton(
|
||||
onTap: onTap,
|
||||
onLongPress: AdaptiveLayout.of(context).isDesktop ? onLongPress : null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
161
lib/screens/login/widgets/discover_servers_widget.dart
Normal file
161
lib/screens/login/widgets/discover_servers_widget.dart
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/credentials_model.dart';
|
||||
import 'package:fladder/providers/discovery_provider.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/theme_extensions.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class DiscoverServersWidget extends ConsumerWidget {
|
||||
final List<CredentialsModel> serverCredentials;
|
||||
final Function(DiscoveryInfo server) onPressed;
|
||||
const DiscoverServersWidget({
|
||||
required this.serverCredentials,
|
||||
required this.onPressed,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final existingServers = serverCredentials
|
||||
.map(
|
||||
(credentials) => DiscoveryInfo(
|
||||
id: credentials.serverId,
|
||||
name: credentials.serverName,
|
||||
address: credentials.server,
|
||||
endPointAddress: null),
|
||||
)
|
||||
.toSet()
|
||||
.toList();
|
||||
final discoverdServersStream = ref.watch(serverDiscoveryProvider);
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(6),
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
if (existingServers.isNotEmpty) ...[
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
context.localized.saved,
|
||||
style: context.textTheme.bodyLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
Opacity(opacity: 0.65, child: Icon(IconsaxOutline.bookmark, size: 16)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
...existingServers
|
||||
.map(
|
||||
(server) => _ServerInfoCard(
|
||||
server: server,
|
||||
onPressed: onPressed,
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
.addInBetween(const SizedBox(height: 4)),
|
||||
const Divider(),
|
||||
],
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
context.localized.discovered,
|
||||
style: context.textTheme.bodyLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
Opacity(opacity: 0.65, child: Icon(IconsaxBold.airdrop, size: 16)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
discoverdServersStream.when(
|
||||
data: (data) {
|
||||
final servers = data.where((discoverdServer) => !existingServers.contains(discoverdServer));
|
||||
return servers.isNotEmpty
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...servers.map(
|
||||
(serverInfo) => _ServerInfoCard(
|
||||
server: serverInfo,
|
||||
onPressed: onPressed,
|
||||
),
|
||||
)
|
||||
].toList().addInBetween(const SizedBox(height: 4)),
|
||||
)
|
||||
: Center(
|
||||
child: Opacity(
|
||||
opacity: 0.65,
|
||||
child: Text(
|
||||
context.localized.noServersFound,
|
||||
style: context.textTheme.bodyLarge,
|
||||
),
|
||||
));
|
||||
},
|
||||
error: (error, stackTrace) => Text(context.localized.error),
|
||||
loading: () => Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 24.0,
|
||||
child: CircularProgressIndicator.adaptive(strokeCap: StrokeCap.round),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServerInfoCard extends StatelessWidget {
|
||||
final Function(DiscoveryInfo server) onPressed;
|
||||
final DiscoveryInfo server;
|
||||
const _ServerInfoCard({
|
||||
required this.server,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: () => onPressed(server),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Card(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Icon(
|
||||
IconsaxBold.driver,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
server.name,
|
||||
style: context.textTheme.bodyLarge,
|
||||
),
|
||||
Opacity(
|
||||
opacity: 0.6,
|
||||
child: Text(
|
||||
server.address,
|
||||
style: context.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(IconsaxOutline.edit_2, size: 16)
|
||||
].addInBetween(const SizedBox(width: 12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
97
lib/screens/login/widgets/login_icon.dart
Normal file
97
lib/screens/login/widgets/login_icon.dart
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import 'package:fladder/models/account_model.dart';
|
||||
import 'package:fladder/screens/shared/flat_button.dart';
|
||||
import 'package:fladder/screens/shared/user_icon.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class LoginIcon extends ConsumerWidget {
|
||||
final AccountModel user;
|
||||
final Function()? onPressed;
|
||||
final Function()? onLongPress;
|
||||
final Function()? onNewPressed;
|
||||
const LoginIcon({
|
||||
required this.user,
|
||||
this.onPressed,
|
||||
this.onLongPress,
|
||||
this.onNewPressed,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: Card(
|
||||
elevation: 1,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
margin: EdgeInsets.zero,
|
||||
child: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: UserIcon(
|
||||
labelStyle: Theme.of(context).textTheme.displayMedium,
|
||||
size: const Size(125, 125),
|
||||
user: user,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (onNewPressed != null)
|
||||
Icon(
|
||||
user.authMethod.icon,
|
||||
size: 26,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
user.name,
|
||||
maxLines: 2,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
softWrap: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (user.credentials.serverName.isNotEmpty)
|
||||
Flexible(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.dns_rounded,
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
user.credentials.serverName,
|
||||
maxLines: 2,
|
||||
softWrap: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
].addInBetween(SizedBox(width: 8, height: 8)),
|
||||
),
|
||||
),
|
||||
FlatButton(
|
||||
onTap: onPressed,
|
||||
onLongPress: onLongPress,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
16
lib/screens/media_content.dart
Normal file
16
lib/screens/media_content.dart
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class MediaContent extends ConsumerStatefulWidget {
|
||||
const MediaContent({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => MediaContentState();
|
||||
}
|
||||
|
||||
class MediaContentState extends ConsumerState<MediaContent> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container();
|
||||
}
|
||||
}
|
||||
165
lib/screens/metadata/edit_item.dart
Normal file
165
lib/screens/metadata/edit_item.dart
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/providers/edit_item_provider.dart';
|
||||
import 'package:fladder/screens/metadata/edit_screens/edit_fields.dart';
|
||||
import 'package:fladder/screens/metadata/edit_screens/edit_image_content.dart';
|
||||
import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/refresh_state.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
Future<ItemBaseModel?> showEditItemPopup(
|
||||
BuildContext context,
|
||||
String itemId,
|
||||
) async {
|
||||
ItemBaseModel? updatedItem;
|
||||
var shouldRefresh = false;
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
useSafeArea: false,
|
||||
builder: (context) {
|
||||
Widget editWidget() => EditDialogSwitcher(
|
||||
id: itemId,
|
||||
itemUpdated: (newItem) => updatedItem = newItem,
|
||||
refreshOnClose: (refresh) => shouldRefresh = refresh,
|
||||
);
|
||||
return AdaptiveLayout.of(context).inputDevice == InputDevice.pointer
|
||||
? Dialog(
|
||||
insetPadding: EdgeInsets.all(64),
|
||||
child: editWidget(),
|
||||
)
|
||||
: Dialog.fullscreen(
|
||||
child: editWidget(),
|
||||
);
|
||||
},
|
||||
);
|
||||
if (shouldRefresh == true) {
|
||||
context.refreshData();
|
||||
}
|
||||
return updatedItem;
|
||||
}
|
||||
|
||||
class EditDialogSwitcher extends ConsumerStatefulWidget {
|
||||
final String id;
|
||||
final Function(ItemBaseModel? newItem) itemUpdated;
|
||||
final Function(bool refresh) refreshOnClose;
|
||||
|
||||
const EditDialogSwitcher({required this.id, required this.itemUpdated, required this.refreshOnClose, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _EditDialogSwitcherState();
|
||||
}
|
||||
|
||||
class _EditDialogSwitcherState extends ConsumerState<EditDialogSwitcher> with TickerProviderStateMixin {
|
||||
late final TabController tabController = TabController(length: 5, vsync: this);
|
||||
|
||||
Future<void> refreshEditor() async {
|
||||
return ref.read(editItemProvider.notifier).fetchInformation(widget.id);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() => refreshEditor());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentItem = ref.watch(editItemProvider.select((value) => value.item));
|
||||
final saving = ref.watch(editItemProvider.select((value) => value.saving));
|
||||
final state = ref.watch(editItemProvider).editedJson;
|
||||
final generalFields = ref.watch(editItemProvider.notifier).getFields ?? {};
|
||||
final advancedFields = ref.watch(editItemProvider.notifier).advancedFields ?? {};
|
||||
|
||||
Map<Tab, Widget> widgets = {
|
||||
Tab(text: "General"): EditFields(fields: generalFields, json: state),
|
||||
Tab(text: "Primary"): EditImageContent(type: ImageType.primary),
|
||||
Tab(text: "Logo"): EditImageContent(type: ImageType.logo),
|
||||
Tab(text: "Backdrops"): EditImageContent(type: ImageType.backdrop),
|
||||
Tab(text: "Advanced"): EditFields(fields: advancedFields, json: state),
|
||||
};
|
||||
|
||||
return Card(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(height: MediaQuery.paddingOf(context).top),
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
currentItem?.detailedName(context) ?? currentItem?.name ?? "",
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
IconButton(onPressed: () => refreshEditor(), icon: Icon(IconsaxOutline.refresh))
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: TabBar(
|
||||
isScrollable: true,
|
||||
controller: tabController,
|
||||
tabs: widgets.keys.toList(),
|
||||
),
|
||||
),
|
||||
Flexible(child: TabBarView(controller: tabController, children: widgets.values.toList())),
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ElevatedButton(onPressed: () => Navigator.of(context).pop(), child: Text(context.localized.close)),
|
||||
const SizedBox(width: 16),
|
||||
FilledButton(
|
||||
onPressed: saving
|
||||
? null
|
||||
: () async {
|
||||
final response = await ref.read(editItemProvider.notifier).saveInformation();
|
||||
if (response != null && context.mounted) {
|
||||
if (response.isSuccessful) {
|
||||
widget.itemUpdated(response.body);
|
||||
fladderSnackbar(context,
|
||||
title: context.localized.metaDataSavedFor(
|
||||
currentItem?.detailedName(context) ?? currentItem?.name ?? ""));
|
||||
} else {
|
||||
fladderSnackbarResponse(context, response);
|
||||
}
|
||||
}
|
||||
widget.refreshOnClose(true);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: saving
|
||||
? SizedBox(
|
||||
width: 21,
|
||||
height: 21,
|
||||
child: CircularProgressIndicator.adaptive(
|
||||
backgroundColor: Theme.of(context).colorScheme.onPrimary, strokeCap: StrokeCap.round),
|
||||
)
|
||||
: Text(context.localized.save),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
738
lib/screens/metadata/edit_screens/edit_fields.dart
Normal file
738
lib/screens/metadata/edit_screens/edit_fields.dart
Normal file
|
|
@ -0,0 +1,738 @@
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
|
||||
import 'package:fladder/models/items/item_shared_models.dart';
|
||||
import 'package:fladder/providers/edit_item_provider.dart';
|
||||
import 'package:fladder/screens/settings/settings_list_tile.dart';
|
||||
import 'package:fladder/screens/shared/focused_outlined_text_field.dart';
|
||||
import 'package:fladder/screens/shared/media/external_urls.dart';
|
||||
import 'package:fladder/screens/shared/outlined_text_field.dart';
|
||||
import 'package:fladder/util/jelly_id.dart';
|
||||
import 'package:fladder/util/list_extensions.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
import 'package:fladder/widgets/shared/adaptive_date_picker.dart';
|
||||
import 'package:fladder/widgets/shared/enum_selection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class EditFields extends ConsumerStatefulWidget {
|
||||
final Map<String, dynamic> fields;
|
||||
final Map<String, dynamic>? json;
|
||||
const EditFields({
|
||||
required this.fields,
|
||||
required this.json,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _EditGeneralState();
|
||||
}
|
||||
|
||||
class _EditGeneralState extends ConsumerState<EditFields> {
|
||||
TextEditingController? currentController = TextEditingController();
|
||||
String? currentEditingKey;
|
||||
List<String> expandedKeys = [];
|
||||
|
||||
final personName = TextEditingController();
|
||||
PersonKind personType = PersonKind.actor;
|
||||
final personRole = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
if (widget.json != null)
|
||||
...widget.fields.entries.map(
|
||||
(e) {
|
||||
final keyLabel = e.key.toUpperCaseSplit();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: switch (e.value) {
|
||||
Map<String, bool> _ => Builder(builder: (context) {
|
||||
final map = e.value as Map<String, bool>;
|
||||
return SettingsListTile(
|
||||
label: Text(keyLabel),
|
||||
trailing: EnumBox(
|
||||
current: map.entries.firstWhereOrNull((element) => element.value == true)?.key ?? "",
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
child: Text(""),
|
||||
onTap: () => ref.read(editItemProvider.notifier).updateField(MapEntry(e.key, "")),
|
||||
),
|
||||
...map.entries.map(
|
||||
(mapEntry) => PopupMenuItem(
|
||||
child: Text(mapEntry.key),
|
||||
onTap: () => ref
|
||||
.read(editItemProvider.notifier)
|
||||
.updateField(MapEntry(e.key, mapEntry.key)),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
List<String> _ => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 21),
|
||||
child: Builder(builder: (context) {
|
||||
final expanded = expandedKeys.contains(e.key);
|
||||
final list = e.value as List<String>;
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: () => setState(() => expandedKeys = expandedKeys.toggle(e.key)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
keyLabel,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () =>
|
||||
setState(() => expandedKeys = expandedKeys.toggle(e.key)),
|
||||
icon: Icon(expanded
|
||||
? Icons.keyboard_arrow_up_rounded
|
||||
: Icons.keyboard_arrow_down_rounded),
|
||||
)
|
||||
],
|
||||
),
|
||||
if (expanded) ...{
|
||||
const SizedBox(height: 6),
|
||||
...list.map(
|
||||
(genre) => Row(
|
||||
children: [
|
||||
Text(genre.toString()),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => ref.read(editItemProvider.notifier).updateField(
|
||||
MapEntry(e.key, list..remove(genre)),
|
||||
),
|
||||
icon: Icon(Icons.remove_rounded))
|
||||
],
|
||||
),
|
||||
),
|
||||
OutlinedTextField(
|
||||
label: "Add",
|
||||
controller: TextEditingController(),
|
||||
onSubmitted: (value) {
|
||||
ref.read(editItemProvider.notifier).updateField(
|
||||
MapEntry(e.key, list..add(value)),
|
||||
);
|
||||
},
|
||||
)
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
List<Person> _ => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 21),
|
||||
child: Builder(builder: (context) {
|
||||
final expanded = expandedKeys.contains(e.key);
|
||||
final list = e.value as List<Person>;
|
||||
|
||||
List<Map<String, dynamic>> listToMap(List<Person> people) {
|
||||
return people.map((e) => e.toPerson().toJson()).toList();
|
||||
}
|
||||
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: () => setState(() => expandedKeys = expandedKeys.toggle(e.key)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
keyLabel,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () =>
|
||||
setState(() => expandedKeys = expandedKeys.toggle(e.key)),
|
||||
icon: Icon(expanded
|
||||
? Icons.keyboard_arrow_up_rounded
|
||||
: Icons.keyboard_arrow_down_rounded),
|
||||
)
|
||||
],
|
||||
),
|
||||
if (expanded) ...{
|
||||
const SizedBox(height: 6),
|
||||
...list.map(
|
||||
(person) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 50,
|
||||
width: 50,
|
||||
child: Card(
|
||||
elevation: 2,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
child: Center(
|
||||
child: Text(
|
||||
person.name.getInitials(),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(person.name),
|
||||
Opacity(
|
||||
opacity: 0.65,
|
||||
child: Text(person.role.isNotEmpty
|
||||
? "${person.role} (${person.type}) "
|
||||
: person.type?.value ?? ""),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
ref.read(editItemProvider.notifier).updateField(
|
||||
MapEntry(e.key, listToMap(list..remove(person))));
|
||||
},
|
||||
icon: Icon(Icons.remove_rounded))
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedTextField(
|
||||
label: "Name",
|
||||
controller: personName,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Flexible(
|
||||
child: EnumBox<PersonKind>(
|
||||
current: personType.name.toUpperCaseSplit(),
|
||||
itemBuilder: (context) => [
|
||||
...PersonKind.values
|
||||
.whereNot(
|
||||
(element) => element == PersonKind.swaggerGeneratedUnknown)
|
||||
.map(
|
||||
(entry) => PopupMenuItem(
|
||||
child: Text(entry.name.toUpperCaseSplit()),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
personType = entry;
|
||||
});
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
ref.read(editItemProvider.notifier).updateField(MapEntry(
|
||||
e.key,
|
||||
listToMap(list
|
||||
..add(
|
||||
Person(
|
||||
id: jellyId,
|
||||
name: personName.text,
|
||||
type: personType,
|
||||
role: personRole.text,
|
||||
),
|
||||
))));
|
||||
setState(() {
|
||||
personName.text = "";
|
||||
personType = PersonKind.actor;
|
||||
personRole.text = "";
|
||||
});
|
||||
},
|
||||
icon: Icon(Icons.add_rounded),
|
||||
)
|
||||
],
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
List<ExternalUrls> _ => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 21),
|
||||
child: Builder(builder: (context) {
|
||||
final expanded = expandedKeys.contains(e.key);
|
||||
final list = e.value as List<ExternalUrls>;
|
||||
final name = TextEditingController();
|
||||
final url = TextEditingController();
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: () => setState(() => expandedKeys = expandedKeys.toggle(e.key)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
keyLabel,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () =>
|
||||
setState(() => expandedKeys = expandedKeys.toggle(e.key)),
|
||||
icon: Icon(expanded
|
||||
? Icons.keyboard_arrow_up_rounded
|
||||
: Icons.keyboard_arrow_down_rounded),
|
||||
)
|
||||
],
|
||||
),
|
||||
if (expanded) ...{
|
||||
const SizedBox(height: 6),
|
||||
...list.map(
|
||||
(externalUrl) => Row(
|
||||
children: [
|
||||
Text(externalUrl.name),
|
||||
const Spacer(),
|
||||
Tooltip(
|
||||
message: "Open in browser",
|
||||
child: IconButton(
|
||||
onPressed: () => launchUrl(context, externalUrl.url),
|
||||
icon: Icon(Icons.open_in_browser_rounded)),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
ref.read(editItemProvider.notifier).updateField(
|
||||
MapEntry(
|
||||
e.key,
|
||||
(list..remove(externalUrl))
|
||||
.map((e) => e.toMap())
|
||||
.toList()),
|
||||
);
|
||||
},
|
||||
icon: Icon(Icons.remove_rounded))
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: OutlinedTextField(
|
||||
label: "Name",
|
||||
controller: name,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Flexible(
|
||||
child: OutlinedTextField(
|
||||
label: "Url",
|
||||
controller: url,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
ref.read(editItemProvider.notifier).updateField(
|
||||
MapEntry(
|
||||
e.key,
|
||||
(list
|
||||
..add(
|
||||
ExternalUrls(name: name.text, url: url.text),
|
||||
))
|
||||
.map((e) => e.toMap())
|
||||
.toList()),
|
||||
);
|
||||
},
|
||||
icon: Icon(Icons.add_rounded),
|
||||
)
|
||||
],
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
List<Studio> _ => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 21),
|
||||
child: Builder(builder: (context) {
|
||||
final expanded = expandedKeys.contains(e.key);
|
||||
|
||||
final list = e.value as List<Studio>;
|
||||
|
||||
void setMapping(List<Studio> newList) {
|
||||
ref.read(editItemProvider.notifier).updateField(
|
||||
MapEntry(e.key, newList.map((e) => e.toMap()).toList()),
|
||||
);
|
||||
}
|
||||
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: () => setState(() => expandedKeys = expandedKeys.toggle(e.key)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
keyLabel,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () =>
|
||||
setState(() => expandedKeys = expandedKeys.toggle(e.key)),
|
||||
icon: Icon(expanded
|
||||
? Icons.keyboard_arrow_up_rounded
|
||||
: Icons.keyboard_arrow_down_rounded),
|
||||
)
|
||||
],
|
||||
),
|
||||
if (expanded) ...[
|
||||
const SizedBox(height: 6),
|
||||
...list.map(
|
||||
(studio) => Row(
|
||||
children: [
|
||||
Text(studio.name),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => setMapping(list..remove(studio)),
|
||||
icon: Icon(Icons.remove_rounded))
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
OutlinedTextField(
|
||||
label: "Add",
|
||||
controller: TextEditingController(),
|
||||
onSubmitted: (value) =>
|
||||
setMapping(list..add(Studio(id: jellyId, name: value))),
|
||||
)
|
||||
]
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
int value => Builder(builder: (context) {
|
||||
final controller = currentEditingKey == e.key
|
||||
? currentController
|
||||
: TextEditingController(text: value.toString());
|
||||
return FocusedOutlinedTextField(
|
||||
label: switch (e.key) {
|
||||
"IndexNumber" => "Episode Number",
|
||||
"ParentIndexNumber" => "Season Number",
|
||||
_ => keyLabel,
|
||||
},
|
||||
controller: controller,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
onFocus: (focused) {
|
||||
if (focused) {
|
||||
currentController = controller;
|
||||
currentEditingKey = e.key;
|
||||
} else {
|
||||
currentController = null;
|
||||
currentEditingKey = null;
|
||||
}
|
||||
},
|
||||
onSubmitted: (value) {
|
||||
final newYear = int.tryParse(value);
|
||||
if (newYear != null) {
|
||||
ref.read(editItemProvider.notifier).updateField(
|
||||
MapEntry(e.key, newYear),
|
||||
);
|
||||
}
|
||||
},
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (value) {
|
||||
if (currentEditingKey != e.key) {
|
||||
currentEditingKey = e.key;
|
||||
currentController = controller;
|
||||
}
|
||||
final newYear = int.tryParse(value);
|
||||
if (newYear != null) {
|
||||
ref.read(editItemProvider.notifier).updateField(
|
||||
MapEntry(e.key, newYear),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
double value => Builder(builder: (context) {
|
||||
final controller = currentEditingKey == e.key
|
||||
? currentController
|
||||
: TextEditingController(text: value.toString());
|
||||
return FocusedOutlinedTextField(
|
||||
label: keyLabel,
|
||||
controller: controller,
|
||||
onFocus: (focused) {
|
||||
if (focused) {
|
||||
currentController = controller;
|
||||
currentEditingKey = e.key;
|
||||
} else {
|
||||
currentController = null;
|
||||
currentEditingKey = null;
|
||||
}
|
||||
},
|
||||
onSubmitted: (newValue) {
|
||||
final newRating = double.tryParse(newValue);
|
||||
if (newRating != null) {
|
||||
ref.read(editItemProvider.notifier).updateField(
|
||||
MapEntry(e.key, newRating),
|
||||
);
|
||||
} else {
|
||||
controller?.text = value.toString();
|
||||
}
|
||||
currentController = null;
|
||||
},
|
||||
keyboardType: TextInputType.number,
|
||||
);
|
||||
}),
|
||||
DateTime _ => Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: FocusedOutlinedTextField(
|
||||
label: keyLabel,
|
||||
onTap: () async {
|
||||
FocusScope.of(context).requestFocus(FocusNode());
|
||||
final newDate = await showAdaptiveDatePicker(
|
||||
context,
|
||||
initialDateTime: e.value,
|
||||
);
|
||||
if (newDate == null) return;
|
||||
ref
|
||||
.read(editItemProvider.notifier)
|
||||
.updateField(MapEntry(e.key, newDate.toIso8601String()));
|
||||
},
|
||||
controller:
|
||||
TextEditingController(text: DateFormat.yMMMEd().format((e.value as DateTime))),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
final newDate = await showDatePicker(
|
||||
context: context,
|
||||
currentDate: DateTime.now(),
|
||||
initialDate: e.value,
|
||||
firstDate: DateTime(1950),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (newDate == null) return;
|
||||
ref
|
||||
.read(editItemProvider.notifier)
|
||||
.updateField(MapEntry(e.key, newDate.toIso8601String()));
|
||||
},
|
||||
icon: Icon(IconsaxOutline.calendar_2))
|
||||
],
|
||||
),
|
||||
DisplayOrder _ => Builder(builder: (context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SettingsListTile(
|
||||
label: Text(keyLabel),
|
||||
trailing: EnumBox(
|
||||
current: (e.value as DisplayOrder).value.toUpperCaseSplit(),
|
||||
itemBuilder: (context) => DisplayOrder.values
|
||||
.map(
|
||||
(mapEntry) => PopupMenuItem(
|
||||
child: Text(mapEntry.value.toUpperCaseSplit()),
|
||||
onTap: () => ref
|
||||
.read(editItemProvider.notifier)
|
||||
.updateField(MapEntry(e.key, mapEntry.value)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text("Order episodes by air date, DVD order, or absolute numbering."),
|
||||
)
|
||||
],
|
||||
);
|
||||
}),
|
||||
ShowStatus _ => Builder(builder: (context) {
|
||||
return SettingsListTile(
|
||||
label: Text(keyLabel),
|
||||
trailing: EnumBox(
|
||||
current: (e.value as ShowStatus).value,
|
||||
itemBuilder: (context) => ShowStatus.values
|
||||
.map(
|
||||
(mapEntry) => PopupMenuItem(
|
||||
child: Text(mapEntry.value),
|
||||
onTap: () => ref
|
||||
.read(editItemProvider.notifier)
|
||||
.updateField(MapEntry(e.key, mapEntry.value)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}),
|
||||
bool _ => SettingsListTile(
|
||||
label: Text(keyLabel),
|
||||
trailing: Switch.adaptive(
|
||||
value: e.value as bool,
|
||||
onChanged: (value) =>
|
||||
ref.read(editItemProvider.notifier).updateField(MapEntry(e.key, value)),
|
||||
),
|
||||
),
|
||||
Duration _ => Builder(builder: (context) {
|
||||
final valueInMinutes = (e.value as Duration).inMinutes.toString();
|
||||
final controller = currentEditingKey == e.key
|
||||
? currentController
|
||||
: TextEditingController(text: valueInMinutes);
|
||||
return FocusedOutlinedTextField(
|
||||
label: keyLabel,
|
||||
controller: controller,
|
||||
onFocus: (focused) {
|
||||
if (focused) {
|
||||
currentController = controller;
|
||||
currentEditingKey = e.key;
|
||||
} else {
|
||||
currentController = null;
|
||||
currentEditingKey = null;
|
||||
}
|
||||
},
|
||||
keyboardType: TextInputType.number,
|
||||
onSubmitted: (value) {
|
||||
final newMinutes = int.tryParse(value);
|
||||
if (newMinutes != null) {
|
||||
ref.read(editItemProvider.notifier).updateField(
|
||||
MapEntry(e.key, Duration(minutes: newMinutes).inMilliseconds * 10000),
|
||||
);
|
||||
} else {
|
||||
controller?.text = valueInMinutes;
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
Map<EditorLockedFields, bool> _ => Builder(builder: (context) {
|
||||
final map = e.value as Map<EditorLockedFields, bool>;
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: () => setState(() => expandedKeys = expandedKeys.toggle(e.key)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(keyLabel, style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
"Uncheck a field to lock it and prevent its data from being changed.",
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Column(
|
||||
children: map.entries
|
||||
.map((values) => Row(
|
||||
children: [
|
||||
Text(values.key.value),
|
||||
const Spacer(),
|
||||
Switch.adaptive(
|
||||
value: !values.value,
|
||||
onChanged: (value) {
|
||||
final newEntries = map;
|
||||
newEntries.update(values.key, (value) => !value);
|
||||
final newValues = newEntries.entries
|
||||
.where((element) => element.value == true)
|
||||
.map((e) => e.key.value);
|
||||
ref
|
||||
.read(editItemProvider.notifier)
|
||||
.updateField(MapEntry(e.key, newValues.toList()));
|
||||
},
|
||||
)
|
||||
],
|
||||
))
|
||||
.toList(),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
String value => Builder(builder: (context) {
|
||||
final controller =
|
||||
currentEditingKey == e.key ? currentController : TextEditingController(text: value);
|
||||
return FocusedOutlinedTextField(
|
||||
label: keyLabel,
|
||||
maxLines: e.key == "Overview" ? 5 : 1,
|
||||
controller: controller,
|
||||
onFocus: (focused) {
|
||||
if (focused) {
|
||||
currentEditingKey = e.key;
|
||||
currentController = controller;
|
||||
} else {
|
||||
currentController = null;
|
||||
currentEditingKey = null;
|
||||
}
|
||||
},
|
||||
onSubmitted: (value) =>
|
||||
ref.read(editItemProvider.notifier).updateField(MapEntry(e.key, value)),
|
||||
onChanged: (value) {
|
||||
if (currentEditingKey != e.key) {
|
||||
currentEditingKey = e.key;
|
||||
currentController = controller;
|
||||
}
|
||||
return ref.read(editItemProvider.notifier).updateField(MapEntry(e.key, value));
|
||||
},
|
||||
);
|
||||
}),
|
||||
_ => Text("Not supported ${e.value.runtimeType}: ${e.value}"),
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator.adaptive(strokeCap: StrokeCap.round),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
243
lib/screens/metadata/edit_screens/edit_image_content.dart
Normal file
243
lib/screens/metadata/edit_screens/edit_image_content.dart
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:fladder/jellyfin/jellyfin_open_api.enums.swagger.dart';
|
||||
import 'package:fladder/models/item_editing_model.dart';
|
||||
import 'package:fladder/providers/edit_item_provider.dart';
|
||||
import 'package:fladder/providers/settings/client_settings_provider.dart';
|
||||
import 'package:fladder/screens/settings/settings_list_tile.dart';
|
||||
import 'package:fladder/screens/shared/file_picker.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class EditImageContent extends ConsumerStatefulWidget {
|
||||
final ImageType type;
|
||||
const EditImageContent({required this.type, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _EditImageContentState();
|
||||
}
|
||||
|
||||
class _EditImageContentState extends ConsumerState<EditImageContent> {
|
||||
bool loading = false;
|
||||
|
||||
Future<void> loadAll() async {
|
||||
setState(() {
|
||||
loading = true;
|
||||
});
|
||||
await ref.read(editItemProvider.notifier).fetchRemoteImages(type: widget.type);
|
||||
setState(() {
|
||||
loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() => loadAll());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final posterSize = MediaQuery.sizeOf(context).width /
|
||||
(AdaptiveLayout.poster(context).gridRatio *
|
||||
ref.watch(clientSettingsProvider.select((value) => value.posterSize)));
|
||||
final decimal = posterSize - posterSize.toInt();
|
||||
final includeAllImages = ref.watch(editItemProvider.select((value) => value.includeAllImages));
|
||||
final images = ref.watch(editItemProvider.select((value) => switch (widget.type) {
|
||||
ImageType.backdrop => value.backdrop.images,
|
||||
ImageType.logo => value.logo.images,
|
||||
ImageType.primary || _ => value.primary.images,
|
||||
}));
|
||||
|
||||
final customImages = ref.watch(editItemProvider.select((value) => switch (widget.type) {
|
||||
ImageType.backdrop => value.backdrop.customImages,
|
||||
ImageType.logo => value.logo.customImages,
|
||||
ImageType.primary || _ => value.primary.customImages,
|
||||
}));
|
||||
|
||||
final selectedImage = ref.watch(editItemProvider.select((value) => switch (widget.type) {
|
||||
ImageType.logo => value.logo.selected,
|
||||
ImageType.primary => value.primary.selected,
|
||||
_ => null,
|
||||
}));
|
||||
|
||||
final serverImages = ref.watch(editItemProvider.select((value) => switch (widget.type) {
|
||||
ImageType.logo => value.logo.serverImages,
|
||||
ImageType.primary => value.primary.serverImages,
|
||||
ImageType.backdrop => value.backdrop.serverImages,
|
||||
_ => null,
|
||||
}));
|
||||
|
||||
final selections = ref.watch(editItemProvider.select((value) => switch (widget.type) {
|
||||
ImageType.backdrop => value.backdrop.selection,
|
||||
_ => [],
|
||||
}));
|
||||
|
||||
final serverImageCards = serverImages?.map((image) {
|
||||
final selected = selectedImage == null;
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: image.ratio,
|
||||
child: Tooltip(
|
||||
message: "Server image",
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? Theme.of(context).colorScheme.primary : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border:
|
||||
Border.all(color: Colors.transparent, width: 4, strokeAlign: BorderSide.strokeAlignInside),
|
||||
),
|
||||
child: Card(
|
||||
color: selected ? Theme.of(context).colorScheme.onPrimary : null,
|
||||
child: InkWell(
|
||||
onTap: () => ref.read(editItemProvider.notifier).selectImage(widget.type, null),
|
||||
child: CachedNetworkImage(
|
||||
cacheKey: image.hashCode.toString(),
|
||||
imageUrl: image.url ?? "",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Transform.translate(
|
||||
offset: Offset(2, 2),
|
||||
child: IconButton.filledTonal(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
onPressed: () async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog.adaptive(
|
||||
title: Text("Delete image"),
|
||||
content: Text("Deleting is permanent are you sure?"),
|
||||
actions: [
|
||||
ElevatedButton(onPressed: () => Navigator.of(context).pop(), child: Text("Cancel")),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
onPressed: () async {
|
||||
await ref.read(editItemProvider.notifier).deleteImage(widget.type, image);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
"Delete",
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: Icon(Icons.delete_rounded),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}) ??
|
||||
[];
|
||||
|
||||
final imageCards = [...customImages, ...images].map((image) {
|
||||
final selected = switch (widget.type) {
|
||||
ImageType.backdrop => selections.contains(image),
|
||||
_ => selectedImage == image,
|
||||
};
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: image.ratio,
|
||||
child: Tooltip(
|
||||
message: "${image.providerName} - ${image.language} \n${image.width}x${image.height}",
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: selected ? Theme.of(context).colorScheme.primary : Colors.transparent,
|
||||
width: 4,
|
||||
strokeAlign: BorderSide.strokeAlignInside),
|
||||
),
|
||||
child: Card(
|
||||
color: selected ? Theme.of(context).colorScheme.onPrimary : null,
|
||||
child: InkWell(
|
||||
onTap: () => ref.read(editItemProvider.notifier).selectImage(widget.type, image),
|
||||
child: image.imageData != null
|
||||
? Image(image: Image.memory(image.imageData!).image)
|
||||
: CachedNetworkImage(
|
||||
imageUrl: image.url ?? "",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList();
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 80,
|
||||
child: FilePickerBar(
|
||||
multipleFiles: switch (widget.type) {
|
||||
ImageType.backdrop => true,
|
||||
_ => false,
|
||||
},
|
||||
extensions: FladderFile.imageTypes,
|
||||
urlPicked: (url) {
|
||||
final newFile = EditingImageModel(providerName: "Custom(URL)", url: url);
|
||||
ref.read(editItemProvider.notifier).addCustomImages(widget.type, [newFile]);
|
||||
},
|
||||
onFilesPicked: (file) {
|
||||
final newFiles = file.map(
|
||||
(e) => EditingImageModel(
|
||||
providerName: "Custom(${e.name})",
|
||||
imageData: e.data,
|
||||
),
|
||||
);
|
||||
ref.read(editItemProvider.notifier).addCustomImages(widget.type, newFiles);
|
||||
},
|
||||
),
|
||||
),
|
||||
SettingsListTile(
|
||||
label: Text("Include all languages"),
|
||||
trailing: Switch.adaptive(
|
||||
value: includeAllImages,
|
||||
onChanged: (value) {
|
||||
ref.read(editItemProvider.notifier).setIncludeImages(value);
|
||||
loadAll();
|
||||
},
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Stack(
|
||||
children: [
|
||||
GridView(
|
||||
shrinkWrap: true,
|
||||
scrollDirection: Axis.vertical,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
mainAxisSpacing: (8 * decimal) + 8,
|
||||
crossAxisSpacing: (8 * decimal) + 8,
|
||||
childAspectRatio: 1.0,
|
||||
crossAxisCount: posterSize.toInt(),
|
||||
),
|
||||
children: [...serverImageCards, ...imageCards],
|
||||
),
|
||||
if (loading) Center(child: CircularProgressIndicator.adaptive(strokeCap: StrokeCap.round)),
|
||||
if (!loading && [...serverImageCards, ...imageCards].isEmpty)
|
||||
Center(child: Text("No ${widget.type.value}s found"))
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
361
lib/screens/metadata/identifty_screen.dart
Normal file
361
lib/screens/metadata/identifty_screen.dart
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/providers/items/identify_provider.dart';
|
||||
import 'package:fladder/screens/shared/adaptive_dialog.dart';
|
||||
import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
||||
import 'package:fladder/screens/shared/focused_outlined_text_field.dart';
|
||||
import 'package:fladder/screens/shared/media/external_urls.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
Future<void> showIdentifyScreen(BuildContext context, ItemBaseModel item) async {
|
||||
return showDialogAdaptive(
|
||||
context: context,
|
||||
builder: (context) => IdentifyScreen(
|
||||
item: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class IdentifyScreen extends ConsumerStatefulWidget {
|
||||
final ItemBaseModel item;
|
||||
const IdentifyScreen({required this.item, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _IdentifyScreenState();
|
||||
}
|
||||
|
||||
class _IdentifyScreenState extends ConsumerState<IdentifyScreen> with TickerProviderStateMixin {
|
||||
late AutoDisposeStateNotifierProvider<IdentifyNotifier, IdentifyModel> provider = identifyProvider(widget.item.id);
|
||||
late final TabController tabController = TabController(length: 2, vsync: this);
|
||||
|
||||
TextEditingController? currentController;
|
||||
String? currentKey;
|
||||
int currentTab = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() => ref.read(provider.notifier).fetchInformation());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(provider);
|
||||
final posters = state.results;
|
||||
final processing = state.processing;
|
||||
return MediaQuery.removePadding(
|
||||
context: context,
|
||||
child: Card(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(height: MediaQuery.paddingOf(context).top),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.item.detailedName(context) ?? widget.item.name,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () async => await ref.read(provider.notifier).fetchInformation(),
|
||||
icon: Icon(IconsaxOutline.refresh)),
|
||||
],
|
||||
),
|
||||
),
|
||||
TabBar(
|
||||
isScrollable: true,
|
||||
controller: tabController,
|
||||
onTap: (value) {
|
||||
setState(() {
|
||||
currentTab = value;
|
||||
});
|
||||
},
|
||||
tabs: [
|
||||
Tab(
|
||||
text: context.localized.search,
|
||||
),
|
||||
Tab(
|
||||
text: context.localized.result,
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
children: [
|
||||
inputFields(state),
|
||||
if (posters.isEmpty)
|
||||
Center(
|
||||
child: processing
|
||||
? CircularProgressIndicator.adaptive(strokeCap: StrokeCap.round)
|
||||
: Text(context.localized.noResults),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text(context.localized.replaceAllImages),
|
||||
const SizedBox(width: 16),
|
||||
Switch.adaptive(
|
||||
value: state.replaceAllImages,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(provider.notifier)
|
||||
.update((state) => state.copyWith(replaceAllImages: value));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: posters
|
||||
.map((result) => ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 75,
|
||||
child: Card(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: result.imageUrl ?? "",
|
||||
errorWidget: (context, url, error) => SizedBox(
|
||||
height: 75,
|
||||
child: Card(
|
||||
child: Center(
|
||||
child: Text(result.name?.getInitials() ?? ""),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"${result.name ?? ""}${result.productionYear != null ? "(${result.productionYear})" : ""}"),
|
||||
Opacity(
|
||||
opacity: 0.65,
|
||||
child: Text(result.providerIds?.keys.join(',') ?? ""))
|
||||
],
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: context.localized.openWebLink,
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
final providerKeyEntry = result.providerIds?.entries.first;
|
||||
final providerKey = providerKeyEntry?.key;
|
||||
final providerValue = providerKeyEntry?.value;
|
||||
|
||||
final externalId = state.externalIds
|
||||
.firstWhereOrNull((element) => element.key == providerKey)
|
||||
?.urlFormatString;
|
||||
|
||||
final url =
|
||||
externalId?.replaceAll("{0}", providerValue?.toString() ?? "");
|
||||
|
||||
launchUrl(context, url ?? "");
|
||||
},
|
||||
icon: Icon(Icons.launch_rounded)),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Select result",
|
||||
child: IconButton(
|
||||
onPressed: !processing
|
||||
? () async {
|
||||
final response =
|
||||
await ref.read(provider.notifier).setIdentity(result);
|
||||
if (response?.isSuccessful == true) {
|
||||
fladderSnackbar(context,
|
||||
title:
|
||||
context.localized.setIdentityTo(result.name ?? ""));
|
||||
} else {
|
||||
fladderSnackbarResponse(context, response,
|
||||
altTitle: context.localized.somethingWentWrong);
|
||||
}
|
||||
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
: null,
|
||||
icon: Icon(Icons.save_alt_rounded),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ElevatedButton(onPressed: () => Navigator.of(context).pop(), child: Text(context.localized.cancel)),
|
||||
const SizedBox(width: 16),
|
||||
FilledButton(
|
||||
onPressed: !processing
|
||||
? () async {
|
||||
await ref.read(provider.notifier).remoteSearch();
|
||||
tabController.animateTo(1);
|
||||
}
|
||||
: null,
|
||||
child: processing
|
||||
? SizedBox(
|
||||
width: 21,
|
||||
height: 21,
|
||||
child: CircularProgressIndicator.adaptive(
|
||||
backgroundColor: Theme.of(context).colorScheme.onPrimary, strokeCap: StrokeCap.round),
|
||||
)
|
||||
: Text(context.localized.search),
|
||||
),
|
||||
SizedBox(height: MediaQuery.paddingOf(context).bottom),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ListView inputFields(IdentifyModel state) {
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
currentController = null;
|
||||
currentKey = "";
|
||||
ref.read(provider.notifier).clearFields();
|
||||
},
|
||||
child: Text(context.localized.clear)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Builder(builder: (context) {
|
||||
final controller =
|
||||
currentKey == "Name" ? currentController : TextEditingController(text: state.searchString);
|
||||
return FocusedOutlinedTextField(
|
||||
label: context.localized.userName,
|
||||
controller: controller,
|
||||
onChanged: (value) {
|
||||
currentController = controller;
|
||||
currentKey = "Name";
|
||||
return ref.read(provider.notifier).update((state) => state.copyWith(searchString: value));
|
||||
},
|
||||
onSubmitted: (value) {
|
||||
return ref.read(provider.notifier).update((state) => state.copyWith(searchString: value));
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Builder(builder: (context) {
|
||||
final controller =
|
||||
currentKey == "Year" ? currentController : TextEditingController(text: state.year?.toString() ?? "");
|
||||
return FocusedOutlinedTextField(
|
||||
label: context.localized.year(1),
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (value) {
|
||||
currentController = controller;
|
||||
currentKey = "Year";
|
||||
if (value.isEmpty) {
|
||||
ref.read(provider.notifier).update((state) => state.copyWith(
|
||||
year: () => null,
|
||||
));
|
||||
return;
|
||||
}
|
||||
final newYear = int.tryParse(value);
|
||||
if (newYear != null) {
|
||||
ref.read(provider.notifier).update((state) => state.copyWith(
|
||||
year: () => newYear,
|
||||
));
|
||||
} else {
|
||||
controller?.text = state.year?.toString() ?? "";
|
||||
}
|
||||
},
|
||||
onSubmitted: (value) {
|
||||
currentController = null;
|
||||
currentKey = null;
|
||||
if (value.isEmpty) {
|
||||
ref.read(provider.notifier).update((state) => state.copyWith(
|
||||
year: () => null,
|
||||
));
|
||||
}
|
||||
final newYear = int.tryParse(value);
|
||||
if (newYear != null) {
|
||||
ref.read(provider.notifier).update((state) => state.copyWith(
|
||||
year: () => newYear,
|
||||
));
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
...state.keys.entries.map(
|
||||
(searchKey) => Builder(builder: (context) {
|
||||
final controller =
|
||||
currentKey == searchKey.key ? currentController : TextEditingController(text: searchKey.value);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: FocusedOutlinedTextField(
|
||||
label: searchKey.key,
|
||||
controller: controller,
|
||||
onChanged: (value) {
|
||||
currentController = controller;
|
||||
currentKey = searchKey.key;
|
||||
ref.read(provider.notifier).updateKey(MapEntry(searchKey.key, value));
|
||||
},
|
||||
onSubmitted: (value) => ref.read(provider.notifier).updateKey(MapEntry(searchKey.key, value)),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
217
lib/screens/metadata/info_screen.dart
Normal file
217
lib/screens/metadata/info_screen.dart
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/information_model.dart';
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/providers/items/information_provider.dart';
|
||||
import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/widgets/shared/clickable_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
Future<void> showInfoScreen(BuildContext context, ItemBaseModel item) async {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => ItemInfoScreen(
|
||||
item: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class ItemInfoScreen extends ConsumerStatefulWidget {
|
||||
final ItemBaseModel item;
|
||||
const ItemInfoScreen({required this.item, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => ItemInfoScreenState();
|
||||
}
|
||||
|
||||
class ItemInfoScreenState extends ConsumerState<ItemInfoScreen> {
|
||||
late AutoDisposeStateNotifierProvider<InformationNotifier, InformationProviderModel> provider =
|
||||
informationProvider(widget.item.id);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() => ref.read(provider.notifier).getItemInformation(widget.item));
|
||||
}
|
||||
|
||||
Widget tileRow(String title, String value) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ClickableText(
|
||||
text: title,
|
||||
onTap: () async {
|
||||
await Clipboard.setData(ClipboardData(text: value));
|
||||
fladderSnackbar(context, title: "Copied to clipboard");
|
||||
},
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
": ",
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Flexible(
|
||||
flex: 3,
|
||||
child: SelectableText(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Card streamModel(String title, Map<String, dynamic> map) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ClickableText(
|
||||
text: title,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: InformationModel.mapToString(map)));
|
||||
fladderSnackbar(context, title: "Copied to clipboard");
|
||||
},
|
||||
icon: const Icon(Icons.copy_all_rounded))
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
...map.entries
|
||||
.where((element) => element.value != null)
|
||||
.map((mapEntry) => tileRow(mapEntry.key, mapEntry.value.toString()))
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final info = ref.watch(provider);
|
||||
final videoStreams = (info.model?.videoStreams.map((map) => streamModel("Video", map)) ?? []).toList();
|
||||
final audioStreams = (info.model?.audioStreams.map((map) => streamModel("Audio", map)) ?? []).toList();
|
||||
final subStreams = (info.model?.subStreams.map((map) => streamModel("Subtitle", map)) ?? []).toList();
|
||||
return Dialog(
|
||||
child: Card(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
widget.item.name,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
Opacity(opacity: 0.3, child: const Divider()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
const Spacer(),
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: info.model.toString()));
|
||||
if (context.mounted) {
|
||||
fladderSnackbar(context, title: "Copied to clipboard");
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.copy_all_rounded)),
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
onPressed: () => ref.read(provider.notifier).getItemInformation(widget.item),
|
||||
icon: const Icon(IconsaxOutline.refresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Flexible(
|
||||
fit: FlexFit.loose,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (info.model != null) ...{
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: double.infinity, child: streamModel("Info", info.model!.baseInformation)),
|
||||
if ([...videoStreams, ...audioStreams, ...subStreams].isNotEmpty) ...{
|
||||
const Divider(),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.start,
|
||||
runAlignment: WrapAlignment.start,
|
||||
crossAxisAlignment: WrapCrossAlignment.start,
|
||||
runSpacing: 16,
|
||||
spacing: 16,
|
||||
children: [
|
||||
...videoStreams,
|
||||
...audioStreams,
|
||||
...subStreams,
|
||||
],
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
},
|
||||
AnimatedOpacity(
|
||||
opacity: info.loading ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: const Center(child: CircularProgressIndicator.adaptive(strokeCap: StrokeCap.round)),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
FilledButton(onPressed: () => Navigator.of(context).pop(), child: Text(context.localized.close))
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
123
lib/screens/metadata/refresh_metadata.dart
Normal file
123
lib/screens/metadata/refresh_metadata.dart
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import 'package:fladder/jellyfin/enum_models.dart';
|
||||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/screens/settings/settings_list_tile.dart';
|
||||
import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/widgets/shared/enum_selection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
Future<void> showRefreshPopup(BuildContext context, String itemId, String itemName) async {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => RefreshPopupDialog(
|
||||
itemId: itemId,
|
||||
name: itemName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class RefreshPopupDialog extends ConsumerStatefulWidget {
|
||||
final String itemId;
|
||||
final String name;
|
||||
|
||||
const RefreshPopupDialog({required this.itemId, required this.name, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _RefreshPopupDialogState();
|
||||
}
|
||||
|
||||
class _RefreshPopupDialogState extends ConsumerState<RefreshPopupDialog> {
|
||||
MetadataRefresh refreshMode = MetadataRefresh.defaultRefresh;
|
||||
bool replaceAllMetadata = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
child: Card(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: AdaptiveLayout.of(context).inputDevice == InputDevice.pointer ? 700 : double.infinity),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
context.localized.refreshPopup(widget.name),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
EnumBox(
|
||||
current: refreshMode.label(context),
|
||||
itemBuilder: (context) => MetadataRefresh.values
|
||||
.map((value) => PopupMenuItem(
|
||||
value: value,
|
||||
child: Text(value.label(context)),
|
||||
onTap: () => setState(() {
|
||||
refreshMode = value;
|
||||
}),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
if (refreshMode != MetadataRefresh.defaultRefresh)
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.replaceExistingImages),
|
||||
trailing: Switch.adaptive(
|
||||
value: replaceAllMetadata,
|
||||
onChanged: (value) => setState(() => replaceAllMetadata = value),
|
||||
),
|
||||
),
|
||||
SettingsListTile(
|
||||
label: Text(
|
||||
context.localized.refreshPopupContentMetadata,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final response = await ref.read(userProvider.notifier).refreshMetaData(
|
||||
widget.itemId,
|
||||
metadataRefreshMode: refreshMode,
|
||||
replaceAllMetadata: replaceAllMetadata,
|
||||
);
|
||||
if (!response.isSuccessful) {
|
||||
fladderSnackbarResponse(context, response);
|
||||
} else {
|
||||
fladderSnackbar(context, title: context.localized.scanningName(widget.name));
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(context.localized.refresh)),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
343
lib/screens/photo_viewer/photo_viewer_controls.dart
Normal file
343
lib/screens/photo_viewer/photo_viewer_controls.dart
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
import 'package:extended_image/extended_image.dart';
|
||||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/items/photos_model.dart';
|
||||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/providers/settings/photo_view_settings_provider.dart';
|
||||
import 'package:fladder/screens/shared/flat_button.dart';
|
||||
import 'package:fladder/screens/shared/input_fields.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/throttler.dart';
|
||||
import 'package:fladder/widgets/shared/elevated_icon.dart';
|
||||
import 'package:fladder/widgets/shared/progress_floating_button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:square_progress_indicator/square_progress_indicator.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
class PhotoViewerControls extends ConsumerStatefulWidget {
|
||||
final EdgeInsets padding;
|
||||
final PhotoModel photo;
|
||||
final int itemCount;
|
||||
final bool loadingMoreItems;
|
||||
final int currentIndex;
|
||||
final ValueChanged<PhotoModel> onPhotoChanged;
|
||||
final Function() openOptions;
|
||||
final ExtendedPageController pageController;
|
||||
final Function(bool? value)? toggleOverlay;
|
||||
const PhotoViewerControls({
|
||||
required this.padding,
|
||||
required this.photo,
|
||||
required this.pageController,
|
||||
required this.loadingMoreItems,
|
||||
required this.openOptions,
|
||||
required this.onPhotoChanged,
|
||||
required this.itemCount,
|
||||
required this.currentIndex,
|
||||
this.toggleOverlay,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _PhotoViewerControllsState();
|
||||
}
|
||||
|
||||
class _PhotoViewerControllsState extends ConsumerState<PhotoViewerControls> with WindowListener {
|
||||
final FocusNode focusNode = FocusNode();
|
||||
final Throttler throttler = Throttler(duration: const Duration(milliseconds: 130));
|
||||
late int currentPage = widget.pageController.page?.round() ?? 0;
|
||||
double dragUpDelta = 0.0;
|
||||
|
||||
final controller = TextEditingController();
|
||||
late final timerController =
|
||||
RestarableTimerController(ref.read(photoViewSettingsProvider).timer, const Duration(milliseconds: 32), () {
|
||||
if (widget.pageController.page == widget.itemCount - 1) {
|
||||
widget.pageController.animateToPage(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut);
|
||||
} else {
|
||||
widget.pageController.nextPage(duration: const Duration(milliseconds: 250), curve: Curves.easeInOut);
|
||||
}
|
||||
});
|
||||
|
||||
void _resetOnScroll() {
|
||||
if (currentPage != widget.pageController.page?.round()) {
|
||||
currentPage = widget.pageController.page?.round() ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() => () {
|
||||
if (AdaptiveLayout.of(context).isDesktop) focusNode.requestFocus();
|
||||
});
|
||||
|
||||
windowManager.addListener(this);
|
||||
widget.pageController.addListener(
|
||||
() {
|
||||
_resetOnScroll();
|
||||
timerController.reset();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMinimize() {
|
||||
timerController.cancel();
|
||||
super.onWindowMinimize();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
timerController.dispose();
|
||||
windowManager.removeListener(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (AdaptiveLayout.of(context).isDesktop) focusNode.requestFocus();
|
||||
|
||||
final gradient = [
|
||||
Colors.black.withOpacity(0.6),
|
||||
Colors.black.withOpacity(0.3),
|
||||
Colors.black.withOpacity(0.1),
|
||||
Colors.black.withOpacity(0.0),
|
||||
];
|
||||
|
||||
if (AdaptiveLayout.of(context).isDesktop) {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
|
||||
final padding = MediaQuery.of(context).padding;
|
||||
return PopScope(
|
||||
onPopInvoked: (popped) async {
|
||||
await WakelockPlus.disable();
|
||||
},
|
||||
child: KeyboardListener(
|
||||
focusNode: focusNode,
|
||||
autofocus: true,
|
||||
onKeyEvent: (value) {
|
||||
if (value is KeyDownEvent) {
|
||||
if (value.logicalKey == LogicalKeyboardKey.arrowLeft) {
|
||||
throttler.run(() => widget.pageController
|
||||
.previousPage(duration: const Duration(milliseconds: 125), curve: Curves.easeInOut));
|
||||
}
|
||||
if (value.logicalKey == LogicalKeyboardKey.arrowRight) {
|
||||
throttler.run(() =>
|
||||
widget.pageController.nextPage(duration: const Duration(milliseconds: 125), curve: Curves.easeInOut));
|
||||
}
|
||||
if (value.logicalKey == LogicalKeyboardKey.keyK) {
|
||||
timerController.playPause();
|
||||
}
|
||||
if (value.logicalKey == LogicalKeyboardKey.space) {
|
||||
widget.toggleOverlay?.call(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.topCenter,
|
||||
widthFactor: 1,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: gradient,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: widget.padding.top),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (AdaptiveLayout.of(context).isDesktop) const SizedBox(height: 25),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12)
|
||||
.add(EdgeInsets.only(left: padding.left, right: padding.right)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
ElevatedIconButton(
|
||||
onPressed: () => Navigator.of(context).pop(widget.pageController.page?.toInt()),
|
||||
icon: getBackIcon(context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Tooltip(
|
||||
message: widget.photo.name,
|
||||
child: Text(
|
||||
widget.photo.name,
|
||||
maxLines: 2,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold, shadows: [
|
||||
BoxShadow(blurRadius: 1, spreadRadius: 1, color: Colors.black.withOpacity(0.7)),
|
||||
BoxShadow(blurRadius: 4, spreadRadius: 4, color: Colors.black.withOpacity(0.4)),
|
||||
BoxShadow(blurRadius: 20, spreadRadius: 6, color: Colors.black.withOpacity(0.2)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Theme.of(context).colorScheme.onPrimary),
|
||||
child: SquareProgressIndicator(
|
||||
value: widget.currentIndex / (widget.itemCount - 1),
|
||||
borderRadius: 7,
|
||||
clockwise: false,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(9),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
"${widget.currentIndex + 1} / ${widget.loadingMoreItems ? "-" : "${widget.itemCount}"} ",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (widget.loadingMoreItems)
|
||||
SizedBox.square(
|
||||
dimension: 16,
|
||||
child: CircularProgressIndicator.adaptive(
|
||||
strokeCap: StrokeCap.round,
|
||||
),
|
||||
),
|
||||
].addInBetween(SizedBox(width: 6)),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: FlatButton(
|
||||
borderRadiusGeometry: BorderRadius.circular(8),
|
||||
onTap: () async {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
child: SizedBox(
|
||||
width: 125,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.localized.goTo,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
IntInputField(
|
||||
controller: TextEditingController(
|
||||
text: (widget.currentIndex + 1).toString()),
|
||||
onSubmitted: (value) {
|
||||
final position =
|
||||
((value ?? 0) - 1).clamp(0, widget.itemCount - 1);
|
||||
widget.pageController.jumpToPage(position);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: gradient.reversed.toList(),
|
||||
),
|
||||
),
|
||||
width: double.infinity,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: widget.padding.bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.all(8.0).add(EdgeInsets.only(left: padding.left, right: padding.right)),
|
||||
child: Row(
|
||||
children: [
|
||||
ElevatedIconButton(
|
||||
onPressed: widget.openOptions,
|
||||
icon: IconsaxOutline.more_2,
|
||||
),
|
||||
Spacer(),
|
||||
ElevatedIconButton(
|
||||
onPressed: markAsFavourite,
|
||||
color: widget.photo.userData.isFavourite ? Colors.red : null,
|
||||
icon: widget.photo.userData.isFavourite ? IconsaxBold.heart : IconsaxOutline.heart,
|
||||
),
|
||||
ProgressFloatingButton(
|
||||
controller: timerController,
|
||||
),
|
||||
].addPadding(const EdgeInsets.symmetric(horizontal: 8)),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void markAsFavourite() {
|
||||
ref.read(userProvider.notifier).setAsFavorite(!widget.photo.userData.isFavourite, widget.photo.id);
|
||||
|
||||
widget.onPhotoChanged(widget.photo
|
||||
.copyWith(userData: widget.photo.userData.copyWith(isFavourite: !widget.photo.userData.isFavourite)));
|
||||
}
|
||||
|
||||
Future<void> sharePhoto() async {
|
||||
final file = await DefaultCacheManager().getSingleFile(widget.photo.downloadPath(ref));
|
||||
await Share.shareXFiles([
|
||||
XFile(
|
||||
file.path,
|
||||
),
|
||||
]);
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
526
lib/screens/photo_viewer/photo_viewer_screen.dart
Normal file
526
lib/screens/photo_viewer/photo_viewer_screen.dart
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:extended_image/extended_image.dart';
|
||||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/main.dart';
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/models/items/photos_model.dart';
|
||||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/screens/photo_viewer/photo_viewer_controls.dart';
|
||||
import 'package:fladder/providers/settings/photo_view_settings_provider.dart';
|
||||
import 'package:fladder/screens/photo_viewer/simple_video_player.dart';
|
||||
import 'package:fladder/screens/shared/default_titlebar.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/themes_data.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/fladder_appbar.dart';
|
||||
import 'package:fladder/widgets/shared/animated_icon.dart';
|
||||
import 'package:fladder/widgets/shared/elevated_icon.dart';
|
||||
import 'package:fladder/widgets/shared/hover_widget.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/services.dart';
|
||||
import 'package:flutter_blurhash/flutter_blurhash.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class PhotoViewerScreen extends ConsumerStatefulWidget {
|
||||
final List<PhotoModel>? items;
|
||||
final int indexOfSelected;
|
||||
final Future<List<PhotoModel>>? loadingItems;
|
||||
const PhotoViewerScreen({
|
||||
this.items,
|
||||
this.indexOfSelected = 0,
|
||||
this.loadingItems,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _PhotoViewerScreenState();
|
||||
}
|
||||
|
||||
class _PhotoViewerScreenState extends ConsumerState<PhotoViewerScreen> with WidgetsBindingObserver {
|
||||
late List<PhotoModel> photos = widget.items ?? [];
|
||||
late final ExtendedPageController controller = ExtendedPageController(initialPage: widget.indexOfSelected);
|
||||
double currentScale = 1.0;
|
||||
late int currentPage = widget.indexOfSelected.clamp(0, photos.length - 1);
|
||||
bool showInterface = true;
|
||||
bool toolbarHover = false;
|
||||
|
||||
late final double topPadding = MediaQuery.of(context).viewPadding.top;
|
||||
late final double bottomPadding = MediaQuery.of(context).viewPadding.bottom;
|
||||
bool loadingItems = false;
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) async {
|
||||
switch (state) {
|
||||
case AppLifecycleState.resumed:
|
||||
SystemChrome.setEnabledSystemUIMode(!showInterface ? SystemUiMode.leanBack : SystemUiMode.edgeToEdge,
|
||||
overlays: []);
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
));
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(timeStamp) async {
|
||||
cacheNeighbors(widget.indexOfSelected, 2);
|
||||
if (widget.loadingItems != null) {
|
||||
setState(() {
|
||||
loadingItems = true;
|
||||
});
|
||||
|
||||
final newItems = await Future.value(widget.loadingItems);
|
||||
|
||||
setState(() {
|
||||
photos = {...photos, ...newItems}.toList();
|
||||
loadingItems = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> removePhoto(ItemBaseModel photo) async {
|
||||
if (photos.length == 1) {
|
||||
Navigator.of(context).pop();
|
||||
} else {
|
||||
setState(() {
|
||||
photos.remove(photo);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _showOverlay({bool? show}) {
|
||||
setState(() {
|
||||
showInterface = show ?? !showInterface;
|
||||
});
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
!showInterface ? SystemUiMode.leanBack : SystemUiMode.edgeToEdge,
|
||||
overlays: [],
|
||||
);
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
));
|
||||
}
|
||||
|
||||
final gestureConfig = GestureConfig(
|
||||
inertialSpeed: 300,
|
||||
initialAlignment: InitialAlignment.center,
|
||||
inPageView: true,
|
||||
initialScale: 1.0,
|
||||
maxScale: 6,
|
||||
minScale: 1,
|
||||
animationMinScale: 0.1,
|
||||
animationMaxScale: 7,
|
||||
cacheGesture: false,
|
||||
reverseMousePointerScrollDirection: true,
|
||||
hitTestBehavior: HitTestBehavior.translucent,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Theme(
|
||||
data: ThemesData.of(context).dark,
|
||||
child: PopScope(
|
||||
onPopInvoked: (popped) async => SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge, overlays: []),
|
||||
child: MouseRegion(
|
||||
opaque: AdaptiveLayout.of(context).isDesktop,
|
||||
onEnter: (event) => setState(() => _showOverlay(show: true)),
|
||||
onExit: (event) => setState(() => _showOverlay(show: false)),
|
||||
child: Scaffold(
|
||||
appBar: photos.isEmpty
|
||||
? FladderAppbar(
|
||||
automaticallyImplyLeading: true,
|
||||
)
|
||||
: null,
|
||||
body: photos.isEmpty
|
||||
? Center(
|
||||
child: Text(context.localized.noItemsToShow),
|
||||
)
|
||||
: buildViewer(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildViewer() {
|
||||
final currentPhoto = photos[currentPage];
|
||||
final imageHash = currentPhoto.images?.primary?.hash;
|
||||
return Stack(
|
||||
children: [
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: ref.watch(photoViewSettingsProvider.select((value) => value.theaterMode)) && imageHash != null
|
||||
? Opacity(
|
||||
key: Key(currentPhoto.id),
|
||||
opacity: 0.7,
|
||||
child: SizedBox.expand(
|
||||
child: Image(
|
||||
fit: BoxFit.cover,
|
||||
filterQuality: FilterQuality.high,
|
||||
image: BlurHashImage(imageHash),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTapUp: (details) => _showOverlay(),
|
||||
onDoubleTapDown: AdaptiveLayout.of(context).isDesktop
|
||||
? null
|
||||
: (details) async {
|
||||
await openOptions(
|
||||
context,
|
||||
currentPhoto,
|
||||
removePhoto,
|
||||
);
|
||||
},
|
||||
onLongPress: () {
|
||||
if (currentPhoto.userData.isFavourite == true) {
|
||||
HapticFeedback.lightImpact();
|
||||
} else {
|
||||
markAsFavourite(currentPhoto, value: true);
|
||||
HapticFeedback.heavyImpact();
|
||||
}
|
||||
},
|
||||
child: ExtendedImageGesturePageView.builder(
|
||||
itemCount: photos.length,
|
||||
controller: controller,
|
||||
onPageChanged: (index) => setState(() {
|
||||
currentPage = index;
|
||||
cacheNeighbors(index, 3);
|
||||
SystemChrome.setEnabledSystemUIMode(!showInterface ? SystemUiMode.leanBack : SystemUiMode.edgeToEdge,
|
||||
overlays: []);
|
||||
}),
|
||||
itemBuilder: (context, index) {
|
||||
final photo = photos[index];
|
||||
return ExtendedImage(
|
||||
key: Key(photo.id),
|
||||
fit: BoxFit.contain,
|
||||
mode: ExtendedImageMode.gesture,
|
||||
initGestureConfigHandler: (state) => gestureConfig,
|
||||
handleLoadingProgress: true,
|
||||
onDoubleTap: (state) {
|
||||
return;
|
||||
},
|
||||
gaplessPlayback: true,
|
||||
loadStateChanged: (state) {
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (state.extendedImageLoadState != LoadState.completed)
|
||||
Positioned.fill(
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.contain,
|
||||
cacheManager: CustomCacheManager.instance,
|
||||
imageUrl: photo.thumbnail?.primary?.path ?? "",
|
||||
),
|
||||
),
|
||||
switch (state.extendedImageLoadState) {
|
||||
LoadState.loading => Center(
|
||||
child: CircularProgressIndicator.adaptive(strokeCap: StrokeCap.round),
|
||||
),
|
||||
LoadState.completed => switch (photo.internalType) {
|
||||
FladderItemType.video => SimpleVideoPlayer(
|
||||
onTapped: _showOverlay,
|
||||
showOverlay: showInterface,
|
||||
video: photos[index],
|
||||
),
|
||||
_ => state.completedWidget,
|
||||
},
|
||||
LoadState.failed || _ => Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24).copyWith(top: topPadding + 85),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.localized.failedToLoadImage,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => state.reLoadImage(),
|
||||
child: Text(context.localized.retry),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
],
|
||||
);
|
||||
},
|
||||
image: CachedNetworkImageProvider(
|
||||
photo.images?.primary?.path ?? "",
|
||||
cacheManager: CustomCacheManager.instance,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
ignoring: !showInterface,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
opacity: showInterface ? 1 : 0,
|
||||
child: PhotoViewerControls(
|
||||
padding: EdgeInsets.only(top: topPadding, bottom: bottomPadding),
|
||||
currentIndex: currentPage,
|
||||
itemCount: photos.length,
|
||||
loadingMoreItems: loadingItems,
|
||||
pageController: controller,
|
||||
photo: currentPhoto,
|
||||
toggleOverlay: (value) => setState(() => showInterface = value ?? !showInterface),
|
||||
openOptions: () => openOptions(context, currentPhoto, removePhoto),
|
||||
onPhotoChanged: (photo) {
|
||||
setState(() {
|
||||
int index = photos.indexOf(currentPhoto);
|
||||
photos.remove(currentPhoto);
|
||||
photos.insert(index, photo);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (AdaptiveLayout.of(context).isDesktop) ...{
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: HoverWidget(
|
||||
child: (visible) => AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
opacity: visible ? 1 : 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
width: 50,
|
||||
height: MediaQuery.sizeOf(context).height * 0.5,
|
||||
child: IconButton.filledTonal(
|
||||
style:
|
||||
IconButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
onPressed: () =>
|
||||
controller.nextPage(duration: const Duration(milliseconds: 125), curve: Curves.easeInOut),
|
||||
icon: const Icon(IconsaxBold.arrow_right_1),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: HoverWidget(
|
||||
child: (visible) => AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
opacity: visible ? 1 : 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Container(
|
||||
alignment: Alignment.centerLeft,
|
||||
width: 50,
|
||||
height: MediaQuery.sizeOf(context).height * 0.5,
|
||||
child: IconButton.filledTonal(
|
||||
style:
|
||||
IconButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
onPressed: () =>
|
||||
controller.previousPage(duration: const Duration(milliseconds: 125), curve: Curves.easeInOut),
|
||||
icon: const Icon(IconsaxBold.arrow_left),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
if (AdaptiveLayout.of(context).isDesktop)
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
opacity: showInterface
|
||||
? 1
|
||||
: toolbarHover
|
||||
? 1
|
||||
: 0,
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
widthFactor: 1,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.5),
|
||||
Colors.black.withOpacity(0),
|
||||
],
|
||||
),
|
||||
),
|
||||
height: 45,
|
||||
child: MouseRegion(
|
||||
onEnter: (event) => setState(() => toolbarHover = true),
|
||||
onExit: (event) => setState(() => toolbarHover = false),
|
||||
child: const Column(
|
||||
children: [
|
||||
DefaultTitleBar(
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
opacity: showInterface ? 0 : 1,
|
||||
child: AnimatedVisibilityIcon(
|
||||
key: Key(currentPhoto.id),
|
||||
isFilled: currentPhoto.userData.isFavourite,
|
||||
filledIcon: IconsaxBold.heart,
|
||||
outlinedIcon: IconsaxOutline.heart,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> openOptions(BuildContext context, PhotoModel currentPhoto, Function(ItemBaseModel item) onRemove) =>
|
||||
showBottomSheetPill(
|
||||
context: context,
|
||||
content: (context, scrollController) {
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
controller: scrollController,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Consumer(builder: (context, ref, child) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ElevatedIconButtonLabel(
|
||||
label: context.localized.loop,
|
||||
onPressed: () => ref
|
||||
.read(photoViewSettingsProvider.notifier)
|
||||
.update((state) => state.copyWith(repeat: !state.repeat)),
|
||||
icon: ref.watch(photoViewSettingsProvider.select((value) => value.repeat))
|
||||
? IconsaxOutline.repeat
|
||||
: IconsaxOutline.repeate_one,
|
||||
),
|
||||
ElevatedIconButtonLabel(
|
||||
label: context.localized.audio,
|
||||
onPressed: () => ref
|
||||
.read(photoViewSettingsProvider.notifier)
|
||||
.update((state) => state.copyWith(mute: !state.mute)),
|
||||
icon: ref.watch(photoViewSettingsProvider.select((value) => value.mute))
|
||||
? IconsaxOutline.volume_slash
|
||||
: IconsaxOutline.volume_high,
|
||||
),
|
||||
ElevatedIconButtonLabel(
|
||||
label: context.localized.autoPlay,
|
||||
onPressed: () => ref
|
||||
.read(photoViewSettingsProvider.notifier)
|
||||
.update((state) => state.copyWith(autoPlay: !state.autoPlay)),
|
||||
icon: ref.watch(photoViewSettingsProvider.select((value) => value.autoPlay))
|
||||
? IconsaxOutline.play_remove
|
||||
: IconsaxOutline.play,
|
||||
),
|
||||
ElevatedIconButtonLabel(
|
||||
label: context.localized.backgroundBlur,
|
||||
onPressed: () => ref
|
||||
.read(photoViewSettingsProvider.notifier)
|
||||
.update((state) => state.copyWith(theaterMode: !state.theaterMode)),
|
||||
icon: ref.watch(photoViewSettingsProvider.select((value) => value.theaterMode))
|
||||
? IconsaxOutline.filter_remove
|
||||
: IconsaxOutline.filter,
|
||||
),
|
||||
].addInBetween(const SizedBox(width: 18)),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
Divider(),
|
||||
...currentPhoto
|
||||
.generateActions(
|
||||
context,
|
||||
ref,
|
||||
exclude: {
|
||||
ItemActions.details,
|
||||
ItemActions.markPlayed,
|
||||
ItemActions.markUnplayed,
|
||||
},
|
||||
onDeleteSuccesFully: onRemove,
|
||||
)
|
||||
.listTileItems(context, useIcons: true),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
void markAsFavourite(PhotoModel photo, {bool? value}) {
|
||||
ref.read(userProvider.notifier).setAsFavorite(value ?? !photo.userData.isFavourite, photo.id);
|
||||
|
||||
setState(() {
|
||||
int index = photos.indexOf(photo);
|
||||
photos.remove(photo);
|
||||
photos.insert(
|
||||
index, photo.copyWith(userData: photo.userData.copyWith(isFavourite: value ?? !photo.userData.isFavourite)));
|
||||
});
|
||||
}
|
||||
|
||||
void cacheNeighbors(int index, int range) {
|
||||
photos
|
||||
.getRange((index - range).clamp(0, photos.length - 1), (index + range).clamp(0, photos.length - 1))
|
||||
.forEach((element) {
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(
|
||||
element.thumbnail?.primary?.path ?? "",
|
||||
cacheManager: CustomCacheManager.instance,
|
||||
),
|
||||
context);
|
||||
if (AdaptiveLayout.of(context).isDesktop) {
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(
|
||||
element.images?.primary?.path ?? "",
|
||||
cacheManager: CustomCacheManager.instance,
|
||||
),
|
||||
context);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
261
lib/screens/photo_viewer/simple_video_player.dart
Normal file
261
lib/screens/photo_viewer/simple_video_player.dart
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/providers/settings/photo_view_settings_provider.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:fladder/widgets/shared/fladder_slider.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
import 'package:fladder/models/items/photos_model.dart';
|
||||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/util/duration_extensions.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
class SimpleVideoPlayer extends ConsumerStatefulWidget {
|
||||
final PhotoModel video;
|
||||
final bool showOverlay;
|
||||
final VoidCallback onTapped;
|
||||
const SimpleVideoPlayer({required this.video, required this.showOverlay, required this.onTapped, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _SimpleVideoPlayerState();
|
||||
}
|
||||
|
||||
class _SimpleVideoPlayerState extends ConsumerState<SimpleVideoPlayer> with WindowListener, WidgetsBindingObserver {
|
||||
final Player player = Player(
|
||||
configuration: const PlayerConfiguration(libass: true),
|
||||
);
|
||||
late VideoController controller = VideoController(player);
|
||||
late String videoUrl = "";
|
||||
|
||||
bool playing = false;
|
||||
bool wasPlaying = false;
|
||||
Duration position = Duration.zero;
|
||||
Duration lastPosition = Duration.zero;
|
||||
Duration duration = Duration.zero;
|
||||
|
||||
List<StreamSubscription> subscriptions = [];
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
switch (state) {
|
||||
case AppLifecycleState.resumed:
|
||||
if (playing) player.play();
|
||||
break;
|
||||
case AppLifecycleState.hidden:
|
||||
case AppLifecycleState.paused:
|
||||
case AppLifecycleState.detached:
|
||||
if (playing) player.pause();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
windowManager.addListener(this);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
playing = player.state.playing;
|
||||
position = player.state.position;
|
||||
duration = player.state.duration;
|
||||
Future.microtask(() async => {_init()});
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMinimize() {
|
||||
if (playing) player.pause();
|
||||
super.onWindowMinimize();
|
||||
}
|
||||
|
||||
void _init() async {
|
||||
final Map<String, String?> directOptions = {
|
||||
'Static': 'true',
|
||||
'mediaSourceId': widget.video.id,
|
||||
'api_key': ref.read(userProvider)?.credentials.token,
|
||||
};
|
||||
|
||||
final params = Uri(queryParameters: directOptions).query;
|
||||
|
||||
videoUrl = '${ref.read(userProvider)?.server ?? ""}/Videos/${widget.video.id}/stream?$params';
|
||||
|
||||
subscriptions.addAll(
|
||||
[
|
||||
player.stream.playing.listen((event) {
|
||||
setState(() {
|
||||
playing = event;
|
||||
});
|
||||
if (playing) {
|
||||
WakelockPlus.enable();
|
||||
} else {
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
}),
|
||||
player.stream.position.listen((event) {
|
||||
setState(() {
|
||||
position = event;
|
||||
});
|
||||
}),
|
||||
player.stream.completed.listen((event) {
|
||||
if (event) {
|
||||
_restartVideo();
|
||||
}
|
||||
}),
|
||||
player.stream.duration.listen((event) {
|
||||
setState(() {
|
||||
duration = event;
|
||||
});
|
||||
}),
|
||||
],
|
||||
);
|
||||
await player.setVolume(ref.watch(photoViewSettingsProvider.select((value) => value.mute)) ? 0 : 100);
|
||||
await player.open(Media(videoUrl), play: !ref.watch(photoViewSettingsProvider).autoPlay);
|
||||
}
|
||||
|
||||
void _restartVideo() {
|
||||
if (ref.read(photoViewSettingsProvider.select((value) => value.repeat))) {
|
||||
player.play();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
Future.microtask(() async {
|
||||
await player.dispose();
|
||||
});
|
||||
for (final s in subscriptions) {
|
||||
s.cancel();
|
||||
}
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
windowManager.removeListener(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textStyle = Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold, shadows: [const Shadow(blurRadius: 2)]);
|
||||
ref.listen(
|
||||
photoViewSettingsProvider.select((value) => value.mute),
|
||||
(previous, next) {
|
||||
if (previous != next) {
|
||||
player.setVolume(next ? 0 : 100);
|
||||
}
|
||||
},
|
||||
);
|
||||
return GestureDetector(
|
||||
onTap: widget.onTapped,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: FladderImage(
|
||||
image: widget.video.thumbnail?.primary,
|
||||
enableBlur: true,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
//Fixes small overlay problems with thumbnail
|
||||
Transform.scale(
|
||||
scaleY: 1.004,
|
||||
child: Video(
|
||||
fit: BoxFit.contain,
|
||||
fill: const Color.fromARGB(0, 123, 62, 62),
|
||||
controller: controller,
|
||||
controls: NoVideoControls,
|
||||
wakelock: false,
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
ignoring: !widget.showOverlay,
|
||||
child: AnimatedOpacity(
|
||||
opacity: widget.showOverlay ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12)
|
||||
.add(EdgeInsets.only(bottom: 80 + MediaQuery.of(context).padding.bottom)),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SizedBox(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: FladderSlider(
|
||||
min: 0.0,
|
||||
max: duration.inMilliseconds.toDouble(),
|
||||
value: position.inMilliseconds.toDouble().clamp(
|
||||
0,
|
||||
duration.inMilliseconds.toDouble(),
|
||||
),
|
||||
onChangeEnd: (e) async {
|
||||
await player.seek(Duration(milliseconds: e ~/ 1));
|
||||
if (wasPlaying) {
|
||||
player.play();
|
||||
}
|
||||
},
|
||||
onChangeStart: (value) {
|
||||
wasPlaying = player.state.playing;
|
||||
player.pause();
|
||||
},
|
||||
onChanged: (e) {
|
||||
setState(() => position = Duration(milliseconds: e ~/ 1));
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(position.readAbleDuration, style: textStyle),
|
||||
const Spacer(),
|
||||
Text((duration - position).readAbleDuration, style: textStyle),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
onPressed: () {
|
||||
player.playOrPause();
|
||||
},
|
||||
icon: Icon(
|
||||
player.state.playing ? IconsaxBold.pause_circle : IconsaxBold.play_circle,
|
||||
shadows: [
|
||||
BoxShadow(blurRadius: 16, spreadRadius: 2, color: Colors.black.withOpacity(0.15))
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
159
lib/screens/playlists/add_to_playlists.dart
Normal file
159
lib/screens/playlists/add_to_playlists.dart
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/providers/playlist_provider.dart';
|
||||
import 'package:fladder/screens/shared/adaptive_dialog.dart';
|
||||
import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/screens/shared/outlined_text_field.dart';
|
||||
|
||||
Future<void> addItemToPlaylist(BuildContext context, List<ItemBaseModel> item) {
|
||||
return showDialogAdaptive(context: context, builder: (context) => AddToPlaylist(items: item));
|
||||
}
|
||||
|
||||
class AddToPlaylist extends ConsumerStatefulWidget {
|
||||
final List<ItemBaseModel> items;
|
||||
const AddToPlaylist({required this.items, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _AddToPlaylistState();
|
||||
}
|
||||
|
||||
class _AddToPlaylistState extends ConsumerState<AddToPlaylist> {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
late final provider = playlistProvider;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() => ref.read(provider.notifier).setItems(widget.items));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final collectonOptions = ref.watch(provider);
|
||||
return Card(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(height: MediaQuery.paddingOf(context).top),
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (widget.items.length == 1)
|
||||
Text(
|
||||
'Add to collection',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'Add ${widget.items.length} item(s) to collection',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => ref.read(provider.notifier).setItems(widget.items),
|
||||
icon: const Icon(IconsaxOutline.refresh),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.items.length == 1) ItemBottomSheetPreview(item: widget.items.first),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: OutlinedTextField(
|
||||
label: 'New Playlist',
|
||||
controller: controller,
|
||||
onChanged: (value) => setState(() {}),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
IconButton(
|
||||
onPressed: controller.text.isNotEmpty
|
||||
? () async {
|
||||
final response = await ref.read(provider.notifier).addToNewPlaylist(
|
||||
name: controller.text,
|
||||
);
|
||||
if (context.mounted) {
|
||||
fladderSnackbar(context,
|
||||
title: response.isSuccessful
|
||||
? "Added to new ${controller.text} playlist"
|
||||
: 'Unable to create new playlist - (${response.statusCode}) - ${response.base.reasonPhrase}');
|
||||
}
|
||||
setState(() => controller.text = '');
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.add_rounded)),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
...collectonOptions.collections.entries.map(
|
||||
(e) {
|
||||
return ListTile(
|
||||
title: Text(e.key.name),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
style: IconButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
onPressed: () async {
|
||||
final response = await ref.read(provider.notifier).addToPlaylist(playlist: e.key);
|
||||
if (context.mounted) {
|
||||
fladderSnackbar(context,
|
||||
title: response.isSuccessful
|
||||
? "Added to ${e.key.name} playlist"
|
||||
: 'Unable to add to playlist - (${response.statusCode}) - ${response.base.reasonPhrase}');
|
||||
}
|
||||
},
|
||||
icon: Icon(Icons.add_rounded, color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(context.localized.close),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
89
lib/screens/search/search_screen.dart
Normal file
89
lib/screens/search/search_screen.dart
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import 'package:fladder/providers/search_provider.dart';
|
||||
import 'package:fladder/screens/shared/media/poster_grid.dart';
|
||||
import 'package:fladder/util/debouncer.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class SearchScreen extends ConsumerStatefulWidget {
|
||||
const SearchScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _SearchScreenState();
|
||||
}
|
||||
|
||||
class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
|
||||
final Debouncer searchDebouncer = Debouncer(const Duration(milliseconds: 500));
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() {
|
||||
ref.read(searchProvider.notifier).clear();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final searchResults = ref.watch(searchProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(0),
|
||||
child: Stack(
|
||||
children: [
|
||||
Transform.translate(
|
||||
offset: const Offset(0, 4),
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
Transform.translate(
|
||||
offset: const Offset(0, -4),
|
||||
child: AnimatedOpacity(
|
||||
opacity: searchResults.loading ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: Transform.translate(
|
||||
offset: const Offset(0, 5),
|
||||
child: const LinearProgressIndicator(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
title: TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: "Search library...",
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onSubmitted: (value) {
|
||||
ref.read(searchProvider.notifier).searchQuery();
|
||||
},
|
||||
onChanged: (query) {
|
||||
ref.read(searchProvider.notifier).setQuery(query);
|
||||
searchDebouncer.run(() {
|
||||
ref.read(searchProvider.notifier).searchQuery();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
children: searchResults.results.entries
|
||||
.map(
|
||||
(e) => PosterGrid(
|
||||
stickyHeader: false,
|
||||
name: e.key.name.capitalize(),
|
||||
posters: e.value,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
476
lib/screens/settings/client_settings_page.dart
Normal file
476
lib/screens/settings/client_settings_page.dart
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:fladder/models/settings/home_settings_model.dart';
|
||||
import 'package:fladder/providers/settings/client_settings_provider.dart';
|
||||
import 'package:fladder/providers/settings/home_settings_provider.dart';
|
||||
import 'package:fladder/providers/shared_provider.dart';
|
||||
import 'package:fladder/providers/sync_provider.dart';
|
||||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/routes/build_routes/route_builder.dart';
|
||||
import 'package:fladder/screens/settings/settings_list_tile.dart';
|
||||
import 'package:fladder/screens/settings/settings_scaffold.dart';
|
||||
import 'package:fladder/screens/settings/widgets/settings_label_divider.dart';
|
||||
import 'package:fladder/screens/shared/default_alert_dialog.dart';
|
||||
import 'package:fladder/screens/shared/input_fields.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/custom_color_themes.dart';
|
||||
import 'package:fladder/util/local_extension.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/option_dialogue.dart';
|
||||
import 'package:fladder/util/simple_duration_picker.dart';
|
||||
import 'package:fladder/util/size_formatting.dart';
|
||||
import 'package:fladder/util/theme_mode_extension.dart';
|
||||
import 'package:fladder/widgets/shared/enum_selection.dart';
|
||||
import 'package:fladder/widgets/shared/fladder_slider.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
class ClientSettingsPage extends ConsumerStatefulWidget {
|
||||
const ClientSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _ClientSettingsPageState();
|
||||
}
|
||||
|
||||
class _ClientSettingsPageState extends ConsumerState<ClientSettingsPage> {
|
||||
late final nextUpDaysEditor = TextEditingController(
|
||||
text: ref.read(clientSettingsProvider.select((value) => value.nextUpDateCutoff?.inDays ?? 14)).toString());
|
||||
|
||||
late final libraryPageSizeController = TextEditingController(
|
||||
text: ref.read(clientSettingsProvider.select((value) => value.libraryPageSize))?.toString() ?? "");
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final clientSettings = ref.watch(clientSettingsProvider);
|
||||
final showBackground = AdaptiveLayout.of(context).layout != LayoutState.phone &&
|
||||
AdaptiveLayout.of(context).size != ScreenLayout.single;
|
||||
final currentFolder = ref.watch(syncProvider.notifier).savePath;
|
||||
Locale currentLocale = WidgetsBinding.instance.platformDispatcher.locale;
|
||||
|
||||
final canSync = ref.watch(userProvider.select((value) => value?.canDownload ?? false));
|
||||
return Card(
|
||||
elevation: showBackground ? 2 : 0,
|
||||
child: SettingsScaffold(
|
||||
label: "Fladder",
|
||||
items: [
|
||||
if (canSync && !kIsWeb) ...[
|
||||
SettingsLabelDivider(label: context.localized.downloadsTitle),
|
||||
if (AdaptiveLayout.of(context).isDesktop) ...[
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.downloadsPath),
|
||||
subLabel: Text(currentFolder ?? "-"),
|
||||
onTap: currentFolder != null
|
||||
? () async => await showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog.adaptive(
|
||||
title: Text(context.localized.pathEditTitle),
|
||||
content: Text(context.localized.pathEditDesc),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
|
||||
dialogTitle: context.localized.pathEditSelect, initialDirectory: currentFolder);
|
||||
if (selectedDirectory != null) {
|
||||
ref.read(clientSettingsProvider.notifier).setSyncPath(selectedDirectory);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(context.localized.change),
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
: () async {
|
||||
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
|
||||
dialogTitle: context.localized.pathEditSelect, initialDirectory: currentFolder);
|
||||
if (selectedDirectory != null) {
|
||||
ref.read(clientSettingsProvider.notifier).setSyncPath(selectedDirectory);
|
||||
}
|
||||
},
|
||||
trailing: currentFolder?.isNotEmpty == true
|
||||
? IconButton(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
onPressed: () async => await showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog.adaptive(
|
||||
title: Text(context.localized.pathClearTitle),
|
||||
content: Text(context.localized.pathEditDesc),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
ref.read(clientSettingsProvider.notifier).setSyncPath(null);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(context.localized.clear),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
icon: Icon(IconsaxOutline.folder_minus),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
FutureBuilder(
|
||||
future: ref.watch(syncProvider.notifier).directorySize,
|
||||
builder: (context, snapshot) {
|
||||
final data = snapshot.data ?? 0;
|
||||
return SettingsListTile(
|
||||
label: Text(context.localized.downloadsSyncedData),
|
||||
subLabel: Text(data.byteFormat ?? ""),
|
||||
trailing: FilledButton(
|
||||
onPressed: () {
|
||||
showDefaultAlertDialog(
|
||||
context,
|
||||
context.localized.downloadsClearTitle,
|
||||
context.localized.downloadsClearDesc,
|
||||
(context) async {
|
||||
await ref.read(syncProvider.notifier).clear();
|
||||
setState(() {});
|
||||
context.pop();
|
||||
},
|
||||
context.localized.clear,
|
||||
(context) => context.pop(),
|
||||
context.localized.cancel,
|
||||
);
|
||||
},
|
||||
child: Text(context.localized.clear),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
],
|
||||
SettingsLabelDivider(label: context.localized.lockscreen),
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.timeOut),
|
||||
subLabel: Text(timePickerString(context, clientSettings.timeOut)),
|
||||
onTap: () async {
|
||||
final timePicker = await showSimpleDurationPicker(
|
||||
context: context,
|
||||
initialValue: clientSettings.timeOut ?? const Duration(),
|
||||
);
|
||||
|
||||
ref.read(clientSettingsProvider.notifier).setTimeOut(timePicker != null
|
||||
? Duration(minutes: timePicker.inMinutes, seconds: timePicker.inSeconds % 60)
|
||||
: null);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
SettingsLabelDivider(label: context.localized.dashboard),
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.settingsHomeCarouselTitle),
|
||||
subLabel: Text(context.localized.settingsHomeCarouselDesc),
|
||||
trailing: EnumBox(
|
||||
current: ref.watch(
|
||||
homeSettingsProvider.select(
|
||||
(value) => value.carouselSettings.label(context),
|
||||
),
|
||||
),
|
||||
itemBuilder: (context) => HomeCarouselSettings.values
|
||||
.map(
|
||||
(entry) => PopupMenuItem(
|
||||
value: entry,
|
||||
child: Text(entry.label(context)),
|
||||
onTap: () => ref
|
||||
.read(homeSettingsProvider.notifier)
|
||||
.update((context) => context.copyWith(carouselSettings: entry)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.settingsHomeNextUpTitle),
|
||||
subLabel: Text(context.localized.settingsHomeNextUpDesc),
|
||||
trailing: EnumBox(
|
||||
current: ref.watch(
|
||||
homeSettingsProvider.select(
|
||||
(value) => value.nextUp.label(context),
|
||||
),
|
||||
),
|
||||
itemBuilder: (context) => HomeNextUp.values
|
||||
.map(
|
||||
(entry) => PopupMenuItem(
|
||||
value: entry,
|
||||
child: Text(entry.label(context)),
|
||||
onTap: () =>
|
||||
ref.read(homeSettingsProvider.notifier).update((context) => context.copyWith(nextUp: entry)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
SettingsLabelDivider(label: context.localized.settingsVisual),
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.displayLanguage),
|
||||
trailing: EnumBox(
|
||||
current: ref.watch(
|
||||
clientSettingsProvider.select(
|
||||
(value) => (value.selectedLocale ?? currentLocale).label(),
|
||||
),
|
||||
),
|
||||
itemBuilder: (context) {
|
||||
return [
|
||||
...AppLocalizations.supportedLocales.map(
|
||||
(entry) => PopupMenuItem(
|
||||
value: entry,
|
||||
child: Text(
|
||||
entry.label(),
|
||||
style: TextStyle(
|
||||
fontWeight: currentLocale.languageCode == entry.languageCode ? FontWeight.bold : null,
|
||||
),
|
||||
),
|
||||
onTap: () => ref
|
||||
.read(clientSettingsProvider.notifier)
|
||||
.update((state) => state.copyWith(selectedLocale: entry)),
|
||||
),
|
||||
)
|
||||
];
|
||||
},
|
||||
),
|
||||
),
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.settingsBlurredPlaceholderTitle),
|
||||
subLabel: Text(context.localized.settingsBlurredPlaceholderDesc),
|
||||
onTap: () =>
|
||||
ref.read(clientSettingsProvider.notifier).setBlurPlaceholders(!clientSettings.blurPlaceHolders),
|
||||
trailing: Switch(
|
||||
value: clientSettings.blurPlaceHolders,
|
||||
onChanged: (value) => ref.read(clientSettingsProvider.notifier).setBlurPlaceholders(value),
|
||||
),
|
||||
),
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.settingsBlurEpisodesTitle),
|
||||
subLabel: Text(context.localized.settingsBlurEpisodesDesc),
|
||||
onTap: () =>
|
||||
ref.read(clientSettingsProvider.notifier).setBlurEpisodes(!clientSettings.blurUpcomingEpisodes),
|
||||
trailing: Switch(
|
||||
value: clientSettings.blurUpcomingEpisodes,
|
||||
onChanged: (value) => ref.read(clientSettingsProvider.notifier).setBlurEpisodes(value),
|
||||
),
|
||||
),
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.settingsEnableOsMediaControls),
|
||||
onTap: () => ref.read(clientSettingsProvider.notifier).setMediaKeys(!clientSettings.enableMediaKeys),
|
||||
trailing: Switch(
|
||||
value: clientSettings.enableMediaKeys,
|
||||
onChanged: (value) => ref.read(clientSettingsProvider.notifier).setMediaKeys(value),
|
||||
),
|
||||
),
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.settingsNextUpCutoffDays),
|
||||
trailing: SizedBox(
|
||||
width: 100,
|
||||
child: IntInputField(
|
||||
suffix: context.localized.days,
|
||||
controller: nextUpDaysEditor,
|
||||
onSubmitted: (value) {
|
||||
if (value != null) {
|
||||
ref.read(clientSettingsProvider.notifier).update((current) => current.copyWith(
|
||||
nextUpDateCutoff: Duration(days: value),
|
||||
));
|
||||
}
|
||||
},
|
||||
)),
|
||||
),
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.libraryPageSizeTitle),
|
||||
subLabel: Text(context.localized.libraryPageSizeDesc),
|
||||
trailing: SizedBox(
|
||||
width: 100,
|
||||
child: IntInputField(
|
||||
controller: libraryPageSizeController,
|
||||
placeHolder: "500",
|
||||
onSubmitted: (value) => ref.read(clientSettingsProvider.notifier).update(
|
||||
(current) => current.copyWith(libraryPageSize: value),
|
||||
),
|
||||
)),
|
||||
),
|
||||
SettingsListTile(
|
||||
label: Text(AdaptiveLayout.of(context).isDesktop
|
||||
? context.localized.settingsShowScaleSlider
|
||||
: context.localized.settingsPosterPinch),
|
||||
onTap: () => ref.read(clientSettingsProvider.notifier).update(
|
||||
(current) => current.copyWith(pinchPosterZoom: !current.pinchPosterZoom),
|
||||
),
|
||||
trailing: Switch(
|
||||
value: clientSettings.pinchPosterZoom,
|
||||
onChanged: (value) => ref.read(clientSettingsProvider.notifier).update(
|
||||
(current) => current.copyWith(pinchPosterZoom: value),
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.settingsPosterSize),
|
||||
trailing: Text(
|
||||
clientSettings.posterSize.toString(),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: FladderSlider(
|
||||
min: 0.5,
|
||||
max: 1.5,
|
||||
value: clientSettings.posterSize,
|
||||
divisions: 20,
|
||||
onChanged: (value) => ref
|
||||
.read(clientSettingsProvider.notifier)
|
||||
.update((current) => current.copyWith(posterSize: value)),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
],
|
||||
),
|
||||
SettingsLabelDivider(label: context.localized.theme),
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.mode),
|
||||
subLabel: Text(clientSettings.themeMode.label(context)),
|
||||
onTap: () => openOptionDialogue(
|
||||
context,
|
||||
label: "${context.localized.theme} ${context.localized.mode}",
|
||||
items: ThemeMode.values,
|
||||
itemBuilder: (type) => RadioListTile(
|
||||
value: type,
|
||||
title: Text(type?.label(context) ?? context.localized.other),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
groupValue: ref.read(clientSettingsProvider.select((value) => value.themeMode)),
|
||||
onChanged: (value) => ref.read(clientSettingsProvider.notifier).setThemeMode(value),
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.color),
|
||||
subLabel: Text(clientSettings.themeColor?.name ?? context.localized.dynamicText),
|
||||
onTap: () => openOptionDialogue<ColorThemes>(
|
||||
context,
|
||||
isNullable: !kIsWeb,
|
||||
label: context.localized.themeColor,
|
||||
items: ColorThemes.values,
|
||||
itemBuilder: (type) => Consumer(
|
||||
builder: (context, ref, child) => ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: type == ref.watch(clientSettingsProvider.select((value) => value.themeColor)),
|
||||
onChanged: (value) => ref.read(clientSettingsProvider.notifier).setThemeColor(type),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
height: 24,
|
||||
width: 24,
|
||||
decoration: BoxDecoration(
|
||||
gradient: type == null
|
||||
? const SweepGradient(
|
||||
center: FractionalOffset.center,
|
||||
colors: <Color>[
|
||||
Color(0xFF4285F4), // blue
|
||||
Color(0xFF34A853), // green
|
||||
Color(0xFFFBBC05), // yellow
|
||||
Color(0xFFEA4335), // red
|
||||
Color(0xFF4285F4), // blue again to seamlessly transition to the start
|
||||
],
|
||||
stops: <double>[0.0, 0.25, 0.5, 0.75, 1.0],
|
||||
)
|
||||
: null,
|
||||
color: type?.color,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(type?.name ?? context.localized.dynamicText),
|
||||
],
|
||||
),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
onTap: () => ref.read(clientSettingsProvider.notifier).setThemeColor(type),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.amoledBlack),
|
||||
subLabel: Text(clientSettings.amoledBlack ? context.localized.enabled : context.localized.disabled),
|
||||
onTap: () => ref.read(clientSettingsProvider.notifier).setAmoledBlack(!clientSettings.amoledBlack),
|
||||
trailing: Switch(
|
||||
value: clientSettings.amoledBlack,
|
||||
onChanged: (value) => ref.read(clientSettingsProvider.notifier).setAmoledBlack(value),
|
||||
),
|
||||
),
|
||||
if (AdaptiveLayout.of(context).isDesktop) ...[
|
||||
const Divider(),
|
||||
SettingsLabelDivider(label: context.localized.controls),
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.mouseDragSupport),
|
||||
subLabel: Text(clientSettings.mouseDragSupport ? context.localized.enabled : context.localized.disabled),
|
||||
onTap: () => ref
|
||||
.read(clientSettingsProvider.notifier)
|
||||
.update((current) => current.copyWith(mouseDragSupport: !clientSettings.mouseDragSupport)),
|
||||
trailing: Switch(
|
||||
value: clientSettings.mouseDragSupport,
|
||||
onChanged: (value) => ref.read(clientSettingsProvider.notifier).setAmoledBlack(value),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 64),
|
||||
SettingsListTile(
|
||||
label: Text(
|
||||
context.localized.clearAllSettings,
|
||||
),
|
||||
contentColor: Theme.of(context).colorScheme.error,
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => Dialog(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.localized.clearAllSettingsQuestion,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.localized.unableToReverseAction,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: Text(context.localized.cancel),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await ref.read(sharedPreferencesProvider).clear();
|
||||
context.routeGo(LoginRoute());
|
||||
},
|
||||
child: Text(context.localized.clear),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
123
lib/screens/settings/player_settings_page.dart
Normal file
123
lib/screens/settings/player_settings_page.dart
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import 'package:fladder/providers/settings/video_player_settings_provider.dart';
|
||||
import 'package:fladder/screens/settings/settings_list_tile.dart';
|
||||
import 'package:fladder/screens/settings/settings_scaffold.dart';
|
||||
import 'package:fladder/screens/settings/widgets/settings_label_divider.dart';
|
||||
import 'package:fladder/screens/settings/widgets/settings_message_box.dart';
|
||||
import 'package:fladder/screens/settings/widgets/subtitle_editor.dart';
|
||||
import 'package:fladder/screens/shared/animated_fade_size.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/box_fit_extension.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/option_dialogue.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
class PlayerSettingsPage extends ConsumerStatefulWidget {
|
||||
const PlayerSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _PlayerSettingsPageState();
|
||||
}
|
||||
|
||||
class _PlayerSettingsPageState extends ConsumerState<PlayerSettingsPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final videoSettings = ref.watch(videoPlayerSettingsProvider);
|
||||
final provider = ref.read(videoPlayerSettingsProvider.notifier);
|
||||
final showBackground = AdaptiveLayout.of(context).layout != LayoutState.phone &&
|
||||
AdaptiveLayout.of(context).size != ScreenLayout.single;
|
||||
return Card(
|
||||
elevation: showBackground ? 2 : 0,
|
||||
child: SettingsScaffold(
|
||||
label: context.localized.settingsPlayerTitle,
|
||||
items: [
|
||||
SettingsLabelDivider(label: context.localized.video),
|
||||
if (!AdaptiveLayout.of(context).isDesktop && !kIsWeb)
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.videoScalingFillScreenTitle),
|
||||
subLabel: Text(context.localized.videoScalingFillScreenDesc),
|
||||
onTap: () => provider.setFillScreen(!videoSettings.fillScreen),
|
||||
trailing: Switch(
|
||||
value: videoSettings.fillScreen,
|
||||
onChanged: (value) => provider.setFillScreen(value),
|
||||
),
|
||||
),
|
||||
AnimatedFadeSize(
|
||||
child: videoSettings.fillScreen
|
||||
? SettingsMessageBox(
|
||||
context.localized.videoScalingFillScreenNotif,
|
||||
messageType: MessageType.warning,
|
||||
)
|
||||
: Container(),
|
||||
),
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.videoScalingFillScreenTitle),
|
||||
subLabel: Text(videoSettings.videoFit.label(context)),
|
||||
onTap: () => openOptionDialogue(
|
||||
context,
|
||||
label: context.localized.videoScalingFillScreenTitle,
|
||||
items: BoxFit.values,
|
||||
itemBuilder: (type) => RadioListTile.adaptive(
|
||||
title: Text(type?.label(context) ?? ""),
|
||||
value: type,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
groupValue: ref.read(videoPlayerSettingsProvider.select((value) => value.videoFit)),
|
||||
onChanged: (value) {
|
||||
provider.setFitType(value);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
SettingsLabelDivider(label: context.localized.advanced),
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.settingsPlayerVideoHWAccelTitle),
|
||||
subLabel: Text(context.localized.settingsPlayerVideoHWAccelDesc),
|
||||
onTap: () => provider.setHardwareAccel(!videoSettings.hardwareAccel),
|
||||
trailing: Switch(
|
||||
value: videoSettings.hardwareAccel,
|
||||
onChanged: (value) => provider.setHardwareAccel(value),
|
||||
),
|
||||
),
|
||||
if (!kIsWeb) ...[
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.settingsPlayerNativeLibassAccelTitle),
|
||||
subLabel: Text(context.localized.settingsPlayerNativeLibassAccelDesc),
|
||||
onTap: () => provider.setUseLibass(!videoSettings.useLibass),
|
||||
trailing: Switch(
|
||||
value: videoSettings.useLibass,
|
||||
onChanged: (value) => provider.setUseLibass(value),
|
||||
),
|
||||
),
|
||||
AnimatedFadeSize(
|
||||
child: videoSettings.useLibass && videoSettings.hardwareAccel && Platform.isAndroid
|
||||
? SettingsMessageBox(
|
||||
context.localized.settingsPlayerMobileWarning,
|
||||
messageType: MessageType.warning,
|
||||
)
|
||||
: Container(),
|
||||
),
|
||||
],
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.settingsPlayerCustomSubtitlesTitle),
|
||||
subLabel: Text(context.localized.settingsPlayerCustomSubtitlesDesc),
|
||||
onTap: videoSettings.useLibass
|
||||
? null
|
||||
: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
useSafeArea: false,
|
||||
builder: (context) => const SubtitleEditor(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
119
lib/screens/settings/quick_connect_window.dart
Normal file
119
lib/screens/settings/quick_connect_window.dart
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/screens/login/widgets/login_icon.dart';
|
||||
import 'package:fladder/screens/shared/outlined_text_field.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
Future<void> openQuickConnectDialog(
|
||||
BuildContext context,
|
||||
) async {
|
||||
return showDialog(context: context, builder: (context) => QuickConnectDialog());
|
||||
}
|
||||
|
||||
class QuickConnectDialog extends ConsumerStatefulWidget {
|
||||
const QuickConnectDialog({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _QuickConnectDialogState();
|
||||
}
|
||||
|
||||
class _QuickConnectDialogState extends ConsumerState<QuickConnectDialog> {
|
||||
final controller = TextEditingController();
|
||||
bool loading = false;
|
||||
String? error;
|
||||
String? success;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = ref.watch(userProvider);
|
||||
return AlertDialog.adaptive(
|
||||
title: Text(context.localized.quickConnectTitle),
|
||||
scrollable: true,
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(context.localized.quickConnectAction),
|
||||
if (user != null) SizedBox(child: LoginIcon(user: user)),
|
||||
Flexible(
|
||||
child: OutlinedTextField(
|
||||
label: context.localized.code,
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (value) {
|
||||
if (value.isNotEmpty) {
|
||||
setState(() {
|
||||
error = null;
|
||||
success = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
),
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: error != null || success != null
|
||||
? Card(
|
||||
key: Key(context.localized.error),
|
||||
color: success == null
|
||||
? Theme.of(context).colorScheme.errorContainer
|
||||
: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
success ?? error ?? "",
|
||||
style: TextStyle(
|
||||
color: success == null
|
||||
? Theme.of(context).colorScheme.onErrorContainer
|
||||
: Theme.of(context).colorScheme.onSurface),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: loading
|
||||
? null
|
||||
: () async {
|
||||
setState(() {
|
||||
error = null;
|
||||
loading = true;
|
||||
});
|
||||
final response = await ref.read(userProvider.notifier).quickConnect(controller.text);
|
||||
if (response.isSuccessful) {
|
||||
setState(
|
||||
() {
|
||||
error = null;
|
||||
success = context.localized.loggedIn;
|
||||
},
|
||||
);
|
||||
await Future.delayed(Duration(seconds: 2));
|
||||
Navigator.of(context).pop();
|
||||
} else {
|
||||
if (controller.text.isEmpty) {
|
||||
error = context.localized.quickConnectInputACode;
|
||||
} else {
|
||||
error = context.localized.quickConnectWrongCode;
|
||||
}
|
||||
}
|
||||
loading = false;
|
||||
setState(
|
||||
() {},
|
||||
);
|
||||
controller.text = "";
|
||||
},
|
||||
child: loading
|
||||
? SizedBox.square(
|
||||
child: CircularProgressIndicator(),
|
||||
dimension: 16.0,
|
||||
)
|
||||
: Text(context.localized.login),
|
||||
)
|
||||
].addInBetween(const SizedBox(height: 16)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
41
lib/screens/settings/security_settings_page.dart
Normal file
41
lib/screens/settings/security_settings_page.dart
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/screens/settings/settings_list_tile.dart';
|
||||
import 'package:fladder/screens/settings/settings_scaffold.dart';
|
||||
import 'package:fladder/screens/settings/widgets/settings_label_divider.dart';
|
||||
import 'package:fladder/screens/shared/authenticate_button_options.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 SecuritySettingsPage extends ConsumerStatefulWidget {
|
||||
const SecuritySettingsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _UserSettingsPageState();
|
||||
}
|
||||
|
||||
class _UserSettingsPageState extends ConsumerState<SecuritySettingsPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = ref.watch(userProvider);
|
||||
final showBackground = AdaptiveLayout.of(context).layout != LayoutState.phone &&
|
||||
AdaptiveLayout.of(context).size != ScreenLayout.single;
|
||||
return Card(
|
||||
elevation: showBackground ? 2 : 0,
|
||||
child: SettingsScaffold(
|
||||
label: context.localized.settingsProfileTitle,
|
||||
items: [
|
||||
SettingsLabelDivider(label: context.localized.settingSecurityApplockTitle),
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.settingSecurityApplockTitle),
|
||||
subLabel: Text(user?.authMethod.name(context) ?? ""),
|
||||
onTap: () => showAuthOptionsDialogue(context, user!, (newUser) {
|
||||
ref.read(userProvider.notifier).updateUser(newUser);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
72
lib/screens/settings/settings_list_tile.dart
Normal file
72
lib/screens/settings/settings_list_tile.dart
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class SettingsListTile extends StatelessWidget {
|
||||
final Widget label;
|
||||
final Widget? subLabel;
|
||||
final Widget? trailing;
|
||||
final bool selected;
|
||||
final IconData? icon;
|
||||
final Widget? suffix;
|
||||
final Color? contentColor;
|
||||
final Function()? onTap;
|
||||
const SettingsListTile({
|
||||
required this.label,
|
||||
this.subLabel,
|
||||
this.trailing,
|
||||
this.selected = false,
|
||||
this.suffix,
|
||||
this.icon,
|
||||
this.contentColor,
|
||||
this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final iconWidget = icon != null ? Icon(icon) : null;
|
||||
return Card(
|
||||
elevation: selected ? 2 : 0,
|
||||
color: selected ? null : Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(topLeft: Radius.circular(8), bottomLeft: Radius.circular(8))),
|
||||
margin: EdgeInsets.zero,
|
||||
child: ListTile(
|
||||
minVerticalPadding: 12,
|
||||
minLeadingWidth: 16,
|
||||
minTileHeight: 75,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
horizontalTitleGap: 0,
|
||||
titleAlignment: ListTileTitleAlignment.center,
|
||||
contentPadding: const EdgeInsets.only(right: 12),
|
||||
leading: (suffix ?? iconWidget) != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0, right: 16.0),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 125),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(selected ? 1 : 0),
|
||||
borderRadius: BorderRadius.circular(selected ? 5 : 20),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 12),
|
||||
child: (suffix ?? iconWidget),
|
||||
),
|
||||
),
|
||||
)
|
||||
: suffix ?? const SizedBox(),
|
||||
title: label,
|
||||
titleTextStyle: Theme.of(context).textTheme.titleLarge,
|
||||
trailing: Padding(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: trailing,
|
||||
),
|
||||
selected: selected,
|
||||
textColor: contentColor,
|
||||
iconColor: contentColor,
|
||||
subtitle: subLabel,
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
95
lib/screens/settings/settings_scaffold.dart
Normal file
95
lib/screens/settings/settings_scaffold.dart
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/screens/shared/user_icon.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class SettingsScaffold extends ConsumerWidget {
|
||||
final String label;
|
||||
final bool showUserIcon;
|
||||
final ScrollController? scrollController;
|
||||
final List<Widget> items;
|
||||
final List<Widget> bottomActions;
|
||||
final Widget? floatingActionButton;
|
||||
const SettingsScaffold({
|
||||
required this.label,
|
||||
this.showUserIcon = false,
|
||||
this.scrollController,
|
||||
required this.items,
|
||||
this.bottomActions = const [],
|
||||
this.floatingActionButton,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final padding = MediaQuery.of(context).padding;
|
||||
return Scaffold(
|
||||
backgroundColor: AdaptiveLayout.of(context).isDesktop ? Colors.transparent : null,
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: floatingActionButton,
|
||||
body: Column(
|
||||
children: [
|
||||
Flexible(
|
||||
child: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
if (AdaptiveLayout.of(context).size == ScreenLayout.single)
|
||||
SliverAppBar.large(
|
||||
titleSpacing: 20,
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
titlePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16)
|
||||
.add(EdgeInsets.only(left: padding.left, right: padding.right)),
|
||||
title: Row(
|
||||
children: [
|
||||
Text(label, style: Theme.of(context).textTheme.headlineSmall),
|
||||
const Spacer(),
|
||||
if (showUserIcon)
|
||||
SizedBox.fromSize(
|
||||
size: const Size.fromRadius(14),
|
||||
child: UserIcon(
|
||||
user: ref.watch(userProvider),
|
||||
cornerRadius: 200,
|
||||
))
|
||||
],
|
||||
),
|
||||
expandedTitleScale: 2,
|
||||
),
|
||||
expandedHeight: 175,
|
||||
collapsedHeight: 100,
|
||||
pinned: false,
|
||||
floating: true,
|
||||
)
|
||||
else
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(AdaptiveLayout.of(context).size == ScreenLayout.single ? label : "",
|
||||
style: Theme.of(context).textTheme.headlineLarge),
|
||||
),
|
||||
),
|
||||
SliverList(
|
||||
delegate: SliverChildListDelegate(items),
|
||||
),
|
||||
if (bottomActions.isEmpty)
|
||||
const SliverToBoxAdapter(child: SizedBox(height: kBottomNavigationBarHeight + 40)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (bottomActions.isNotEmpty) ...{
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32)
|
||||
.add(EdgeInsets.only(left: padding.left, right: padding.right)),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: bottomActions,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: kBottomNavigationBarHeight + 40),
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
223
lib/screens/settings/settings_screen.dart
Normal file
223
lib/screens/settings/settings_screen.dart
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
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/settings/quick_connect_window.dart';
|
||||
import 'package:fladder/screens/settings/settings_list_tile.dart';
|
||||
import 'package:fladder/screens/settings/settings_scaffold.dart';
|
||||
import 'package:fladder/screens/shared/animated_fade_size.dart';
|
||||
import 'package:fladder/screens/shared/fladder_icon.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/theme_extensions.dart';
|
||||
import 'package:fladder/widgets/shared/hide_on_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:fladder/providers/auth_provider.dart';
|
||||
import 'package:fladder/util/application_info.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerStatefulWidget {
|
||||
final Widget? child;
|
||||
final String? location;
|
||||
const SettingsScreen({this.child, this.location, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
final scrollController = ScrollController();
|
||||
late final singlePane = widget.child == null;
|
||||
final minVerticalPadding = 20.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (singlePane) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
child: _leftPane(context),
|
||||
);
|
||||
} else {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(flex: 1, child: _leftPane(context)),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: widget.child ?? Container(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
IconData get deviceIcon {
|
||||
if (AdaptiveLayout.of(context).isDesktop) {
|
||||
return IconsaxOutline.monitor;
|
||||
}
|
||||
switch (AdaptiveLayout.of(context).layout) {
|
||||
case LayoutState.phone:
|
||||
return IconsaxOutline.mobile;
|
||||
case LayoutState.tablet:
|
||||
return IconsaxOutline.monitor;
|
||||
case LayoutState.desktop:
|
||||
return IconsaxOutline.monitor;
|
||||
}
|
||||
}
|
||||
|
||||
bool containsRoute(CustomRoute route) => widget.location == route.route;
|
||||
|
||||
Widget _leftPane(BuildContext context) {
|
||||
final quickConnectAvailable =
|
||||
ref.watch(userProvider.select((value) => value?.serverConfiguration?.quickConnectAvailable ?? false));
|
||||
return SettingsScaffold(
|
||||
label: context.localized.settings,
|
||||
scrollController: scrollController,
|
||||
showUserIcon: true,
|
||||
items: [
|
||||
if (context.canPop() && AdaptiveLayout.of(context).isDesktop)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: IconButton.filledTonal(
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface.withOpacity(0.8),
|
||||
),
|
||||
onPressed: () {
|
||||
context.pop();
|
||||
},
|
||||
icon: Padding(
|
||||
padding: EdgeInsets.all(AdaptiveLayout.of(context).inputDevice == InputDevice.pointer ? 0 : 4),
|
||||
child: Icon(IconsaxOutline.arrow_left_2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.settingsClientTitle),
|
||||
subLabel: Text(context.localized.settingsClientDesc),
|
||||
selected: containsRoute(ClientSettingsRoute()),
|
||||
icon: deviceIcon,
|
||||
onTap: () => context.routeReplaceOrPush(ClientSettingsRoute()),
|
||||
),
|
||||
if (quickConnectAvailable)
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.settingsQuickConnectTitle),
|
||||
icon: IconsaxOutline.password_check,
|
||||
onTap: () => openQuickConnectDialog(context),
|
||||
),
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.settingsProfileTitle),
|
||||
subLabel: Text(context.localized.settingsProfileDesc),
|
||||
selected: containsRoute(SecuritySettingsRoute()),
|
||||
icon: IconsaxOutline.security_user,
|
||||
onTap: () => context.routeReplaceOrPush(SecuritySettingsRoute()),
|
||||
),
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.settingsPlayerTitle),
|
||||
subLabel: Text(context.localized.settingsPlayerDesc),
|
||||
selected: containsRoute(PlayerSettingsRoute()),
|
||||
icon: IconsaxOutline.video_play,
|
||||
onTap: () => context.routeReplaceOrPush(PlayerSettingsRoute()),
|
||||
),
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.about),
|
||||
subLabel: Text("Fladder"),
|
||||
suffix: Opacity(
|
||||
opacity: 1,
|
||||
child: FladderIconOutlined(
|
||||
size: 24,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
)),
|
||||
onTap: () => showAboutDialog(
|
||||
context: context,
|
||||
applicationIcon: FladderIcon(size: 85),
|
||||
applicationVersion: ref.watch(applicationInfoProvider).versionAndPlatform,
|
||||
applicationLegalese: "Donut Factory",
|
||||
),
|
||||
),
|
||||
],
|
||||
floatingActionButton: HideOnScroll(
|
||||
controller: scrollController,
|
||||
visibleBuilder: (visible) {
|
||||
return AnimatedFadeSize(
|
||||
child: visible
|
||||
? Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: MediaQuery.paddingOf(context).horizontal),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
const Spacer(),
|
||||
FloatingActionButton(
|
||||
key: Key(context.localized.switchUser),
|
||||
tooltip: context.localized.switchUser,
|
||||
onPressed: () {
|
||||
ref.read(userProvider.notifier).logoutUser();
|
||||
context.routeGo(LoginRoute());
|
||||
},
|
||||
child: const Icon(
|
||||
IconsaxOutline.arrow_swap_horizontal,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
FloatingActionButton(
|
||||
key: Key(context.localized.logout),
|
||||
tooltip: context.localized.logout,
|
||||
backgroundColor: Theme.of(context).colorScheme.errorContainer,
|
||||
onPressed: () {
|
||||
final user = ref.read(userProvider);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog.adaptive(
|
||||
title: Text(context.localized.logoutUserPopupTitle(user?.name ?? "")),
|
||||
scrollable: true,
|
||||
content: Text(
|
||||
context.localized.logoutUserPopupContent(user?.name ?? "", user?.server ?? ""),
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.localized.cancel),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom().copyWith(
|
||||
foregroundColor:
|
||||
WidgetStatePropertyAll(Theme.of(context).colorScheme.onErrorContainer),
|
||||
backgroundColor:
|
||||
WidgetStatePropertyAll(Theme.of(context).colorScheme.errorContainer),
|
||||
),
|
||||
onPressed: () async {
|
||||
await ref.read(authProvider.notifier).logOutUser();
|
||||
if (context.mounted) context.routeGo(SplashRoute());
|
||||
},
|
||||
child: Text(context.localized.logout),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
IconsaxOutline.logout,
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
height: 0,
|
||||
key: UniqueKey(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
25
lib/screens/settings/widgets/settings_label_divider.dart
Normal file
25
lib/screens/settings/widgets/settings_label_divider.dart
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class SettingsLabelDivider extends ConsumerWidget {
|
||||
final String label;
|
||||
const SettingsLabelDivider({required this.label, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8).add(
|
||||
EdgeInsets.symmetric(
|
||||
horizontal: MediaQuery.paddingOf(context).horizontal,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
47
lib/screens/settings/widgets/settings_message_box.dart
Normal file
47
lib/screens/settings/widgets/settings_message_box.dart
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
enum MessageType {
|
||||
info,
|
||||
warning,
|
||||
error;
|
||||
|
||||
Color color(BuildContext context) {
|
||||
switch (this) {
|
||||
case info:
|
||||
return Theme.of(context).colorScheme.surface;
|
||||
case warning:
|
||||
return Theme.of(context).colorScheme.primaryContainer;
|
||||
case error:
|
||||
return Theme.of(context).colorScheme.errorContainer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SettingsMessageBox extends ConsumerWidget {
|
||||
final String message;
|
||||
final MessageType messageType;
|
||||
const SettingsMessageBox(this.message, {this.messageType = MessageType.info, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8).add(
|
||||
EdgeInsets.symmetric(
|
||||
horizontal: MediaQuery.paddingOf(context).horizontal,
|
||||
),
|
||||
),
|
||||
child: Card(
|
||||
elevation: 2,
|
||||
color: messageType.color(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(message),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
94
lib/screens/settings/widgets/subtitle_editor.dart
Normal file
94
lib/screens/settings/widgets/subtitle_editor.dart
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import 'package:fladder/models/settings/subtitle_settings_model.dart';
|
||||
import 'package:fladder/providers/settings/subtitle_settings_provider.dart';
|
||||
import 'package:fladder/providers/settings/video_player_settings_provider.dart';
|
||||
import 'package:fladder/screens/video_player/components/video_subtitle_controls.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/fladder_appbar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_blurhash/flutter_blurhash.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
|
||||
class SubtitleEditor extends ConsumerStatefulWidget {
|
||||
const SubtitleEditor({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _SubtitleEditorState();
|
||||
}
|
||||
|
||||
class _SubtitleEditorState extends ConsumerState<SubtitleEditor> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = ref.watch(subtitleSettingsProvider);
|
||||
final fillScreen = ref.watch(videoPlayerSettingsProvider.select((value) => value.fillScreen));
|
||||
final padding = MediaQuery.of(context).padding;
|
||||
final fakeText = context.localized.subtitleConfiguratorPlaceHolder;
|
||||
double lastScale = 0.0;
|
||||
|
||||
return Scaffold(
|
||||
body: Dialog.fullscreen(
|
||||
child: GestureDetector(
|
||||
onScaleUpdate: (details) {
|
||||
lastScale = details.scale;
|
||||
},
|
||||
onScaleEnd: (details) {
|
||||
if (lastScale < 1.0) {
|
||||
ref.read(videoPlayerSettingsProvider.notifier).setFillScreen(false, context: context);
|
||||
} else if (lastScale > 1.0) {
|
||||
ref.read(videoPlayerSettingsProvider.notifier).setFillScreen(true, context: context);
|
||||
}
|
||||
lastScale = 0.0;
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: (fillScreen ? EdgeInsets.zero : EdgeInsets.only(left: padding.left, right: padding.right)),
|
||||
child: const Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 2.1,
|
||||
child: Card(
|
||||
child: Image(
|
||||
image: BlurHashImage('LEF}}|0000~p8w~W%N4n~pIU4o%g'),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SubtitleText(subModel: settings, padding: padding, offset: settings.verticalOffset, text: fakeText),
|
||||
Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Padding(
|
||||
padding:
|
||||
MediaQuery.paddingOf(context).add(const EdgeInsets.all(32).add(const EdgeInsets.only(top: 48))),
|
||||
child: SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.95,
|
||||
child: const VideoSubtitleControls(),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: MediaQuery.paddingOf(context),
|
||||
child: Column(
|
||||
children: [
|
||||
if (AdaptiveLayout.of(context).isDesktop) const FladderAppbar(),
|
||||
Row(
|
||||
children: [
|
||||
const BackButton(),
|
||||
Text(
|
||||
context.localized.subtitleConfigurator,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
23
lib/screens/shared/adaptive_dialog.dart
Normal file
23
lib/screens/shared/adaptive_dialog.dart
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Future<void> showDialogAdaptive(
|
||||
{required BuildContext context, bool useSafeArea = true, required Widget Function(BuildContext context) builder}) {
|
||||
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
useSafeArea: useSafeArea,
|
||||
builder: (context) => Dialog(
|
||||
child: builder(context),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return showDialog(
|
||||
context: context,
|
||||
useSafeArea: useSafeArea,
|
||||
builder: (context) => Dialog.fullscreen(
|
||||
child: builder(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
26
lib/screens/shared/animated_fade_size.dart
Normal file
26
lib/screens/shared/animated_fade_size.dart
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class AnimatedFadeSize extends ConsumerWidget {
|
||||
final Duration duration;
|
||||
final Widget child;
|
||||
const AnimatedFadeSize({
|
||||
this.duration = const Duration(milliseconds: 125),
|
||||
required this.child,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return AnimatedSize(
|
||||
duration: duration,
|
||||
curve: Curves.easeInOutCubic,
|
||||
child: AnimatedSwitcher(
|
||||
duration: duration,
|
||||
switchInCurve: Curves.easeInOutCubic,
|
||||
switchOutCurve: Curves.easeInOutCubic,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
72
lib/screens/shared/authenticate_button_options.dart
Normal file
72
lib/screens/shared/authenticate_button_options.dart
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/account_model.dart';
|
||||
import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
||||
import 'package:fladder/screens/shared/passcode_input.dart';
|
||||
import 'package:fladder/util/auth_service.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
showAuthOptionsDialogue(
|
||||
BuildContext context,
|
||||
AccountModel currentUser,
|
||||
Function(AccountModel) setMethod,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog.adaptive(
|
||||
scrollable: true,
|
||||
icon: const Icon(IconsaxBold.lock_1),
|
||||
title: Text(context.localized.appLockTitle(currentUser.name)),
|
||||
actionsOverflowDirection: VerticalDirection.down,
|
||||
actions: Authentication.values
|
||||
.where((element) => element.available(context))
|
||||
.map(
|
||||
(method) => SizedBox(
|
||||
height: 50,
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
switch (method) {
|
||||
case Authentication.autoLogin:
|
||||
setMethod.call(currentUser.copyWith(authMethod: method));
|
||||
break;
|
||||
case Authentication.biometrics:
|
||||
final authenticated = await AuthService.authenticateUser(context, currentUser);
|
||||
if (authenticated) {
|
||||
setMethod.call(currentUser.copyWith(authMethod: method));
|
||||
} else if (context.mounted) {
|
||||
fladderSnackbar(context, title: context.localized.biometricsFailedCheckAgain);
|
||||
}
|
||||
break;
|
||||
case Authentication.passcode:
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
Future.microtask(() {
|
||||
showPassCodeDialog(context, (newPin) {
|
||||
setMethod.call(currentUser.copyWith(authMethod: method, localPin: newPin));
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
case Authentication.none:
|
||||
setMethod.call(currentUser.copyWith(authMethod: method));
|
||||
break;
|
||||
}
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
icon: Icon(method.icon),
|
||||
label: Text(
|
||||
method.name(context),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
.addPadding(const EdgeInsets.symmetric(vertical: 8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
264
lib/screens/shared/chips/category_chip.dart
Normal file
264
lib/screens/shared/chips/category_chip.dart
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/map_bool_helper.dart';
|
||||
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
|
||||
import 'package:fladder/widgets/shared/modal_side_sheet.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CategoryChip<T> extends StatelessWidget {
|
||||
final Map<T, bool> items;
|
||||
final Widget label;
|
||||
final Widget? dialogueTitle;
|
||||
final Widget Function(T item) labelBuilder;
|
||||
final IconData? activeIcon;
|
||||
final Function(Map<T, bool> value)? onSave;
|
||||
final VoidCallback? onCancel;
|
||||
final VoidCallback? onClear;
|
||||
final VoidCallback? onDismiss;
|
||||
|
||||
const CategoryChip({
|
||||
required this.label,
|
||||
this.dialogueTitle,
|
||||
this.activeIcon,
|
||||
required this.items,
|
||||
required this.labelBuilder,
|
||||
this.onSave,
|
||||
this.onCancel,
|
||||
this.onClear,
|
||||
this.onDismiss,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var selection = items.included.isNotEmpty;
|
||||
return FilterChip(
|
||||
selected: selection,
|
||||
showCheckmark: activeIcon == null,
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (activeIcon != null)
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: selection
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Icon(
|
||||
activeIcon!,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: const SizedBox(),
|
||||
),
|
||||
label,
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.arrow_drop_down_rounded,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
onSelected: items.isNotEmpty
|
||||
? (_) async {
|
||||
final newEntry = await openActionSheet(context);
|
||||
if (newEntry != null) {
|
||||
onSave?.call(newEntry);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<T, bool>?> openActionSheet(BuildContext context) async {
|
||||
Map<T, bool>? newEntry;
|
||||
List<Widget> actions() => [
|
||||
FilledButton.tonal(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
newEntry = null;
|
||||
onCancel?.call();
|
||||
},
|
||||
child: Text(context.localized.cancel),
|
||||
),
|
||||
if (onClear != null)
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
newEntry = null;
|
||||
onClear!();
|
||||
},
|
||||
icon: const Icon(IconsaxOutline.back_square),
|
||||
label: Text(context.localized.clear),
|
||||
)
|
||||
].addInBetween(const SizedBox(width: 6));
|
||||
Widget header() => Row(
|
||||
children: [
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
textStyle: Theme.of(context).textTheme.titleLarge,
|
||||
child: dialogueTitle ?? label,
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton.tonal(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
newEntry = null;
|
||||
onCancel?.call();
|
||||
},
|
||||
child: Text(context.localized.cancel),
|
||||
),
|
||||
if (onClear != null)
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
newEntry = null;
|
||||
onClear!();
|
||||
},
|
||||
icon: const Icon(IconsaxOutline.back_square),
|
||||
label: Text(context.localized.clear),
|
||||
)
|
||||
].addInBetween(const SizedBox(width: 6)),
|
||||
);
|
||||
|
||||
if (AdaptiveLayout.of(context).layout != LayoutState.phone) {
|
||||
await showModalSideSheet(
|
||||
context,
|
||||
addDivider: true,
|
||||
header: dialogueTitle ?? label,
|
||||
actions: actions(),
|
||||
content: CategoryChipEditor(
|
||||
labelBuilder: labelBuilder,
|
||||
items: items,
|
||||
onChanged: (value) {
|
||||
newEntry = value;
|
||||
}),
|
||||
onDismiss: () {
|
||||
if (newEntry != null) {
|
||||
onSave?.call(newEntry!);
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await showBottomSheetPill(
|
||||
context: context,
|
||||
content: (context, scrollController) => ListView(
|
||||
shrinkWrap: true,
|
||||
controller: scrollController,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: header(),
|
||||
),
|
||||
const Divider(),
|
||||
CategoryChipEditor(
|
||||
labelBuilder: labelBuilder,
|
||||
controller: scrollController,
|
||||
items: items,
|
||||
onChanged: (value) => newEntry = value),
|
||||
],
|
||||
),
|
||||
onDismiss: () {
|
||||
if (newEntry != null) {
|
||||
onSave?.call(newEntry!);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
return newEntry;
|
||||
}
|
||||
}
|
||||
|
||||
class CategoryChipEditor<T> extends StatefulWidget {
|
||||
final Map<T, bool> items;
|
||||
final Widget Function(T item) labelBuilder;
|
||||
final Function(Map<T, bool> value) onChanged;
|
||||
final ScrollController? controller;
|
||||
const CategoryChipEditor({
|
||||
required this.items,
|
||||
required this.labelBuilder,
|
||||
required this.onChanged,
|
||||
this.controller,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CategoryChipEditor<T>> createState() => _CategoryChipEditorState<T>();
|
||||
}
|
||||
|
||||
class _CategoryChipEditorState<T> extends State<CategoryChipEditor<T>> {
|
||||
late Map<T, bool?> currentState = Map.fromEntries(widget.items.entries);
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Iterable<MapEntry<T, bool>> activeItems = widget.items.entries.where((element) => element.value);
|
||||
Iterable<MapEntry<T, bool>> otherItems = widget.items.entries.where((element) => !element.value);
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
controller: widget.controller,
|
||||
children: [
|
||||
if (activeItems.isNotEmpty == true) ...{
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
context.localized.active,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
...activeItems.mapIndexed((index, element) {
|
||||
return CheckboxListTile.adaptive(
|
||||
value: currentState[element.key],
|
||||
title: widget.labelBuilder(element.key),
|
||||
fillColor: WidgetStateProperty.resolveWith(
|
||||
(states) {
|
||||
if (currentState[element.key] == null) {
|
||||
return Colors.redAccent;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
tristate: true,
|
||||
onChanged: (value) => updateKey(MapEntry(element.key, value == null ? null : element.value)),
|
||||
);
|
||||
}),
|
||||
Divider(),
|
||||
},
|
||||
...otherItems.mapIndexed((index, element) {
|
||||
return CheckboxListTile.adaptive(
|
||||
value: currentState[element.key],
|
||||
title: widget.labelBuilder(element.key),
|
||||
fillColor: WidgetStateProperty.resolveWith(
|
||||
(states) {
|
||||
if (currentState[element.key] == null || states.contains(WidgetState.selected)) {
|
||||
return Colors.greenAccent;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
tristate: true,
|
||||
onChanged: (value) => updateKey(MapEntry(element.key, value != false ? null : element.value)),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void updateKey(MapEntry<T, bool?> entry) {
|
||||
setState(() {
|
||||
currentState.update(
|
||||
entry.key,
|
||||
(value) => entry.value,
|
||||
);
|
||||
});
|
||||
widget.onChanged(Map.from(currentState.map(
|
||||
(key, value) {
|
||||
final origKey = widget.items[key] == true;
|
||||
return MapEntry(key, origKey ? (value == null ? false : origKey) : (value == null ? true : origKey));
|
||||
},
|
||||
)));
|
||||
}
|
||||
}
|
||||
71
lib/screens/shared/default_alert_dialog.dart
Normal file
71
lib/screens/shared/default_alert_dialog.dart
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Future<void> showDefaultAlertDialog(
|
||||
BuildContext context,
|
||||
String title,
|
||||
String? content,
|
||||
FutureOr Function(BuildContext context)? accept,
|
||||
String? acceptTitle,
|
||||
FutureOr Function(BuildContext context)? decline,
|
||||
String declineTitle,
|
||||
) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog.adaptive(
|
||||
title: Text(title),
|
||||
content: content != null ? Text(content) : null,
|
||||
actions: [
|
||||
if (decline != null)
|
||||
ElevatedButton(
|
||||
onPressed: () => decline.call(context),
|
||||
child: Text(declineTitle),
|
||||
),
|
||||
if (accept != null)
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.errorContainer,
|
||||
foregroundColor: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
onPressed: () => accept.call(context),
|
||||
child: Text(acceptTitle ?? "Accept"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showDefaultActionDialog(
|
||||
BuildContext context,
|
||||
String title,
|
||||
String? content,
|
||||
FutureOr Function(BuildContext context)? accept,
|
||||
String? acceptTitle,
|
||||
FutureOr Function(BuildContext context)? decline,
|
||||
String declineTitle,
|
||||
) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog.adaptive(
|
||||
title: Text(title),
|
||||
content: content != null ? Text(content) : null,
|
||||
actions: [
|
||||
if (decline != null)
|
||||
ElevatedButton(
|
||||
onPressed: () => decline.call(context),
|
||||
child: Text(declineTitle),
|
||||
),
|
||||
if (accept != null)
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
onPressed: () => accept.call(context),
|
||||
child: Text(acceptTitle ?? "Accept"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
169
lib/screens/shared/default_titlebar.dart
Normal file
169
lib/screens/shared/default_titlebar.dart
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
|
||||
class DefaultTitleBar extends ConsumerStatefulWidget {
|
||||
final String? label;
|
||||
final double? height;
|
||||
final Brightness? brightness;
|
||||
const DefaultTitleBar({this.height = 35, this.label, this.brightness, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _DefaultTitleBarState();
|
||||
}
|
||||
|
||||
class _DefaultTitleBarState extends ConsumerState<DefaultTitleBar> with WindowListener {
|
||||
@override
|
||||
void initState() {
|
||||
windowManager.addListener(this);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
windowManager.removeListener(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final brightness = widget.brightness ?? Theme.of(context).brightness;
|
||||
final shadows = brightness == Brightness.dark
|
||||
? [
|
||||
BoxShadow(blurRadius: 1, spreadRadius: 1, color: Theme.of(context).colorScheme.surface.withOpacity(1)),
|
||||
BoxShadow(blurRadius: 8, spreadRadius: 2, color: Colors.black.withOpacity(0.2)),
|
||||
BoxShadow(blurRadius: 3, spreadRadius: 2, color: Colors.black.withOpacity(0.3)),
|
||||
]
|
||||
: <BoxShadow>[];
|
||||
final iconColor = Theme.of(context).colorScheme.onSurface.withOpacity(0.65);
|
||||
return SizedBox(
|
||||
height: widget.height,
|
||||
child: switch (AdaptiveLayout.of(context).platform) {
|
||||
TargetPlatform.windows || TargetPlatform.linux => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DragToMoveArea(
|
||||
child: Container(
|
||||
color: Colors.red.withOpacity(0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
color: iconColor,
|
||||
fontSize: 14,
|
||||
),
|
||||
child: Text(widget.label ?? ""),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
FutureBuilder(
|
||||
future: windowManager.isMinimizable(),
|
||||
builder: (context, data) {
|
||||
final isMinimized = !(data.data ?? false);
|
||||
return IconButton(
|
||||
style: IconButton.styleFrom(
|
||||
hoverColor: brightness == Brightness.light
|
||||
? Colors.black.withOpacity(0.1)
|
||||
: Colors.white.withOpacity(0.2),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2))),
|
||||
onPressed: () async {
|
||||
if (isMinimized) {
|
||||
windowManager.restore();
|
||||
} else {
|
||||
windowManager.minimize();
|
||||
}
|
||||
},
|
||||
icon: Transform.translate(
|
||||
offset: Offset(0, -2),
|
||||
child: Icon(
|
||||
Icons.minimize_rounded,
|
||||
color: iconColor,
|
||||
size: 20,
|
||||
shadows: shadows,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
FutureBuilder<List<bool>>(
|
||||
future: Future.microtask(() async {
|
||||
final isMaximized = await windowManager.isMaximized();
|
||||
final isFullScreen = await windowManager.isFullScreen();
|
||||
return [isMaximized, isFullScreen];
|
||||
}),
|
||||
builder: (BuildContext context, AsyncSnapshot<List<bool>> snapshot) {
|
||||
final maximized = snapshot.data?.firstOrNull ?? false;
|
||||
final fullScreen = snapshot.data?.lastOrNull ?? false;
|
||||
return IconButton(
|
||||
style: IconButton.styleFrom(
|
||||
hoverColor: brightness == Brightness.light
|
||||
? Colors.black.withOpacity(0.1)
|
||||
: Colors.white.withOpacity(0.2),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)),
|
||||
),
|
||||
onPressed: () async {
|
||||
if (fullScreen == true && maximized == true) {
|
||||
await windowManager.setFullScreen(false);
|
||||
await windowManager.unmaximize();
|
||||
return;
|
||||
}
|
||||
if (fullScreen == true) {
|
||||
windowManager.setFullScreen(false);
|
||||
} else {
|
||||
maximized == false ? windowManager.maximize() : windowManager.unmaximize();
|
||||
}
|
||||
},
|
||||
icon: Transform.translate(
|
||||
offset: Offset(0, 0),
|
||||
child: Icon(
|
||||
maximized ? Icons.maximize_rounded : Icons.crop_square_rounded,
|
||||
color: iconColor,
|
||||
size: 19,
|
||||
shadows: shadows,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
style: IconButton.styleFrom(
|
||||
hoverColor: Colors.red,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
onPressed: () async {
|
||||
windowManager.close();
|
||||
},
|
||||
icon: Transform.translate(
|
||||
offset: Offset(0, -2),
|
||||
child: Icon(
|
||||
Icons.close_rounded,
|
||||
color: iconColor,
|
||||
size: 23,
|
||||
shadows: shadows,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
TargetPlatform.macOS => null,
|
||||
_ => Text(widget.label ?? "Fladder"),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
303
lib/screens/shared/detail_scaffold.dart
Normal file
303
lib/screens/shared/detail_scaffold.dart
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/screens/shared/animated_fade_size.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/settings_user_icon.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/models/items/images_models.dart';
|
||||
import 'package:fladder/models/media_playback_model.dart';
|
||||
import 'package:fladder/providers/items/item_details_provider.dart';
|
||||
import 'package:fladder/providers/video_player_provider.dart';
|
||||
import 'package:fladder/routes/build_routes/home_routes.dart';
|
||||
import 'package:fladder/routes/build_routes/route_builder.dart';
|
||||
import 'package:fladder/theme.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:fladder/util/refresh_state.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/floating_player_bar.dart';
|
||||
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
|
||||
import 'package:fladder/widgets/shared/pull_to_refresh.dart';
|
||||
|
||||
class DetailScreen extends ConsumerStatefulWidget {
|
||||
final String id;
|
||||
final ItemBaseModel? item;
|
||||
const DetailScreen({required this.id, this.item, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _DetailScreenState();
|
||||
}
|
||||
|
||||
class _DetailScreenState extends ConsumerState<DetailScreen> {
|
||||
late Widget currentWidget = const Center(
|
||||
key: Key("progress-indicator"),
|
||||
child: CircularProgressIndicator.adaptive(strokeCap: StrokeCap.round),
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() async {
|
||||
if (widget.item != null) {
|
||||
setState(() {
|
||||
currentWidget = widget.item!.detailScreenWidget;
|
||||
});
|
||||
} else {
|
||||
final response = await ref.read(itemDetailsProvider.notifier).fetchDetails(widget.id);
|
||||
if (context.mounted) {
|
||||
if (response != null) {
|
||||
setState(() {
|
||||
currentWidget = response.detailScreenWidget;
|
||||
});
|
||||
} else {
|
||||
context.routeGo(DashboardRoute());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Hero(
|
||||
tag: widget.id,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface.withOpacity(1.0),
|
||||
),
|
||||
//Small offset to match detailscaffold
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, -5), child: FladderImage(image: widget.item?.getPosters?.primary)),
|
||||
),
|
||||
),
|
||||
AnimatedFadeSize(
|
||||
duration: const Duration(seconds: 1),
|
||||
child: currentWidget,
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DetailScaffold extends ConsumerStatefulWidget {
|
||||
final String label;
|
||||
final ItemBaseModel? item;
|
||||
final List<ItemAction>? Function(BuildContext context)? actions;
|
||||
final Color? backgroundColor;
|
||||
final ImagesData? backDrops;
|
||||
final Function(EdgeInsets padding) content;
|
||||
final Future<void> Function()? onRefresh;
|
||||
const DetailScaffold({
|
||||
required this.label,
|
||||
this.item,
|
||||
this.actions,
|
||||
this.backgroundColor,
|
||||
required this.content,
|
||||
this.backDrops,
|
||||
this.onRefresh,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _DetailScaffoldState();
|
||||
}
|
||||
|
||||
class _DetailScaffoldState extends ConsumerState<DetailScaffold> {
|
||||
List<ImageData>? lastImages;
|
||||
ImageData? backgroundImage;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant DetailScaffold oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (lastImages == null) {
|
||||
lastImages = widget.backDrops?.backDrop;
|
||||
setState(() {
|
||||
backgroundImage = widget.backDrops?.randomBackDrop;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final padding = EdgeInsets.symmetric(horizontal: MediaQuery.of(context).size.width / 25);
|
||||
final backGroundColor = Theme.of(context).colorScheme.surface.withOpacity(0.8);
|
||||
final playerState = ref.watch(mediaPlaybackProvider.select((value) => value.state));
|
||||
return PullToRefresh(
|
||||
onRefresh: () async {
|
||||
await widget.onRefresh?.call();
|
||||
setState(() {
|
||||
if (widget.backDrops?.backDrop?.contains(backgroundImage) == true) {
|
||||
backgroundImage = widget.backDrops?.randomBackDrop;
|
||||
}
|
||||
});
|
||||
},
|
||||
refreshOnStart: true,
|
||||
child: Scaffold(
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: switch (playerState) {
|
||||
VideoPlayerState.minimized => Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: FloatingPlayerBar(),
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
extendBodyBehindAppBar: true,
|
||||
body: Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: Stack(
|
||||
alignment: Alignment.topCenter,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).size.height - 10,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: FladderImage(
|
||||
image: backgroundImage,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: MediaQuery.of(context).size.height,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Theme.of(context).colorScheme.surface.withOpacity(0),
|
||||
Theme.of(context).colorScheme.surface.withOpacity(0.10),
|
||||
Theme.of(context).colorScheme.surface.withOpacity(0.35),
|
||||
Theme.of(context).colorScheme.surface.withOpacity(0.85),
|
||||
Theme.of(context).colorScheme.surface,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: MediaQuery.of(context).size.height,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
color: widget.backgroundColor,
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 0,
|
||||
left: MediaQuery.of(context).padding.left,
|
||||
top: MediaQuery.of(context).padding.top + 50),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: MediaQuery.of(context).size.height,
|
||||
maxWidth: MediaQuery.of(context).size.width,
|
||||
),
|
||||
child: widget.content(padding),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
//Top row buttons
|
||||
IconTheme(
|
||||
data: IconThemeData(color: Theme.of(context).colorScheme.onSurface),
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, kToolbarHeight),
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 16),
|
||||
child: IconButton.filledTonal(
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: backGroundColor,
|
||||
),
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.replace(DashboardRoute().route);
|
||||
}
|
||||
},
|
||||
icon: Padding(
|
||||
padding:
|
||||
EdgeInsets.all(AdaptiveLayout.of(context).inputDevice == InputDevice.pointer ? 0 : 4),
|
||||
child: Icon(IconsaxOutline.arrow_left_2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backGroundColor, borderRadius: FladderTheme.defaultShape.borderRadius),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.item != null) ...[
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final newActions = widget.actions?.call(context);
|
||||
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) {
|
||||
return PopupMenuButton(
|
||||
tooltip: context.localized.moreOptions,
|
||||
enabled: newActions?.isNotEmpty == true,
|
||||
icon: Icon(widget.item!.type.icon),
|
||||
itemBuilder: (context) => newActions?.popupMenuItems(useIcons: true) ?? [],
|
||||
);
|
||||
} else {
|
||||
return IconButton(
|
||||
onPressed: () => showBottomSheetPill(
|
||||
context: context,
|
||||
content: (context, scrollController) => ListView(
|
||||
controller: scrollController,
|
||||
shrinkWrap: true,
|
||||
children: newActions?.listTileItems(context, useIcons: true) ?? [],
|
||||
),
|
||||
),
|
||||
icon: Icon(
|
||||
widget.item!.type.icon,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer)
|
||||
Builder(
|
||||
builder: (context) => Tooltip(
|
||||
message: context.localized.refresh,
|
||||
child: IconButton(
|
||||
onPressed: () => context.refreshData(),
|
||||
icon: Icon(IconsaxOutline.refresh),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(height: 30, width: 30, child: SettingsUserIcon()),
|
||||
Tooltip(
|
||||
message: context.localized.home,
|
||||
child: IconButton(
|
||||
onPressed: () => context.routeGo(DashboardRoute()),
|
||||
icon: Icon(IconsaxOutline.home),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
198
lib/screens/shared/file_picker.dart
Normal file
198
lib/screens/shared/file_picker.dart
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/screens/shared/outlined_text_field.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class FladderFile {
|
||||
final String name;
|
||||
final String? path;
|
||||
final Uint8List? data;
|
||||
FladderFile({
|
||||
required this.name,
|
||||
this.path,
|
||||
this.data,
|
||||
});
|
||||
|
||||
static final Set<String> imageTypes = {
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"webp",
|
||||
"gif",
|
||||
};
|
||||
|
||||
@override
|
||||
String toString() => 'FladderFile(name: $name, path: $path, data: ${data?.length})';
|
||||
}
|
||||
|
||||
class FilePickerBar extends ConsumerStatefulWidget {
|
||||
final Function(List<FladderFile> file)? onFilesPicked;
|
||||
final Function(String url)? urlPicked;
|
||||
final Set<String> extensions;
|
||||
final bool multipleFiles;
|
||||
final double stripesAngle;
|
||||
const FilePickerBar({
|
||||
this.onFilesPicked,
|
||||
this.urlPicked,
|
||||
this.multipleFiles = false,
|
||||
this.stripesAngle = -0.90,
|
||||
this.extensions = const {},
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _FilePickerBarState();
|
||||
}
|
||||
|
||||
class _FilePickerBarState extends ConsumerState<FilePickerBar> {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
bool dragStart = false;
|
||||
bool inputField = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final offColor = Theme.of(context).colorScheme.secondaryContainer;
|
||||
final onColor = Theme.of(context).colorScheme.secondaryContainer.withOpacity(0.7);
|
||||
final contentColor = Theme.of(context).colorScheme.onSecondaryContainer;
|
||||
return DropTarget(
|
||||
enable: !inputField,
|
||||
onDragEntered: (details) => setState(() => dragStart = true),
|
||||
onDragDone: (details) async {
|
||||
if (widget.multipleFiles) {
|
||||
List<FladderFile> newFiles = [];
|
||||
await Future.forEach(details.files, (element) async {
|
||||
if (widget.extensions.contains(p.extension(element.path).substring(1))) {
|
||||
newFiles.add(
|
||||
FladderFile(
|
||||
name: element.name,
|
||||
path: element.path,
|
||||
data: await element.readAsBytes(),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
widget.onFilesPicked?.call(newFiles);
|
||||
} else {
|
||||
final file = details.files.lastOrNull;
|
||||
if (file != null) {
|
||||
widget.onFilesPicked?.call([
|
||||
FladderFile(
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
data: await file.readAsBytes(),
|
||||
)
|
||||
]);
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragExited: (details) => setState(() => dragStart = false),
|
||||
child: Container(
|
||||
constraints: BoxConstraints(minHeight: 50, minWidth: 50),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment(widget.stripesAngle, -0),
|
||||
stops: [0.0, 0.5, 0.5, 1],
|
||||
colors: [offColor, offColor, onColor, onColor],
|
||||
tileMode: TileMode.repeated,
|
||||
),
|
||||
),
|
||||
child: AnimatedSwitcher(
|
||||
duration: Duration(milliseconds: 250),
|
||||
child: inputField
|
||||
? OutlinedTextField(
|
||||
controller: controller,
|
||||
autoFocus: true,
|
||||
onSubmitted: (value) {
|
||||
if (_parseUrl(value)) {
|
||||
widget.urlPicked?.call(value);
|
||||
}
|
||||
controller.text = "";
|
||||
setState(() => inputField = false);
|
||||
},
|
||||
)
|
||||
: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
if (AdaptiveLayout.of(context).isDesktop || kIsWeb)
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.multipleFiles ? "drop multiple file(s)" : "drop a file",
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: contentColor),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Icon(
|
||||
IconsaxBold.folder_add,
|
||||
color: contentColor,
|
||||
)
|
||||
],
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => setState(() => inputField = true),
|
||||
child: Text(
|
||||
"enter a url",
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: contentColor),
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: dragStart
|
||||
? null
|
||||
: () async {
|
||||
FilePickerResult? result = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: widget.multipleFiles,
|
||||
allowedExtensions: widget.extensions.toList(),
|
||||
type: FileType.custom,
|
||||
withData: true,
|
||||
);
|
||||
if (result != null && result.count != 0) {
|
||||
List<FladderFile> newFiles = [];
|
||||
await Future.forEach(result.files, (element) async {
|
||||
newFiles.add(
|
||||
FladderFile(
|
||||
name: element.name,
|
||||
path: element.path,
|
||||
data: element.bytes,
|
||||
),
|
||||
);
|
||||
});
|
||||
widget.onFilesPicked?.call(newFiles);
|
||||
}
|
||||
FilePicker.platform.clearTemporaryFiles();
|
||||
},
|
||||
child: Text(
|
||||
widget.multipleFiles ? "file(s) picker" : "file picker",
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _parseUrl(String url) {
|
||||
if (url.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
if (!Uri.parse(url).isAbsolute) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!url.startsWith('https://') && !url.startsWith('http://')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
57
lib/screens/shared/fladder_icon.dart
Normal file
57
lib/screens/shared/fladder_icon.dart
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import 'package:fladder/util/theme_extensions.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
class FladderIcon extends StatelessWidget {
|
||||
final double size;
|
||||
const FladderIcon({this.size = 100, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
ShaderMask(
|
||||
shaderCallback: (Rect bounds) {
|
||||
return ui.Gradient.linear(
|
||||
const Offset(30, 30),
|
||||
const Offset(80, 80),
|
||||
[
|
||||
Theme.of(context).colorScheme.primary,
|
||||
Theme.of(context).colorScheme.secondary,
|
||||
],
|
||||
);
|
||||
},
|
||||
child: RotatedBox(
|
||||
quarterTurns: 1,
|
||||
child: SvgPicture.asset(
|
||||
"icons/fladder_icon_grayscale.svg",
|
||||
width: size,
|
||||
colorFilter: const ColorFilter.mode(Colors.white, BlendMode.srcIn),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FladderIconOutlined extends StatelessWidget {
|
||||
final double size;
|
||||
final Color? color;
|
||||
const FladderIconOutlined({this.size = 100, this.color, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RotatedBox(
|
||||
quarterTurns: 1,
|
||||
child: SvgPicture.asset(
|
||||
"icons/fladder_icon_outline.svg",
|
||||
width: size,
|
||||
colorFilter: ColorFilter.mode(color ?? context.colors.onSurfaceVariant, BlendMode.srcATop),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
32
lib/screens/shared/fladder_logo.dart
Normal file
32
lib/screens/shared/fladder_logo.dart
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import 'package:fladder/screens/shared/fladder_icon.dart';
|
||||
import 'package:fladder/util/application_info.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
import 'package:fladder/util/theme_extensions.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class FladderLogo extends ConsumerWidget {
|
||||
const FladderLogo({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Hero(
|
||||
tag: "Fladder_Logo_Tag",
|
||||
child: Wrap(
|
||||
runAlignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 16,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
const FladderIcon(),
|
||||
Text(
|
||||
ref.read(applicationInfoProvider).name.capitalize(),
|
||||
style: context.textTheme.displayLarge,
|
||||
textAlign: TextAlign.center,
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
189
lib/screens/shared/fladder_snackbar.dart
Normal file
189
lib/screens/shared/fladder_snackbar.dart
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import 'package:chopper/chopper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
void fladderSnackbar(
|
||||
BuildContext context, {
|
||||
String title = "",
|
||||
bool permanent = false,
|
||||
SnackBarAction? action,
|
||||
bool showCloseButton = false,
|
||||
Duration duration = const Duration(seconds: 3),
|
||||
}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(
|
||||
title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w500, color: Theme.of(context).colorScheme.onSecondary),
|
||||
),
|
||||
clipBehavior: Clip.none,
|
||||
showCloseIcon: showCloseButton,
|
||||
duration: duration,
|
||||
padding: EdgeInsets.all(18),
|
||||
action: action,
|
||||
));
|
||||
}
|
||||
|
||||
void fladderSnackbarResponse(BuildContext context, Response? response, {String? altTitle}) {
|
||||
if (response != null) {
|
||||
fladderSnackbar(context,
|
||||
title: "(${response.base.statusCode}) ${response.base.reasonPhrase ?? "Something went wrong!"}");
|
||||
return;
|
||||
} else if (altTitle != null) {
|
||||
fladderSnackbar(context, title: altTitle);
|
||||
}
|
||||
}
|
||||
|
||||
// void _showOverlay(
|
||||
// BuildContext context, {
|
||||
// required String title,
|
||||
// Widget? leading,
|
||||
// bool showCloseButton = false,
|
||||
// bool permanent = false,
|
||||
// Duration duration = const Duration(seconds: 3),
|
||||
// }) {
|
||||
// late OverlayEntry overlayEntry;
|
||||
|
||||
// overlayEntry = OverlayEntry(
|
||||
// builder: (context) => _OverlayAnimationWidget(
|
||||
// title: title,
|
||||
// leading: leading,
|
||||
// showCloseButton: showCloseButton,
|
||||
// permanent: permanent,
|
||||
// duration: duration,
|
||||
// overlayEntry: overlayEntry,
|
||||
// ),
|
||||
// );
|
||||
|
||||
// // Insert the overlay entry into the overlay
|
||||
// Overlay.of(context).insert(overlayEntry);
|
||||
// }
|
||||
|
||||
// class _OverlayAnimationWidget extends StatefulWidget {
|
||||
// final String title;
|
||||
// final Widget? leading;
|
||||
// final bool showCloseButton;
|
||||
// final bool permanent;
|
||||
// final Duration duration;
|
||||
// final OverlayEntry overlayEntry;
|
||||
|
||||
// _OverlayAnimationWidget({
|
||||
// required this.title,
|
||||
// this.leading,
|
||||
// this.showCloseButton = false,
|
||||
// this.permanent = false,
|
||||
// this.duration = const Duration(seconds: 3),
|
||||
// required this.overlayEntry,
|
||||
// });
|
||||
|
||||
// @override
|
||||
// _OverlayAnimationWidgetState createState() => _OverlayAnimationWidgetState();
|
||||
// }
|
||||
|
||||
// class _OverlayAnimationWidgetState extends State<_OverlayAnimationWidget> with SingleTickerProviderStateMixin {
|
||||
// late AnimationController _controller;
|
||||
// late Animation<Offset> _offsetAnimation;
|
||||
|
||||
// void remove() {
|
||||
// // Optionally, you can use a Future.delayed to remove the overlay after a certain duration
|
||||
// _controller.reverse();
|
||||
// // Remove the overlay entry after the animation completes
|
||||
// Future.delayed(Duration(seconds: 1), () {
|
||||
// widget.overlayEntry.remove();
|
||||
// });
|
||||
// }
|
||||
|
||||
// @override
|
||||
// void initState() {
|
||||
// super.initState();
|
||||
|
||||
// _controller = AnimationController(
|
||||
// vsync: this,
|
||||
// duration: Duration(milliseconds: 250),
|
||||
// );
|
||||
|
||||
// _offsetAnimation = Tween<Offset>(
|
||||
// begin: Offset(0.0, 1.5),
|
||||
// end: Offset.zero,
|
||||
// ).animate(CurvedAnimation(
|
||||
// parent: _controller,
|
||||
// curve: Curves.fastOutSlowIn,
|
||||
// ));
|
||||
|
||||
// // Start the animation
|
||||
// _controller.forward();
|
||||
|
||||
// Future.delayed(widget.duration, () {
|
||||
// if (!widget.permanent) {
|
||||
// remove();
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
|
||||
// @override
|
||||
// void dispose() {
|
||||
// _controller.dispose();
|
||||
// super.dispose();
|
||||
// }
|
||||
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// return Positioned(
|
||||
// bottom: 10 + MediaQuery.of(context).padding.bottom,
|
||||
// left: 25,
|
||||
// right: 25,
|
||||
// child: Dismissible(
|
||||
// key: UniqueKey(),
|
||||
// direction: DismissDirection.horizontal,
|
||||
// confirmDismiss: (direction) async {
|
||||
// remove();
|
||||
// return true;
|
||||
// },
|
||||
// child: SlideTransition(
|
||||
// position: _offsetAnimation,
|
||||
// child: Card(
|
||||
// elevation: 5,
|
||||
// color: Colors.transparent,
|
||||
// surfaceTintColor: Colors.transparent,
|
||||
// child: Container(
|
||||
// decoration: BoxDecoration(
|
||||
// color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
// ),
|
||||
// child: Padding(
|
||||
// padding: const EdgeInsets.all(12.0),
|
||||
// child: ConstrainedBox(
|
||||
// constraints: BoxConstraints(minHeight: 45),
|
||||
// child: Row(
|
||||
// children: [
|
||||
// if (widget.leading != null) widget.leading!,
|
||||
// Expanded(
|
||||
// child: Text(
|
||||
// widget.title,
|
||||
// style: TextStyle(
|
||||
// fontSize: 16,
|
||||
// fontWeight: FontWeight.w400,
|
||||
// color: Theme.of(context).colorScheme.onSecondaryContainer),
|
||||
// ),
|
||||
// ),
|
||||
// const SizedBox(width: 6),
|
||||
// if (widget.showCloseButton || widget.permanent)
|
||||
// IconButton(
|
||||
// onPressed: () => remove(),
|
||||
// icon: Icon(
|
||||
// IconsaxOutline.close_square,
|
||||
// size: 28,
|
||||
// color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
// ),
|
||||
// )
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
46
lib/screens/shared/flat_button.dart
Normal file
46
lib/screens/shared/flat_button.dart
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import 'package:fladder/theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class FlatButton extends ConsumerWidget {
|
||||
final Widget? child;
|
||||
final Function()? onTap;
|
||||
final Function()? onLongPress;
|
||||
final Function()? onDoubleTap;
|
||||
final Function(TapDownDetails details)? onSecondaryTapDown;
|
||||
final BorderRadius? borderRadiusGeometry;
|
||||
final Color? splashColor;
|
||||
final double elevation;
|
||||
final Clip clipBehavior;
|
||||
const FlatButton(
|
||||
{this.child,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.onDoubleTap,
|
||||
this.onSecondaryTapDown,
|
||||
this.borderRadiusGeometry,
|
||||
this.splashColor,
|
||||
this.elevation = 0,
|
||||
this.clipBehavior = Clip.none,
|
||||
super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
clipBehavior: clipBehavior,
|
||||
borderRadius: borderRadiusGeometry ?? FladderTheme.defaultShape.borderRadius,
|
||||
elevation: 0,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
onDoubleTap: onDoubleTap,
|
||||
onSecondaryTapDown: onSecondaryTapDown,
|
||||
borderRadius: borderRadiusGeometry ?? BorderRadius.circular(10),
|
||||
splashColor: splashColor ?? Theme.of(context).colorScheme.primary.withOpacity(0.5),
|
||||
splashFactory: InkSparkle.splashFactory,
|
||||
child: child ?? Container(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
102
lib/screens/shared/floating_search_bar.dart
Normal file
102
lib/screens/shared/floating_search_bar.dart
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import 'package:animations/animations.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
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/search/search_screen.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class FloatingSearchBar extends ConsumerStatefulWidget {
|
||||
final List<Widget> trailing;
|
||||
final String hintText;
|
||||
final bool showLoading;
|
||||
final bool showUserIcon;
|
||||
final bool automaticallyImplyLeading;
|
||||
final double height;
|
||||
const FloatingSearchBar({
|
||||
this.trailing = const [],
|
||||
this.showLoading = false,
|
||||
this.showUserIcon = true,
|
||||
this.height = 50,
|
||||
required this.hintText,
|
||||
this.automaticallyImplyLeading = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _FloatingSearchBarState();
|
||||
}
|
||||
|
||||
class _FloatingSearchBarState extends ConsumerState<FloatingSearchBar> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = ref.watch(userProvider);
|
||||
return Hero(
|
||||
tag: "FloatingSearchBarHome",
|
||||
child: SizedBox(
|
||||
height: widget.height,
|
||||
width: double.infinity,
|
||||
child: OpenContainer(
|
||||
openBuilder: (context, action) {
|
||||
return const SearchScreen();
|
||||
},
|
||||
openColor: Colors.transparent,
|
||||
openElevation: 0,
|
||||
closedColor: Colors.transparent,
|
||||
closedElevation: 0,
|
||||
closedBuilder: (context, openAction) => Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shadowColor: Colors.transparent,
|
||||
elevation: 5,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(500)),
|
||||
child: InkWell(
|
||||
onTap: () => openAction(),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (context.canPop())
|
||||
IconButton(
|
||||
onPressed: () => context.pop(),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.hintText,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
)),
|
||||
IconButton(
|
||||
onPressed: () => openAction(),
|
||||
icon: const Icon(
|
||||
Icons.search_rounded,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
context.routeGo(SecuritySettingsRoute());
|
||||
},
|
||||
icon: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(200),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: user?.avatar ?? "",
|
||||
memCacheHeight: 125,
|
||||
imageBuilder: (context, imageProvider) => Image(image: imageProvider),
|
||||
errorWidget: (context, url, error) => CircleAvatar(
|
||||
child: Text(user?.name.getInitials() ?? ""),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
89
lib/screens/shared/focused_outlined_text_field.dart
Normal file
89
lib/screens/shared/focused_outlined_text_field.dart
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import 'package:fladder/screens/shared/outlined_text_field.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class FocusedOutlinedTextField extends ConsumerStatefulWidget {
|
||||
final String? label;
|
||||
final TextEditingController? controller;
|
||||
final int maxLines;
|
||||
final Function()? onTap;
|
||||
final Function(String value)? onChanged;
|
||||
final Function(String value)? onSubmitted;
|
||||
final List<String>? autoFillHints;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final bool autocorrect;
|
||||
final TextStyle? style;
|
||||
final double borderWidth;
|
||||
final Color? fillColor;
|
||||
final TextAlign textAlign;
|
||||
final TextInputType? keyboardType;
|
||||
final TextInputAction? textInputAction;
|
||||
final Function(bool focused)? onFocus;
|
||||
final String? errorText;
|
||||
final bool? enabled;
|
||||
const FocusedOutlinedTextField({
|
||||
this.label,
|
||||
this.controller,
|
||||
this.maxLines = 1,
|
||||
this.onTap,
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
this.fillColor,
|
||||
this.style,
|
||||
this.borderWidth = 1,
|
||||
this.textAlign = TextAlign.start,
|
||||
this.autoFillHints,
|
||||
this.inputFormatters,
|
||||
this.autocorrect = true,
|
||||
this.keyboardType,
|
||||
this.textInputAction,
|
||||
this.onFocus,
|
||||
this.errorText,
|
||||
this.enabled,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => FocuesOutlinedTextFieldState();
|
||||
}
|
||||
|
||||
class FocuesOutlinedTextFieldState extends ConsumerState<FocusedOutlinedTextField> {
|
||||
late FocusNode focusNode = FocusNode();
|
||||
late bool previousFocus = focusNode.hasFocus;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
focusNode.addListener(() {
|
||||
if (previousFocus != focusNode.hasFocus) {
|
||||
previousFocus = focusNode.hasFocus;
|
||||
widget.onFocus?.call(focusNode.hasFocus);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OutlinedTextField(
|
||||
controller: widget.controller,
|
||||
onTap: widget.onTap,
|
||||
onChanged: widget.onChanged,
|
||||
focusNode: focusNode,
|
||||
keyboardType: widget.keyboardType,
|
||||
autocorrect: widget.autocorrect,
|
||||
onSubmitted: widget.onSubmitted,
|
||||
textInputAction: widget.textInputAction,
|
||||
style: widget.style,
|
||||
maxLines: widget.maxLines,
|
||||
inputFormatters: widget.inputFormatters,
|
||||
textAlign: widget.textAlign,
|
||||
fillColor: widget.fillColor,
|
||||
errorText: widget.errorText,
|
||||
autoFillHints: widget.autoFillHints,
|
||||
borderWidth: widget.borderWidth,
|
||||
enabled: widget.enabled,
|
||||
label: widget.label,
|
||||
);
|
||||
}
|
||||
}
|
||||
44
lib/screens/shared/input_fields.dart
Normal file
44
lib/screens/shared/input_fields.dart
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class IntInputField extends ConsumerWidget {
|
||||
final int? value;
|
||||
final TextEditingController? controller;
|
||||
final String? placeHolder;
|
||||
final String? suffix;
|
||||
final Function(int? value)? onSubmitted;
|
||||
const IntInputField({
|
||||
this.value,
|
||||
this.controller,
|
||||
this.suffix,
|
||||
this.placeHolder,
|
||||
this.onSubmitted,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Card(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer.withOpacity(0.25),
|
||||
elevation: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: TextField(
|
||||
controller: controller ?? TextEditingController(text: (value ?? 0).toString()),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: false, signed: false),
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (value) => onSubmitted?.call(int.tryParse(value)),
|
||||
textAlign: TextAlign.center,
|
||||
decoration: InputDecoration(
|
||||
contentPadding: EdgeInsets.all(0),
|
||||
hintText: placeHolder,
|
||||
suffixText: suffix,
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
378
lib/screens/shared/media/carousel_banner.dart
Normal file
378
lib/screens/shared/media/carousel_banner.dart
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
import 'package:async/async.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/models/items/movie_model.dart';
|
||||
import 'package:fladder/screens/shared/media/components/media_play_button.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||
import 'package:fladder/util/item_base_model/play_item_helpers.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/themes_data.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 CarouselBanner extends ConsumerStatefulWidget {
|
||||
final PageController? controller;
|
||||
final List<ItemBaseModel> items;
|
||||
const CarouselBanner({
|
||||
this.controller,
|
||||
required this.items,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _CarouselBannerState();
|
||||
}
|
||||
|
||||
class _CarouselBannerState extends ConsumerState<CarouselBanner> {
|
||||
bool showControls = false;
|
||||
bool interacting = false;
|
||||
int currentPage = 0;
|
||||
double dragOffset = 0;
|
||||
double dragIntensity = 1;
|
||||
double slidePosition = 1;
|
||||
|
||||
late final RestartableTimer timer = RestartableTimer(Duration(seconds: 8), () => nextSlide());
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
timer.reset();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
timer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void nextSlide() {
|
||||
if (!interacting) {
|
||||
setState(() {
|
||||
if (currentPage == widget.items.length - 1) {
|
||||
currentPage = 0;
|
||||
} else {
|
||||
currentPage++;
|
||||
}
|
||||
});
|
||||
}
|
||||
timer.reset();
|
||||
}
|
||||
|
||||
void previousSlide() {
|
||||
if (!interacting) {
|
||||
setState(() {
|
||||
if (currentPage == 0) {
|
||||
currentPage = widget.items.length - 1;
|
||||
} else {
|
||||
currentPage--;
|
||||
}
|
||||
});
|
||||
}
|
||||
timer.reset();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final overlayColor = ThemesData.of(context).dark.colorScheme.onSecondary;
|
||||
final shadows = [
|
||||
BoxShadow(blurRadius: 12, spreadRadius: 8, color: overlayColor),
|
||||
];
|
||||
final currentItem = widget.items[currentPage.clamp(0, widget.items.length - 1)];
|
||||
final actions = currentItem.generateActions(context, ref);
|
||||
|
||||
final double dragOpacity = (1 - dragOffset.abs()).clamp(0, 1);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Card(
|
||||
elevation: 16,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
surfaceTintColor: overlayColor,
|
||||
color: overlayColor,
|
||||
child: GestureDetector(
|
||||
onTap: () => currentItem.navigateTo(context),
|
||||
onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.touch
|
||||
? () async {
|
||||
interacting = true;
|
||||
await showBottomSheetPill(
|
||||
context: context,
|
||||
content: (context, scrollController) => ListView(
|
||||
controller: scrollController,
|
||||
shrinkWrap: true,
|
||||
children: actions.listTileItems(context, useIcons: true),
|
||||
),
|
||||
);
|
||||
interacting = false;
|
||||
timer.reset();
|
||||
}
|
||||
: null,
|
||||
child: MouseRegion(
|
||||
onEnter: (event) => setState(() => showControls = true),
|
||||
onHover: (event) => timer.reset(),
|
||||
onExit: (event) => setState(() => showControls = false),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Dismissible(
|
||||
key: Key("Dismissable"),
|
||||
direction: DismissDirection.horizontal,
|
||||
onUpdate: (details) {
|
||||
setState(() {
|
||||
dragOffset = details.progress * 4;
|
||||
});
|
||||
},
|
||||
confirmDismiss: (direction) async {
|
||||
if (direction == DismissDirection.startToEnd) {
|
||||
previousSlide();
|
||||
} else {
|
||||
nextSlide();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: AnimatedOpacity(
|
||||
duration: Duration(milliseconds: 125),
|
||||
opacity: dragOpacity.abs(),
|
||||
child: AnimatedSwitcher(
|
||||
duration: Duration(milliseconds: 125),
|
||||
child: Container(
|
||||
key: Key(currentItem.id),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
foregroundDecoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.10), strokeAlign: BorderSide.strokeAlignInside),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomLeft,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
overlayColor.withOpacity(1),
|
||||
overlayColor.withOpacity(0.75),
|
||||
overlayColor.withOpacity(0.45),
|
||||
overlayColor.withOpacity(0.15),
|
||||
overlayColor.withOpacity(0),
|
||||
overlayColor.withOpacity(0),
|
||||
overlayColor.withOpacity(0.1),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(1),
|
||||
child: FladderImage(
|
||||
fit: BoxFit.cover,
|
||||
image: currentItem.bannerImage,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: IgnorePointer(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
currentItem.title,
|
||||
maxLines: 3,
|
||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||
shadows: shadows,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (currentItem.label(context) != null && currentItem is! MovieModel)
|
||||
Flexible(
|
||||
child: Text(
|
||||
currentItem.label(context)!,
|
||||
maxLines: 3,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
shadows: shadows,
|
||||
color: Colors.white.withOpacity(0.75),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (currentItem.overview.summary.isNotEmpty &&
|
||||
AdaptiveLayout.layoutOf(context) != LayoutState.phone)
|
||||
Flexible(
|
||||
child: Text(
|
||||
currentItem.overview.summary,
|
||||
maxLines: 3,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
shadows: shadows,
|
||||
color: Colors.white.withOpacity(0.75),
|
||||
),
|
||||
),
|
||||
),
|
||||
].addInBetween(SizedBox(height: 6)),
|
||||
),
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
runSpacing: 6,
|
||||
spacing: 6,
|
||||
children: [
|
||||
if (currentItem.playAble)
|
||||
MediaPlayButton(
|
||||
item: currentItem,
|
||||
onPressed: () async {
|
||||
await currentItem.play(
|
||||
context,
|
||||
ref,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
].addInBetween(SizedBox(height: 16)),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: AnimatedOpacity(
|
||||
opacity: showControls ? 1 : 0,
|
||||
duration: Duration(milliseconds: 250),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
onPressed: () => nextSlide(),
|
||||
icon: Icon(IconsaxOutline.arrow_right_3),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer)
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
child: PopupMenuButton(
|
||||
onOpened: () => interacting = true,
|
||||
onCanceled: () {
|
||||
interacting = false;
|
||||
timer.reset();
|
||||
},
|
||||
itemBuilder: (context) => actions.popupMenuItems(useIcons: true),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onHorizontalDragUpdate: (details) {
|
||||
final delta = (details.primaryDelta ?? 0) / 20;
|
||||
slidePosition += delta;
|
||||
if (slidePosition > 1) {
|
||||
nextSlide();
|
||||
slidePosition = 0;
|
||||
} else if (slidePosition < -1) {
|
||||
previousSlide();
|
||||
slidePosition = 0;
|
||||
}
|
||||
},
|
||||
onHorizontalDragStart: (details) {
|
||||
slidePosition = 0;
|
||||
},
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
runAlignment: WrapAlignment.center,
|
||||
children: widget.items.mapIndexed((index, e) {
|
||||
return Tooltip(
|
||||
message: '${e.name}\n${e.detailedName}',
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTapUp: currentPage == index
|
||||
? null
|
||||
: (details) {
|
||||
animateToTarget(index);
|
||||
timer.reset();
|
||||
},
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
color: Colors.red.withOpacity(0),
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: AnimatedContainer(
|
||||
duration: Duration(milliseconds: 125),
|
||||
width: currentItem == e ? 22 : 6,
|
||||
height: currentItem == e ? 10 : 6,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: currentItem == e
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.primary.withOpacity(0.25),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void animateToTarget(int nextIndex) {
|
||||
int step = currentPage < nextIndex ? 1 : -1;
|
||||
void updateItem(int item) {
|
||||
Future.delayed(Duration(milliseconds: 64 ~/ ((currentPage - nextIndex).abs() / 3)), () {
|
||||
setState(() {
|
||||
currentPage = item;
|
||||
});
|
||||
|
||||
if (currentPage != nextIndex) {
|
||||
updateItem(item + step);
|
||||
}
|
||||
});
|
||||
timer.reset();
|
||||
}
|
||||
|
||||
updateItem(currentPage + step);
|
||||
}
|
||||
}
|
||||
117
lib/screens/shared/media/chapter_row.dart
Normal file
117
lib/screens/shared/media/chapter_row.dart
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:fladder/models/items/chapters_model.dart';
|
||||
import 'package:fladder/screens/shared/flat_button.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/disable_keypad_focus.dart';
|
||||
import 'package:fladder/util/humanize_duration.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/widgets/shared/horizontal_list.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 ChapterRow extends ConsumerWidget {
|
||||
final List<Chapter> chapters;
|
||||
final EdgeInsets contentPadding;
|
||||
final Function(Chapter)? onPressed;
|
||||
const ChapterRow({required this.contentPadding, this.onPressed, required this.chapters, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return HorizontalList(
|
||||
label: context.localized.chapter(chapters.length),
|
||||
height: AdaptiveLayout.poster(context).size / 1.75,
|
||||
items: chapters,
|
||||
itemBuilder: (context, index) {
|
||||
final chapter = chapters[index];
|
||||
List<ItemAction> generateActions() {
|
||||
return [
|
||||
ItemActionButton(
|
||||
action: () => onPressed?.call(chapter), label: Text(context.localized.playFrom(chapter.name)))
|
||||
];
|
||||
}
|
||||
|
||||
return AspectRatio(
|
||||
aspectRatio: 1.75,
|
||||
child: Card(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: chapter.imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(5),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
shadowColor: Colors.transparent,
|
||||
color: Theme.of(context).cardColor.withOpacity(0.4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(5),
|
||||
child: Text(
|
||||
"${chapter.name} \n${chapter.startPosition.humanize ?? context.localized.start}",
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
BoxShadow(color: Theme.of(context).cardColor, blurRadius: 6, spreadRadius: 2.0)
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
FlatButton(
|
||||
onSecondaryTapDown: (details) async {
|
||||
Offset localPosition = details.globalPosition;
|
||||
RelativeRect position = RelativeRect.fromLTRB(
|
||||
localPosition.dx - 80, localPosition.dy, localPosition.dx, localPosition.dy);
|
||||
await showMenu(
|
||||
context: context,
|
||||
position: position,
|
||||
items: generateActions().popupMenuItems(),
|
||||
);
|
||||
},
|
||||
onLongPress: () {
|
||||
showBottomSheetPill(
|
||||
context: context,
|
||||
content: (context, scrollController) {
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
controller: scrollController,
|
||||
children: [
|
||||
...generateActions().listTileItems(context),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
if (AdaptiveLayout.of(context).isDesktop)
|
||||
DisableFocus(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: PopupMenuButton(
|
||||
tooltip: context.localized.options,
|
||||
icon: const Icon(
|
||||
Icons.more_vert,
|
||||
color: Colors.white,
|
||||
),
|
||||
itemBuilder: (context) => generateActions().popupMenuItems(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
contentPadding: contentPadding,
|
||||
);
|
||||
}
|
||||
}
|
||||
26
lib/screens/shared/media/components/chip_button.dart
Normal file
26
lib/screens/shared/media/components/chip_button.dart
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class ChipButton extends ConsumerWidget {
|
||||
final String label;
|
||||
final Function()? onPressed;
|
||||
const ChipButton({required this.label, this.onPressed, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return TextButton(
|
||||
onPressed: onPressed,
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface.withOpacity(0.75),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide.none,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
53
lib/screens/shared/media/components/media_header.dart
Normal file
53
lib/screens/shared/media/components/media_header.dart
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import 'package:fladder/models/items/images_models.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';
|
||||
|
||||
class MediaHeader extends ConsumerWidget {
|
||||
final String name;
|
||||
final ImageData? logo;
|
||||
const MediaHeader({
|
||||
required this.name,
|
||||
required this.logo,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final maxWidth =
|
||||
switch (AdaptiveLayout.layoutOf(context)) { LayoutState.desktop || LayoutState.tablet => 0.55, _ => 1 };
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Material(
|
||||
elevation: 30,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(150)),
|
||||
shadowColor: Colors.black.withOpacity(0.35),
|
||||
color: Colors.transparent,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.2,
|
||||
maxWidth: MediaQuery.sizeOf(context).width * maxWidth,
|
||||
),
|
||||
child: FladderImage(
|
||||
image: logo,
|
||||
enableBlur: true,
|
||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) => Container(
|
||||
color: Colors.red,
|
||||
width: 512,
|
||||
height: 512,
|
||||
child: child,
|
||||
),
|
||||
placeHolder: const SizedBox(height: 0),
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
81
lib/screens/shared/media/components/media_play_button.dart
Normal file
81
lib/screens/shared/media/components/media_play_button.dart
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/screens/shared/animated_fade_size.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class MediaPlayButton extends ConsumerWidget {
|
||||
final ItemBaseModel? item;
|
||||
final Function()? onPressed;
|
||||
final Function()? onLongPressed;
|
||||
const MediaPlayButton({
|
||||
required this.item,
|
||||
this.onPressed,
|
||||
this.onLongPressed,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final resume = (item?.progress ?? 0) > 0;
|
||||
Widget buttonBuilder(bool resume, ButtonStyle? style, Color? textColor) {
|
||||
return ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
onLongPress: onLongPressed,
|
||||
style: style,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
item?.playButtonLabel(context) ?? "",
|
||||
maxLines: 2,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
IconsaxBold.play,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return AnimatedFadeSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: onPressed != null
|
||||
? Stack(
|
||||
children: [
|
||||
buttonBuilder(resume, null, null),
|
||||
IgnorePointer(
|
||||
child: ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: (item?.progress ?? 0) / 100,
|
||||
child: buttonBuilder(
|
||||
resume,
|
||||
ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.primary),
|
||||
foregroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onPrimary),
|
||||
),
|
||||
Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Container(
|
||||
key: UniqueKey(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
103
lib/screens/shared/media/components/next_up_episode.dart
Normal file
103
lib/screens/shared/media/components/next_up_episode.dart
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import 'package:fladder/models/items/episode_model.dart';
|
||||
import 'package:fladder/providers/sync_provider.dart';
|
||||
import 'package:fladder/screens/details_screens/components/media_stream_information.dart';
|
||||
import 'package:fladder/screens/shared/media/episode_posters.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/sticky_header_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
|
||||
|
||||
class NextUpEpisode extends ConsumerWidget {
|
||||
final EpisodeModel nextEpisode;
|
||||
final Function(EpisodeModel episode)? onChanged;
|
||||
const NextUpEpisode({required this.nextEpisode, this.onChanged, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final alreadyPlayed = nextEpisode.userData.played;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
StickyHeaderText(
|
||||
label: alreadyPlayed ? context.localized.reWatch : context.localized.nextUp,
|
||||
),
|
||||
Opacity(
|
||||
opacity: 0.75,
|
||||
child: SelectableText(
|
||||
"${context.localized.season(1)} ${nextEpisode.season} - ${context.localized.episode(1)} ${nextEpisode.episode}",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
SelectableText(
|
||||
nextEpisode.name,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final syncedItem = ref.read(syncProvider.notifier).getSyncedItem(nextEpisode);
|
||||
if (constraints.maxWidth < 550) {
|
||||
return Column(
|
||||
children: [
|
||||
EpisodePoster(
|
||||
episode: nextEpisode,
|
||||
syncedItem: syncedItem,
|
||||
showLabel: false,
|
||||
onTap: () => nextEpisode.navigateTo(context),
|
||||
actions: const [],
|
||||
isCurrentEpisode: false,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (nextEpisode.overview.summary.isNotEmpty)
|
||||
HtmlWidget(
|
||||
nextEpisode.overview.summary,
|
||||
textStyle: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Row(
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: AdaptiveLayout.poster(context).gridRatio,
|
||||
maxWidth: MediaQuery.of(context).size.width / 2),
|
||||
child: EpisodePoster(
|
||||
episode: nextEpisode,
|
||||
syncedItem: syncedItem,
|
||||
showLabel: false,
|
||||
onTap: () => nextEpisode.navigateTo(context),
|
||||
actions: const [],
|
||||
isCurrentEpisode: false,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
Flexible(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MediaStreamInformation(
|
||||
mediaStream: nextEpisode.mediaStreams,
|
||||
onAudioIndexChanged: (index) => onChanged?.call(nextEpisode.copyWith(
|
||||
mediaStreams: nextEpisode.mediaStreams.copyWith(defaultAudioStreamIndex: index))),
|
||||
onSubIndexChanged: (index) => onChanged?.call(nextEpisode.copyWith(
|
||||
mediaStreams: nextEpisode.mediaStreams.copyWith(defaultSubStreamIndex: index))),
|
||||
),
|
||||
if (nextEpisode.overview.summary.isNotEmpty)
|
||||
HtmlWidget(nextEpisode.overview.summary, textStyle: Theme.of(context).textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
428
lib/screens/shared/media/components/poster_image.dart
Normal file
428
lib/screens/shared/media/components/poster_image.dart
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/book_model.dart';
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/models/items/item_shared_models.dart';
|
||||
import 'package:fladder/models/items/photos_model.dart';
|
||||
import 'package:fladder/models/items/series_model.dart';
|
||||
import 'package:fladder/screens/shared/flat_button.dart';
|
||||
import 'package:fladder/theme.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/disable_keypad_focus.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:fladder/util/humanize_duration.dart';
|
||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/refresh_state.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
|
||||
import 'package:fladder/widgets/shared/status_card.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class PosterImage extends ConsumerStatefulWidget {
|
||||
final ItemBaseModel poster;
|
||||
final bool heroTag;
|
||||
final bool? selected;
|
||||
final ValueChanged<bool>? playVideo;
|
||||
final bool inlineTitle;
|
||||
final Set<ItemActions> excludeActions;
|
||||
final List<ItemAction> otherActions;
|
||||
final Function(UserData? newData)? onUserDataChanged;
|
||||
final Function(ItemBaseModel newItem)? onItemUpdated;
|
||||
final Function(ItemBaseModel oldItem)? onItemRemoved;
|
||||
final Function(Function() action, ItemBaseModel item)? onPressed;
|
||||
const PosterImage({
|
||||
required this.poster,
|
||||
this.heroTag = false,
|
||||
this.selected,
|
||||
this.playVideo,
|
||||
this.inlineTitle = false,
|
||||
this.onItemUpdated,
|
||||
this.onItemRemoved,
|
||||
this.excludeActions = const {},
|
||||
this.otherActions = const [],
|
||||
this.onPressed,
|
||||
this.onUserDataChanged,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _PosterImageState();
|
||||
}
|
||||
|
||||
class _PosterImageState extends ConsumerState<PosterImage> {
|
||||
late String currentTag = widget.heroTag == true ? widget.poster.id : UniqueKey().toString();
|
||||
bool hover = false;
|
||||
|
||||
Widget get placeHolder {
|
||||
return Center(
|
||||
child: Icon(widget.poster.type.icon),
|
||||
);
|
||||
}
|
||||
|
||||
void pressedWidget() async {
|
||||
if (widget.heroTag == false) {
|
||||
setState(() {
|
||||
currentTag = widget.poster.id;
|
||||
});
|
||||
}
|
||||
if (widget.onPressed != null) {
|
||||
widget.onPressed?.call(() async {
|
||||
await navigateToDetails();
|
||||
if (context.mounted) {
|
||||
context.refreshData();
|
||||
}
|
||||
}, widget.poster);
|
||||
} else {
|
||||
await navigateToDetails();
|
||||
if (context.mounted) {
|
||||
context.refreshData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> navigateToDetails() async {
|
||||
await widget.poster.navigateTo(context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final poster = widget.poster;
|
||||
final padding = EdgeInsets.all(5);
|
||||
return Hero(
|
||||
tag: currentTag,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (event) => setState(() => hover = true),
|
||||
onExit: (event) => setState(() => hover = false),
|
||||
child: Card(
|
||||
elevation: 8,
|
||||
color: Theme.of(context).colorScheme.secondaryContainer.withOpacity(0.2),
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(
|
||||
width: 1.0,
|
||||
color: Colors.white.withOpacity(0.10),
|
||||
),
|
||||
borderRadius: FladderTheme.defaultShape.borderRadius,
|
||||
),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
FladderImage(
|
||||
image: widget.poster.getPosters?.primary ?? widget.poster.getPosters?.backDrop?.lastOrNull,
|
||||
placeHolder: placeHolder,
|
||||
),
|
||||
if (poster.userData.progress > 0 && widget.poster.type == FladderItemType.book)
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(5.5),
|
||||
child: Text(
|
||||
context.localized.page((widget.poster as BookModel).currentPage),
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.selected == true)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.15),
|
||||
border: Border.all(width: 3, color: Theme.of(context).colorScheme.primary),
|
||||
borderRadius: FladderTheme.defaultShape.borderRadius,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
alignment: Alignment.topCenter,
|
||||
children: [
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: double.infinity,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: Text(
|
||||
widget.poster.name,
|
||||
maxLines: 2,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelMedium
|
||||
?.copyWith(color: Theme.of(context).colorScheme.onPrimary, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.poster.userData.isFavourite)
|
||||
Row(
|
||||
children: [
|
||||
StatusCard(
|
||||
color: Colors.red,
|
||||
child: Icon(
|
||||
IconsaxBold.heart,
|
||||
size: 21,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if ((poster.userData.progress > 0 && poster.userData.progress < 100) &&
|
||||
widget.poster.type != FladderItemType.book) ...{
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3).copyWith(bottom: 3).add(padding),
|
||||
child: Card(
|
||||
color: Colors.transparent,
|
||||
elevation: 3,
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: 7.5,
|
||||
backgroundColor: Theme.of(context).colorScheme.onPrimary.withOpacity(0.5),
|
||||
value: poster.userData.progress / 100,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
//Desktop overlay
|
||||
if (AdaptiveLayout.of(context).inputDevice != InputDevice.touch &&
|
||||
widget.poster.type != FladderItemType.person)
|
||||
AnimatedOpacity(
|
||||
opacity: hover ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 125),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
//Hover color overlay
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.55),
|
||||
border: Border.all(width: 3, color: Theme.of(context).colorScheme.primary),
|
||||
borderRadius: FladderTheme.defaultShape.borderRadius,
|
||||
)),
|
||||
//Poster Button
|
||||
Focus(
|
||||
onFocusChange: (value) => setState(() => hover = value),
|
||||
child: FlatButton(
|
||||
onTap: pressedWidget,
|
||||
onSecondaryTapDown: (details) async {
|
||||
Offset localPosition = details.globalPosition;
|
||||
RelativeRect position = RelativeRect.fromLTRB(
|
||||
localPosition.dx - 320, localPosition.dy, localPosition.dx, localPosition.dy);
|
||||
await showMenu(
|
||||
context: context,
|
||||
position: position,
|
||||
items: widget.poster
|
||||
.generateActions(
|
||||
context,
|
||||
ref,
|
||||
exclude: widget.excludeActions,
|
||||
otherActions: widget.otherActions,
|
||||
onUserDataChanged: widget.onUserDataChanged,
|
||||
onDeleteSuccesFully: widget.onItemRemoved,
|
||||
onItemUpdated: widget.onItemUpdated,
|
||||
)
|
||||
.popupMenuItems(useIcons: true),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
//Play Button
|
||||
if (widget.poster.playAble)
|
||||
DisableFocus(
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: IconButton.filledTonal(
|
||||
onPressed: () => widget.playVideo?.call(false),
|
||||
icon: const Icon(
|
||||
IconsaxBold.play,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
DisableFocus(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PopupMenuButton(
|
||||
tooltip: "Options",
|
||||
icon: const Icon(
|
||||
Icons.more_vert,
|
||||
color: Colors.white,
|
||||
),
|
||||
itemBuilder: (context) => widget.poster
|
||||
.generateActions(
|
||||
context,
|
||||
ref,
|
||||
exclude: widget.excludeActions,
|
||||
otherActions: widget.otherActions,
|
||||
onUserDataChanged: widget.onUserDataChanged,
|
||||
onDeleteSuccesFully: widget.onItemRemoved,
|
||||
onItemUpdated: widget.onItemUpdated,
|
||||
)
|
||||
.popupMenuItems(useIcons: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: pressedWidget,
|
||||
onLongPress: () {
|
||||
showBottomSheetPill(
|
||||
context: context,
|
||||
item: widget.poster,
|
||||
content: (scrollContext, scrollController) => ListView(
|
||||
shrinkWrap: true,
|
||||
controller: scrollController,
|
||||
children: widget.poster
|
||||
.generateActions(
|
||||
context,
|
||||
ref,
|
||||
exclude: widget.excludeActions,
|
||||
otherActions: widget.otherActions,
|
||||
onUserDataChanged: widget.onUserDataChanged,
|
||||
onDeleteSuccesFully: widget.onItemRemoved,
|
||||
onItemUpdated: widget.onItemUpdated,
|
||||
)
|
||||
.listTileItems(scrollContext, useIcons: true),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (widget.poster.unWatched)
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: StatusCard(
|
||||
color: Colors.amber,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.amber,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.inlineTitle)
|
||||
IgnorePointer(
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
widget.poster.title.maxLength(limitTo: 25),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelLarge
|
||||
?.copyWith(fontSize: 20, fontWeight: FontWeight.bold, shadows: [
|
||||
BoxShadow(blurRadius: 8, spreadRadius: 16),
|
||||
BoxShadow(blurRadius: 2, spreadRadius: 16),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.poster.unPlayedItemCount != null && widget.poster is SeriesModel)
|
||||
IgnorePointer(
|
||||
child: Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: StatusCard(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: widget.poster.unPlayedItemCount != 0
|
||||
? Container(
|
||||
constraints: const BoxConstraints(minWidth: 18),
|
||||
child: Text(
|
||||
widget.poster.userData.unPlayedItemCount.toString(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
overflow: TextOverflow.visible,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.check_rounded,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.poster.overview.runTime != null &&
|
||||
((widget.poster is PhotoModel) &&
|
||||
(widget.poster as PhotoModel).internalType == FladderItemType.video)) ...{
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: Card(
|
||||
elevation: 5,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
widget.poster.overview.runTime.humanizeSmall ?? "",
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fladder/models/items/item_shared_models.dart';
|
||||
import 'package:fladder/models/items/movie_model.dart';
|
||||
import 'package:fladder/screens/shared/media/components/chip_button.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
|
||||
class Ratings extends StatelessWidget {
|
||||
final double? communityRating;
|
||||
final String? officialRating;
|
||||
const Ratings({
|
||||
super.key,
|
||||
this.communityRating,
|
||||
this.officialRating,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
if (communityRating != null) ...{
|
||||
const Icon(
|
||||
Icons.star_rounded,
|
||||
color: Colors.yellow,
|
||||
),
|
||||
Text(
|
||||
communityRating?.toStringAsFixed(1) ?? "",
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
},
|
||||
if (officialRating != null) ...{
|
||||
Card(
|
||||
elevation: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Text(
|
||||
officialRating ?? "",
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Tags extends StatelessWidget {
|
||||
final List<String> tags;
|
||||
const Tags({
|
||||
super.key,
|
||||
required this.tags,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
runSpacing: 8,
|
||||
spacing: 8,
|
||||
children: tags
|
||||
.map((tag) => ChipButton(
|
||||
onPressed: () {},
|
||||
label: tag.capitalize(),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Genres extends StatelessWidget {
|
||||
final List<GenreItems> genres;
|
||||
const Genres({
|
||||
super.key,
|
||||
required this.genres,
|
||||
this.details,
|
||||
});
|
||||
|
||||
final MovieModel? details;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
runSpacing: 8,
|
||||
spacing: 8,
|
||||
children: genres
|
||||
.map(
|
||||
(genre) => ChipButton(
|
||||
onPressed: null,
|
||||
label: genre.name.capitalize(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
159
lib/screens/shared/media/episode_details_list.dart
Normal file
159
lib/screens/shared/media/episode_details_list.dart
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/providers/settings/client_settings_provider.dart';
|
||||
import 'package:fladder/providers/sync_provider.dart';
|
||||
import 'package:fladder/screens/shared/media/episode_posters.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:fladder/models/items/episode_model.dart';
|
||||
import 'package:fladder/util/humanize_duration.dart';
|
||||
|
||||
enum EpisodeDetailsViewType {
|
||||
list(icon: IconsaxBold.grid_6),
|
||||
grid(icon: IconsaxBold.grid_2);
|
||||
|
||||
const EpisodeDetailsViewType({required this.icon});
|
||||
|
||||
String label(BuildContext context) => switch (this) {
|
||||
EpisodeDetailsViewType.list => context.localized.list,
|
||||
EpisodeDetailsViewType.grid => context.localized.grid,
|
||||
};
|
||||
|
||||
final IconData icon;
|
||||
}
|
||||
|
||||
class EpisodeDetailsList extends ConsumerWidget {
|
||||
final EpisodeDetailsViewType viewType;
|
||||
final List<EpisodeModel> episodes;
|
||||
final EdgeInsets? padding;
|
||||
const EpisodeDetailsList({required this.viewType, required this.episodes, this.padding, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final size = MediaQuery.sizeOf(context).width /
|
||||
((AdaptiveLayout.poster(context).gridRatio * 2) *
|
||||
ref.watch(clientSettingsProvider.select((value) => value.posterSize)));
|
||||
final decimals = size - size.toInt();
|
||||
return AnimatedSwitcher(
|
||||
duration: Duration(milliseconds: 250),
|
||||
child: switch (viewType) {
|
||||
EpisodeDetailsViewType.list => ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: padding,
|
||||
itemCount: episodes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final episode = episodes[index];
|
||||
final syncedItem = ref.watch(syncProvider.notifier).getSyncedItem(episode);
|
||||
List<Widget> children = [
|
||||
Flexible(
|
||||
flex: 1,
|
||||
child: EpisodePoster(
|
||||
episode: episode,
|
||||
showLabel: false,
|
||||
syncedItem: syncedItem,
|
||||
actions: episode.generateActions(context, ref),
|
||||
onTap: () => episode.navigateTo(context),
|
||||
isCurrentEpisode: false,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16, height: 16),
|
||||
Flexible(
|
||||
flex: 3,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: 0.65,
|
||||
child: SelectableText(
|
||||
episode.seasonEpisodeLabel(context),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (episode.overview.runTime != null)
|
||||
Opacity(
|
||||
opacity: 0.65,
|
||||
child: SelectableText(
|
||||
" - ${episode.overview.runTime!.humanize!}",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SelectableText(
|
||||
episode.name,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
SelectableText(
|
||||
episode.overview.summary,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
].addPadding(const EdgeInsets.symmetric(vertical: 4)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
];
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: constraints.maxWidth > 800
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: children,
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
EpisodeDetailsViewType.grid => GridView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: padding,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: size.toInt(),
|
||||
mainAxisSpacing: (8 * decimals) + 8,
|
||||
crossAxisSpacing: (8 * decimals) + 8,
|
||||
childAspectRatio: 1.67),
|
||||
itemCount: episodes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final episode = episodes[index];
|
||||
return EpisodePoster(
|
||||
episode: episode,
|
||||
actions: episode.generateActions(context, ref),
|
||||
onTap: () => episode.navigateTo(context),
|
||||
isCurrentEpisode: false,
|
||||
);
|
||||
},
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
306
lib/screens/shared/media/episode_posters.dart
Normal file
306
lib/screens/shared/media/episode_posters.dart
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
import 'package:fladder/models/items/episode_model.dart';
|
||||
import 'package:fladder/models/syncing/sync_item.dart';
|
||||
import 'package:fladder/providers/settings/client_settings_provider.dart';
|
||||
import 'package:fladder/providers/sync/sync_provider_helpers.dart';
|
||||
import 'package:fladder/providers/sync_provider.dart';
|
||||
import 'package:fladder/screens/shared/flat_button.dart';
|
||||
import 'package:fladder/screens/syncing/sync_button.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/disable_keypad_focus.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/widgets/shared/clickable_text.dart';
|
||||
import 'package:fladder/widgets/shared/enum_selection.dart';
|
||||
import 'package:fladder/widgets/shared/horizontal_list.dart';
|
||||
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
|
||||
import 'package:fladder/widgets/shared/status_card.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class EpisodePosters extends ConsumerStatefulWidget {
|
||||
final List<EpisodeModel> episodes;
|
||||
final String? label;
|
||||
final ValueChanged<EpisodeModel> playEpisode;
|
||||
final EdgeInsets contentPadding;
|
||||
final Function(VoidCallback action, EpisodeModel episodeModel)? onEpisodeTap;
|
||||
const EpisodePosters({
|
||||
this.label,
|
||||
required this.contentPadding,
|
||||
required this.playEpisode,
|
||||
required this.episodes,
|
||||
this.onEpisodeTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _EpisodePosterState();
|
||||
}
|
||||
|
||||
class _EpisodePosterState extends ConsumerState<EpisodePosters> {
|
||||
late int? selectedSeason = widget.episodes.nextUp?.season;
|
||||
|
||||
List<EpisodeModel> get episodes {
|
||||
if (selectedSeason == null) {
|
||||
return widget.episodes;
|
||||
} else {
|
||||
return widget.episodes.where((element) => element.season == selectedSeason).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final indexOfCurrent = (episodes.nextUp != null ? episodes.indexOf(episodes.nextUp!) : 0).clamp(0, episodes.length);
|
||||
final episodesBySeason = widget.episodes.episodesBySeason;
|
||||
final allPlayed = episodes.allPlayed;
|
||||
|
||||
return HorizontalList(
|
||||
label: widget.label,
|
||||
titleActions: [
|
||||
if (episodesBySeason.isNotEmpty && episodesBySeason.length > 1) ...{
|
||||
SizedBox(width: 12),
|
||||
EnumBox(
|
||||
current: selectedSeason != null ? "${context.localized.season(1)} $selectedSeason" : context.localized.all,
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
child: Text(context.localized.all),
|
||||
onTap: () => setState(() => selectedSeason = null),
|
||||
),
|
||||
...episodesBySeason.entries.map(
|
||||
(e) => PopupMenuItem(
|
||||
child: Text("${context.localized.season(1)} ${e.key}"),
|
||||
onTap: () {
|
||||
setState(() => selectedSeason = e.key);
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
},
|
||||
],
|
||||
height: AdaptiveLayout.poster(context).gridRatio,
|
||||
contentPadding: widget.contentPadding,
|
||||
startIndex: indexOfCurrent,
|
||||
items: episodes,
|
||||
itemBuilder: (context, index) {
|
||||
final episode = episodes[index];
|
||||
final isCurrentEpisode = index == indexOfCurrent;
|
||||
final syncedItem = ref.watch(syncProvider.notifier).getSyncedItem(episode);
|
||||
return EpisodePoster(
|
||||
episode: episode,
|
||||
blur: allPlayed ? false : indexOfCurrent < index,
|
||||
syncedItem: syncedItem,
|
||||
onTap: widget.onEpisodeTap != null
|
||||
? () {
|
||||
widget.onEpisodeTap?.call(
|
||||
() {
|
||||
episode.navigateTo(context);
|
||||
},
|
||||
episode,
|
||||
);
|
||||
}
|
||||
: () {
|
||||
episode.navigateTo(context);
|
||||
},
|
||||
onLongPress: () {
|
||||
showBottomSheetPill(
|
||||
context: context,
|
||||
item: episode,
|
||||
content: (context, scrollController) {
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
controller: scrollController,
|
||||
children: [
|
||||
...episode.generateActions(context, ref).listTileItems(context, useIcons: true),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
actions: episode.generateActions(context, ref),
|
||||
isCurrentEpisode: isCurrentEpisode,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EpisodePoster extends ConsumerWidget {
|
||||
final EpisodeModel episode;
|
||||
final SyncedItem? syncedItem;
|
||||
final bool showLabel;
|
||||
final Function()? onTap;
|
||||
final Function()? onLongPress;
|
||||
final bool blur;
|
||||
final List<ItemAction> actions;
|
||||
final bool isCurrentEpisode;
|
||||
|
||||
const EpisodePoster({
|
||||
super.key,
|
||||
required this.episode,
|
||||
this.syncedItem,
|
||||
this.showLabel = true,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.blur = false,
|
||||
required this.actions,
|
||||
required this.isCurrentEpisode,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Widget placeHolder = Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: const Icon(Icons.local_movies_outlined),
|
||||
);
|
||||
final SyncedItem? iSyncedItem = syncedItem;
|
||||
bool episodeAvailable = episode.status == EpisodeStatus.available;
|
||||
return AspectRatio(
|
||||
aspectRatio: 1.76,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Card(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
FladderImage(
|
||||
image: switch (episode.status) {
|
||||
EpisodeStatus.unaired || EpisodeStatus.missing => episode.parentImages?.primary,
|
||||
_ => episode.images?.primary
|
||||
},
|
||||
placeHolder: placeHolder,
|
||||
blurOnly:
|
||||
ref.watch(clientSettingsProvider.select((value) => value.blurUpcomingEpisodes)) ? blur : false,
|
||||
),
|
||||
if (!episodeAvailable)
|
||||
Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Card(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
elevation: 3,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
episode.status.name,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (iSyncedItem != null)
|
||||
Consumer(builder: (context, ref, child) {
|
||||
final SyncStatus syncStatus =
|
||||
ref.watch(syncStatusesProvider(iSyncedItem)).value ?? SyncStatus.partially;
|
||||
return StatusCard(
|
||||
color: syncStatus.color,
|
||||
child: SyncButton(item: episode, syncedItem: syncedItem),
|
||||
);
|
||||
}),
|
||||
if (episode.userData.isFavourite)
|
||||
StatusCard(
|
||||
color: Colors.red,
|
||||
child: Icon(
|
||||
Icons.favorite_rounded,
|
||||
),
|
||||
),
|
||||
if (episode.userData.played)
|
||||
StatusCard(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
child: Icon(
|
||||
Icons.check_rounded,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if ((episode.userData.progress) > 0)
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: 6,
|
||||
backgroundColor: Colors.black.withOpacity(0.75),
|
||||
value: episode.userData.progress / 100,
|
||||
),
|
||||
),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return FlatButton(
|
||||
onSecondaryTapDown: (details) {
|
||||
Offset localPosition = details.globalPosition;
|
||||
RelativeRect position = RelativeRect.fromLTRB(
|
||||
localPosition.dx - 260, localPosition.dy, localPosition.dx, localPosition.dy);
|
||||
|
||||
showMenu(context: context, position: position, items: actions.popupMenuItems(useIcons: true));
|
||||
},
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer && actions.isNotEmpty)
|
||||
DisableFocus(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: PopupMenuButton(
|
||||
tooltip: "Options",
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(color: Colors.black.withOpacity(0.45), blurRadius: 8.0),
|
||||
const Shadow(color: Colors.black, blurRadius: 16.0),
|
||||
const Shadow(color: Colors.black, blurRadius: 32.0),
|
||||
const Shadow(color: Colors.black, blurRadius: 64.0),
|
||||
],
|
||||
),
|
||||
itemBuilder: (context) => actions.popupMenuItems(useIcons: true),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showLabel) ...{
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
if (isCurrentEpisode)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Container(
|
||||
height: 12,
|
||||
width: 12,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: ClickableText(
|
||||
text: episode.episodeLabel(context),
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
}
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
84
lib/screens/shared/media/expanding_overview.dart
Normal file
84
lib/screens/shared/media/expanding_overview.dart
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/sticky_header_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
|
||||
|
||||
class ExpandingOverview extends ConsumerStatefulWidget {
|
||||
final String text;
|
||||
const ExpandingOverview({required this.text, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _ExpandingOverviewState();
|
||||
}
|
||||
|
||||
class _ExpandingOverviewState extends ConsumerState<ExpandingOverview> {
|
||||
bool expanded = false;
|
||||
|
||||
void toggleState() {
|
||||
setState(() {
|
||||
expanded = !expanded;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = Theme.of(context).colorScheme.onSurface;
|
||||
const int maxLength = 200;
|
||||
final bool canExpand = widget.text.length > maxLength;
|
||||
return AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
alignment: Alignment.topCenter,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
StickyHeaderText(
|
||||
label: context.localized.overview,
|
||||
),
|
||||
ShaderMask(
|
||||
shaderCallback: (bounds) => LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: const [0, 1],
|
||||
colors: [
|
||||
color,
|
||||
color.withOpacity(!canExpand
|
||||
? 1
|
||||
: expanded
|
||||
? 1
|
||||
: 0),
|
||||
],
|
||||
).createShader(bounds),
|
||||
child: HtmlWidget(
|
||||
widget.text.substring(0, !expanded ? maxLength.clamp(0, widget.text.length) : widget.text.length - 1),
|
||||
textStyle: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
if (canExpand) ...{
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, expanded ? 0 : -15),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: expanded
|
||||
? IconButton.filledTonal(
|
||||
onPressed: toggleState,
|
||||
icon: const Icon(IconsaxOutline.arrow_up_2),
|
||||
)
|
||||
: IconButton.filledTonal(
|
||||
onPressed: toggleState,
|
||||
icon: const Icon(IconsaxOutline.arrow_down_1),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
68
lib/screens/shared/media/external_urls.dart
Normal file
68
lib/screens/shared/media/external_urls.dart
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import 'package:fladder/models/items/item_shared_models.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_custom_tabs/flutter_custom_tabs.dart' as customtab;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart' as urllauncher;
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class ExternalUrlsRow extends ConsumerWidget {
|
||||
final List<ExternalUrls>? urls;
|
||||
const ExternalUrlsRow({
|
||||
this.urls,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Wrap(
|
||||
children: urls
|
||||
?.map(
|
||||
(url) => TextButton(
|
||||
onPressed: () => launchUrl(context, url.url),
|
||||
child: Text(url.name),
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> launchUrl(BuildContext context, String link) async {
|
||||
final Uri url = Uri.parse(link);
|
||||
|
||||
if (AdaptiveLayout.of(context).isDesktop) {
|
||||
if (!await urllauncher.launchUrl(url, mode: LaunchMode.externalApplication)) {
|
||||
throw Exception('Could not launch $url');
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await customtab.launch(
|
||||
link,
|
||||
customTabsOption: customtab.CustomTabsOption(
|
||||
toolbarColor: Theme.of(context).primaryColor,
|
||||
enableDefaultShare: true,
|
||||
enableUrlBarHiding: true,
|
||||
showPageTitle: true,
|
||||
extraCustomTabs: const <String>[
|
||||
// ref. https://play.google.com/store/apps/details?id=org.mozilla.firefox
|
||||
'org.mozilla.firefox',
|
||||
// ref. https://play.google.com/store/apps/details?id=com.microsoft.emmx
|
||||
'com.microsoft.emmx',
|
||||
],
|
||||
),
|
||||
safariVCOption: customtab.SafariViewControllerOption(
|
||||
preferredBarTintColor: Theme.of(context).primaryColor,
|
||||
preferredControlTintColor: Colors.white,
|
||||
barCollapsingEnabled: true,
|
||||
entersReaderIfAvailable: false,
|
||||
dismissButtonStyle: customtab.SafariViewControllerDismissButtonStyle.close,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
// An exception is thrown if browser app is not installed on Android device.
|
||||
debugPrint(e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
110
lib/screens/shared/media/item_detail_list_widget.dart
Normal file
110
lib/screens/shared/media/item_detail_list_widget.dart
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/screens/shared/flat_button.dart';
|
||||
import 'package:fladder/util/duration_extensions.dart';
|
||||
|
||||
class ItemDetailListWidget extends ConsumerStatefulWidget {
|
||||
final ItemBaseModel item;
|
||||
final Widget? iconOverlay;
|
||||
final double elevation;
|
||||
final List<Widget> actions;
|
||||
const ItemDetailListWidget(
|
||||
{super.key, required this.item, this.iconOverlay, this.elevation = 1, this.actions = const []});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _ItemDetailListWidgetState();
|
||||
}
|
||||
|
||||
class _ItemDetailListWidgetState extends ConsumerState<ItemDetailListWidget> {
|
||||
bool showImageOverlay = false;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: widget.elevation,
|
||||
margin: EdgeInsets.zero,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
children: [
|
||||
FlatButton(
|
||||
onTap: () {},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 32),
|
||||
child: Row(
|
||||
children: [
|
||||
MouseRegion(
|
||||
onEnter: (event) => setState(() => showImageOverlay = true),
|
||||
onExit: (event) => setState(() => showImageOverlay = false),
|
||||
child: Stack(
|
||||
children: [
|
||||
FladderImage(image: widget.item.images?.primary),
|
||||
if (widget.item.subTextShort(context) != null)
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(7),
|
||||
child: Text(
|
||||
widget.item.subTextShort(context) ?? "",
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.iconOverlay != null)
|
||||
Positioned.fill(
|
||||
child: AnimatedOpacity(
|
||||
opacity: showImageOverlay ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: widget.iconOverlay!,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: IgnorePointer(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.item.name,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: Opacity(
|
||||
opacity: 0.65,
|
||||
child: Text(
|
||||
widget.item.overview.summary,
|
||||
overflow: TextOverflow.fade,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
...widget.actions,
|
||||
if (widget.item.overview.runTime != null)
|
||||
Opacity(opacity: 0.65, child: Text(widget.item.overview.runTime?.readAbleDuration ?? "")),
|
||||
const VerticalDivider(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
99
lib/screens/shared/media/people_row.dart
Normal file
99
lib/screens/shared/media/people_row.dart
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import 'package:animations/animations.dart';
|
||||
import 'package:fladder/models/items/item_shared_models.dart';
|
||||
import 'package:fladder/screens/details_screens/person_detail_screen.dart';
|
||||
import 'package:fladder/screens/shared/flat_button.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
import 'package:fladder/widgets/shared/clickable_text.dart';
|
||||
import 'package:fladder/widgets/shared/horizontal_list.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class PeopleRow extends ConsumerWidget {
|
||||
final List<Person> people;
|
||||
final EdgeInsets contentPadding;
|
||||
const PeopleRow({required this.people, required this.contentPadding, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Widget placeHolder(String name) {
|
||||
return Card(
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: 0.4,
|
||||
child: Card(
|
||||
elevation: 5,
|
||||
shape: const CircleBorder(),
|
||||
child: Center(
|
||||
child: Text(
|
||||
name.getInitials(),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return HorizontalList(
|
||||
label: context.localized.actor(people.length),
|
||||
height: AdaptiveLayout.poster(context).size * 0.9,
|
||||
contentPadding: contentPadding,
|
||||
items: people,
|
||||
itemBuilder: (context, index) {
|
||||
final person = people[index];
|
||||
return AspectRatio(
|
||||
aspectRatio: 0.6,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Flexible(
|
||||
child: OpenContainer(
|
||||
closedColor: Colors.transparent,
|
||||
closedElevation: 5,
|
||||
openElevation: 0,
|
||||
closedShape: const RoundedRectangleBorder(),
|
||||
transitionType: ContainerTransitionType.fadeThrough,
|
||||
openColor: Colors.transparent,
|
||||
tappable: false,
|
||||
closedBuilder: (context, action) => Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Card(
|
||||
child: FladderImage(
|
||||
image: person.image,
|
||||
placeHolder: placeHolder(person.name),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
FlatButton(onTap: () => action()),
|
||||
],
|
||||
),
|
||||
openBuilder: (context, action) => PersonDetailScreen(
|
||||
person: person,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ClickableText(
|
||||
text: person.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ClickableText(
|
||||
opacity: 0.45,
|
||||
text: person.role,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontSize: 13, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
38
lib/screens/shared/media/person_list_.dart
Normal file
38
lib/screens/shared/media/person_list_.dart
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import 'package:fladder/models/items/item_shared_models.dart';
|
||||
import 'package:fladder/screens/details_screens/details_screens.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class PersonList extends ConsumerWidget {
|
||||
final String label;
|
||||
final List<Person> people;
|
||||
final ValueChanged<Person>? onPersonTap;
|
||||
const PersonList({required this.label, required this.people, this.onPersonTap, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
...people
|
||||
.map((person) => TextButton(
|
||||
onPressed:
|
||||
onPersonTap != null ? () => onPersonTap?.call(person) : () => openPersonDetailPage(context, person),
|
||||
child: Text(person.name)))
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void openPersonDetailPage(BuildContext context, Person person) {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (context) => PersonDetailScreen(person: person),
|
||||
));
|
||||
}
|
||||
}
|
||||
71
lib/screens/shared/media/poster_grid.dart
Normal file
71
lib/screens/shared/media/poster_grid.dart
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/providers/settings/client_settings_provider.dart';
|
||||
import 'package:fladder/screens/shared/media/poster_widget.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/sticky_header_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sticky_headers/sticky_headers.dart';
|
||||
|
||||
class PosterGrid extends ConsumerWidget {
|
||||
final String? name;
|
||||
final List<ItemBaseModel> posters;
|
||||
final Widget? Function(BuildContext context, int index)? itemBuilder;
|
||||
final bool stickyHeader;
|
||||
final Function(VoidCallback action, ItemBaseModel item)? onPressed;
|
||||
const PosterGrid(
|
||||
{this.stickyHeader = true, this.itemBuilder, this.name, required this.posters, this.onPressed, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final size = MediaQuery.sizeOf(context).width /
|
||||
(AdaptiveLayout.poster(context).gridRatio *
|
||||
ref.watch(clientSettingsProvider.select((value) => value.posterSize)));
|
||||
final decimals = size - size.toInt();
|
||||
var posterBuilder = GridView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: size.toInt(),
|
||||
mainAxisSpacing: (8 * decimals) + 8,
|
||||
crossAxisSpacing: (8 * decimals) + 8,
|
||||
childAspectRatio: AdaptiveLayout.poster(context).ratio,
|
||||
),
|
||||
itemCount: posters.length,
|
||||
itemBuilder: itemBuilder ??
|
||||
(context, index) {
|
||||
return PosterWidget(
|
||||
poster: posters[index],
|
||||
onPressed: onPressed,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (stickyHeader) {
|
||||
//Translate fixes small peaking pixel line
|
||||
return StickyHeader(
|
||||
header: name != null
|
||||
? StickyHeaderText(label: name ?? "")
|
||||
: const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
content: posterBuilder,
|
||||
);
|
||||
} else {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text(
|
||||
name ?? "",
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
posterBuilder,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
218
lib/screens/shared/media/poster_list_item.dart
Normal file
218
lib/screens/shared/media/poster_list_item.dart
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/book_model.dart';
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/models/items/item_shared_models.dart';
|
||||
import 'package:fladder/providers/settings/client_settings_provider.dart';
|
||||
import 'package:fladder/screens/shared/flat_button.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/widgets/shared/clickable_text.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 PosterListItem extends ConsumerWidget {
|
||||
final ItemBaseModel poster;
|
||||
final bool? selected;
|
||||
final Widget? subTitle;
|
||||
final Set<ItemActions> excludeActions;
|
||||
final List<ItemAction> otherActions;
|
||||
|
||||
// Useful for intercepting button press
|
||||
final Function(VoidCallback action, ItemBaseModel item)? onPressed;
|
||||
final Function(String id, UserData? newData)? onUserDataChanged;
|
||||
final Function(ItemBaseModel newItem)? onItemUpdated;
|
||||
final Function(ItemBaseModel oldItem)? onItemRemoved;
|
||||
|
||||
const PosterListItem({
|
||||
super.key,
|
||||
this.selected,
|
||||
this.subTitle,
|
||||
this.excludeActions = const {},
|
||||
this.otherActions = const [],
|
||||
required this.poster,
|
||||
this.onPressed,
|
||||
this.onItemUpdated,
|
||||
this.onItemRemoved,
|
||||
this.onUserDataChanged,
|
||||
});
|
||||
|
||||
void pressedWidget(BuildContext context) {
|
||||
if (onPressed != null) {
|
||||
onPressed?.call(() {
|
||||
poster.navigateTo(context);
|
||||
}, poster);
|
||||
} else {
|
||||
poster.navigateTo(context);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 2),
|
||||
child: Card(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: SizedBox(
|
||||
height: 75 * ref.read(clientSettingsProvider.select((value) => value.posterSize)),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(selected == true ? 0.25 : 0),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: FlatButton(
|
||||
onTap: () => pressedWidget(context),
|
||||
onSecondaryTapDown: (details) async {
|
||||
Offset localPosition = details.globalPosition;
|
||||
RelativeRect position =
|
||||
RelativeRect.fromLTRB(localPosition.dx - 320, localPosition.dy, localPosition.dx, localPosition.dy);
|
||||
await showMenu(
|
||||
context: context,
|
||||
position: position,
|
||||
items: poster
|
||||
.generateActions(
|
||||
context,
|
||||
ref,
|
||||
exclude: excludeActions,
|
||||
otherActions: otherActions,
|
||||
onUserDataChanged: (newData) => onUserDataChanged?.call(poster.id, newData),
|
||||
onDeleteSuccesFully: onItemRemoved,
|
||||
onItemUpdated: onItemUpdated,
|
||||
)
|
||||
.popupMenuItems(useIcons: true),
|
||||
);
|
||||
},
|
||||
onLongPress: () {
|
||||
showBottomSheetPill(
|
||||
context: context,
|
||||
item: poster,
|
||||
content: (scrollContext, scrollController) => ListView(
|
||||
shrinkWrap: true,
|
||||
controller: scrollController,
|
||||
children: poster
|
||||
.generateActions(
|
||||
context,
|
||||
ref,
|
||||
exclude: excludeActions,
|
||||
otherActions: otherActions,
|
||||
onUserDataChanged: (newData) => onUserDataChanged?.call(poster.id, newData),
|
||||
onDeleteSuccesFully: onItemRemoved,
|
||||
onItemUpdated: onItemUpdated,
|
||||
)
|
||||
.listTileItems(scrollContext, useIcons: true),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: Hero(
|
||||
tag: poster.id,
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: FladderImage(
|
||||
image: poster.getPosters?.primary ?? poster.getPosters?.backDrop?.lastOrNull,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
poster.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if ((poster.subText ?? poster.subTextShort(context))?.isNotEmpty == true)
|
||||
Opacity(
|
||||
opacity: 0.45,
|
||||
child: Text(
|
||||
poster.subText ?? poster.subTextShort(context) ?? "",
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
if (subTitle != null) ...[
|
||||
subTitle!,
|
||||
Spacer(),
|
||||
],
|
||||
if (poster.subText != null && poster.subText != poster.name)
|
||||
ClickableText(
|
||||
opacity: 0.45,
|
||||
text: poster.subText!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (poster.type == FladderItemType.book)
|
||||
if (poster.userData.progress > 0)
|
||||
Card(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Text(
|
||||
context.localized.page((poster as BookModel).currentPage),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onPrimary),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (poster.userData.isFavourite)
|
||||
Icon(
|
||||
IconsaxBold.heart,
|
||||
color: Colors.red,
|
||||
),
|
||||
if (AdaptiveLayout.of(context).isDesktop)
|
||||
Tooltip(
|
||||
message: context.localized.options,
|
||||
child: PopupMenuButton(
|
||||
tooltip: context.localized.options,
|
||||
icon: const Icon(
|
||||
Icons.more_vert,
|
||||
color: Colors.white,
|
||||
),
|
||||
itemBuilder: (context) => poster
|
||||
.generateActions(
|
||||
context,
|
||||
ref,
|
||||
exclude: excludeActions,
|
||||
otherActions: otherActions,
|
||||
onUserDataChanged: (newData) => onUserDataChanged?.call(poster.id, newData),
|
||||
onDeleteSuccesFully: onItemRemoved,
|
||||
onItemUpdated: onItemUpdated,
|
||||
)
|
||||
.popupMenuItems(useIcons: true),
|
||||
),
|
||||
)
|
||||
].addInBetween(SizedBox(width: 8)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
49
lib/screens/shared/media/poster_row.dart
Normal file
49
lib/screens/shared/media/poster_row.dart
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/screens/shared/media/poster_widget.dart';
|
||||
import 'package:fladder/widgets/shared/horizontal_list.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class PosterRow extends ConsumerStatefulWidget {
|
||||
final List<ItemBaseModel> posters;
|
||||
final String label;
|
||||
final Function()? onLabelClick;
|
||||
final EdgeInsets contentPadding;
|
||||
const PosterRow({
|
||||
required this.posters,
|
||||
this.contentPadding = const EdgeInsets.symmetric(horizontal: 16),
|
||||
required this.label,
|
||||
this.onLabelClick,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _PosterRowState();
|
||||
}
|
||||
|
||||
class _PosterRowState extends ConsumerState<PosterRow> {
|
||||
late final controller = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return HorizontalList(
|
||||
contentPadding: widget.contentPadding,
|
||||
label: widget.label,
|
||||
onLabelClick: widget.onLabelClick,
|
||||
items: widget.posters,
|
||||
itemBuilder: (context, index) {
|
||||
final poster = widget.posters[index];
|
||||
return PosterWidget(
|
||||
poster: poster,
|
||||
key: Key(poster.id),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
127
lib/screens/shared/media/poster_widget.dart
Normal file
127
lib/screens/shared/media/poster_widget.dart
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/models/items/item_shared_models.dart';
|
||||
import 'package:fladder/screens/shared/media/components/poster_image.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||
import 'package:fladder/util/item_base_model/play_item_helpers.dart';
|
||||
import 'package:fladder/widgets/shared/clickable_text.dart';
|
||||
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class PosterWidget extends ConsumerWidget {
|
||||
final ItemBaseModel poster;
|
||||
final Widget? subTitle;
|
||||
final bool? selected;
|
||||
final bool? heroTag;
|
||||
final int maxLines;
|
||||
final double? aspectRatio;
|
||||
final bool inlineTitle;
|
||||
final Set<ItemActions> excludeActions;
|
||||
final List<ItemAction> otherActions;
|
||||
final Function(String id, UserData? newData)? onUserDataChanged;
|
||||
final Function(ItemBaseModel newItem)? onItemUpdated;
|
||||
final Function(ItemBaseModel oldItem)? onItemRemoved;
|
||||
final Function(VoidCallback action, ItemBaseModel item)? onPressed;
|
||||
const PosterWidget(
|
||||
{required this.poster,
|
||||
this.subTitle,
|
||||
this.maxLines = 3,
|
||||
this.selected,
|
||||
this.heroTag,
|
||||
this.aspectRatio,
|
||||
this.inlineTitle = false,
|
||||
this.excludeActions = const {},
|
||||
this.otherActions = const [],
|
||||
this.onUserDataChanged,
|
||||
this.onItemUpdated,
|
||||
this.onItemRemoved,
|
||||
this.onPressed,
|
||||
super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final opacity = 0.65;
|
||||
return AspectRatio(
|
||||
aspectRatio: aspectRatio ?? AdaptiveLayout.poster(context).ratio,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Expanded(
|
||||
child: PosterImage(
|
||||
poster: poster,
|
||||
heroTag: heroTag ?? false,
|
||||
selected: selected,
|
||||
playVideo: (value) async => await poster.play(context, ref),
|
||||
inlineTitle: inlineTitle,
|
||||
excludeActions: excludeActions,
|
||||
otherActions: otherActions,
|
||||
onUserDataChanged: (newData) => onUserDataChanged?.call(poster.id, newData),
|
||||
onItemRemoved: onItemRemoved,
|
||||
onItemUpdated: onItemUpdated,
|
||||
onPressed: onPressed,
|
||||
),
|
||||
),
|
||||
if (!inlineTitle)
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ClickableText(
|
||||
onTap: AdaptiveLayout.of(context).layout != LayoutState.phone
|
||||
? () => poster.parentBaseModel.navigateTo(context)
|
||||
: null,
|
||||
text: poster.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
if (subTitle != null) ...[
|
||||
Opacity(
|
||||
opacity: opacity,
|
||||
child: subTitle!,
|
||||
),
|
||||
Spacer()
|
||||
],
|
||||
if (poster.subText?.isNotEmpty ?? false)
|
||||
Flexible(
|
||||
child: ClickableText(
|
||||
opacity: opacity,
|
||||
text: poster.subText ?? "",
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ClickableText(
|
||||
opacity: opacity,
|
||||
text: poster.subTextShort(context) ?? "",
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Flexible(
|
||||
child: ClickableText(
|
||||
opacity: opacity,
|
||||
text: poster.subText?.isNotEmpty ?? false ? poster.subTextShort(context) ?? "" : "",
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
].take(maxLines).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
186
lib/screens/shared/media/season_row.dart
Normal file
186
lib/screens/shared/media/season_row.dart
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/disable_keypad_focus.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
|
||||
import 'package:fladder/widgets/shared/status_card.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fladder/models/items/season_model.dart';
|
||||
import 'package:fladder/screens/shared/flat_button.dart';
|
||||
import 'package:fladder/widgets/shared/clickable_text.dart';
|
||||
import 'package:fladder/widgets/shared/horizontal_list.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class SeasonsRow extends ConsumerWidget {
|
||||
final EdgeInsets contentPadding;
|
||||
final ValueChanged<SeasonModel>? onSeasonPressed;
|
||||
final List<SeasonModel>? seasons;
|
||||
|
||||
const SeasonsRow({
|
||||
super.key,
|
||||
this.onSeasonPressed,
|
||||
required this.seasons,
|
||||
this.contentPadding = const EdgeInsets.symmetric(horizontal: 16),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return HorizontalList(
|
||||
label: context.localized.season(seasons?.length ?? 1),
|
||||
items: seasons ?? [],
|
||||
height: AdaptiveLayout.poster(context).size,
|
||||
contentPadding: contentPadding,
|
||||
itemBuilder: (
|
||||
context,
|
||||
index,
|
||||
) {
|
||||
final season = (seasons ?? [])[index];
|
||||
return SeasonPoster(
|
||||
season: season,
|
||||
onSeasonPressed: onSeasonPressed,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SeasonPoster extends ConsumerWidget {
|
||||
final SeasonModel season;
|
||||
final ValueChanged<SeasonModel>? onSeasonPressed;
|
||||
|
||||
const SeasonPoster({required this.season, this.onSeasonPressed, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
placeHolder(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Container(
|
||||
child: Card(
|
||||
color: Theme.of(context).colorScheme.surface.withOpacity(0.65),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
|
||||
child: Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return AspectRatio(
|
||||
aspectRatio: 0.6,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Card(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: FladderImage(
|
||||
image: season.getPosters?.primary ??
|
||||
season.parentImages?.backDrop?.firstOrNull ??
|
||||
season.parentImages?.primary,
|
||||
placeHolder: placeHolder(season.name),
|
||||
),
|
||||
),
|
||||
if (season.images?.primary == null)
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: placeHolder(season.name),
|
||||
),
|
||||
if (season.userData.unPlayedItemCount != 0)
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: StatusCard(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
child: Center(
|
||||
child: Text(
|
||||
season.userData.unPlayedItemCount.toString(),
|
||||
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: StatusCard(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
child: Icon(
|
||||
Icons.check_rounded,
|
||||
),
|
||||
),
|
||||
),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return FlatButton(
|
||||
onSecondaryTapDown: (details) {
|
||||
Offset localPosition = details.globalPosition;
|
||||
RelativeRect position = RelativeRect.fromLTRB(
|
||||
localPosition.dx - 260, localPosition.dy, localPosition.dx, localPosition.dy);
|
||||
showMenu(
|
||||
context: context,
|
||||
position: position,
|
||||
items: season.generateActions(context, ref).popupMenuItems(useIcons: true));
|
||||
},
|
||||
onTap: () => onSeasonPressed?.call(season),
|
||||
onLongPress: AdaptiveLayout.of(context).inputDevice != InputDevice.touch
|
||||
? () {
|
||||
showBottomSheetPill(
|
||||
context: context,
|
||||
content: (context, scrollController) => ListView(
|
||||
shrinkWrap: true,
|
||||
controller: scrollController,
|
||||
children:
|
||||
season.generateActions(context, ref).listTileItems(context, useIcons: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer)
|
||||
DisableFocus(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: PopupMenuButton(
|
||||
tooltip: context.localized.options,
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(color: Colors.black.withOpacity(0.45), blurRadius: 8.0),
|
||||
const Shadow(color: Colors.black, blurRadius: 16.0),
|
||||
const Shadow(color: Colors.black, blurRadius: 32.0),
|
||||
const Shadow(color: Colors.black, blurRadius: 64.0),
|
||||
],
|
||||
),
|
||||
itemBuilder: (context) => season.generateActions(context, ref).popupMenuItems(useIcons: true),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ClickableText(
|
||||
text: season.localizedName(context),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
38
lib/screens/shared/nested_bottom_appbar.dart
Normal file
38
lib/screens/shared/nested_bottom_appbar.dart
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/widgets/shared/shapes.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class NestedBottomAppBar extends ConsumerWidget {
|
||||
final Widget child;
|
||||
const NestedBottomAppBar({required this.child, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final double bottomPadding =
|
||||
(AdaptiveLayout.of(context).isDesktop || kIsWeb) ? 12 : MediaQuery.of(context).padding.bottom;
|
||||
return Card(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
shape: BottomBarShape(),
|
||||
elevation: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: SizedBox(
|
||||
height: kBottomNavigationBarHeight + 12 + bottomPadding,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12)
|
||||
.copyWith(
|
||||
bottom: bottomPadding,
|
||||
)
|
||||
.add(EdgeInsets.only(
|
||||
left: MediaQuery.of(context).padding.left,
|
||||
right: MediaQuery.of(context).padding.right,
|
||||
)),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
34
lib/screens/shared/nested_scaffold.dart
Normal file
34
lib/screens/shared/nested_scaffold.dart
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import 'package:fladder/models/media_playback_model.dart';
|
||||
import 'package:fladder/providers/video_player_provider.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/floating_player_bar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class NestedScaffold extends ConsumerWidget {
|
||||
final Widget body;
|
||||
const NestedScaffold({required this.body, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final playerState = ref.watch(mediaPlaybackProvider.select((value) => value.state));
|
||||
|
||||
return Card(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: switch (AdaptiveLayout.layoutOf(context)) {
|
||||
LayoutState.phone => null,
|
||||
_ => switch (playerState) {
|
||||
VideoPlayerState.minimized => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: FloatingPlayerBar(),
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
},
|
||||
body: body,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
83
lib/screens/shared/nested_sliver_appbar.dart
Normal file
83
lib/screens/shared/nested_sliver_appbar.dart
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/routes/build_routes/route_builder.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/settings_user_icon.dart';
|
||||
import 'package:fladder/widgets/shared/shapes.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class NestedSliverAppBar extends ConsumerWidget {
|
||||
final BuildContext parent;
|
||||
final String? searchTitle;
|
||||
final CustomRoute? route;
|
||||
const NestedSliverAppBar({required this.parent, this.route, this.searchTitle, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return SliverAppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
elevation: 16,
|
||||
forceElevated: true,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
shape: AppBarShape(),
|
||||
title: SizedBox(
|
||||
height: 65,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.surface),
|
||||
),
|
||||
onPressed: () => Scaffold.of(parent).openDrawer(),
|
||||
icon: Icon(
|
||||
IconsaxBold.menu,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Hero(
|
||||
tag: "PrimarySearch",
|
||||
child: Card(
|
||||
elevation: 3,
|
||||
shadowColor: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: route != null
|
||||
? () {
|
||||
context.routePushOrGo(route!);
|
||||
}
|
||||
: null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Opacity(
|
||||
opacity: 0.65,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(IconsaxOutline.search_normal),
|
||||
const SizedBox(width: 16),
|
||||
Transform.translate(
|
||||
offset: Offset(0, 2.5), child: Text(searchTitle ?? "${context.localized.search}...")),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsUserIcon()
|
||||
].addInBetween(const SizedBox(width: 16)),
|
||||
),
|
||||
),
|
||||
),
|
||||
toolbarHeight: 80,
|
||||
floating: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
177
lib/screens/shared/outlined_text_field.dart
Normal file
177
lib/screens/shared/outlined_text_field.dart
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import 'package:fladder/screens/shared/animated_fade_size.dart';
|
||||
import 'package:fladder/theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class OutlinedTextField extends ConsumerStatefulWidget {
|
||||
final String? label;
|
||||
final FocusNode? focusNode;
|
||||
final bool autoFocus;
|
||||
final TextEditingController? controller;
|
||||
final int maxLines;
|
||||
final Function()? onTap;
|
||||
final Function(String value)? onChanged;
|
||||
final Function(String value)? onSubmitted;
|
||||
final List<String>? autoFillHints;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final bool autocorrect;
|
||||
final TextStyle? style;
|
||||
final double borderWidth;
|
||||
final Color? fillColor;
|
||||
final TextAlign textAlign;
|
||||
final TextInputType? keyboardType;
|
||||
final TextInputAction? textInputAction;
|
||||
final String? errorText;
|
||||
final bool? enabled;
|
||||
|
||||
const OutlinedTextField({
|
||||
this.label,
|
||||
this.focusNode,
|
||||
this.autoFocus = false,
|
||||
this.controller,
|
||||
this.maxLines = 1,
|
||||
this.onTap,
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
this.fillColor,
|
||||
this.style,
|
||||
this.borderWidth = 1,
|
||||
this.textAlign = TextAlign.start,
|
||||
this.autoFillHints,
|
||||
this.inputFormatters,
|
||||
this.autocorrect = true,
|
||||
this.keyboardType,
|
||||
this.textInputAction,
|
||||
this.errorText,
|
||||
this.enabled,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _OutlinedTextFieldState();
|
||||
}
|
||||
|
||||
class _OutlinedTextFieldState extends ConsumerState<OutlinedTextField> {
|
||||
late FocusNode focusNode = widget.focusNode ?? FocusNode();
|
||||
bool _obscureText = true;
|
||||
void _toggle() {
|
||||
setState(() {
|
||||
_obscureText = !_obscureText;
|
||||
});
|
||||
}
|
||||
|
||||
Color getColor() {
|
||||
if (widget.errorText != null) return Theme.of(context).colorScheme.errorContainer;
|
||||
return Theme.of(context).colorScheme.secondaryContainer.withOpacity(0.25);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isPasswordField = widget.keyboardType == TextInputType.visiblePassword;
|
||||
if (widget.autoFocus) {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
focusNode.addListener(
|
||||
() {},
|
||||
);
|
||||
return Column(
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: AnimatedContainer(
|
||||
duration: Duration(milliseconds: 250),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.fillColor ?? getColor(),
|
||||
borderRadius: FladderTheme.defaultShape.borderRadius,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
ignoring: widget.enabled == false,
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
onChanged: widget.onChanged,
|
||||
focusNode: focusNode,
|
||||
onTap: widget.onTap,
|
||||
autofillHints: widget.autoFillHints,
|
||||
keyboardType: widget.keyboardType,
|
||||
autocorrect: widget.autocorrect,
|
||||
onSubmitted: widget.onSubmitted,
|
||||
textInputAction: widget.textInputAction,
|
||||
obscureText: isPasswordField ? _obscureText : false,
|
||||
style: widget.style,
|
||||
maxLines: widget.maxLines,
|
||||
inputFormatters: widget.inputFormatters,
|
||||
textAlign: widget.textAlign,
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0),
|
||||
width: widget.borderWidth,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0),
|
||||
width: widget.borderWidth,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0),
|
||||
width: widget.borderWidth,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0),
|
||||
width: widget.borderWidth,
|
||||
),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0),
|
||||
width: widget.borderWidth,
|
||||
),
|
||||
),
|
||||
filled: widget.fillColor != null,
|
||||
fillColor: widget.fillColor,
|
||||
labelText: widget.label,
|
||||
// errorText: widget.errorText,
|
||||
suffixIcon: isPasswordField
|
||||
? InkWell(
|
||||
onTap: _toggle,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
child: Icon(
|
||||
_obscureText ? Icons.visibility : Icons.visibility_off,
|
||||
size: 16.0,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
AnimatedFadeSize(
|
||||
child: widget.errorText != null
|
||||
? Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
widget.errorText ?? "",
|
||||
style:
|
||||
Theme.of(context).textTheme.labelMedium?.copyWith(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
)
|
||||
: Container(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue