mirror of
https://github.com/gabehf/Fladder.git
synced 2026-03-08 23:18:16 -07:00
Init repo
This commit is contained in:
commit
764b6034e3
566 changed files with 212335 additions and 0 deletions
23
lib/screens/shared/adaptive_dialog.dart
Normal file
23
lib/screens/shared/adaptive_dialog.dart
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Future<void> showDialogAdaptive(
|
||||
{required BuildContext context, bool useSafeArea = true, required Widget Function(BuildContext context) builder}) {
|
||||
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
useSafeArea: useSafeArea,
|
||||
builder: (context) => Dialog(
|
||||
child: builder(context),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return showDialog(
|
||||
context: context,
|
||||
useSafeArea: useSafeArea,
|
||||
builder: (context) => Dialog.fullscreen(
|
||||
child: builder(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
26
lib/screens/shared/animated_fade_size.dart
Normal file
26
lib/screens/shared/animated_fade_size.dart
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class AnimatedFadeSize extends ConsumerWidget {
|
||||
final Duration duration;
|
||||
final Widget child;
|
||||
const AnimatedFadeSize({
|
||||
this.duration = const Duration(milliseconds: 125),
|
||||
required this.child,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return AnimatedSize(
|
||||
duration: duration,
|
||||
curve: Curves.easeInOutCubic,
|
||||
child: AnimatedSwitcher(
|
||||
duration: duration,
|
||||
switchInCurve: Curves.easeInOutCubic,
|
||||
switchOutCurve: Curves.easeInOutCubic,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
72
lib/screens/shared/authenticate_button_options.dart
Normal file
72
lib/screens/shared/authenticate_button_options.dart
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/account_model.dart';
|
||||
import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
||||
import 'package:fladder/screens/shared/passcode_input.dart';
|
||||
import 'package:fladder/util/auth_service.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
showAuthOptionsDialogue(
|
||||
BuildContext context,
|
||||
AccountModel currentUser,
|
||||
Function(AccountModel) setMethod,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog.adaptive(
|
||||
scrollable: true,
|
||||
icon: const Icon(IconsaxBold.lock_1),
|
||||
title: Text(context.localized.appLockTitle(currentUser.name)),
|
||||
actionsOverflowDirection: VerticalDirection.down,
|
||||
actions: Authentication.values
|
||||
.where((element) => element.available(context))
|
||||
.map(
|
||||
(method) => SizedBox(
|
||||
height: 50,
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
switch (method) {
|
||||
case Authentication.autoLogin:
|
||||
setMethod.call(currentUser.copyWith(authMethod: method));
|
||||
break;
|
||||
case Authentication.biometrics:
|
||||
final authenticated = await AuthService.authenticateUser(context, currentUser);
|
||||
if (authenticated) {
|
||||
setMethod.call(currentUser.copyWith(authMethod: method));
|
||||
} else if (context.mounted) {
|
||||
fladderSnackbar(context, title: context.localized.biometricsFailedCheckAgain);
|
||||
}
|
||||
break;
|
||||
case Authentication.passcode:
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
Future.microtask(() {
|
||||
showPassCodeDialog(context, (newPin) {
|
||||
setMethod.call(currentUser.copyWith(authMethod: method, localPin: newPin));
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
case Authentication.none:
|
||||
setMethod.call(currentUser.copyWith(authMethod: method));
|
||||
break;
|
||||
}
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
icon: Icon(method.icon),
|
||||
label: Text(
|
||||
method.name(context),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
.addPadding(const EdgeInsets.symmetric(vertical: 8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
264
lib/screens/shared/chips/category_chip.dart
Normal file
264
lib/screens/shared/chips/category_chip.dart
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/map_bool_helper.dart';
|
||||
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
|
||||
import 'package:fladder/widgets/shared/modal_side_sheet.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CategoryChip<T> extends StatelessWidget {
|
||||
final Map<T, bool> items;
|
||||
final Widget label;
|
||||
final Widget? dialogueTitle;
|
||||
final Widget Function(T item) labelBuilder;
|
||||
final IconData? activeIcon;
|
||||
final Function(Map<T, bool> value)? onSave;
|
||||
final VoidCallback? onCancel;
|
||||
final VoidCallback? onClear;
|
||||
final VoidCallback? onDismiss;
|
||||
|
||||
const CategoryChip({
|
||||
required this.label,
|
||||
this.dialogueTitle,
|
||||
this.activeIcon,
|
||||
required this.items,
|
||||
required this.labelBuilder,
|
||||
this.onSave,
|
||||
this.onCancel,
|
||||
this.onClear,
|
||||
this.onDismiss,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var selection = items.included.isNotEmpty;
|
||||
return FilterChip(
|
||||
selected: selection,
|
||||
showCheckmark: activeIcon == null,
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (activeIcon != null)
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: selection
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Icon(
|
||||
activeIcon!,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: const SizedBox(),
|
||||
),
|
||||
label,
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.arrow_drop_down_rounded,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
onSelected: items.isNotEmpty
|
||||
? (_) async {
|
||||
final newEntry = await openActionSheet(context);
|
||||
if (newEntry != null) {
|
||||
onSave?.call(newEntry);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<T, bool>?> openActionSheet(BuildContext context) async {
|
||||
Map<T, bool>? newEntry;
|
||||
List<Widget> actions() => [
|
||||
FilledButton.tonal(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
newEntry = null;
|
||||
onCancel?.call();
|
||||
},
|
||||
child: Text(context.localized.cancel),
|
||||
),
|
||||
if (onClear != null)
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
newEntry = null;
|
||||
onClear!();
|
||||
},
|
||||
icon: const Icon(IconsaxOutline.back_square),
|
||||
label: Text(context.localized.clear),
|
||||
)
|
||||
].addInBetween(const SizedBox(width: 6));
|
||||
Widget header() => Row(
|
||||
children: [
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
textStyle: Theme.of(context).textTheme.titleLarge,
|
||||
child: dialogueTitle ?? label,
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton.tonal(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
newEntry = null;
|
||||
onCancel?.call();
|
||||
},
|
||||
child: Text(context.localized.cancel),
|
||||
),
|
||||
if (onClear != null)
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
newEntry = null;
|
||||
onClear!();
|
||||
},
|
||||
icon: const Icon(IconsaxOutline.back_square),
|
||||
label: Text(context.localized.clear),
|
||||
)
|
||||
].addInBetween(const SizedBox(width: 6)),
|
||||
);
|
||||
|
||||
if (AdaptiveLayout.of(context).layout != LayoutState.phone) {
|
||||
await showModalSideSheet(
|
||||
context,
|
||||
addDivider: true,
|
||||
header: dialogueTitle ?? label,
|
||||
actions: actions(),
|
||||
content: CategoryChipEditor(
|
||||
labelBuilder: labelBuilder,
|
||||
items: items,
|
||||
onChanged: (value) {
|
||||
newEntry = value;
|
||||
}),
|
||||
onDismiss: () {
|
||||
if (newEntry != null) {
|
||||
onSave?.call(newEntry!);
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await showBottomSheetPill(
|
||||
context: context,
|
||||
content: (context, scrollController) => ListView(
|
||||
shrinkWrap: true,
|
||||
controller: scrollController,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: header(),
|
||||
),
|
||||
const Divider(),
|
||||
CategoryChipEditor(
|
||||
labelBuilder: labelBuilder,
|
||||
controller: scrollController,
|
||||
items: items,
|
||||
onChanged: (value) => newEntry = value),
|
||||
],
|
||||
),
|
||||
onDismiss: () {
|
||||
if (newEntry != null) {
|
||||
onSave?.call(newEntry!);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
return newEntry;
|
||||
}
|
||||
}
|
||||
|
||||
class CategoryChipEditor<T> extends StatefulWidget {
|
||||
final Map<T, bool> items;
|
||||
final Widget Function(T item) labelBuilder;
|
||||
final Function(Map<T, bool> value) onChanged;
|
||||
final ScrollController? controller;
|
||||
const CategoryChipEditor({
|
||||
required this.items,
|
||||
required this.labelBuilder,
|
||||
required this.onChanged,
|
||||
this.controller,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CategoryChipEditor<T>> createState() => _CategoryChipEditorState<T>();
|
||||
}
|
||||
|
||||
class _CategoryChipEditorState<T> extends State<CategoryChipEditor<T>> {
|
||||
late Map<T, bool?> currentState = Map.fromEntries(widget.items.entries);
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Iterable<MapEntry<T, bool>> activeItems = widget.items.entries.where((element) => element.value);
|
||||
Iterable<MapEntry<T, bool>> otherItems = widget.items.entries.where((element) => !element.value);
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
controller: widget.controller,
|
||||
children: [
|
||||
if (activeItems.isNotEmpty == true) ...{
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
context.localized.active,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
...activeItems.mapIndexed((index, element) {
|
||||
return CheckboxListTile.adaptive(
|
||||
value: currentState[element.key],
|
||||
title: widget.labelBuilder(element.key),
|
||||
fillColor: WidgetStateProperty.resolveWith(
|
||||
(states) {
|
||||
if (currentState[element.key] == null) {
|
||||
return Colors.redAccent;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
tristate: true,
|
||||
onChanged: (value) => updateKey(MapEntry(element.key, value == null ? null : element.value)),
|
||||
);
|
||||
}),
|
||||
Divider(),
|
||||
},
|
||||
...otherItems.mapIndexed((index, element) {
|
||||
return CheckboxListTile.adaptive(
|
||||
value: currentState[element.key],
|
||||
title: widget.labelBuilder(element.key),
|
||||
fillColor: WidgetStateProperty.resolveWith(
|
||||
(states) {
|
||||
if (currentState[element.key] == null || states.contains(WidgetState.selected)) {
|
||||
return Colors.greenAccent;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
tristate: true,
|
||||
onChanged: (value) => updateKey(MapEntry(element.key, value != false ? null : element.value)),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void updateKey(MapEntry<T, bool?> entry) {
|
||||
setState(() {
|
||||
currentState.update(
|
||||
entry.key,
|
||||
(value) => entry.value,
|
||||
);
|
||||
});
|
||||
widget.onChanged(Map.from(currentState.map(
|
||||
(key, value) {
|
||||
final origKey = widget.items[key] == true;
|
||||
return MapEntry(key, origKey ? (value == null ? false : origKey) : (value == null ? true : origKey));
|
||||
},
|
||||
)));
|
||||
}
|
||||
}
|
||||
71
lib/screens/shared/default_alert_dialog.dart
Normal file
71
lib/screens/shared/default_alert_dialog.dart
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Future<void> showDefaultAlertDialog(
|
||||
BuildContext context,
|
||||
String title,
|
||||
String? content,
|
||||
FutureOr Function(BuildContext context)? accept,
|
||||
String? acceptTitle,
|
||||
FutureOr Function(BuildContext context)? decline,
|
||||
String declineTitle,
|
||||
) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog.adaptive(
|
||||
title: Text(title),
|
||||
content: content != null ? Text(content) : null,
|
||||
actions: [
|
||||
if (decline != null)
|
||||
ElevatedButton(
|
||||
onPressed: () => decline.call(context),
|
||||
child: Text(declineTitle),
|
||||
),
|
||||
if (accept != null)
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.errorContainer,
|
||||
foregroundColor: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
onPressed: () => accept.call(context),
|
||||
child: Text(acceptTitle ?? "Accept"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showDefaultActionDialog(
|
||||
BuildContext context,
|
||||
String title,
|
||||
String? content,
|
||||
FutureOr Function(BuildContext context)? accept,
|
||||
String? acceptTitle,
|
||||
FutureOr Function(BuildContext context)? decline,
|
||||
String declineTitle,
|
||||
) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog.adaptive(
|
||||
title: Text(title),
|
||||
content: content != null ? Text(content) : null,
|
||||
actions: [
|
||||
if (decline != null)
|
||||
ElevatedButton(
|
||||
onPressed: () => decline.call(context),
|
||||
child: Text(declineTitle),
|
||||
),
|
||||
if (accept != null)
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
onPressed: () => accept.call(context),
|
||||
child: Text(acceptTitle ?? "Accept"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
169
lib/screens/shared/default_titlebar.dart
Normal file
169
lib/screens/shared/default_titlebar.dart
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
|
||||
class DefaultTitleBar extends ConsumerStatefulWidget {
|
||||
final String? label;
|
||||
final double? height;
|
||||
final Brightness? brightness;
|
||||
const DefaultTitleBar({this.height = 35, this.label, this.brightness, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _DefaultTitleBarState();
|
||||
}
|
||||
|
||||
class _DefaultTitleBarState extends ConsumerState<DefaultTitleBar> with WindowListener {
|
||||
@override
|
||||
void initState() {
|
||||
windowManager.addListener(this);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
windowManager.removeListener(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final brightness = widget.brightness ?? Theme.of(context).brightness;
|
||||
final shadows = brightness == Brightness.dark
|
||||
? [
|
||||
BoxShadow(blurRadius: 1, spreadRadius: 1, color: Theme.of(context).colorScheme.surface.withOpacity(1)),
|
||||
BoxShadow(blurRadius: 8, spreadRadius: 2, color: Colors.black.withOpacity(0.2)),
|
||||
BoxShadow(blurRadius: 3, spreadRadius: 2, color: Colors.black.withOpacity(0.3)),
|
||||
]
|
||||
: <BoxShadow>[];
|
||||
final iconColor = Theme.of(context).colorScheme.onSurface.withOpacity(0.65);
|
||||
return SizedBox(
|
||||
height: widget.height,
|
||||
child: switch (AdaptiveLayout.of(context).platform) {
|
||||
TargetPlatform.windows || TargetPlatform.linux => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DragToMoveArea(
|
||||
child: Container(
|
||||
color: Colors.red.withOpacity(0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
color: iconColor,
|
||||
fontSize: 14,
|
||||
),
|
||||
child: Text(widget.label ?? ""),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
FutureBuilder(
|
||||
future: windowManager.isMinimizable(),
|
||||
builder: (context, data) {
|
||||
final isMinimized = !(data.data ?? false);
|
||||
return IconButton(
|
||||
style: IconButton.styleFrom(
|
||||
hoverColor: brightness == Brightness.light
|
||||
? Colors.black.withOpacity(0.1)
|
||||
: Colors.white.withOpacity(0.2),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2))),
|
||||
onPressed: () async {
|
||||
if (isMinimized) {
|
||||
windowManager.restore();
|
||||
} else {
|
||||
windowManager.minimize();
|
||||
}
|
||||
},
|
||||
icon: Transform.translate(
|
||||
offset: Offset(0, -2),
|
||||
child: Icon(
|
||||
Icons.minimize_rounded,
|
||||
color: iconColor,
|
||||
size: 20,
|
||||
shadows: shadows,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
FutureBuilder<List<bool>>(
|
||||
future: Future.microtask(() async {
|
||||
final isMaximized = await windowManager.isMaximized();
|
||||
final isFullScreen = await windowManager.isFullScreen();
|
||||
return [isMaximized, isFullScreen];
|
||||
}),
|
||||
builder: (BuildContext context, AsyncSnapshot<List<bool>> snapshot) {
|
||||
final maximized = snapshot.data?.firstOrNull ?? false;
|
||||
final fullScreen = snapshot.data?.lastOrNull ?? false;
|
||||
return IconButton(
|
||||
style: IconButton.styleFrom(
|
||||
hoverColor: brightness == Brightness.light
|
||||
? Colors.black.withOpacity(0.1)
|
||||
: Colors.white.withOpacity(0.2),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)),
|
||||
),
|
||||
onPressed: () async {
|
||||
if (fullScreen == true && maximized == true) {
|
||||
await windowManager.setFullScreen(false);
|
||||
await windowManager.unmaximize();
|
||||
return;
|
||||
}
|
||||
if (fullScreen == true) {
|
||||
windowManager.setFullScreen(false);
|
||||
} else {
|
||||
maximized == false ? windowManager.maximize() : windowManager.unmaximize();
|
||||
}
|
||||
},
|
||||
icon: Transform.translate(
|
||||
offset: Offset(0, 0),
|
||||
child: Icon(
|
||||
maximized ? Icons.maximize_rounded : Icons.crop_square_rounded,
|
||||
color: iconColor,
|
||||
size: 19,
|
||||
shadows: shadows,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
style: IconButton.styleFrom(
|
||||
hoverColor: Colors.red,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
onPressed: () async {
|
||||
windowManager.close();
|
||||
},
|
||||
icon: Transform.translate(
|
||||
offset: Offset(0, -2),
|
||||
child: Icon(
|
||||
Icons.close_rounded,
|
||||
color: iconColor,
|
||||
size: 23,
|
||||
shadows: shadows,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
TargetPlatform.macOS => null,
|
||||
_ => Text(widget.label ?? "Fladder"),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
303
lib/screens/shared/detail_scaffold.dart
Normal file
303
lib/screens/shared/detail_scaffold.dart
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/screens/shared/animated_fade_size.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/settings_user_icon.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/models/items/images_models.dart';
|
||||
import 'package:fladder/models/media_playback_model.dart';
|
||||
import 'package:fladder/providers/items/item_details_provider.dart';
|
||||
import 'package:fladder/providers/video_player_provider.dart';
|
||||
import 'package:fladder/routes/build_routes/home_routes.dart';
|
||||
import 'package:fladder/routes/build_routes/route_builder.dart';
|
||||
import 'package:fladder/theme.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:fladder/util/refresh_state.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/floating_player_bar.dart';
|
||||
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
|
||||
import 'package:fladder/widgets/shared/pull_to_refresh.dart';
|
||||
|
||||
class DetailScreen extends ConsumerStatefulWidget {
|
||||
final String id;
|
||||
final ItemBaseModel? item;
|
||||
const DetailScreen({required this.id, this.item, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _DetailScreenState();
|
||||
}
|
||||
|
||||
class _DetailScreenState extends ConsumerState<DetailScreen> {
|
||||
late Widget currentWidget = const Center(
|
||||
key: Key("progress-indicator"),
|
||||
child: CircularProgressIndicator.adaptive(strokeCap: StrokeCap.round),
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() async {
|
||||
if (widget.item != null) {
|
||||
setState(() {
|
||||
currentWidget = widget.item!.detailScreenWidget;
|
||||
});
|
||||
} else {
|
||||
final response = await ref.read(itemDetailsProvider.notifier).fetchDetails(widget.id);
|
||||
if (context.mounted) {
|
||||
if (response != null) {
|
||||
setState(() {
|
||||
currentWidget = response.detailScreenWidget;
|
||||
});
|
||||
} else {
|
||||
context.routeGo(DashboardRoute());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Hero(
|
||||
tag: widget.id,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface.withOpacity(1.0),
|
||||
),
|
||||
//Small offset to match detailscaffold
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, -5), child: FladderImage(image: widget.item?.getPosters?.primary)),
|
||||
),
|
||||
),
|
||||
AnimatedFadeSize(
|
||||
duration: const Duration(seconds: 1),
|
||||
child: currentWidget,
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DetailScaffold extends ConsumerStatefulWidget {
|
||||
final String label;
|
||||
final ItemBaseModel? item;
|
||||
final List<ItemAction>? Function(BuildContext context)? actions;
|
||||
final Color? backgroundColor;
|
||||
final ImagesData? backDrops;
|
||||
final Function(EdgeInsets padding) content;
|
||||
final Future<void> Function()? onRefresh;
|
||||
const DetailScaffold({
|
||||
required this.label,
|
||||
this.item,
|
||||
this.actions,
|
||||
this.backgroundColor,
|
||||
required this.content,
|
||||
this.backDrops,
|
||||
this.onRefresh,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _DetailScaffoldState();
|
||||
}
|
||||
|
||||
class _DetailScaffoldState extends ConsumerState<DetailScaffold> {
|
||||
List<ImageData>? lastImages;
|
||||
ImageData? backgroundImage;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant DetailScaffold oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (lastImages == null) {
|
||||
lastImages = widget.backDrops?.backDrop;
|
||||
setState(() {
|
||||
backgroundImage = widget.backDrops?.randomBackDrop;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final padding = EdgeInsets.symmetric(horizontal: MediaQuery.of(context).size.width / 25);
|
||||
final backGroundColor = Theme.of(context).colorScheme.surface.withOpacity(0.8);
|
||||
final playerState = ref.watch(mediaPlaybackProvider.select((value) => value.state));
|
||||
return PullToRefresh(
|
||||
onRefresh: () async {
|
||||
await widget.onRefresh?.call();
|
||||
setState(() {
|
||||
if (widget.backDrops?.backDrop?.contains(backgroundImage) == true) {
|
||||
backgroundImage = widget.backDrops?.randomBackDrop;
|
||||
}
|
||||
});
|
||||
},
|
||||
refreshOnStart: true,
|
||||
child: Scaffold(
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: switch (playerState) {
|
||||
VideoPlayerState.minimized => Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: FloatingPlayerBar(),
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
extendBodyBehindAppBar: true,
|
||||
body: Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: Stack(
|
||||
alignment: Alignment.topCenter,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).size.height - 10,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: FladderImage(
|
||||
image: backgroundImage,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: MediaQuery.of(context).size.height,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Theme.of(context).colorScheme.surface.withOpacity(0),
|
||||
Theme.of(context).colorScheme.surface.withOpacity(0.10),
|
||||
Theme.of(context).colorScheme.surface.withOpacity(0.35),
|
||||
Theme.of(context).colorScheme.surface.withOpacity(0.85),
|
||||
Theme.of(context).colorScheme.surface,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: MediaQuery.of(context).size.height,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
color: widget.backgroundColor,
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 0,
|
||||
left: MediaQuery.of(context).padding.left,
|
||||
top: MediaQuery.of(context).padding.top + 50),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: MediaQuery.of(context).size.height,
|
||||
maxWidth: MediaQuery.of(context).size.width,
|
||||
),
|
||||
child: widget.content(padding),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
//Top row buttons
|
||||
IconTheme(
|
||||
data: IconThemeData(color: Theme.of(context).colorScheme.onSurface),
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, kToolbarHeight),
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 16),
|
||||
child: IconButton.filledTonal(
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: backGroundColor,
|
||||
),
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.replace(DashboardRoute().route);
|
||||
}
|
||||
},
|
||||
icon: Padding(
|
||||
padding:
|
||||
EdgeInsets.all(AdaptiveLayout.of(context).inputDevice == InputDevice.pointer ? 0 : 4),
|
||||
child: Icon(IconsaxOutline.arrow_left_2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backGroundColor, borderRadius: FladderTheme.defaultShape.borderRadius),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.item != null) ...[
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final newActions = widget.actions?.call(context);
|
||||
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) {
|
||||
return PopupMenuButton(
|
||||
tooltip: context.localized.moreOptions,
|
||||
enabled: newActions?.isNotEmpty == true,
|
||||
icon: Icon(widget.item!.type.icon),
|
||||
itemBuilder: (context) => newActions?.popupMenuItems(useIcons: true) ?? [],
|
||||
);
|
||||
} else {
|
||||
return IconButton(
|
||||
onPressed: () => showBottomSheetPill(
|
||||
context: context,
|
||||
content: (context, scrollController) => ListView(
|
||||
controller: scrollController,
|
||||
shrinkWrap: true,
|
||||
children: newActions?.listTileItems(context, useIcons: true) ?? [],
|
||||
),
|
||||
),
|
||||
icon: Icon(
|
||||
widget.item!.type.icon,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer)
|
||||
Builder(
|
||||
builder: (context) => Tooltip(
|
||||
message: context.localized.refresh,
|
||||
child: IconButton(
|
||||
onPressed: () => context.refreshData(),
|
||||
icon: Icon(IconsaxOutline.refresh),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(height: 30, width: 30, child: SettingsUserIcon()),
|
||||
Tooltip(
|
||||
message: context.localized.home,
|
||||
child: IconButton(
|
||||
onPressed: () => context.routeGo(DashboardRoute()),
|
||||
icon: Icon(IconsaxOutline.home),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
198
lib/screens/shared/file_picker.dart
Normal file
198
lib/screens/shared/file_picker.dart
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/screens/shared/outlined_text_field.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class FladderFile {
|
||||
final String name;
|
||||
final String? path;
|
||||
final Uint8List? data;
|
||||
FladderFile({
|
||||
required this.name,
|
||||
this.path,
|
||||
this.data,
|
||||
});
|
||||
|
||||
static final Set<String> imageTypes = {
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"webp",
|
||||
"gif",
|
||||
};
|
||||
|
||||
@override
|
||||
String toString() => 'FladderFile(name: $name, path: $path, data: ${data?.length})';
|
||||
}
|
||||
|
||||
class FilePickerBar extends ConsumerStatefulWidget {
|
||||
final Function(List<FladderFile> file)? onFilesPicked;
|
||||
final Function(String url)? urlPicked;
|
||||
final Set<String> extensions;
|
||||
final bool multipleFiles;
|
||||
final double stripesAngle;
|
||||
const FilePickerBar({
|
||||
this.onFilesPicked,
|
||||
this.urlPicked,
|
||||
this.multipleFiles = false,
|
||||
this.stripesAngle = -0.90,
|
||||
this.extensions = const {},
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _FilePickerBarState();
|
||||
}
|
||||
|
||||
class _FilePickerBarState extends ConsumerState<FilePickerBar> {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
bool dragStart = false;
|
||||
bool inputField = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final offColor = Theme.of(context).colorScheme.secondaryContainer;
|
||||
final onColor = Theme.of(context).colorScheme.secondaryContainer.withOpacity(0.7);
|
||||
final contentColor = Theme.of(context).colorScheme.onSecondaryContainer;
|
||||
return DropTarget(
|
||||
enable: !inputField,
|
||||
onDragEntered: (details) => setState(() => dragStart = true),
|
||||
onDragDone: (details) async {
|
||||
if (widget.multipleFiles) {
|
||||
List<FladderFile> newFiles = [];
|
||||
await Future.forEach(details.files, (element) async {
|
||||
if (widget.extensions.contains(p.extension(element.path).substring(1))) {
|
||||
newFiles.add(
|
||||
FladderFile(
|
||||
name: element.name,
|
||||
path: element.path,
|
||||
data: await element.readAsBytes(),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
widget.onFilesPicked?.call(newFiles);
|
||||
} else {
|
||||
final file = details.files.lastOrNull;
|
||||
if (file != null) {
|
||||
widget.onFilesPicked?.call([
|
||||
FladderFile(
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
data: await file.readAsBytes(),
|
||||
)
|
||||
]);
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragExited: (details) => setState(() => dragStart = false),
|
||||
child: Container(
|
||||
constraints: BoxConstraints(minHeight: 50, minWidth: 50),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment(widget.stripesAngle, -0),
|
||||
stops: [0.0, 0.5, 0.5, 1],
|
||||
colors: [offColor, offColor, onColor, onColor],
|
||||
tileMode: TileMode.repeated,
|
||||
),
|
||||
),
|
||||
child: AnimatedSwitcher(
|
||||
duration: Duration(milliseconds: 250),
|
||||
child: inputField
|
||||
? OutlinedTextField(
|
||||
controller: controller,
|
||||
autoFocus: true,
|
||||
onSubmitted: (value) {
|
||||
if (_parseUrl(value)) {
|
||||
widget.urlPicked?.call(value);
|
||||
}
|
||||
controller.text = "";
|
||||
setState(() => inputField = false);
|
||||
},
|
||||
)
|
||||
: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
if (AdaptiveLayout.of(context).isDesktop || kIsWeb)
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.multipleFiles ? "drop multiple file(s)" : "drop a file",
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: contentColor),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Icon(
|
||||
IconsaxBold.folder_add,
|
||||
color: contentColor,
|
||||
)
|
||||
],
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => setState(() => inputField = true),
|
||||
child: Text(
|
||||
"enter a url",
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: contentColor),
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: dragStart
|
||||
? null
|
||||
: () async {
|
||||
FilePickerResult? result = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: widget.multipleFiles,
|
||||
allowedExtensions: widget.extensions.toList(),
|
||||
type: FileType.custom,
|
||||
withData: true,
|
||||
);
|
||||
if (result != null && result.count != 0) {
|
||||
List<FladderFile> newFiles = [];
|
||||
await Future.forEach(result.files, (element) async {
|
||||
newFiles.add(
|
||||
FladderFile(
|
||||
name: element.name,
|
||||
path: element.path,
|
||||
data: element.bytes,
|
||||
),
|
||||
);
|
||||
});
|
||||
widget.onFilesPicked?.call(newFiles);
|
||||
}
|
||||
FilePicker.platform.clearTemporaryFiles();
|
||||
},
|
||||
child: Text(
|
||||
widget.multipleFiles ? "file(s) picker" : "file picker",
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _parseUrl(String url) {
|
||||
if (url.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
if (!Uri.parse(url).isAbsolute) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!url.startsWith('https://') && !url.startsWith('http://')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
57
lib/screens/shared/fladder_icon.dart
Normal file
57
lib/screens/shared/fladder_icon.dart
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import 'package:fladder/util/theme_extensions.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
class FladderIcon extends StatelessWidget {
|
||||
final double size;
|
||||
const FladderIcon({this.size = 100, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
ShaderMask(
|
||||
shaderCallback: (Rect bounds) {
|
||||
return ui.Gradient.linear(
|
||||
const Offset(30, 30),
|
||||
const Offset(80, 80),
|
||||
[
|
||||
Theme.of(context).colorScheme.primary,
|
||||
Theme.of(context).colorScheme.secondary,
|
||||
],
|
||||
);
|
||||
},
|
||||
child: RotatedBox(
|
||||
quarterTurns: 1,
|
||||
child: SvgPicture.asset(
|
||||
"icons/fladder_icon_grayscale.svg",
|
||||
width: size,
|
||||
colorFilter: const ColorFilter.mode(Colors.white, BlendMode.srcIn),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FladderIconOutlined extends StatelessWidget {
|
||||
final double size;
|
||||
final Color? color;
|
||||
const FladderIconOutlined({this.size = 100, this.color, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RotatedBox(
|
||||
quarterTurns: 1,
|
||||
child: SvgPicture.asset(
|
||||
"icons/fladder_icon_outline.svg",
|
||||
width: size,
|
||||
colorFilter: ColorFilter.mode(color ?? context.colors.onSurfaceVariant, BlendMode.srcATop),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
32
lib/screens/shared/fladder_logo.dart
Normal file
32
lib/screens/shared/fladder_logo.dart
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import 'package:fladder/screens/shared/fladder_icon.dart';
|
||||
import 'package:fladder/util/application_info.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
import 'package:fladder/util/theme_extensions.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class FladderLogo extends ConsumerWidget {
|
||||
const FladderLogo({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Hero(
|
||||
tag: "Fladder_Logo_Tag",
|
||||
child: Wrap(
|
||||
runAlignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 16,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
const FladderIcon(),
|
||||
Text(
|
||||
ref.read(applicationInfoProvider).name.capitalize(),
|
||||
style: context.textTheme.displayLarge,
|
||||
textAlign: TextAlign.center,
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
189
lib/screens/shared/fladder_snackbar.dart
Normal file
189
lib/screens/shared/fladder_snackbar.dart
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import 'package:chopper/chopper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
void fladderSnackbar(
|
||||
BuildContext context, {
|
||||
String title = "",
|
||||
bool permanent = false,
|
||||
SnackBarAction? action,
|
||||
bool showCloseButton = false,
|
||||
Duration duration = const Duration(seconds: 3),
|
||||
}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(
|
||||
title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w500, color: Theme.of(context).colorScheme.onSecondary),
|
||||
),
|
||||
clipBehavior: Clip.none,
|
||||
showCloseIcon: showCloseButton,
|
||||
duration: duration,
|
||||
padding: EdgeInsets.all(18),
|
||||
action: action,
|
||||
));
|
||||
}
|
||||
|
||||
void fladderSnackbarResponse(BuildContext context, Response? response, {String? altTitle}) {
|
||||
if (response != null) {
|
||||
fladderSnackbar(context,
|
||||
title: "(${response.base.statusCode}) ${response.base.reasonPhrase ?? "Something went wrong!"}");
|
||||
return;
|
||||
} else if (altTitle != null) {
|
||||
fladderSnackbar(context, title: altTitle);
|
||||
}
|
||||
}
|
||||
|
||||
// void _showOverlay(
|
||||
// BuildContext context, {
|
||||
// required String title,
|
||||
// Widget? leading,
|
||||
// bool showCloseButton = false,
|
||||
// bool permanent = false,
|
||||
// Duration duration = const Duration(seconds: 3),
|
||||
// }) {
|
||||
// late OverlayEntry overlayEntry;
|
||||
|
||||
// overlayEntry = OverlayEntry(
|
||||
// builder: (context) => _OverlayAnimationWidget(
|
||||
// title: title,
|
||||
// leading: leading,
|
||||
// showCloseButton: showCloseButton,
|
||||
// permanent: permanent,
|
||||
// duration: duration,
|
||||
// overlayEntry: overlayEntry,
|
||||
// ),
|
||||
// );
|
||||
|
||||
// // Insert the overlay entry into the overlay
|
||||
// Overlay.of(context).insert(overlayEntry);
|
||||
// }
|
||||
|
||||
// class _OverlayAnimationWidget extends StatefulWidget {
|
||||
// final String title;
|
||||
// final Widget? leading;
|
||||
// final bool showCloseButton;
|
||||
// final bool permanent;
|
||||
// final Duration duration;
|
||||
// final OverlayEntry overlayEntry;
|
||||
|
||||
// _OverlayAnimationWidget({
|
||||
// required this.title,
|
||||
// this.leading,
|
||||
// this.showCloseButton = false,
|
||||
// this.permanent = false,
|
||||
// this.duration = const Duration(seconds: 3),
|
||||
// required this.overlayEntry,
|
||||
// });
|
||||
|
||||
// @override
|
||||
// _OverlayAnimationWidgetState createState() => _OverlayAnimationWidgetState();
|
||||
// }
|
||||
|
||||
// class _OverlayAnimationWidgetState extends State<_OverlayAnimationWidget> with SingleTickerProviderStateMixin {
|
||||
// late AnimationController _controller;
|
||||
// late Animation<Offset> _offsetAnimation;
|
||||
|
||||
// void remove() {
|
||||
// // Optionally, you can use a Future.delayed to remove the overlay after a certain duration
|
||||
// _controller.reverse();
|
||||
// // Remove the overlay entry after the animation completes
|
||||
// Future.delayed(Duration(seconds: 1), () {
|
||||
// widget.overlayEntry.remove();
|
||||
// });
|
||||
// }
|
||||
|
||||
// @override
|
||||
// void initState() {
|
||||
// super.initState();
|
||||
|
||||
// _controller = AnimationController(
|
||||
// vsync: this,
|
||||
// duration: Duration(milliseconds: 250),
|
||||
// );
|
||||
|
||||
// _offsetAnimation = Tween<Offset>(
|
||||
// begin: Offset(0.0, 1.5),
|
||||
// end: Offset.zero,
|
||||
// ).animate(CurvedAnimation(
|
||||
// parent: _controller,
|
||||
// curve: Curves.fastOutSlowIn,
|
||||
// ));
|
||||
|
||||
// // Start the animation
|
||||
// _controller.forward();
|
||||
|
||||
// Future.delayed(widget.duration, () {
|
||||
// if (!widget.permanent) {
|
||||
// remove();
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
|
||||
// @override
|
||||
// void dispose() {
|
||||
// _controller.dispose();
|
||||
// super.dispose();
|
||||
// }
|
||||
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// return Positioned(
|
||||
// bottom: 10 + MediaQuery.of(context).padding.bottom,
|
||||
// left: 25,
|
||||
// right: 25,
|
||||
// child: Dismissible(
|
||||
// key: UniqueKey(),
|
||||
// direction: DismissDirection.horizontal,
|
||||
// confirmDismiss: (direction) async {
|
||||
// remove();
|
||||
// return true;
|
||||
// },
|
||||
// child: SlideTransition(
|
||||
// position: _offsetAnimation,
|
||||
// child: Card(
|
||||
// elevation: 5,
|
||||
// color: Colors.transparent,
|
||||
// surfaceTintColor: Colors.transparent,
|
||||
// child: Container(
|
||||
// decoration: BoxDecoration(
|
||||
// color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
// ),
|
||||
// child: Padding(
|
||||
// padding: const EdgeInsets.all(12.0),
|
||||
// child: ConstrainedBox(
|
||||
// constraints: BoxConstraints(minHeight: 45),
|
||||
// child: Row(
|
||||
// children: [
|
||||
// if (widget.leading != null) widget.leading!,
|
||||
// Expanded(
|
||||
// child: Text(
|
||||
// widget.title,
|
||||
// style: TextStyle(
|
||||
// fontSize: 16,
|
||||
// fontWeight: FontWeight.w400,
|
||||
// color: Theme.of(context).colorScheme.onSecondaryContainer),
|
||||
// ),
|
||||
// ),
|
||||
// const SizedBox(width: 6),
|
||||
// if (widget.showCloseButton || widget.permanent)
|
||||
// IconButton(
|
||||
// onPressed: () => remove(),
|
||||
// icon: Icon(
|
||||
// IconsaxOutline.close_square,
|
||||
// size: 28,
|
||||
// color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
// ),
|
||||
// )
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
46
lib/screens/shared/flat_button.dart
Normal file
46
lib/screens/shared/flat_button.dart
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import 'package:fladder/theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class FlatButton extends ConsumerWidget {
|
||||
final Widget? child;
|
||||
final Function()? onTap;
|
||||
final Function()? onLongPress;
|
||||
final Function()? onDoubleTap;
|
||||
final Function(TapDownDetails details)? onSecondaryTapDown;
|
||||
final BorderRadius? borderRadiusGeometry;
|
||||
final Color? splashColor;
|
||||
final double elevation;
|
||||
final Clip clipBehavior;
|
||||
const FlatButton(
|
||||
{this.child,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.onDoubleTap,
|
||||
this.onSecondaryTapDown,
|
||||
this.borderRadiusGeometry,
|
||||
this.splashColor,
|
||||
this.elevation = 0,
|
||||
this.clipBehavior = Clip.none,
|
||||
super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
clipBehavior: clipBehavior,
|
||||
borderRadius: borderRadiusGeometry ?? FladderTheme.defaultShape.borderRadius,
|
||||
elevation: 0,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
onDoubleTap: onDoubleTap,
|
||||
onSecondaryTapDown: onSecondaryTapDown,
|
||||
borderRadius: borderRadiusGeometry ?? BorderRadius.circular(10),
|
||||
splashColor: splashColor ?? Theme.of(context).colorScheme.primary.withOpacity(0.5),
|
||||
splashFactory: InkSparkle.splashFactory,
|
||||
child: child ?? Container(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
102
lib/screens/shared/floating_search_bar.dart
Normal file
102
lib/screens/shared/floating_search_bar.dart
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import 'package:animations/animations.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/routes/build_routes/route_builder.dart';
|
||||
import 'package:fladder/routes/build_routes/settings_routes.dart';
|
||||
import 'package:fladder/screens/search/search_screen.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class FloatingSearchBar extends ConsumerStatefulWidget {
|
||||
final List<Widget> trailing;
|
||||
final String hintText;
|
||||
final bool showLoading;
|
||||
final bool showUserIcon;
|
||||
final bool automaticallyImplyLeading;
|
||||
final double height;
|
||||
const FloatingSearchBar({
|
||||
this.trailing = const [],
|
||||
this.showLoading = false,
|
||||
this.showUserIcon = true,
|
||||
this.height = 50,
|
||||
required this.hintText,
|
||||
this.automaticallyImplyLeading = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _FloatingSearchBarState();
|
||||
}
|
||||
|
||||
class _FloatingSearchBarState extends ConsumerState<FloatingSearchBar> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = ref.watch(userProvider);
|
||||
return Hero(
|
||||
tag: "FloatingSearchBarHome",
|
||||
child: SizedBox(
|
||||
height: widget.height,
|
||||
width: double.infinity,
|
||||
child: OpenContainer(
|
||||
openBuilder: (context, action) {
|
||||
return const SearchScreen();
|
||||
},
|
||||
openColor: Colors.transparent,
|
||||
openElevation: 0,
|
||||
closedColor: Colors.transparent,
|
||||
closedElevation: 0,
|
||||
closedBuilder: (context, openAction) => Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shadowColor: Colors.transparent,
|
||||
elevation: 5,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(500)),
|
||||
child: InkWell(
|
||||
onTap: () => openAction(),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (context.canPop())
|
||||
IconButton(
|
||||
onPressed: () => context.pop(),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.hintText,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
)),
|
||||
IconButton(
|
||||
onPressed: () => openAction(),
|
||||
icon: const Icon(
|
||||
Icons.search_rounded,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
context.routeGo(SecuritySettingsRoute());
|
||||
},
|
||||
icon: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(200),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: user?.avatar ?? "",
|
||||
memCacheHeight: 125,
|
||||
imageBuilder: (context, imageProvider) => Image(image: imageProvider),
|
||||
errorWidget: (context, url, error) => CircleAvatar(
|
||||
child: Text(user?.name.getInitials() ?? ""),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
89
lib/screens/shared/focused_outlined_text_field.dart
Normal file
89
lib/screens/shared/focused_outlined_text_field.dart
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import 'package:fladder/screens/shared/outlined_text_field.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class FocusedOutlinedTextField extends ConsumerStatefulWidget {
|
||||
final String? label;
|
||||
final TextEditingController? controller;
|
||||
final int maxLines;
|
||||
final Function()? onTap;
|
||||
final Function(String value)? onChanged;
|
||||
final Function(String value)? onSubmitted;
|
||||
final List<String>? autoFillHints;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final bool autocorrect;
|
||||
final TextStyle? style;
|
||||
final double borderWidth;
|
||||
final Color? fillColor;
|
||||
final TextAlign textAlign;
|
||||
final TextInputType? keyboardType;
|
||||
final TextInputAction? textInputAction;
|
||||
final Function(bool focused)? onFocus;
|
||||
final String? errorText;
|
||||
final bool? enabled;
|
||||
const FocusedOutlinedTextField({
|
||||
this.label,
|
||||
this.controller,
|
||||
this.maxLines = 1,
|
||||
this.onTap,
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
this.fillColor,
|
||||
this.style,
|
||||
this.borderWidth = 1,
|
||||
this.textAlign = TextAlign.start,
|
||||
this.autoFillHints,
|
||||
this.inputFormatters,
|
||||
this.autocorrect = true,
|
||||
this.keyboardType,
|
||||
this.textInputAction,
|
||||
this.onFocus,
|
||||
this.errorText,
|
||||
this.enabled,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => FocuesOutlinedTextFieldState();
|
||||
}
|
||||
|
||||
class FocuesOutlinedTextFieldState extends ConsumerState<FocusedOutlinedTextField> {
|
||||
late FocusNode focusNode = FocusNode();
|
||||
late bool previousFocus = focusNode.hasFocus;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
focusNode.addListener(() {
|
||||
if (previousFocus != focusNode.hasFocus) {
|
||||
previousFocus = focusNode.hasFocus;
|
||||
widget.onFocus?.call(focusNode.hasFocus);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OutlinedTextField(
|
||||
controller: widget.controller,
|
||||
onTap: widget.onTap,
|
||||
onChanged: widget.onChanged,
|
||||
focusNode: focusNode,
|
||||
keyboardType: widget.keyboardType,
|
||||
autocorrect: widget.autocorrect,
|
||||
onSubmitted: widget.onSubmitted,
|
||||
textInputAction: widget.textInputAction,
|
||||
style: widget.style,
|
||||
maxLines: widget.maxLines,
|
||||
inputFormatters: widget.inputFormatters,
|
||||
textAlign: widget.textAlign,
|
||||
fillColor: widget.fillColor,
|
||||
errorText: widget.errorText,
|
||||
autoFillHints: widget.autoFillHints,
|
||||
borderWidth: widget.borderWidth,
|
||||
enabled: widget.enabled,
|
||||
label: widget.label,
|
||||
);
|
||||
}
|
||||
}
|
||||
44
lib/screens/shared/input_fields.dart
Normal file
44
lib/screens/shared/input_fields.dart
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class IntInputField extends ConsumerWidget {
|
||||
final int? value;
|
||||
final TextEditingController? controller;
|
||||
final String? placeHolder;
|
||||
final String? suffix;
|
||||
final Function(int? value)? onSubmitted;
|
||||
const IntInputField({
|
||||
this.value,
|
||||
this.controller,
|
||||
this.suffix,
|
||||
this.placeHolder,
|
||||
this.onSubmitted,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Card(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer.withOpacity(0.25),
|
||||
elevation: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: TextField(
|
||||
controller: controller ?? TextEditingController(text: (value ?? 0).toString()),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: false, signed: false),
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (value) => onSubmitted?.call(int.tryParse(value)),
|
||||
textAlign: TextAlign.center,
|
||||
decoration: InputDecoration(
|
||||
contentPadding: EdgeInsets.all(0),
|
||||
hintText: placeHolder,
|
||||
suffixText: suffix,
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
378
lib/screens/shared/media/carousel_banner.dart
Normal file
378
lib/screens/shared/media/carousel_banner.dart
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
import 'package:async/async.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/models/items/movie_model.dart';
|
||||
import 'package:fladder/screens/shared/media/components/media_play_button.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||
import 'package:fladder/util/item_base_model/play_item_helpers.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/themes_data.dart';
|
||||
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class CarouselBanner extends ConsumerStatefulWidget {
|
||||
final PageController? controller;
|
||||
final List<ItemBaseModel> items;
|
||||
const CarouselBanner({
|
||||
this.controller,
|
||||
required this.items,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _CarouselBannerState();
|
||||
}
|
||||
|
||||
class _CarouselBannerState extends ConsumerState<CarouselBanner> {
|
||||
bool showControls = false;
|
||||
bool interacting = false;
|
||||
int currentPage = 0;
|
||||
double dragOffset = 0;
|
||||
double dragIntensity = 1;
|
||||
double slidePosition = 1;
|
||||
|
||||
late final RestartableTimer timer = RestartableTimer(Duration(seconds: 8), () => nextSlide());
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
timer.reset();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
timer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void nextSlide() {
|
||||
if (!interacting) {
|
||||
setState(() {
|
||||
if (currentPage == widget.items.length - 1) {
|
||||
currentPage = 0;
|
||||
} else {
|
||||
currentPage++;
|
||||
}
|
||||
});
|
||||
}
|
||||
timer.reset();
|
||||
}
|
||||
|
||||
void previousSlide() {
|
||||
if (!interacting) {
|
||||
setState(() {
|
||||
if (currentPage == 0) {
|
||||
currentPage = widget.items.length - 1;
|
||||
} else {
|
||||
currentPage--;
|
||||
}
|
||||
});
|
||||
}
|
||||
timer.reset();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final overlayColor = ThemesData.of(context).dark.colorScheme.onSecondary;
|
||||
final shadows = [
|
||||
BoxShadow(blurRadius: 12, spreadRadius: 8, color: overlayColor),
|
||||
];
|
||||
final currentItem = widget.items[currentPage.clamp(0, widget.items.length - 1)];
|
||||
final actions = currentItem.generateActions(context, ref);
|
||||
|
||||
final double dragOpacity = (1 - dragOffset.abs()).clamp(0, 1);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Card(
|
||||
elevation: 16,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
surfaceTintColor: overlayColor,
|
||||
color: overlayColor,
|
||||
child: GestureDetector(
|
||||
onTap: () => currentItem.navigateTo(context),
|
||||
onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.touch
|
||||
? () async {
|
||||
interacting = true;
|
||||
await showBottomSheetPill(
|
||||
context: context,
|
||||
content: (context, scrollController) => ListView(
|
||||
controller: scrollController,
|
||||
shrinkWrap: true,
|
||||
children: actions.listTileItems(context, useIcons: true),
|
||||
),
|
||||
);
|
||||
interacting = false;
|
||||
timer.reset();
|
||||
}
|
||||
: null,
|
||||
child: MouseRegion(
|
||||
onEnter: (event) => setState(() => showControls = true),
|
||||
onHover: (event) => timer.reset(),
|
||||
onExit: (event) => setState(() => showControls = false),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Dismissible(
|
||||
key: Key("Dismissable"),
|
||||
direction: DismissDirection.horizontal,
|
||||
onUpdate: (details) {
|
||||
setState(() {
|
||||
dragOffset = details.progress * 4;
|
||||
});
|
||||
},
|
||||
confirmDismiss: (direction) async {
|
||||
if (direction == DismissDirection.startToEnd) {
|
||||
previousSlide();
|
||||
} else {
|
||||
nextSlide();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: AnimatedOpacity(
|
||||
duration: Duration(milliseconds: 125),
|
||||
opacity: dragOpacity.abs(),
|
||||
child: AnimatedSwitcher(
|
||||
duration: Duration(milliseconds: 125),
|
||||
child: Container(
|
||||
key: Key(currentItem.id),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
foregroundDecoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.10), strokeAlign: BorderSide.strokeAlignInside),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomLeft,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
overlayColor.withOpacity(1),
|
||||
overlayColor.withOpacity(0.75),
|
||||
overlayColor.withOpacity(0.45),
|
||||
overlayColor.withOpacity(0.15),
|
||||
overlayColor.withOpacity(0),
|
||||
overlayColor.withOpacity(0),
|
||||
overlayColor.withOpacity(0.1),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(1),
|
||||
child: FladderImage(
|
||||
fit: BoxFit.cover,
|
||||
image: currentItem.bannerImage,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: IgnorePointer(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
currentItem.title,
|
||||
maxLines: 3,
|
||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||
shadows: shadows,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (currentItem.label(context) != null && currentItem is! MovieModel)
|
||||
Flexible(
|
||||
child: Text(
|
||||
currentItem.label(context)!,
|
||||
maxLines: 3,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
shadows: shadows,
|
||||
color: Colors.white.withOpacity(0.75),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (currentItem.overview.summary.isNotEmpty &&
|
||||
AdaptiveLayout.layoutOf(context) != LayoutState.phone)
|
||||
Flexible(
|
||||
child: Text(
|
||||
currentItem.overview.summary,
|
||||
maxLines: 3,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
shadows: shadows,
|
||||
color: Colors.white.withOpacity(0.75),
|
||||
),
|
||||
),
|
||||
),
|
||||
].addInBetween(SizedBox(height: 6)),
|
||||
),
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
runSpacing: 6,
|
||||
spacing: 6,
|
||||
children: [
|
||||
if (currentItem.playAble)
|
||||
MediaPlayButton(
|
||||
item: currentItem,
|
||||
onPressed: () async {
|
||||
await currentItem.play(
|
||||
context,
|
||||
ref,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
].addInBetween(SizedBox(height: 16)),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: AnimatedOpacity(
|
||||
opacity: showControls ? 1 : 0,
|
||||
duration: Duration(milliseconds: 250),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
onPressed: () => nextSlide(),
|
||||
icon: Icon(IconsaxOutline.arrow_right_3),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer)
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
child: PopupMenuButton(
|
||||
onOpened: () => interacting = true,
|
||||
onCanceled: () {
|
||||
interacting = false;
|
||||
timer.reset();
|
||||
},
|
||||
itemBuilder: (context) => actions.popupMenuItems(useIcons: true),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onHorizontalDragUpdate: (details) {
|
||||
final delta = (details.primaryDelta ?? 0) / 20;
|
||||
slidePosition += delta;
|
||||
if (slidePosition > 1) {
|
||||
nextSlide();
|
||||
slidePosition = 0;
|
||||
} else if (slidePosition < -1) {
|
||||
previousSlide();
|
||||
slidePosition = 0;
|
||||
}
|
||||
},
|
||||
onHorizontalDragStart: (details) {
|
||||
slidePosition = 0;
|
||||
},
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
runAlignment: WrapAlignment.center,
|
||||
children: widget.items.mapIndexed((index, e) {
|
||||
return Tooltip(
|
||||
message: '${e.name}\n${e.detailedName}',
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTapUp: currentPage == index
|
||||
? null
|
||||
: (details) {
|
||||
animateToTarget(index);
|
||||
timer.reset();
|
||||
},
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
color: Colors.red.withOpacity(0),
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: AnimatedContainer(
|
||||
duration: Duration(milliseconds: 125),
|
||||
width: currentItem == e ? 22 : 6,
|
||||
height: currentItem == e ? 10 : 6,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: currentItem == e
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.primary.withOpacity(0.25),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void animateToTarget(int nextIndex) {
|
||||
int step = currentPage < nextIndex ? 1 : -1;
|
||||
void updateItem(int item) {
|
||||
Future.delayed(Duration(milliseconds: 64 ~/ ((currentPage - nextIndex).abs() / 3)), () {
|
||||
setState(() {
|
||||
currentPage = item;
|
||||
});
|
||||
|
||||
if (currentPage != nextIndex) {
|
||||
updateItem(item + step);
|
||||
}
|
||||
});
|
||||
timer.reset();
|
||||
}
|
||||
|
||||
updateItem(currentPage + step);
|
||||
}
|
||||
}
|
||||
117
lib/screens/shared/media/chapter_row.dart
Normal file
117
lib/screens/shared/media/chapter_row.dart
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:fladder/models/items/chapters_model.dart';
|
||||
import 'package:fladder/screens/shared/flat_button.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/disable_keypad_focus.dart';
|
||||
import 'package:fladder/util/humanize_duration.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/widgets/shared/horizontal_list.dart';
|
||||
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class ChapterRow extends ConsumerWidget {
|
||||
final List<Chapter> chapters;
|
||||
final EdgeInsets contentPadding;
|
||||
final Function(Chapter)? onPressed;
|
||||
const ChapterRow({required this.contentPadding, this.onPressed, required this.chapters, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return HorizontalList(
|
||||
label: context.localized.chapter(chapters.length),
|
||||
height: AdaptiveLayout.poster(context).size / 1.75,
|
||||
items: chapters,
|
||||
itemBuilder: (context, index) {
|
||||
final chapter = chapters[index];
|
||||
List<ItemAction> generateActions() {
|
||||
return [
|
||||
ItemActionButton(
|
||||
action: () => onPressed?.call(chapter), label: Text(context.localized.playFrom(chapter.name)))
|
||||
];
|
||||
}
|
||||
|
||||
return AspectRatio(
|
||||
aspectRatio: 1.75,
|
||||
child: Card(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: chapter.imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(5),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
shadowColor: Colors.transparent,
|
||||
color: Theme.of(context).cardColor.withOpacity(0.4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(5),
|
||||
child: Text(
|
||||
"${chapter.name} \n${chapter.startPosition.humanize ?? context.localized.start}",
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
BoxShadow(color: Theme.of(context).cardColor, blurRadius: 6, spreadRadius: 2.0)
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
FlatButton(
|
||||
onSecondaryTapDown: (details) async {
|
||||
Offset localPosition = details.globalPosition;
|
||||
RelativeRect position = RelativeRect.fromLTRB(
|
||||
localPosition.dx - 80, localPosition.dy, localPosition.dx, localPosition.dy);
|
||||
await showMenu(
|
||||
context: context,
|
||||
position: position,
|
||||
items: generateActions().popupMenuItems(),
|
||||
);
|
||||
},
|
||||
onLongPress: () {
|
||||
showBottomSheetPill(
|
||||
context: context,
|
||||
content: (context, scrollController) {
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
controller: scrollController,
|
||||
children: [
|
||||
...generateActions().listTileItems(context),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
if (AdaptiveLayout.of(context).isDesktop)
|
||||
DisableFocus(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: PopupMenuButton(
|
||||
tooltip: context.localized.options,
|
||||
icon: const Icon(
|
||||
Icons.more_vert,
|
||||
color: Colors.white,
|
||||
),
|
||||
itemBuilder: (context) => generateActions().popupMenuItems(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
contentPadding: contentPadding,
|
||||
);
|
||||
}
|
||||
}
|
||||
26
lib/screens/shared/media/components/chip_button.dart
Normal file
26
lib/screens/shared/media/components/chip_button.dart
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class ChipButton extends ConsumerWidget {
|
||||
final String label;
|
||||
final Function()? onPressed;
|
||||
const ChipButton({required this.label, this.onPressed, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return TextButton(
|
||||
onPressed: onPressed,
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface.withOpacity(0.75),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide.none,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
53
lib/screens/shared/media/components/media_header.dart
Normal file
53
lib/screens/shared/media/components/media_header.dart
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import 'package:fladder/models/items/images_models.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class MediaHeader extends ConsumerWidget {
|
||||
final String name;
|
||||
final ImageData? logo;
|
||||
const MediaHeader({
|
||||
required this.name,
|
||||
required this.logo,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final maxWidth =
|
||||
switch (AdaptiveLayout.layoutOf(context)) { LayoutState.desktop || LayoutState.tablet => 0.55, _ => 1 };
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Material(
|
||||
elevation: 30,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(150)),
|
||||
shadowColor: Colors.black.withOpacity(0.35),
|
||||
color: Colors.transparent,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.2,
|
||||
maxWidth: MediaQuery.sizeOf(context).width * maxWidth,
|
||||
),
|
||||
child: FladderImage(
|
||||
image: logo,
|
||||
enableBlur: true,
|
||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) => Container(
|
||||
color: Colors.red,
|
||||
width: 512,
|
||||
height: 512,
|
||||
child: child,
|
||||
),
|
||||
placeHolder: const SizedBox(height: 0),
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
81
lib/screens/shared/media/components/media_play_button.dart
Normal file
81
lib/screens/shared/media/components/media_play_button.dart
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/screens/shared/animated_fade_size.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class MediaPlayButton extends ConsumerWidget {
|
||||
final ItemBaseModel? item;
|
||||
final Function()? onPressed;
|
||||
final Function()? onLongPressed;
|
||||
const MediaPlayButton({
|
||||
required this.item,
|
||||
this.onPressed,
|
||||
this.onLongPressed,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final resume = (item?.progress ?? 0) > 0;
|
||||
Widget buttonBuilder(bool resume, ButtonStyle? style, Color? textColor) {
|
||||
return ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
onLongPress: onLongPressed,
|
||||
style: style,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
item?.playButtonLabel(context) ?? "",
|
||||
maxLines: 2,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
IconsaxBold.play,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return AnimatedFadeSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: onPressed != null
|
||||
? Stack(
|
||||
children: [
|
||||
buttonBuilder(resume, null, null),
|
||||
IgnorePointer(
|
||||
child: ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: (item?.progress ?? 0) / 100,
|
||||
child: buttonBuilder(
|
||||
resume,
|
||||
ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.primary),
|
||||
foregroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onPrimary),
|
||||
),
|
||||
Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Container(
|
||||
key: UniqueKey(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
103
lib/screens/shared/media/components/next_up_episode.dart
Normal file
103
lib/screens/shared/media/components/next_up_episode.dart
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import 'package:fladder/models/items/episode_model.dart';
|
||||
import 'package:fladder/providers/sync_provider.dart';
|
||||
import 'package:fladder/screens/details_screens/components/media_stream_information.dart';
|
||||
import 'package:fladder/screens/shared/media/episode_posters.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/sticky_header_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
|
||||
|
||||
class NextUpEpisode extends ConsumerWidget {
|
||||
final EpisodeModel nextEpisode;
|
||||
final Function(EpisodeModel episode)? onChanged;
|
||||
const NextUpEpisode({required this.nextEpisode, this.onChanged, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final alreadyPlayed = nextEpisode.userData.played;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
StickyHeaderText(
|
||||
label: alreadyPlayed ? context.localized.reWatch : context.localized.nextUp,
|
||||
),
|
||||
Opacity(
|
||||
opacity: 0.75,
|
||||
child: SelectableText(
|
||||
"${context.localized.season(1)} ${nextEpisode.season} - ${context.localized.episode(1)} ${nextEpisode.episode}",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
SelectableText(
|
||||
nextEpisode.name,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final syncedItem = ref.read(syncProvider.notifier).getSyncedItem(nextEpisode);
|
||||
if (constraints.maxWidth < 550) {
|
||||
return Column(
|
||||
children: [
|
||||
EpisodePoster(
|
||||
episode: nextEpisode,
|
||||
syncedItem: syncedItem,
|
||||
showLabel: false,
|
||||
onTap: () => nextEpisode.navigateTo(context),
|
||||
actions: const [],
|
||||
isCurrentEpisode: false,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (nextEpisode.overview.summary.isNotEmpty)
|
||||
HtmlWidget(
|
||||
nextEpisode.overview.summary,
|
||||
textStyle: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Row(
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: AdaptiveLayout.poster(context).gridRatio,
|
||||
maxWidth: MediaQuery.of(context).size.width / 2),
|
||||
child: EpisodePoster(
|
||||
episode: nextEpisode,
|
||||
syncedItem: syncedItem,
|
||||
showLabel: false,
|
||||
onTap: () => nextEpisode.navigateTo(context),
|
||||
actions: const [],
|
||||
isCurrentEpisode: false,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
Flexible(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MediaStreamInformation(
|
||||
mediaStream: nextEpisode.mediaStreams,
|
||||
onAudioIndexChanged: (index) => onChanged?.call(nextEpisode.copyWith(
|
||||
mediaStreams: nextEpisode.mediaStreams.copyWith(defaultAudioStreamIndex: index))),
|
||||
onSubIndexChanged: (index) => onChanged?.call(nextEpisode.copyWith(
|
||||
mediaStreams: nextEpisode.mediaStreams.copyWith(defaultSubStreamIndex: index))),
|
||||
),
|
||||
if (nextEpisode.overview.summary.isNotEmpty)
|
||||
HtmlWidget(nextEpisode.overview.summary, textStyle: Theme.of(context).textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
428
lib/screens/shared/media/components/poster_image.dart
Normal file
428
lib/screens/shared/media/components/poster_image.dart
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/book_model.dart';
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/models/items/item_shared_models.dart';
|
||||
import 'package:fladder/models/items/photos_model.dart';
|
||||
import 'package:fladder/models/items/series_model.dart';
|
||||
import 'package:fladder/screens/shared/flat_button.dart';
|
||||
import 'package:fladder/theme.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/disable_keypad_focus.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:fladder/util/humanize_duration.dart';
|
||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/refresh_state.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
|
||||
import 'package:fladder/widgets/shared/status_card.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class PosterImage extends ConsumerStatefulWidget {
|
||||
final ItemBaseModel poster;
|
||||
final bool heroTag;
|
||||
final bool? selected;
|
||||
final ValueChanged<bool>? playVideo;
|
||||
final bool inlineTitle;
|
||||
final Set<ItemActions> excludeActions;
|
||||
final List<ItemAction> otherActions;
|
||||
final Function(UserData? newData)? onUserDataChanged;
|
||||
final Function(ItemBaseModel newItem)? onItemUpdated;
|
||||
final Function(ItemBaseModel oldItem)? onItemRemoved;
|
||||
final Function(Function() action, ItemBaseModel item)? onPressed;
|
||||
const PosterImage({
|
||||
required this.poster,
|
||||
this.heroTag = false,
|
||||
this.selected,
|
||||
this.playVideo,
|
||||
this.inlineTitle = false,
|
||||
this.onItemUpdated,
|
||||
this.onItemRemoved,
|
||||
this.excludeActions = const {},
|
||||
this.otherActions = const [],
|
||||
this.onPressed,
|
||||
this.onUserDataChanged,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _PosterImageState();
|
||||
}
|
||||
|
||||
class _PosterImageState extends ConsumerState<PosterImage> {
|
||||
late String currentTag = widget.heroTag == true ? widget.poster.id : UniqueKey().toString();
|
||||
bool hover = false;
|
||||
|
||||
Widget get placeHolder {
|
||||
return Center(
|
||||
child: Icon(widget.poster.type.icon),
|
||||
);
|
||||
}
|
||||
|
||||
void pressedWidget() async {
|
||||
if (widget.heroTag == false) {
|
||||
setState(() {
|
||||
currentTag = widget.poster.id;
|
||||
});
|
||||
}
|
||||
if (widget.onPressed != null) {
|
||||
widget.onPressed?.call(() async {
|
||||
await navigateToDetails();
|
||||
if (context.mounted) {
|
||||
context.refreshData();
|
||||
}
|
||||
}, widget.poster);
|
||||
} else {
|
||||
await navigateToDetails();
|
||||
if (context.mounted) {
|
||||
context.refreshData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> navigateToDetails() async {
|
||||
await widget.poster.navigateTo(context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final poster = widget.poster;
|
||||
final padding = EdgeInsets.all(5);
|
||||
return Hero(
|
||||
tag: currentTag,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (event) => setState(() => hover = true),
|
||||
onExit: (event) => setState(() => hover = false),
|
||||
child: Card(
|
||||
elevation: 8,
|
||||
color: Theme.of(context).colorScheme.secondaryContainer.withOpacity(0.2),
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(
|
||||
width: 1.0,
|
||||
color: Colors.white.withOpacity(0.10),
|
||||
),
|
||||
borderRadius: FladderTheme.defaultShape.borderRadius,
|
||||
),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
FladderImage(
|
||||
image: widget.poster.getPosters?.primary ?? widget.poster.getPosters?.backDrop?.lastOrNull,
|
||||
placeHolder: placeHolder,
|
||||
),
|
||||
if (poster.userData.progress > 0 && widget.poster.type == FladderItemType.book)
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(5.5),
|
||||
child: Text(
|
||||
context.localized.page((widget.poster as BookModel).currentPage),
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.selected == true)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.15),
|
||||
border: Border.all(width: 3, color: Theme.of(context).colorScheme.primary),
|
||||
borderRadius: FladderTheme.defaultShape.borderRadius,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
alignment: Alignment.topCenter,
|
||||
children: [
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: double.infinity,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: Text(
|
||||
widget.poster.name,
|
||||
maxLines: 2,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelMedium
|
||||
?.copyWith(color: Theme.of(context).colorScheme.onPrimary, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.poster.userData.isFavourite)
|
||||
Row(
|
||||
children: [
|
||||
StatusCard(
|
||||
color: Colors.red,
|
||||
child: Icon(
|
||||
IconsaxBold.heart,
|
||||
size: 21,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if ((poster.userData.progress > 0 && poster.userData.progress < 100) &&
|
||||
widget.poster.type != FladderItemType.book) ...{
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3).copyWith(bottom: 3).add(padding),
|
||||
child: Card(
|
||||
color: Colors.transparent,
|
||||
elevation: 3,
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: 7.5,
|
||||
backgroundColor: Theme.of(context).colorScheme.onPrimary.withOpacity(0.5),
|
||||
value: poster.userData.progress / 100,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
//Desktop overlay
|
||||
if (AdaptiveLayout.of(context).inputDevice != InputDevice.touch &&
|
||||
widget.poster.type != FladderItemType.person)
|
||||
AnimatedOpacity(
|
||||
opacity: hover ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 125),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
//Hover color overlay
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.55),
|
||||
border: Border.all(width: 3, color: Theme.of(context).colorScheme.primary),
|
||||
borderRadius: FladderTheme.defaultShape.borderRadius,
|
||||
)),
|
||||
//Poster Button
|
||||
Focus(
|
||||
onFocusChange: (value) => setState(() => hover = value),
|
||||
child: FlatButton(
|
||||
onTap: pressedWidget,
|
||||
onSecondaryTapDown: (details) async {
|
||||
Offset localPosition = details.globalPosition;
|
||||
RelativeRect position = RelativeRect.fromLTRB(
|
||||
localPosition.dx - 320, localPosition.dy, localPosition.dx, localPosition.dy);
|
||||
await showMenu(
|
||||
context: context,
|
||||
position: position,
|
||||
items: widget.poster
|
||||
.generateActions(
|
||||
context,
|
||||
ref,
|
||||
exclude: widget.excludeActions,
|
||||
otherActions: widget.otherActions,
|
||||
onUserDataChanged: widget.onUserDataChanged,
|
||||
onDeleteSuccesFully: widget.onItemRemoved,
|
||||
onItemUpdated: widget.onItemUpdated,
|
||||
)
|
||||
.popupMenuItems(useIcons: true),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
//Play Button
|
||||
if (widget.poster.playAble)
|
||||
DisableFocus(
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: IconButton.filledTonal(
|
||||
onPressed: () => widget.playVideo?.call(false),
|
||||
icon: const Icon(
|
||||
IconsaxBold.play,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
DisableFocus(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PopupMenuButton(
|
||||
tooltip: "Options",
|
||||
icon: const Icon(
|
||||
Icons.more_vert,
|
||||
color: Colors.white,
|
||||
),
|
||||
itemBuilder: (context) => widget.poster
|
||||
.generateActions(
|
||||
context,
|
||||
ref,
|
||||
exclude: widget.excludeActions,
|
||||
otherActions: widget.otherActions,
|
||||
onUserDataChanged: widget.onUserDataChanged,
|
||||
onDeleteSuccesFully: widget.onItemRemoved,
|
||||
onItemUpdated: widget.onItemUpdated,
|
||||
)
|
||||
.popupMenuItems(useIcons: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: pressedWidget,
|
||||
onLongPress: () {
|
||||
showBottomSheetPill(
|
||||
context: context,
|
||||
item: widget.poster,
|
||||
content: (scrollContext, scrollController) => ListView(
|
||||
shrinkWrap: true,
|
||||
controller: scrollController,
|
||||
children: widget.poster
|
||||
.generateActions(
|
||||
context,
|
||||
ref,
|
||||
exclude: widget.excludeActions,
|
||||
otherActions: widget.otherActions,
|
||||
onUserDataChanged: widget.onUserDataChanged,
|
||||
onDeleteSuccesFully: widget.onItemRemoved,
|
||||
onItemUpdated: widget.onItemUpdated,
|
||||
)
|
||||
.listTileItems(scrollContext, useIcons: true),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (widget.poster.unWatched)
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: StatusCard(
|
||||
color: Colors.amber,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.amber,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.inlineTitle)
|
||||
IgnorePointer(
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
widget.poster.title.maxLength(limitTo: 25),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelLarge
|
||||
?.copyWith(fontSize: 20, fontWeight: FontWeight.bold, shadows: [
|
||||
BoxShadow(blurRadius: 8, spreadRadius: 16),
|
||||
BoxShadow(blurRadius: 2, spreadRadius: 16),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.poster.unPlayedItemCount != null && widget.poster is SeriesModel)
|
||||
IgnorePointer(
|
||||
child: Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: StatusCard(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: widget.poster.unPlayedItemCount != 0
|
||||
? Container(
|
||||
constraints: const BoxConstraints(minWidth: 18),
|
||||
child: Text(
|
||||
widget.poster.userData.unPlayedItemCount.toString(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
overflow: TextOverflow.visible,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.check_rounded,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.poster.overview.runTime != null &&
|
||||
((widget.poster is PhotoModel) &&
|
||||
(widget.poster as PhotoModel).internalType == FladderItemType.video)) ...{
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: Card(
|
||||
elevation: 5,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
widget.poster.overview.runTime.humanizeSmall ?? "",
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fladder/models/items/item_shared_models.dart';
|
||||
import 'package:fladder/models/items/movie_model.dart';
|
||||
import 'package:fladder/screens/shared/media/components/chip_button.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
|
||||
class Ratings extends StatelessWidget {
|
||||
final double? communityRating;
|
||||
final String? officialRating;
|
||||
const Ratings({
|
||||
super.key,
|
||||
this.communityRating,
|
||||
this.officialRating,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
if (communityRating != null) ...{
|
||||
const Icon(
|
||||
Icons.star_rounded,
|
||||
color: Colors.yellow,
|
||||
),
|
||||
Text(
|
||||
communityRating?.toStringAsFixed(1) ?? "",
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
},
|
||||
if (officialRating != null) ...{
|
||||
Card(
|
||||
elevation: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Text(
|
||||
officialRating ?? "",
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Tags extends StatelessWidget {
|
||||
final List<String> tags;
|
||||
const Tags({
|
||||
super.key,
|
||||
required this.tags,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
runSpacing: 8,
|
||||
spacing: 8,
|
||||
children: tags
|
||||
.map((tag) => ChipButton(
|
||||
onPressed: () {},
|
||||
label: tag.capitalize(),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Genres extends StatelessWidget {
|
||||
final List<GenreItems> genres;
|
||||
const Genres({
|
||||
super.key,
|
||||
required this.genres,
|
||||
this.details,
|
||||
});
|
||||
|
||||
final MovieModel? details;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
runSpacing: 8,
|
||||
spacing: 8,
|
||||
children: genres
|
||||
.map(
|
||||
(genre) => ChipButton(
|
||||
onPressed: null,
|
||||
label: genre.name.capitalize(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
159
lib/screens/shared/media/episode_details_list.dart
Normal file
159
lib/screens/shared/media/episode_details_list.dart
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/providers/settings/client_settings_provider.dart';
|
||||
import 'package:fladder/providers/sync_provider.dart';
|
||||
import 'package:fladder/screens/shared/media/episode_posters.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:fladder/models/items/episode_model.dart';
|
||||
import 'package:fladder/util/humanize_duration.dart';
|
||||
|
||||
enum EpisodeDetailsViewType {
|
||||
list(icon: IconsaxBold.grid_6),
|
||||
grid(icon: IconsaxBold.grid_2);
|
||||
|
||||
const EpisodeDetailsViewType({required this.icon});
|
||||
|
||||
String label(BuildContext context) => switch (this) {
|
||||
EpisodeDetailsViewType.list => context.localized.list,
|
||||
EpisodeDetailsViewType.grid => context.localized.grid,
|
||||
};
|
||||
|
||||
final IconData icon;
|
||||
}
|
||||
|
||||
class EpisodeDetailsList extends ConsumerWidget {
|
||||
final EpisodeDetailsViewType viewType;
|
||||
final List<EpisodeModel> episodes;
|
||||
final EdgeInsets? padding;
|
||||
const EpisodeDetailsList({required this.viewType, required this.episodes, this.padding, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final size = MediaQuery.sizeOf(context).width /
|
||||
((AdaptiveLayout.poster(context).gridRatio * 2) *
|
||||
ref.watch(clientSettingsProvider.select((value) => value.posterSize)));
|
||||
final decimals = size - size.toInt();
|
||||
return AnimatedSwitcher(
|
||||
duration: Duration(milliseconds: 250),
|
||||
child: switch (viewType) {
|
||||
EpisodeDetailsViewType.list => ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: padding,
|
||||
itemCount: episodes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final episode = episodes[index];
|
||||
final syncedItem = ref.watch(syncProvider.notifier).getSyncedItem(episode);
|
||||
List<Widget> children = [
|
||||
Flexible(
|
||||
flex: 1,
|
||||
child: EpisodePoster(
|
||||
episode: episode,
|
||||
showLabel: false,
|
||||
syncedItem: syncedItem,
|
||||
actions: episode.generateActions(context, ref),
|
||||
onTap: () => episode.navigateTo(context),
|
||||
isCurrentEpisode: false,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16, height: 16),
|
||||
Flexible(
|
||||
flex: 3,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: 0.65,
|
||||
child: SelectableText(
|
||||
episode.seasonEpisodeLabel(context),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (episode.overview.runTime != null)
|
||||
Opacity(
|
||||
opacity: 0.65,
|
||||
child: SelectableText(
|
||||
" - ${episode.overview.runTime!.humanize!}",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SelectableText(
|
||||
episode.name,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
SelectableText(
|
||||
episode.overview.summary,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
].addPadding(const EdgeInsets.symmetric(vertical: 4)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
];
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: constraints.maxWidth > 800
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: children,
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
EpisodeDetailsViewType.grid => GridView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: padding,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: size.toInt(),
|
||||
mainAxisSpacing: (8 * decimals) + 8,
|
||||
crossAxisSpacing: (8 * decimals) + 8,
|
||||
childAspectRatio: 1.67),
|
||||
itemCount: episodes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final episode = episodes[index];
|
||||
return EpisodePoster(
|
||||
episode: episode,
|
||||
actions: episode.generateActions(context, ref),
|
||||
onTap: () => episode.navigateTo(context),
|
||||
isCurrentEpisode: false,
|
||||
);
|
||||
},
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
306
lib/screens/shared/media/episode_posters.dart
Normal file
306
lib/screens/shared/media/episode_posters.dart
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
import 'package:fladder/models/items/episode_model.dart';
|
||||
import 'package:fladder/models/syncing/sync_item.dart';
|
||||
import 'package:fladder/providers/settings/client_settings_provider.dart';
|
||||
import 'package:fladder/providers/sync/sync_provider_helpers.dart';
|
||||
import 'package:fladder/providers/sync_provider.dart';
|
||||
import 'package:fladder/screens/shared/flat_button.dart';
|
||||
import 'package:fladder/screens/syncing/sync_button.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/disable_keypad_focus.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/widgets/shared/clickable_text.dart';
|
||||
import 'package:fladder/widgets/shared/enum_selection.dart';
|
||||
import 'package:fladder/widgets/shared/horizontal_list.dart';
|
||||
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
|
||||
import 'package:fladder/widgets/shared/status_card.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class EpisodePosters extends ConsumerStatefulWidget {
|
||||
final List<EpisodeModel> episodes;
|
||||
final String? label;
|
||||
final ValueChanged<EpisodeModel> playEpisode;
|
||||
final EdgeInsets contentPadding;
|
||||
final Function(VoidCallback action, EpisodeModel episodeModel)? onEpisodeTap;
|
||||
const EpisodePosters({
|
||||
this.label,
|
||||
required this.contentPadding,
|
||||
required this.playEpisode,
|
||||
required this.episodes,
|
||||
this.onEpisodeTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _EpisodePosterState();
|
||||
}
|
||||
|
||||
class _EpisodePosterState extends ConsumerState<EpisodePosters> {
|
||||
late int? selectedSeason = widget.episodes.nextUp?.season;
|
||||
|
||||
List<EpisodeModel> get episodes {
|
||||
if (selectedSeason == null) {
|
||||
return widget.episodes;
|
||||
} else {
|
||||
return widget.episodes.where((element) => element.season == selectedSeason).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final indexOfCurrent = (episodes.nextUp != null ? episodes.indexOf(episodes.nextUp!) : 0).clamp(0, episodes.length);
|
||||
final episodesBySeason = widget.episodes.episodesBySeason;
|
||||
final allPlayed = episodes.allPlayed;
|
||||
|
||||
return HorizontalList(
|
||||
label: widget.label,
|
||||
titleActions: [
|
||||
if (episodesBySeason.isNotEmpty && episodesBySeason.length > 1) ...{
|
||||
SizedBox(width: 12),
|
||||
EnumBox(
|
||||
current: selectedSeason != null ? "${context.localized.season(1)} $selectedSeason" : context.localized.all,
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
child: Text(context.localized.all),
|
||||
onTap: () => setState(() => selectedSeason = null),
|
||||
),
|
||||
...episodesBySeason.entries.map(
|
||||
(e) => PopupMenuItem(
|
||||
child: Text("${context.localized.season(1)} ${e.key}"),
|
||||
onTap: () {
|
||||
setState(() => selectedSeason = e.key);
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
},
|
||||
],
|
||||
height: AdaptiveLayout.poster(context).gridRatio,
|
||||
contentPadding: widget.contentPadding,
|
||||
startIndex: indexOfCurrent,
|
||||
items: episodes,
|
||||
itemBuilder: (context, index) {
|
||||
final episode = episodes[index];
|
||||
final isCurrentEpisode = index == indexOfCurrent;
|
||||
final syncedItem = ref.watch(syncProvider.notifier).getSyncedItem(episode);
|
||||
return EpisodePoster(
|
||||
episode: episode,
|
||||
blur: allPlayed ? false : indexOfCurrent < index,
|
||||
syncedItem: syncedItem,
|
||||
onTap: widget.onEpisodeTap != null
|
||||
? () {
|
||||
widget.onEpisodeTap?.call(
|
||||
() {
|
||||
episode.navigateTo(context);
|
||||
},
|
||||
episode,
|
||||
);
|
||||
}
|
||||
: () {
|
||||
episode.navigateTo(context);
|
||||
},
|
||||
onLongPress: () {
|
||||
showBottomSheetPill(
|
||||
context: context,
|
||||
item: episode,
|
||||
content: (context, scrollController) {
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
controller: scrollController,
|
||||
children: [
|
||||
...episode.generateActions(context, ref).listTileItems(context, useIcons: true),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
actions: episode.generateActions(context, ref),
|
||||
isCurrentEpisode: isCurrentEpisode,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EpisodePoster extends ConsumerWidget {
|
||||
final EpisodeModel episode;
|
||||
final SyncedItem? syncedItem;
|
||||
final bool showLabel;
|
||||
final Function()? onTap;
|
||||
final Function()? onLongPress;
|
||||
final bool blur;
|
||||
final List<ItemAction> actions;
|
||||
final bool isCurrentEpisode;
|
||||
|
||||
const EpisodePoster({
|
||||
super.key,
|
||||
required this.episode,
|
||||
this.syncedItem,
|
||||
this.showLabel = true,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.blur = false,
|
||||
required this.actions,
|
||||
required this.isCurrentEpisode,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Widget placeHolder = Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: const Icon(Icons.local_movies_outlined),
|
||||
);
|
||||
final SyncedItem? iSyncedItem = syncedItem;
|
||||
bool episodeAvailable = episode.status == EpisodeStatus.available;
|
||||
return AspectRatio(
|
||||
aspectRatio: 1.76,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Card(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
FladderImage(
|
||||
image: switch (episode.status) {
|
||||
EpisodeStatus.unaired || EpisodeStatus.missing => episode.parentImages?.primary,
|
||||
_ => episode.images?.primary
|
||||
},
|
||||
placeHolder: placeHolder,
|
||||
blurOnly:
|
||||
ref.watch(clientSettingsProvider.select((value) => value.blurUpcomingEpisodes)) ? blur : false,
|
||||
),
|
||||
if (!episodeAvailable)
|
||||
Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Card(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
elevation: 3,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
episode.status.name,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (iSyncedItem != null)
|
||||
Consumer(builder: (context, ref, child) {
|
||||
final SyncStatus syncStatus =
|
||||
ref.watch(syncStatusesProvider(iSyncedItem)).value ?? SyncStatus.partially;
|
||||
return StatusCard(
|
||||
color: syncStatus.color,
|
||||
child: SyncButton(item: episode, syncedItem: syncedItem),
|
||||
);
|
||||
}),
|
||||
if (episode.userData.isFavourite)
|
||||
StatusCard(
|
||||
color: Colors.red,
|
||||
child: Icon(
|
||||
Icons.favorite_rounded,
|
||||
),
|
||||
),
|
||||
if (episode.userData.played)
|
||||
StatusCard(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
child: Icon(
|
||||
Icons.check_rounded,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if ((episode.userData.progress) > 0)
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: 6,
|
||||
backgroundColor: Colors.black.withOpacity(0.75),
|
||||
value: episode.userData.progress / 100,
|
||||
),
|
||||
),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return FlatButton(
|
||||
onSecondaryTapDown: (details) {
|
||||
Offset localPosition = details.globalPosition;
|
||||
RelativeRect position = RelativeRect.fromLTRB(
|
||||
localPosition.dx - 260, localPosition.dy, localPosition.dx, localPosition.dy);
|
||||
|
||||
showMenu(context: context, position: position, items: actions.popupMenuItems(useIcons: true));
|
||||
},
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer && actions.isNotEmpty)
|
||||
DisableFocus(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: PopupMenuButton(
|
||||
tooltip: "Options",
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(color: Colors.black.withOpacity(0.45), blurRadius: 8.0),
|
||||
const Shadow(color: Colors.black, blurRadius: 16.0),
|
||||
const Shadow(color: Colors.black, blurRadius: 32.0),
|
||||
const Shadow(color: Colors.black, blurRadius: 64.0),
|
||||
],
|
||||
),
|
||||
itemBuilder: (context) => actions.popupMenuItems(useIcons: true),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showLabel) ...{
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
if (isCurrentEpisode)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Container(
|
||||
height: 12,
|
||||
width: 12,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: ClickableText(
|
||||
text: episode.episodeLabel(context),
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
}
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
84
lib/screens/shared/media/expanding_overview.dart
Normal file
84
lib/screens/shared/media/expanding_overview.dart
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/sticky_header_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
|
||||
|
||||
class ExpandingOverview extends ConsumerStatefulWidget {
|
||||
final String text;
|
||||
const ExpandingOverview({required this.text, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _ExpandingOverviewState();
|
||||
}
|
||||
|
||||
class _ExpandingOverviewState extends ConsumerState<ExpandingOverview> {
|
||||
bool expanded = false;
|
||||
|
||||
void toggleState() {
|
||||
setState(() {
|
||||
expanded = !expanded;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = Theme.of(context).colorScheme.onSurface;
|
||||
const int maxLength = 200;
|
||||
final bool canExpand = widget.text.length > maxLength;
|
||||
return AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
alignment: Alignment.topCenter,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
StickyHeaderText(
|
||||
label: context.localized.overview,
|
||||
),
|
||||
ShaderMask(
|
||||
shaderCallback: (bounds) => LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: const [0, 1],
|
||||
colors: [
|
||||
color,
|
||||
color.withOpacity(!canExpand
|
||||
? 1
|
||||
: expanded
|
||||
? 1
|
||||
: 0),
|
||||
],
|
||||
).createShader(bounds),
|
||||
child: HtmlWidget(
|
||||
widget.text.substring(0, !expanded ? maxLength.clamp(0, widget.text.length) : widget.text.length - 1),
|
||||
textStyle: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
if (canExpand) ...{
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, expanded ? 0 : -15),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: expanded
|
||||
? IconButton.filledTonal(
|
||||
onPressed: toggleState,
|
||||
icon: const Icon(IconsaxOutline.arrow_up_2),
|
||||
)
|
||||
: IconButton.filledTonal(
|
||||
onPressed: toggleState,
|
||||
icon: const Icon(IconsaxOutline.arrow_down_1),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
68
lib/screens/shared/media/external_urls.dart
Normal file
68
lib/screens/shared/media/external_urls.dart
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import 'package:fladder/models/items/item_shared_models.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_custom_tabs/flutter_custom_tabs.dart' as customtab;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart' as urllauncher;
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class ExternalUrlsRow extends ConsumerWidget {
|
||||
final List<ExternalUrls>? urls;
|
||||
const ExternalUrlsRow({
|
||||
this.urls,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Wrap(
|
||||
children: urls
|
||||
?.map(
|
||||
(url) => TextButton(
|
||||
onPressed: () => launchUrl(context, url.url),
|
||||
child: Text(url.name),
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> launchUrl(BuildContext context, String link) async {
|
||||
final Uri url = Uri.parse(link);
|
||||
|
||||
if (AdaptiveLayout.of(context).isDesktop) {
|
||||
if (!await urllauncher.launchUrl(url, mode: LaunchMode.externalApplication)) {
|
||||
throw Exception('Could not launch $url');
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await customtab.launch(
|
||||
link,
|
||||
customTabsOption: customtab.CustomTabsOption(
|
||||
toolbarColor: Theme.of(context).primaryColor,
|
||||
enableDefaultShare: true,
|
||||
enableUrlBarHiding: true,
|
||||
showPageTitle: true,
|
||||
extraCustomTabs: const <String>[
|
||||
// ref. https://play.google.com/store/apps/details?id=org.mozilla.firefox
|
||||
'org.mozilla.firefox',
|
||||
// ref. https://play.google.com/store/apps/details?id=com.microsoft.emmx
|
||||
'com.microsoft.emmx',
|
||||
],
|
||||
),
|
||||
safariVCOption: customtab.SafariViewControllerOption(
|
||||
preferredBarTintColor: Theme.of(context).primaryColor,
|
||||
preferredControlTintColor: Colors.white,
|
||||
barCollapsingEnabled: true,
|
||||
entersReaderIfAvailable: false,
|
||||
dismissButtonStyle: customtab.SafariViewControllerDismissButtonStyle.close,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
// An exception is thrown if browser app is not installed on Android device.
|
||||
debugPrint(e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
110
lib/screens/shared/media/item_detail_list_widget.dart
Normal file
110
lib/screens/shared/media/item_detail_list_widget.dart
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/screens/shared/flat_button.dart';
|
||||
import 'package:fladder/util/duration_extensions.dart';
|
||||
|
||||
class ItemDetailListWidget extends ConsumerStatefulWidget {
|
||||
final ItemBaseModel item;
|
||||
final Widget? iconOverlay;
|
||||
final double elevation;
|
||||
final List<Widget> actions;
|
||||
const ItemDetailListWidget(
|
||||
{super.key, required this.item, this.iconOverlay, this.elevation = 1, this.actions = const []});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _ItemDetailListWidgetState();
|
||||
}
|
||||
|
||||
class _ItemDetailListWidgetState extends ConsumerState<ItemDetailListWidget> {
|
||||
bool showImageOverlay = false;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: widget.elevation,
|
||||
margin: EdgeInsets.zero,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
children: [
|
||||
FlatButton(
|
||||
onTap: () {},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 32),
|
||||
child: Row(
|
||||
children: [
|
||||
MouseRegion(
|
||||
onEnter: (event) => setState(() => showImageOverlay = true),
|
||||
onExit: (event) => setState(() => showImageOverlay = false),
|
||||
child: Stack(
|
||||
children: [
|
||||
FladderImage(image: widget.item.images?.primary),
|
||||
if (widget.item.subTextShort(context) != null)
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(7),
|
||||
child: Text(
|
||||
widget.item.subTextShort(context) ?? "",
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.iconOverlay != null)
|
||||
Positioned.fill(
|
||||
child: AnimatedOpacity(
|
||||
opacity: showImageOverlay ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: widget.iconOverlay!,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: IgnorePointer(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.item.name,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: Opacity(
|
||||
opacity: 0.65,
|
||||
child: Text(
|
||||
widget.item.overview.summary,
|
||||
overflow: TextOverflow.fade,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
...widget.actions,
|
||||
if (widget.item.overview.runTime != null)
|
||||
Opacity(opacity: 0.65, child: Text(widget.item.overview.runTime?.readAbleDuration ?? "")),
|
||||
const VerticalDivider(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
99
lib/screens/shared/media/people_row.dart
Normal file
99
lib/screens/shared/media/people_row.dart
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import 'package:animations/animations.dart';
|
||||
import 'package:fladder/models/items/item_shared_models.dart';
|
||||
import 'package:fladder/screens/details_screens/person_detail_screen.dart';
|
||||
import 'package:fladder/screens/shared/flat_button.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
import 'package:fladder/widgets/shared/clickable_text.dart';
|
||||
import 'package:fladder/widgets/shared/horizontal_list.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class PeopleRow extends ConsumerWidget {
|
||||
final List<Person> people;
|
||||
final EdgeInsets contentPadding;
|
||||
const PeopleRow({required this.people, required this.contentPadding, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Widget placeHolder(String name) {
|
||||
return Card(
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: 0.4,
|
||||
child: Card(
|
||||
elevation: 5,
|
||||
shape: const CircleBorder(),
|
||||
child: Center(
|
||||
child: Text(
|
||||
name.getInitials(),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return HorizontalList(
|
||||
label: context.localized.actor(people.length),
|
||||
height: AdaptiveLayout.poster(context).size * 0.9,
|
||||
contentPadding: contentPadding,
|
||||
items: people,
|
||||
itemBuilder: (context, index) {
|
||||
final person = people[index];
|
||||
return AspectRatio(
|
||||
aspectRatio: 0.6,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Flexible(
|
||||
child: OpenContainer(
|
||||
closedColor: Colors.transparent,
|
||||
closedElevation: 5,
|
||||
openElevation: 0,
|
||||
closedShape: const RoundedRectangleBorder(),
|
||||
transitionType: ContainerTransitionType.fadeThrough,
|
||||
openColor: Colors.transparent,
|
||||
tappable: false,
|
||||
closedBuilder: (context, action) => Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Card(
|
||||
child: FladderImage(
|
||||
image: person.image,
|
||||
placeHolder: placeHolder(person.name),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
FlatButton(onTap: () => action()),
|
||||
],
|
||||
),
|
||||
openBuilder: (context, action) => PersonDetailScreen(
|
||||
person: person,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ClickableText(
|
||||
text: person.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ClickableText(
|
||||
opacity: 0.45,
|
||||
text: person.role,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontSize: 13, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
38
lib/screens/shared/media/person_list_.dart
Normal file
38
lib/screens/shared/media/person_list_.dart
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import 'package:fladder/models/items/item_shared_models.dart';
|
||||
import 'package:fladder/screens/details_screens/details_screens.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class PersonList extends ConsumerWidget {
|
||||
final String label;
|
||||
final List<Person> people;
|
||||
final ValueChanged<Person>? onPersonTap;
|
||||
const PersonList({required this.label, required this.people, this.onPersonTap, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
...people
|
||||
.map((person) => TextButton(
|
||||
onPressed:
|
||||
onPersonTap != null ? () => onPersonTap?.call(person) : () => openPersonDetailPage(context, person),
|
||||
child: Text(person.name)))
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void openPersonDetailPage(BuildContext context, Person person) {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (context) => PersonDetailScreen(person: person),
|
||||
));
|
||||
}
|
||||
}
|
||||
71
lib/screens/shared/media/poster_grid.dart
Normal file
71
lib/screens/shared/media/poster_grid.dart
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/providers/settings/client_settings_provider.dart';
|
||||
import 'package:fladder/screens/shared/media/poster_widget.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/sticky_header_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sticky_headers/sticky_headers.dart';
|
||||
|
||||
class PosterGrid extends ConsumerWidget {
|
||||
final String? name;
|
||||
final List<ItemBaseModel> posters;
|
||||
final Widget? Function(BuildContext context, int index)? itemBuilder;
|
||||
final bool stickyHeader;
|
||||
final Function(VoidCallback action, ItemBaseModel item)? onPressed;
|
||||
const PosterGrid(
|
||||
{this.stickyHeader = true, this.itemBuilder, this.name, required this.posters, this.onPressed, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final size = MediaQuery.sizeOf(context).width /
|
||||
(AdaptiveLayout.poster(context).gridRatio *
|
||||
ref.watch(clientSettingsProvider.select((value) => value.posterSize)));
|
||||
final decimals = size - size.toInt();
|
||||
var posterBuilder = GridView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: size.toInt(),
|
||||
mainAxisSpacing: (8 * decimals) + 8,
|
||||
crossAxisSpacing: (8 * decimals) + 8,
|
||||
childAspectRatio: AdaptiveLayout.poster(context).ratio,
|
||||
),
|
||||
itemCount: posters.length,
|
||||
itemBuilder: itemBuilder ??
|
||||
(context, index) {
|
||||
return PosterWidget(
|
||||
poster: posters[index],
|
||||
onPressed: onPressed,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (stickyHeader) {
|
||||
//Translate fixes small peaking pixel line
|
||||
return StickyHeader(
|
||||
header: name != null
|
||||
? StickyHeaderText(label: name ?? "")
|
||||
: const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
content: posterBuilder,
|
||||
);
|
||||
} else {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text(
|
||||
name ?? "",
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
posterBuilder,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
218
lib/screens/shared/media/poster_list_item.dart
Normal file
218
lib/screens/shared/media/poster_list_item.dart
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/book_model.dart';
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/models/items/item_shared_models.dart';
|
||||
import 'package:fladder/providers/settings/client_settings_provider.dart';
|
||||
import 'package:fladder/screens/shared/flat_button.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/widgets/shared/clickable_text.dart';
|
||||
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class PosterListItem extends ConsumerWidget {
|
||||
final ItemBaseModel poster;
|
||||
final bool? selected;
|
||||
final Widget? subTitle;
|
||||
final Set<ItemActions> excludeActions;
|
||||
final List<ItemAction> otherActions;
|
||||
|
||||
// Useful for intercepting button press
|
||||
final Function(VoidCallback action, ItemBaseModel item)? onPressed;
|
||||
final Function(String id, UserData? newData)? onUserDataChanged;
|
||||
final Function(ItemBaseModel newItem)? onItemUpdated;
|
||||
final Function(ItemBaseModel oldItem)? onItemRemoved;
|
||||
|
||||
const PosterListItem({
|
||||
super.key,
|
||||
this.selected,
|
||||
this.subTitle,
|
||||
this.excludeActions = const {},
|
||||
this.otherActions = const [],
|
||||
required this.poster,
|
||||
this.onPressed,
|
||||
this.onItemUpdated,
|
||||
this.onItemRemoved,
|
||||
this.onUserDataChanged,
|
||||
});
|
||||
|
||||
void pressedWidget(BuildContext context) {
|
||||
if (onPressed != null) {
|
||||
onPressed?.call(() {
|
||||
poster.navigateTo(context);
|
||||
}, poster);
|
||||
} else {
|
||||
poster.navigateTo(context);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 2),
|
||||
child: Card(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: SizedBox(
|
||||
height: 75 * ref.read(clientSettingsProvider.select((value) => value.posterSize)),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(selected == true ? 0.25 : 0),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: FlatButton(
|
||||
onTap: () => pressedWidget(context),
|
||||
onSecondaryTapDown: (details) async {
|
||||
Offset localPosition = details.globalPosition;
|
||||
RelativeRect position =
|
||||
RelativeRect.fromLTRB(localPosition.dx - 320, localPosition.dy, localPosition.dx, localPosition.dy);
|
||||
await showMenu(
|
||||
context: context,
|
||||
position: position,
|
||||
items: poster
|
||||
.generateActions(
|
||||
context,
|
||||
ref,
|
||||
exclude: excludeActions,
|
||||
otherActions: otherActions,
|
||||
onUserDataChanged: (newData) => onUserDataChanged?.call(poster.id, newData),
|
||||
onDeleteSuccesFully: onItemRemoved,
|
||||
onItemUpdated: onItemUpdated,
|
||||
)
|
||||
.popupMenuItems(useIcons: true),
|
||||
);
|
||||
},
|
||||
onLongPress: () {
|
||||
showBottomSheetPill(
|
||||
context: context,
|
||||
item: poster,
|
||||
content: (scrollContext, scrollController) => ListView(
|
||||
shrinkWrap: true,
|
||||
controller: scrollController,
|
||||
children: poster
|
||||
.generateActions(
|
||||
context,
|
||||
ref,
|
||||
exclude: excludeActions,
|
||||
otherActions: otherActions,
|
||||
onUserDataChanged: (newData) => onUserDataChanged?.call(poster.id, newData),
|
||||
onDeleteSuccesFully: onItemRemoved,
|
||||
onItemUpdated: onItemUpdated,
|
||||
)
|
||||
.listTileItems(scrollContext, useIcons: true),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: Hero(
|
||||
tag: poster.id,
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: FladderImage(
|
||||
image: poster.getPosters?.primary ?? poster.getPosters?.backDrop?.lastOrNull,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
poster.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if ((poster.subText ?? poster.subTextShort(context))?.isNotEmpty == true)
|
||||
Opacity(
|
||||
opacity: 0.45,
|
||||
child: Text(
|
||||
poster.subText ?? poster.subTextShort(context) ?? "",
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
if (subTitle != null) ...[
|
||||
subTitle!,
|
||||
Spacer(),
|
||||
],
|
||||
if (poster.subText != null && poster.subText != poster.name)
|
||||
ClickableText(
|
||||
opacity: 0.45,
|
||||
text: poster.subText!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (poster.type == FladderItemType.book)
|
||||
if (poster.userData.progress > 0)
|
||||
Card(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Text(
|
||||
context.localized.page((poster as BookModel).currentPage),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onPrimary),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (poster.userData.isFavourite)
|
||||
Icon(
|
||||
IconsaxBold.heart,
|
||||
color: Colors.red,
|
||||
),
|
||||
if (AdaptiveLayout.of(context).isDesktop)
|
||||
Tooltip(
|
||||
message: context.localized.options,
|
||||
child: PopupMenuButton(
|
||||
tooltip: context.localized.options,
|
||||
icon: const Icon(
|
||||
Icons.more_vert,
|
||||
color: Colors.white,
|
||||
),
|
||||
itemBuilder: (context) => poster
|
||||
.generateActions(
|
||||
context,
|
||||
ref,
|
||||
exclude: excludeActions,
|
||||
otherActions: otherActions,
|
||||
onUserDataChanged: (newData) => onUserDataChanged?.call(poster.id, newData),
|
||||
onDeleteSuccesFully: onItemRemoved,
|
||||
onItemUpdated: onItemUpdated,
|
||||
)
|
||||
.popupMenuItems(useIcons: true),
|
||||
),
|
||||
)
|
||||
].addInBetween(SizedBox(width: 8)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
49
lib/screens/shared/media/poster_row.dart
Normal file
49
lib/screens/shared/media/poster_row.dart
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/screens/shared/media/poster_widget.dart';
|
||||
import 'package:fladder/widgets/shared/horizontal_list.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class PosterRow extends ConsumerStatefulWidget {
|
||||
final List<ItemBaseModel> posters;
|
||||
final String label;
|
||||
final Function()? onLabelClick;
|
||||
final EdgeInsets contentPadding;
|
||||
const PosterRow({
|
||||
required this.posters,
|
||||
this.contentPadding = const EdgeInsets.symmetric(horizontal: 16),
|
||||
required this.label,
|
||||
this.onLabelClick,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _PosterRowState();
|
||||
}
|
||||
|
||||
class _PosterRowState extends ConsumerState<PosterRow> {
|
||||
late final controller = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return HorizontalList(
|
||||
contentPadding: widget.contentPadding,
|
||||
label: widget.label,
|
||||
onLabelClick: widget.onLabelClick,
|
||||
items: widget.posters,
|
||||
itemBuilder: (context, index) {
|
||||
final poster = widget.posters[index];
|
||||
return PosterWidget(
|
||||
poster: poster,
|
||||
key: Key(poster.id),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
127
lib/screens/shared/media/poster_widget.dart
Normal file
127
lib/screens/shared/media/poster_widget.dart
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/models/items/item_shared_models.dart';
|
||||
import 'package:fladder/screens/shared/media/components/poster_image.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||
import 'package:fladder/util/item_base_model/play_item_helpers.dart';
|
||||
import 'package:fladder/widgets/shared/clickable_text.dart';
|
||||
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class PosterWidget extends ConsumerWidget {
|
||||
final ItemBaseModel poster;
|
||||
final Widget? subTitle;
|
||||
final bool? selected;
|
||||
final bool? heroTag;
|
||||
final int maxLines;
|
||||
final double? aspectRatio;
|
||||
final bool inlineTitle;
|
||||
final Set<ItemActions> excludeActions;
|
||||
final List<ItemAction> otherActions;
|
||||
final Function(String id, UserData? newData)? onUserDataChanged;
|
||||
final Function(ItemBaseModel newItem)? onItemUpdated;
|
||||
final Function(ItemBaseModel oldItem)? onItemRemoved;
|
||||
final Function(VoidCallback action, ItemBaseModel item)? onPressed;
|
||||
const PosterWidget(
|
||||
{required this.poster,
|
||||
this.subTitle,
|
||||
this.maxLines = 3,
|
||||
this.selected,
|
||||
this.heroTag,
|
||||
this.aspectRatio,
|
||||
this.inlineTitle = false,
|
||||
this.excludeActions = const {},
|
||||
this.otherActions = const [],
|
||||
this.onUserDataChanged,
|
||||
this.onItemUpdated,
|
||||
this.onItemRemoved,
|
||||
this.onPressed,
|
||||
super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final opacity = 0.65;
|
||||
return AspectRatio(
|
||||
aspectRatio: aspectRatio ?? AdaptiveLayout.poster(context).ratio,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Expanded(
|
||||
child: PosterImage(
|
||||
poster: poster,
|
||||
heroTag: heroTag ?? false,
|
||||
selected: selected,
|
||||
playVideo: (value) async => await poster.play(context, ref),
|
||||
inlineTitle: inlineTitle,
|
||||
excludeActions: excludeActions,
|
||||
otherActions: otherActions,
|
||||
onUserDataChanged: (newData) => onUserDataChanged?.call(poster.id, newData),
|
||||
onItemRemoved: onItemRemoved,
|
||||
onItemUpdated: onItemUpdated,
|
||||
onPressed: onPressed,
|
||||
),
|
||||
),
|
||||
if (!inlineTitle)
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ClickableText(
|
||||
onTap: AdaptiveLayout.of(context).layout != LayoutState.phone
|
||||
? () => poster.parentBaseModel.navigateTo(context)
|
||||
: null,
|
||||
text: poster.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
if (subTitle != null) ...[
|
||||
Opacity(
|
||||
opacity: opacity,
|
||||
child: subTitle!,
|
||||
),
|
||||
Spacer()
|
||||
],
|
||||
if (poster.subText?.isNotEmpty ?? false)
|
||||
Flexible(
|
||||
child: ClickableText(
|
||||
opacity: opacity,
|
||||
text: poster.subText ?? "",
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ClickableText(
|
||||
opacity: opacity,
|
||||
text: poster.subTextShort(context) ?? "",
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Flexible(
|
||||
child: ClickableText(
|
||||
opacity: opacity,
|
||||
text: poster.subText?.isNotEmpty ?? false ? poster.subTextShort(context) ?? "" : "",
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
].take(maxLines).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
186
lib/screens/shared/media/season_row.dart
Normal file
186
lib/screens/shared/media/season_row.dart
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/disable_keypad_focus.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
|
||||
import 'package:fladder/widgets/shared/status_card.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fladder/models/items/season_model.dart';
|
||||
import 'package:fladder/screens/shared/flat_button.dart';
|
||||
import 'package:fladder/widgets/shared/clickable_text.dart';
|
||||
import 'package:fladder/widgets/shared/horizontal_list.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class SeasonsRow extends ConsumerWidget {
|
||||
final EdgeInsets contentPadding;
|
||||
final ValueChanged<SeasonModel>? onSeasonPressed;
|
||||
final List<SeasonModel>? seasons;
|
||||
|
||||
const SeasonsRow({
|
||||
super.key,
|
||||
this.onSeasonPressed,
|
||||
required this.seasons,
|
||||
this.contentPadding = const EdgeInsets.symmetric(horizontal: 16),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return HorizontalList(
|
||||
label: context.localized.season(seasons?.length ?? 1),
|
||||
items: seasons ?? [],
|
||||
height: AdaptiveLayout.poster(context).size,
|
||||
contentPadding: contentPadding,
|
||||
itemBuilder: (
|
||||
context,
|
||||
index,
|
||||
) {
|
||||
final season = (seasons ?? [])[index];
|
||||
return SeasonPoster(
|
||||
season: season,
|
||||
onSeasonPressed: onSeasonPressed,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SeasonPoster extends ConsumerWidget {
|
||||
final SeasonModel season;
|
||||
final ValueChanged<SeasonModel>? onSeasonPressed;
|
||||
|
||||
const SeasonPoster({required this.season, this.onSeasonPressed, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
placeHolder(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Container(
|
||||
child: Card(
|
||||
color: Theme.of(context).colorScheme.surface.withOpacity(0.65),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
|
||||
child: Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return AspectRatio(
|
||||
aspectRatio: 0.6,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Card(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: FladderImage(
|
||||
image: season.getPosters?.primary ??
|
||||
season.parentImages?.backDrop?.firstOrNull ??
|
||||
season.parentImages?.primary,
|
||||
placeHolder: placeHolder(season.name),
|
||||
),
|
||||
),
|
||||
if (season.images?.primary == null)
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: placeHolder(season.name),
|
||||
),
|
||||
if (season.userData.unPlayedItemCount != 0)
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: StatusCard(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
child: Center(
|
||||
child: Text(
|
||||
season.userData.unPlayedItemCount.toString(),
|
||||
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: StatusCard(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
child: Icon(
|
||||
Icons.check_rounded,
|
||||
),
|
||||
),
|
||||
),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return FlatButton(
|
||||
onSecondaryTapDown: (details) {
|
||||
Offset localPosition = details.globalPosition;
|
||||
RelativeRect position = RelativeRect.fromLTRB(
|
||||
localPosition.dx - 260, localPosition.dy, localPosition.dx, localPosition.dy);
|
||||
showMenu(
|
||||
context: context,
|
||||
position: position,
|
||||
items: season.generateActions(context, ref).popupMenuItems(useIcons: true));
|
||||
},
|
||||
onTap: () => onSeasonPressed?.call(season),
|
||||
onLongPress: AdaptiveLayout.of(context).inputDevice != InputDevice.touch
|
||||
? () {
|
||||
showBottomSheetPill(
|
||||
context: context,
|
||||
content: (context, scrollController) => ListView(
|
||||
shrinkWrap: true,
|
||||
controller: scrollController,
|
||||
children:
|
||||
season.generateActions(context, ref).listTileItems(context, useIcons: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer)
|
||||
DisableFocus(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: PopupMenuButton(
|
||||
tooltip: context.localized.options,
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(color: Colors.black.withOpacity(0.45), blurRadius: 8.0),
|
||||
const Shadow(color: Colors.black, blurRadius: 16.0),
|
||||
const Shadow(color: Colors.black, blurRadius: 32.0),
|
||||
const Shadow(color: Colors.black, blurRadius: 64.0),
|
||||
],
|
||||
),
|
||||
itemBuilder: (context) => season.generateActions(context, ref).popupMenuItems(useIcons: true),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ClickableText(
|
||||
text: season.localizedName(context),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
38
lib/screens/shared/nested_bottom_appbar.dart
Normal file
38
lib/screens/shared/nested_bottom_appbar.dart
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/widgets/shared/shapes.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class NestedBottomAppBar extends ConsumerWidget {
|
||||
final Widget child;
|
||||
const NestedBottomAppBar({required this.child, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final double bottomPadding =
|
||||
(AdaptiveLayout.of(context).isDesktop || kIsWeb) ? 12 : MediaQuery.of(context).padding.bottom;
|
||||
return Card(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
shape: BottomBarShape(),
|
||||
elevation: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: SizedBox(
|
||||
height: kBottomNavigationBarHeight + 12 + bottomPadding,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12)
|
||||
.copyWith(
|
||||
bottom: bottomPadding,
|
||||
)
|
||||
.add(EdgeInsets.only(
|
||||
left: MediaQuery.of(context).padding.left,
|
||||
right: MediaQuery.of(context).padding.right,
|
||||
)),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
34
lib/screens/shared/nested_scaffold.dart
Normal file
34
lib/screens/shared/nested_scaffold.dart
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import 'package:fladder/models/media_playback_model.dart';
|
||||
import 'package:fladder/providers/video_player_provider.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/floating_player_bar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class NestedScaffold extends ConsumerWidget {
|
||||
final Widget body;
|
||||
const NestedScaffold({required this.body, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final playerState = ref.watch(mediaPlaybackProvider.select((value) => value.state));
|
||||
|
||||
return Card(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: switch (AdaptiveLayout.layoutOf(context)) {
|
||||
LayoutState.phone => null,
|
||||
_ => switch (playerState) {
|
||||
VideoPlayerState.minimized => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: FloatingPlayerBar(),
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
},
|
||||
body: body,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
83
lib/screens/shared/nested_sliver_appbar.dart
Normal file
83
lib/screens/shared/nested_sliver_appbar.dart
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/routes/build_routes/route_builder.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/widgets/navigation_scaffold/components/settings_user_icon.dart';
|
||||
import 'package:fladder/widgets/shared/shapes.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class NestedSliverAppBar extends ConsumerWidget {
|
||||
final BuildContext parent;
|
||||
final String? searchTitle;
|
||||
final CustomRoute? route;
|
||||
const NestedSliverAppBar({required this.parent, this.route, this.searchTitle, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return SliverAppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
elevation: 16,
|
||||
forceElevated: true,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
shape: AppBarShape(),
|
||||
title: SizedBox(
|
||||
height: 65,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.surface),
|
||||
),
|
||||
onPressed: () => Scaffold.of(parent).openDrawer(),
|
||||
icon: Icon(
|
||||
IconsaxBold.menu,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Hero(
|
||||
tag: "PrimarySearch",
|
||||
child: Card(
|
||||
elevation: 3,
|
||||
shadowColor: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: route != null
|
||||
? () {
|
||||
context.routePushOrGo(route!);
|
||||
}
|
||||
: null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Opacity(
|
||||
opacity: 0.65,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(IconsaxOutline.search_normal),
|
||||
const SizedBox(width: 16),
|
||||
Transform.translate(
|
||||
offset: Offset(0, 2.5), child: Text(searchTitle ?? "${context.localized.search}...")),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsUserIcon()
|
||||
].addInBetween(const SizedBox(width: 16)),
|
||||
),
|
||||
),
|
||||
),
|
||||
toolbarHeight: 80,
|
||||
floating: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
177
lib/screens/shared/outlined_text_field.dart
Normal file
177
lib/screens/shared/outlined_text_field.dart
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import 'package:fladder/screens/shared/animated_fade_size.dart';
|
||||
import 'package:fladder/theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class OutlinedTextField extends ConsumerStatefulWidget {
|
||||
final String? label;
|
||||
final FocusNode? focusNode;
|
||||
final bool autoFocus;
|
||||
final TextEditingController? controller;
|
||||
final int maxLines;
|
||||
final Function()? onTap;
|
||||
final Function(String value)? onChanged;
|
||||
final Function(String value)? onSubmitted;
|
||||
final List<String>? autoFillHints;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final bool autocorrect;
|
||||
final TextStyle? style;
|
||||
final double borderWidth;
|
||||
final Color? fillColor;
|
||||
final TextAlign textAlign;
|
||||
final TextInputType? keyboardType;
|
||||
final TextInputAction? textInputAction;
|
||||
final String? errorText;
|
||||
final bool? enabled;
|
||||
|
||||
const OutlinedTextField({
|
||||
this.label,
|
||||
this.focusNode,
|
||||
this.autoFocus = false,
|
||||
this.controller,
|
||||
this.maxLines = 1,
|
||||
this.onTap,
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
this.fillColor,
|
||||
this.style,
|
||||
this.borderWidth = 1,
|
||||
this.textAlign = TextAlign.start,
|
||||
this.autoFillHints,
|
||||
this.inputFormatters,
|
||||
this.autocorrect = true,
|
||||
this.keyboardType,
|
||||
this.textInputAction,
|
||||
this.errorText,
|
||||
this.enabled,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _OutlinedTextFieldState();
|
||||
}
|
||||
|
||||
class _OutlinedTextFieldState extends ConsumerState<OutlinedTextField> {
|
||||
late FocusNode focusNode = widget.focusNode ?? FocusNode();
|
||||
bool _obscureText = true;
|
||||
void _toggle() {
|
||||
setState(() {
|
||||
_obscureText = !_obscureText;
|
||||
});
|
||||
}
|
||||
|
||||
Color getColor() {
|
||||
if (widget.errorText != null) return Theme.of(context).colorScheme.errorContainer;
|
||||
return Theme.of(context).colorScheme.secondaryContainer.withOpacity(0.25);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isPasswordField = widget.keyboardType == TextInputType.visiblePassword;
|
||||
if (widget.autoFocus) {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
focusNode.addListener(
|
||||
() {},
|
||||
);
|
||||
return Column(
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: AnimatedContainer(
|
||||
duration: Duration(milliseconds: 250),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.fillColor ?? getColor(),
|
||||
borderRadius: FladderTheme.defaultShape.borderRadius,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
ignoring: widget.enabled == false,
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
onChanged: widget.onChanged,
|
||||
focusNode: focusNode,
|
||||
onTap: widget.onTap,
|
||||
autofillHints: widget.autoFillHints,
|
||||
keyboardType: widget.keyboardType,
|
||||
autocorrect: widget.autocorrect,
|
||||
onSubmitted: widget.onSubmitted,
|
||||
textInputAction: widget.textInputAction,
|
||||
obscureText: isPasswordField ? _obscureText : false,
|
||||
style: widget.style,
|
||||
maxLines: widget.maxLines,
|
||||
inputFormatters: widget.inputFormatters,
|
||||
textAlign: widget.textAlign,
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0),
|
||||
width: widget.borderWidth,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0),
|
||||
width: widget.borderWidth,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0),
|
||||
width: widget.borderWidth,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0),
|
||||
width: widget.borderWidth,
|
||||
),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0),
|
||||
width: widget.borderWidth,
|
||||
),
|
||||
),
|
||||
filled: widget.fillColor != null,
|
||||
fillColor: widget.fillColor,
|
||||
labelText: widget.label,
|
||||
// errorText: widget.errorText,
|
||||
suffixIcon: isPasswordField
|
||||
? InkWell(
|
||||
onTap: _toggle,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
child: Icon(
|
||||
_obscureText ? Icons.visibility : Icons.visibility_off,
|
||||
size: 16.0,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
AnimatedFadeSize(
|
||||
child: widget.errorText != null
|
||||
? Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
widget.errorText ?? "",
|
||||
style:
|
||||
Theme.of(context).textTheme.labelMedium?.copyWith(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
)
|
||||
: Container(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
173
lib/screens/shared/passcode_input.dart
Normal file
173
lib/screens/shared/passcode_input.dart
Normal 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);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
72
lib/screens/shared/user_icon.dart
Normal file
72
lib/screens/shared/user_icon.dart
Normal 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,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue