Init repo

This commit is contained in:
PartyDonut 2024-09-15 14:12:28 +02:00
commit 764b6034e3
566 changed files with 212335 additions and 0 deletions

View 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),
),
);
}
}

View 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,
),
);
}
}

View 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)),
),
);
}

View 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));
},
)));
}
}

View 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"),
),
],
),
);
}

View 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"),
},
);
}
}

View 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),
),
),
],
),
),
),
),
],
),
),
),
],
),
),
);
}
}

View 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;
}

View 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),
),
);
}
}

View 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,
)
],
),
);
}
}

View 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,
// ),
// )
// ],
// ),
// ),
// ),
// ),
// ),
// ),
// ),
// );
// }
// }

View 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(),
),
);
}
}

View 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() ?? ""),
),
),
),
),
],
),
),
),
),
),
);
}
}

View 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,
);
}
}

View 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,
),
),
),
);
}
}

View 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);
}
}

View 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,
);
}
}

View 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),
),
);
}
}

View 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,
),
),
),
),
),
);
}
}

View 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(),
),
);
}
}

View 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),
],
),
),
],
);
}
},
),
],
);
}
}

View 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,
),
],
),
),
),
),
)
}
],
),
),
),
);
}
}

View file

@ -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(),
);
}
}

View 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,
);
},
)
},
);
}
}

View 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,
),
),
],
),
}
],
),
);
}
}

View 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),
),
),
),
),
},
],
),
);
}
}

View 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());
}
}
}

View 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(),
],
),
),
],
),
);
}
}

View 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),
),
],
),
);
},
);
}
}

View 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),
));
}
}

View 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,
],
);
}
}
}

View 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)),
),
),
),
),
),
),
);
}
}

View 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),
);
},
);
}
}

View 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(),
),
],
),
);
}
}

View 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),
),
],
),
);
}
}

View 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,
),
),
),
);
}
}

View 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,
),
);
}
}

View 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,
);
}
}

View 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(),
),
],
);
}
}

View file

@ -0,0 +1,173 @@
import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class PassCodeInput extends ConsumerStatefulWidget {
final ValueChanged<String> passCode;
const PassCodeInput({required this.passCode, super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _PassCodeInputState();
}
class _PassCodeInputState extends ConsumerState<PassCodeInput> {
final iconSize = 45.0;
final passCodeLength = 4;
var currentPasscode = "";
final focusNode = FocusNode();
@override
Widget build(BuildContext context) {
focusNode.requestFocus();
return KeyboardListener(
focusNode: focusNode,
autofocus: true,
onKeyEvent: (value) {
if (value is KeyDownEvent) {
final keyInt = int.tryParse(value.logicalKey.keyLabel);
if (keyInt != null) {
addToPassCode(value.logicalKey.keyLabel);
}
if (value.logicalKey == LogicalKeyboardKey.backspace) {
backSpace();
}
}
},
child: AlertDialog.adaptive(
scrollable: true,
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(
passCodeLength,
(index) => Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: SizedBox(
height: iconSize * 1.2,
width: iconSize * 1.2,
child: Card(
child: Transform.translate(
offset: const Offset(0, 5),
child: AnimatedFadeSize(
child: Text(
currentPasscode.length > index ? "*" : "",
style: Theme.of(context).textTheme.displayLarge?.copyWith(fontSize: 60),
),
),
),
),
),
),
),
).toList(),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.of([1, 2, 3]).map((e) => passCodeNumber(e)).toList(),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.of([4, 5, 6]).map((e) => passCodeNumber(e)).toList(),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.of([7, 8, 9]).map((e) => passCodeNumber(e)).toList(),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
backSpaceButton,
passCodeNumber(0),
clearAllButton,
],
)
].addPadding(const EdgeInsets.symmetric(vertical: 8)),
),
),
);
}
Widget passCodeNumber(int value) {
return IconButton.filledTonal(
onPressed: () async {
addToPassCode(value.toString());
},
icon: Container(
width: iconSize,
height: iconSize,
alignment: Alignment.center,
child: Text(
value.toString(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(fontWeight: FontWeight.bold),
),
),
);
}
void addToPassCode(String value) async {
String newPasscode = currentPasscode + value.toString();
if (newPasscode.length == passCodeLength) {
Navigator.of(context).pop();
await Future.delayed(const Duration(milliseconds: 250));
widget.passCode(newPasscode);
} else {
setState(() {
currentPasscode = newPasscode;
});
}
}
void backSpace() {
setState(() {
if (currentPasscode.isNotEmpty) {
currentPasscode = currentPasscode.substring(0, currentPasscode.length - 1);
}
});
}
Widget get clearAllButton {
return IconButton.filled(
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.errorContainer),
iconColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onErrorContainer),
),
onPressed: () {
setState(() {
currentPasscode = "";
});
},
icon: Icon(
Icons.clear_rounded,
size: iconSize,
),
);
}
Widget get backSpaceButton {
return IconButton.filled(
onPressed: () => backSpace(),
icon: Icon(
Icons.backspace_rounded,
size: iconSize,
),
);
}
}
void showPassCodeDialog(BuildContext context, ValueChanged<String> newPin) {
showDialog(
context: context,
builder: (context) => PassCodeInput(
passCode: (value) {
newPin.call(value);
},
),
);
}

View file

@ -0,0 +1,72 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:fladder/models/account_model.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/util/string_extensions.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class UserIcon extends ConsumerWidget {
final AccountModel? user;
final Size size;
final TextStyle? labelStyle;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final double cornerRadius;
const UserIcon({
this.size = const Size(50, 50),
this.labelStyle,
this.cornerRadius = 5,
this.onTap,
this.onLongPress,
required this.user,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget placeHolder() {
return Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: Center(
child: Text(
user?.name.getInitials() ?? "",
style: (labelStyle ?? Theme.of(context).textTheme.titleMedium)?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
);
}
return Hero(
tag: Key(user?.id ?? "empty-user-avatar"),
child: AspectRatio(
aspectRatio: 1,
child: Card(
elevation: 0,
surfaceTintColor: Colors.transparent,
color: Colors.transparent,
clipBehavior: Clip.antiAlias,
child: SizedBox.fromSize(
size: size,
child: Stack(
alignment: Alignment.center,
children: [
CachedNetworkImage(
imageUrl: user?.avatar ?? "",
progressIndicatorBuilder: (context, url, progress) => placeHolder(),
errorWidget: (context, url, error) => placeHolder(),
),
FlatButton(
onTap: onTap,
onLongPress: onLongPress,
)
],
),
),
),
),
);
}
}