Init repo

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

View file

@ -0,0 +1,57 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
Future<DateTime?> showAdaptiveDatePicker(
BuildContext context, {
DateTime? initialDateTime,
}) async {
final ThemeData theme = Theme.of(context);
if (theme.platform == TargetPlatform.iOS) {
return _buildCupertinoDatePicker(
context,
initialDateTime: initialDateTime,
);
} else {
return _buildMaterialDatePicker(
context,
initialDateTime: initialDateTime,
);
}
}
Future<DateTime?> _buildCupertinoDatePicker(
BuildContext context, {
DateTime? initialDateTime,
}) async {
DateTime? newDate;
showModalBottomSheet(
context: context,
builder: (BuildContext builder) {
return Container(
height: MediaQuery.of(context).copyWith().size.height / 3,
color: Theme.of(context).colorScheme.surface,
child: CupertinoDatePicker(
onDateTimeChanged: (value) {
newDate = value;
},
initialDateTime: initialDateTime,
dateOrder: DatePickerDateOrder.ymd,
),
);
},
);
return newDate;
}
Future<DateTime?> _buildMaterialDatePicker(
BuildContext context, {
DateTime? initialDateTime,
}) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: initialDateTime ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2025),
);
return picked;
}

View file

@ -0,0 +1,92 @@
import 'dart:async';
import 'package:flutter/material.dart';
class AnimatedVisibilityIcon extends StatefulWidget {
final bool isFilled;
final IconData filledIcon;
final Color filledColor;
final IconData outlinedIcon;
final Color? outlinedColor;
final Duration displayDuration;
const AnimatedVisibilityIcon({
super.key,
required this.isFilled,
required this.filledIcon,
this.filledColor = Colors.redAccent,
required this.outlinedIcon,
this.outlinedColor,
this.displayDuration = const Duration(seconds: 2),
});
@override
AnimatedVisibilityIconState createState() => AnimatedVisibilityIconState();
}
class AnimatedVisibilityIconState extends State<AnimatedVisibilityIcon> {
bool _isVisible = false;
bool _currentFilledState = false;
Timer? timer;
@override
void didUpdateWidget(covariant AnimatedVisibilityIcon oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isFilled != oldWidget.isFilled) {
_animateIconChange();
}
}
void _animateIconChange() {
timer?.cancel();
setState(() {
_isVisible = true;
_currentFilledState = widget.isFilled;
});
timer = Timer.periodic(
widget.displayDuration,
(timer) {
if (mounted) {
setState(() {
_isVisible = false;
});
}
timer.cancel();
},
);
}
@override
Widget build(BuildContext context) {
return AnimatedScale(
duration: const Duration(milliseconds: 300),
scale: _currentFilledState ? 1.2 : 1.0,
curve: Curves.easeInOutCubic,
child: AnimatedOpacity(
opacity: _isVisible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
decoration: BoxDecoration(
color: (_currentFilledState ? widget.filledColor : widget.outlinedColor)?.withOpacity(0.2),
shape: BoxShape.circle,
),
child: Padding(
padding: const EdgeInsets.only(left: 12, right: 12, top: 8, bottom: 6),
child: Icon(
_currentFilledState ? widget.filledIcon : widget.outlinedIcon,
size: 42,
color: _currentFilledState ? widget.filledColor : widget.outlinedColor,
key: ValueKey<bool>(_currentFilledState),
),
),
),
),
),
);
}
}

View file

@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class ClickableText extends ConsumerStatefulWidget {
final String text;
final double opacity;
final int? maxLines;
final TextOverflow? overflow;
final TextStyle? style;
final VoidCallback? onTap;
const ClickableText(
{required this.text,
this.style,
this.maxLines,
this.overflow = TextOverflow.ellipsis,
this.opacity = 1.0,
this.onTap,
super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _ClickableTextState();
}
class _ClickableTextState extends ConsumerState<ClickableText> {
bool hovering = false;
Widget _textWidget(bool showDecoration) {
return Opacity(
opacity: widget.opacity,
child: Text(
widget.text,
maxLines: widget.maxLines,
overflow: widget.overflow,
style: widget.style?.copyWith(
color: showDecoration ? Theme.of(context).colorScheme.primary : null,
decoration: showDecoration ? TextDecoration.underline : TextDecoration.none,
decorationColor: showDecoration ? Theme.of(context).colorScheme.primary : null,
decorationThickness: 3,
),
),
);
}
Widget _buildClickable() {
final showDecoration = ((widget.onTap != null) && hovering);
return MouseRegion(
cursor: widget.onTap != null ? SystemMouseCursors.click : SystemMouseCursors.basic,
onEnter: (event) => setState(() => hovering = true),
onExit: (event) => setState(() => hovering = false),
child: GestureDetector(
onTap: widget.onTap,
child: Tooltip(message: widget.text, child: _textWidget(showDecoration)),
),
);
}
@override
Widget build(BuildContext context) {
return widget.onTap != null ? _buildClickable() : _textWidget(false);
}
}

View file

@ -0,0 +1,85 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
IconData getBackIcon(BuildContext context) {
if (kIsWeb) {
// Always use 'Icons.arrow_back' as a back_button icon in web.
return Icons.arrow_back;
}
switch (Theme.of(context).platform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
return Icons.arrow_back;
case TargetPlatform.iOS:
case TargetPlatform.macOS:
return Icons.arrow_back_ios;
}
}
final _shadows = [
BoxShadow(blurRadius: 1, spreadRadius: 1, color: Colors.black.withOpacity(0.2)),
BoxShadow(blurRadius: 4, spreadRadius: 4, color: Colors.black.withOpacity(0.1)),
BoxShadow(blurRadius: 16, spreadRadius: 6, color: Colors.black.withOpacity(0.2)),
];
class ElevatedIconButton extends ConsumerWidget {
final Function() onPressed;
final IconData icon;
final Color? color;
const ElevatedIconButton({required this.onPressed, required this.icon, this.color, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return IconButton(
onPressed: onPressed,
style: IconButtonTheme.of(context).style?.copyWith(
backgroundColor: WidgetStatePropertyAll(color?.withOpacity(0.15)),
),
color: color,
icon: Icon(
icon,
shadows: _shadows,
),
);
}
}
class ElevatedIconButtonLabel extends StatelessWidget {
final Function() onPressed;
final String label;
final IconData icon;
final Color? color;
const ElevatedIconButtonLabel({
required this.onPressed,
required this.label,
required this.icon,
this.color,
super.key,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: label,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 65),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedIconButton(onPressed: onPressed, icon: icon),
Flexible(
child: Text(
label,
textAlign: TextAlign.center,
maxLines: 2,
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
class EnumBox<T> extends StatelessWidget {
final String current;
final List<PopupMenuEntry<T>> Function(BuildContext context) itemBuilder;
const EnumBox({required this.current, required this.itemBuilder, super.key});
@override
Widget build(BuildContext context) {
final textStyle = Theme.of(context).textTheme.titleMedium;
const padding = EdgeInsets.symmetric(horizontal: 12, vertical: 6);
final itemList = itemBuilder(context);
return Card(
color: Theme.of(context).colorScheme.primaryContainer,
shadowColor: Colors.transparent,
elevation: 0,
child: PopupMenuButton(
tooltip: '',
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
enabled: itemList.length > 1,
itemBuilder: itemBuilder,
padding: padding,
child: Padding(
padding: padding,
child: Material(
textStyle: textStyle?.copyWith(
fontWeight: FontWeight.bold,
color: itemList.length > 1 ? Theme.of(context).colorScheme.onPrimaryContainer : null),
color: Colors.transparent,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Flexible(
child: Text(
current,
textAlign: TextAlign.start,
),
),
const SizedBox(width: 6),
if (itemList.length > 1)
Icon(
Icons.keyboard_arrow_down,
color: Theme.of(context).colorScheme.onPrimaryContainer,
)
],
),
),
),
),
);
}
}
class EnumSelection<T> extends StatelessWidget {
final Text label;
final String current;
final List<PopupMenuEntry<T>> Function(BuildContext context) itemBuilder;
const EnumSelection({
required this.label,
required this.current,
required this.itemBuilder,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
textStyle: Theme.of(context).textTheme.titleMedium,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
label,
const Spacer(),
EnumBox(current: current, itemBuilder: itemBuilder),
].toList(),
),
);
}
}

View file

@ -0,0 +1,61 @@
import 'dart:async';
import 'dart:developer';
import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:flutter/material.dart';
class FilledButtonAwait extends StatefulWidget {
final FutureOr<dynamic> Function() onPressed;
final ButtonStyle? style;
final Widget child;
const FilledButtonAwait({
required this.onPressed,
this.style,
required this.child,
super.key,
});
@override
State<FilledButtonAwait> createState() => FilledButtonAwaitState();
}
class FilledButtonAwaitState extends State<FilledButtonAwait> {
bool loading = false;
@override
Widget build(BuildContext context) {
const duration = Duration(milliseconds: 250);
const iconSize = 24.0;
return FilledButton(
style: widget.style,
onPressed: loading
? null
: () async {
setState(() => loading = true);
try {
await widget.onPressed();
} catch (e) {
log(e.toString());
} finally {
setState(() => loading = false);
}
},
child: AnimatedFadeSize(
duration: duration,
child: loading
? Opacity(
opacity: 0.75,
child: SizedBox(
width: iconSize,
height: iconSize,
child: CircularProgressIndicator(
strokeCap: StrokeCap.round,
color: widget.style?.foregroundColor?.resolve({WidgetState.hovered}),
),
),
)
: widget.child,
));
}
}

View file

@ -0,0 +1,39 @@
import 'package:flexible_scrollbar/flexible_scrollbar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class FladderScrollbar extends ConsumerWidget {
final ScrollController controller;
final Widget child;
final bool visible;
const FladderScrollbar({
required this.controller,
required this.child,
this.visible = true,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return visible
? FlexibleScrollbar(
child: child,
controller: controller,
alwaysVisible: false,
scrollThumbBuilder: (ScrollbarInfo info) {
return AnimatedContainer(
width: info.isDragging ? 24 : 8,
height: (info.thumbMainAxisSize / 2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
color: info.isDragging
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.secondaryContainer.withOpacity(0.75),
),
duration: Duration(milliseconds: 250),
);
},
)
: child;
}
}

View file

@ -0,0 +1,202 @@
import 'package:fladder/util/num_extension.dart';
import 'package:fladder/widgets/gapped_container_shape.dart';
import 'package:flutter/material.dart';
double normalize(double min, double max, double value) {
return (value - min) / (max - min);
}
class FladderSlider extends StatefulWidget {
final double value;
final double min;
final double max;
final int? divisions;
final double thumbWidth;
final bool showThumb;
final Duration animation;
final Function(double value)? onChanged;
final Function(double value)? onChangeStart;
final Function(double value)? onChangeEnd;
const FladderSlider({
required this.value,
this.min = 0.0,
this.max = 1.0,
this.divisions,
this.onChanged,
this.thumbWidth = 6.5,
this.showThumb = true,
this.animation = const Duration(milliseconds: 100),
this.onChangeStart,
this.onChangeEnd,
super.key,
}) : assert(value >= min || value <= max);
@override
FladderSliderState createState() => FladderSliderState();
}
class FladderSliderState extends State<FladderSlider> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
double _currentValue = 0.0;
bool hovering = false;
bool dragging = false;
@override
void initState() {
super.initState();
_currentValue = widget.value;
_controller = AnimationController(vsync: this, duration: widget.animation);
_animation = Tween<double>(begin: widget.value, end: widget.value).animate(_controller);
}
@override
void didUpdateWidget(covariant FladderSlider oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.value != widget.value || oldWidget.divisions != widget.divisions) {
double newValue = widget.value;
if (widget.divisions != null) {
final stepSize = (widget.max - widget.min) / widget.divisions!;
newValue = ((newValue - widget.min) / stepSize).round() * stepSize + widget.min;
}
setState(() {
_currentValue = newValue;
});
_animation = Tween<double>(begin: _animation.value, end: _currentValue).animate(_controller);
_controller.forward(from: 0.0);
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
double normalize(double min, double max, double value) {
return (value - min) / (max - min);
}
@override
Widget build(BuildContext context) {
final thumbWidth = widget.thumbWidth;
final height = Theme.of(context).sliderTheme.trackHeight ?? 24.0;
double calculateChange(double offset, double width) {
double relativeOffset = (offset / width).clamp(0.0, 1.0);
double newValue = (widget.max - widget.min) * relativeOffset + widget.min;
if (widget.divisions != null) {
final stepSize = (widget.max - widget.min) / widget.divisions!;
newValue = ((newValue - widget.min) / stepSize).round() * stepSize + widget.min;
}
setState(() {
_currentValue = newValue.clamp(widget.min, widget.max);
});
return _currentValue.roundTo(2);
}
return Container(
height: height * 4,
color: Colors.transparent,
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final divisionSize = 5.0 * 0.95;
final stepSize = constraints.maxWidth / (widget.divisions ?? 1);
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (event) => setState(() => hovering = true),
onExit: (event) => setState(() => hovering = false),
child: GestureDetector(
onTapUp: (details) => widget.onChangeEnd?.call(calculateChange(details.localPosition.dx, width)),
onTapDown: (details) => widget.onChanged?.call(calculateChange(details.localPosition.dx, width)),
onHorizontalDragStart: (details) {
setState(() {
dragging = true;
});
widget.onChangeStart?.call(calculateChange(details.localPosition.dx, width));
},
onHorizontalDragEnd: (details) {
setState(() {
dragging = false;
});
widget.onChangeEnd?.call(calculateChange(details.localPosition.dx, width));
},
onHorizontalDragUpdate: (details) =>
widget.onChanged?.call(calculateChange(details.localPosition.dx, width)),
child: Container(
color: Colors.transparent,
child: AnimatedBuilder(
animation: _animation,
builder: (context, child) {
final relativeValue = normalize(widget.min, widget.max, _animation.value);
return Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
SizedBox(
height: height,
width: constraints.maxWidth,
child: GappedContainerShape(
thumbPosition: relativeValue,
),
),
if (widget.divisions != null && stepSize > divisionSize * 3)
...List.generate(
widget.divisions! + 1,
(index) {
final offset = (stepSize * index)
.clamp(divisionSize / 1.2, constraints.maxWidth - divisionSize / 1.2);
final active = (1.0 / widget.divisions!) * index > relativeValue;
return Positioned(
left: offset - divisionSize / 2,
child: Container(
width: divisionSize,
height: divisionSize,
decoration: BoxDecoration(
color: active
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onPrimary,
shape: BoxShape.circle,
),
),
);
},
),
// Thumb
if (widget.showThumb)
Positioned(
left:
(width * relativeValue).clamp(thumbWidth / 2, width - thumbWidth / 2) - thumbWidth / 2,
child: AnimatedContainer(
duration: const Duration(milliseconds: 125),
height: (hovering || dragging) ? height * 3 : height,
width: thumbWidth,
decoration: BoxDecoration(
color: (hovering || dragging)
? Theme.of(context).colorScheme.onSurface
: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(8),
),
),
),
],
);
},
),
),
),
);
},
),
);
}
}

View file

@ -0,0 +1,77 @@
import 'package:fladder/util/adaptive_layout.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class HideOnScroll extends ConsumerStatefulWidget {
final Widget? child;
final ScrollController? controller;
final double height;
final Widget? Function(bool visible)? visibleBuilder;
final Duration duration;
const HideOnScroll({
this.child,
this.controller,
this.height = kBottomNavigationBarHeight,
this.visibleBuilder,
this.duration = const Duration(milliseconds: 200),
super.key,
}) : assert(child != null || visibleBuilder != null);
@override
ConsumerState<ConsumerStatefulWidget> createState() => _HideOnScrollState();
}
class _HideOnScrollState extends ConsumerState<HideOnScroll> {
late final scrollController = widget.controller ?? ScrollController();
bool isVisible = true;
bool atEdge = false;
@override
void initState() {
super.initState();
scrollController.addListener(listen);
}
@override
void dispose() {
scrollController.removeListener(listen);
super.dispose();
}
void listen() {
final direction = scrollController.position.userScrollDirection;
if (scrollController.offset < scrollController.position.maxScrollExtent) {
if (direction == ScrollDirection.forward) {
if (!isVisible) {
setState(() => isVisible = true);
}
} else if (direction == ScrollDirection.reverse) {
if (isVisible) {
setState(() => isVisible = false);
}
}
} else {
setState(() {
isVisible = true;
});
}
}
@override
Widget build(BuildContext context) {
if (widget.visibleBuilder != null) return widget.visibleBuilder!(isVisible)!;
if (widget.child == null) return const SizedBox();
if (AdaptiveLayout.of(context).layout == LayoutState.desktop) {
return widget.child!;
} else {
return AnimatedAlign(
alignment: const Alignment(0, -1),
heightFactor: isVisible ? 1.0 : 0,
duration: widget.duration,
child: Wrap(children: [widget.child!]),
);
}
}
}

View file

@ -0,0 +1,190 @@
import 'package:ficonsax/ficonsax.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/util/disable_keypad_focus.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/sticky_header_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class HorizontalList extends ConsumerStatefulWidget {
final String? label;
final List<Widget> titleActions;
final Function()? onLabelClick;
final String? subtext;
final List items;
final int? startIndex;
final Widget Function(BuildContext context, int index) itemBuilder;
final bool scrollToEnd;
final EdgeInsets contentPadding;
final double? height;
final bool shrinkWrap;
const HorizontalList({
required this.items,
required this.itemBuilder,
this.startIndex,
this.height,
this.label,
this.titleActions = const [],
this.onLabelClick,
this.scrollToEnd = false,
this.contentPadding = const EdgeInsets.symmetric(horizontal: 16),
this.subtext,
this.shrinkWrap = false,
super.key,
});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _HorizontalListState();
}
class _HorizontalListState extends ConsumerState<HorizontalList> {
final itemScrollController = ItemScrollController();
late final scrollOffsetController = ScrollOffsetController();
@override
void initState() {
super.initState();
Future.microtask(() async {
if (widget.startIndex != null) {
itemScrollController.jumpTo(index: widget.startIndex!);
scrollOffsetController.animateScroll(
offset: -widget.contentPadding.left, duration: const Duration(milliseconds: 125));
}
});
}
@override
void dispose() {
super.dispose();
}
void _scrollToStart() {
itemScrollController.scrollTo(index: 0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut);
}
void _scrollToEnd() {
itemScrollController.scrollTo(
index: widget.items.length, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut);
}
@override
Widget build(BuildContext context) {
final hasPointer = AdaptiveLayout.of(context).inputDevice == InputDevice.pointer;
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DisableFocus(
child: Padding(
padding: widget.contentPadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (widget.label != null)
Flexible(
child: StickyHeaderText(
label: widget.label ?? "",
onClick: widget.onLabelClick,
),
),
if (widget.subtext != null)
Opacity(
opacity: 0.5,
child: Text(
widget.subtext!,
style: Theme.of(context).textTheme.titleMedium,
),
),
...widget.titleActions
],
),
),
if (widget.items.length > 1)
Card(
elevation: 5,
color: Theme.of(context).colorScheme.surface,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (hasPointer)
GestureDetector(
onLongPress: () => _scrollToStart(),
child: IconButton(
onPressed: () {
scrollOffsetController.animateScroll(
offset: -(MediaQuery.of(context).size.width / 1.75),
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut);
},
icon: const Icon(
IconsaxOutline.arrow_left_2,
size: 20,
)),
),
if (widget.startIndex != null)
IconButton(
tooltip: "Scroll to current",
onPressed: () {
if (widget.startIndex != null) {
itemScrollController.jumpTo(index: widget.startIndex!);
scrollOffsetController.animateScroll(
offset: -widget.contentPadding.left,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOutQuad);
}
},
icon: const Icon(
Icons.circle,
size: 16,
)),
if (hasPointer)
GestureDetector(
onLongPress: () => _scrollToEnd(),
child: IconButton(
onPressed: () {
scrollOffsetController.animateScroll(
offset: (MediaQuery.of(context).size.width / 1.75),
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut);
},
icon: const Icon(
IconsaxOutline.arrow_right_3,
size: 20,
)),
),
],
),
),
].addPadding(const EdgeInsets.symmetric(horizontal: 6)),
),
),
),
const SizedBox(height: 8),
SizedBox(
height: widget.height ??
AdaptiveLayout.poster(context).size *
ref.watch(clientSettingsProvider.select((value) => value.posterSize)),
child: ScrollablePositionedList.separated(
shrinkWrap: widget.shrinkWrap,
itemScrollController: itemScrollController,
scrollOffsetController: scrollOffsetController,
padding: widget.contentPadding,
itemCount: widget.items.length,
scrollDirection: Axis.horizontal,
separatorBuilder: (context, index) => const SizedBox(
width: 16,
),
itemBuilder: widget.itemBuilder,
),
),
],
);
}
}

View file

@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class HoverWidget extends ConsumerStatefulWidget {
final Size size;
final Widget Function(bool visible) child;
const HoverWidget({
this.size = Size.infinite,
required this.child,
super.key,
});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _HoverWidgetState();
}
class _HoverWidgetState extends ConsumerState<HoverWidget> {
bool hovering = false;
void setHovering(bool value) => setState(() => hovering = value);
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (event) => setHovering(true),
onExit: (event) => setHovering(false),
child: widget.child(hovering),
);
}
}

View file

@ -0,0 +1,56 @@
import 'dart:async';
import 'dart:developer';
import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:flutter/material.dart';
class IconButtonAwait extends StatefulWidget {
final FutureOr<dynamic> Function() onPressed;
final Color? color;
final Widget icon;
const IconButtonAwait({required this.onPressed, required this.icon, this.color, super.key});
@override
State<IconButtonAwait> createState() => IconButtonAwaitState();
}
class IconButtonAwaitState extends State<IconButtonAwait> {
bool loading = false;
@override
Widget build(BuildContext context) {
const duration = Duration(milliseconds: 250);
const iconSize = 24.0;
return IconButton(
color: widget.color,
onPressed: loading
? null
: () async {
setState(() => loading = true);
try {
await widget.onPressed();
} catch (e) {
log(e.toString());
} finally {
setState(() => loading = false);
}
},
icon: AnimatedFadeSize(
duration: duration,
child: loading
? Opacity(
opacity: 0.75,
child: SizedBox(
width: iconSize,
height: iconSize,
child: CircularProgressIndicator(
strokeCap: StrokeCap.round,
color: Theme.of(context).colorScheme.primary,
),
),
)
: widget.icon,
));
}
}

View file

@ -0,0 +1,119 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
abstract class ItemAction {
Widget toMenuItemButton();
PopupMenuEntry toPopupMenuItem({bool useIcons = false});
Widget toLabel();
Widget toListItem(BuildContext context, {bool useIcons = false, bool shouldPop = true});
}
class ItemActionDivider extends ItemAction {
Widget toWidget() => Divider();
@override
Divider toMenuItemButton() => Divider();
@override
PopupMenuEntry toPopupMenuItem({bool useIcons = false}) => PopupMenuDivider(height: 3);
@override
Widget toLabel() => Container();
@override
Widget toListItem(BuildContext context, {bool useIcons = false, bool shouldPop = true}) => Divider();
}
class ItemActionButton extends ItemAction {
final Widget? icon;
final Widget? label;
final FutureOr<void> Function()? action;
ItemActionButton({
this.icon,
this.label,
this.action,
});
ItemActionButton copyWith({
Widget? icon,
Widget? label,
Future<void> Function()? action,
}) {
return ItemActionButton(
icon: icon ?? this.icon,
label: label ?? this.label,
action: action ?? this.action,
);
}
@override
MenuItemButton toMenuItemButton() {
return MenuItemButton(leadingIcon: icon, onPressed: action, child: label);
}
@override
PopupMenuItem toPopupMenuItem({bool useIcons = false}) {
return PopupMenuItem(
onTap: action,
child: useIcons
? Builder(builder: (context) {
return Padding(
padding: const EdgeInsets.all(4.0),
child: Theme(
data: ThemeData(
iconTheme: IconThemeData(color: Theme.of(context).colorScheme.onSurface),
),
child: Row(
children: [if (icon != null) icon!, SizedBox(width: 8), if (label != null) Flexible(child: label!)],
),
),
);
})
: label,
);
}
@override
Widget toLabel() {
return label ?? const Text("Empty");
}
@override
ListTile toListItem(BuildContext context, {bool useIcons = false, bool shouldPop = true}) {
return ListTile(
onTap: () {
if (shouldPop) {
Navigator.of(context).pop();
}
action?.call();
},
title: useIcons
? Builder(builder: (context) {
return Theme(
data: ThemeData(
iconTheme: IconThemeData(color: Theme.of(context).colorScheme.onSurface),
),
child: Row(
children: [if (icon != null) icon!, SizedBox(width: 8), if (label != null) Flexible(child: label!)],
),
);
})
: label,
);
}
}
extension ItemActionExtension on List<ItemAction> {
List<PopupMenuEntry> popupMenuItems({bool useIcons = false}) => map((e) => e.toPopupMenuItem(useIcons: useIcons))
.whereNotIndexed((index, element) => (index == 0 && element is PopupMenuDivider))
.toList();
List<Widget> menuItemButtonItems() =>
map((e) => e.toMenuItemButton()).whereNotIndexed((index, element) => (index == 0 && element is Divider)).toList();
List<Widget> listTileItems(BuildContext context, {bool useIcons = false, bool shouldPop = true}) =>
map((e) => e.toListItem(context, useIcons: useIcons, shouldPop: shouldPop))
.whereNotIndexed((index, element) => (index == 0 && element is Divider))
.toList();
}

View file

@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class ListButton extends ConsumerWidget {
final String label;
final Icon icon;
final VoidCallback onTap;
final double height;
const ListButton({required this.label, required this.icon, required this.onTap, this.height = 56, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
onTap: onTap,
horizontalTitleGap: 15,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 5),
leading: Padding(
padding: const EdgeInsets.all(3),
child: icon,
),
title: Text(
label,
style: Theme.of(context).textTheme.labelLarge,
),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(28.0)),
),
);
}
}

View file

@ -0,0 +1,101 @@
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/util/fladder_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
Future<void> showBottomSheetPill({
ItemBaseModel? item,
bool showPill = true,
Function()? onDismiss,
EdgeInsets padding = const EdgeInsets.all(16),
required BuildContext context,
required Widget Function(
BuildContext context,
ScrollController scrollController,
) content,
}) async {
await showModalBottomSheet(
isScrollControlled: true,
useRootNavigator: true,
showDragHandle: true,
enableDrag: true,
context: context,
constraints: AdaptiveLayout.of(context).layout == LayoutState.phone
? BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.9)
: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75, maxHeight: MediaQuery.of(context).size.height * 0.85),
builder: (context) {
final controller = ScrollController();
return ListView(
shrinkWrap: true,
controller: controller,
children: [
if (item != null) ...{
ItemBottomSheetPreview(item: item),
const Divider(),
},
content(context, controller),
],
);
},
);
onDismiss?.call();
}
class ItemBottomSheetPreview extends ConsumerWidget {
final ItemBaseModel item;
const ItemBottomSheetPreview({required this.item, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Card(
child: SizedBox(
height: 90,
child: AspectRatio(
aspectRatio: 1,
child: FladderImage(
image: item.images?.primary,
fit: BoxFit.contain,
),
),
),
),
const SizedBox(width: 16),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge,
),
if (item.subText?.isNotEmpty ?? false)
Opacity(
opacity: 0.75,
child: Text(
item.subText!,
overflow: TextOverflow.ellipsis,
maxLines: 2,
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
),
],
),
),
],
);
}
}

View file

@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
Future<void> showModalSideSheet(
BuildContext context, {
required Widget content,
Widget? header,
bool barrierDismissible = true,
bool backButton = false,
bool closeButton = false,
bool addDivider = true,
List<Widget>? actions,
Function()? onDismiss,
Duration? transitionDuration,
}) async {
await showGeneralDialog(
context: context,
transitionDuration: transitionDuration ?? const Duration(milliseconds: 200),
barrierDismissible: barrierDismissible,
barrierColor: Theme.of(context).colorScheme.scrim.withOpacity(0.3),
barrierLabel: 'Material 3 side sheet',
useRootNavigator: false,
transitionBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween(begin: const Offset(1, 0), end: const Offset(0, 0)).animate(
animation,
),
child: child,
);
},
pageBuilder: (context, animation1, animation2) {
return Align(
alignment: Alignment.centerRight,
child: Sheet(
header: header,
backButton: backButton,
closeButton: closeButton,
actions: actions,
content: content,
addDivider: addDivider,
),
);
},
);
onDismiss?.call();
}
class Sheet extends StatelessWidget {
final Widget? header;
final bool backButton;
final bool closeButton;
final Widget content;
final bool addDivider;
final List<Widget>? actions;
const Sheet({
super.key,
this.header,
required this.backButton,
required this.closeButton,
required this.content,
required this.addDivider,
this.actions,
});
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Material(
elevation: 1,
color: colorScheme.surface,
surfaceTintColor: colorScheme.onSurface,
borderRadius: const BorderRadius.horizontal(left: Radius.circular(20)),
child: Padding(
padding: MediaQuery.of(context).padding,
child: Container(
constraints: BoxConstraints(
minWidth: 256,
maxWidth: size.width <= 600 ? size.width : 400,
minHeight: size.height,
maxHeight: size.height,
),
child: Column(
children: [
_buildHeader(context),
Expanded(
child: content,
),
if (actions?.isNotEmpty ?? false) _buildFooter(context)
],
),
),
),
);
}
Widget _buildHeader(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 16, 16),
child: Row(
children: [
Visibility(
visible: backButton,
child: const BackButton(),
),
if (header != null)
Material(
textStyle: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
color: Colors.transparent,
child: header!,
),
const Spacer(),
Visibility(
visible: closeButton,
child: const CloseButton(),
),
],
),
);
}
Widget _buildFooter(BuildContext context) {
return Column(
children: [
Visibility(
visible: addDivider,
child: const Divider(
indent: 24,
endIndent: 24,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24.0, 16, 24, 24),
child: Row(
children: actions ?? [],
),
),
],
);
}
}

View file

@ -0,0 +1,33 @@
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class PinchPosterZoom extends ConsumerStatefulWidget {
final Widget child;
final Function(double difference)? scaleDifference;
const PinchPosterZoom({required this.child, this.scaleDifference, super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _PinchPosterZoomState();
}
class _PinchPosterZoomState extends ConsumerState<PinchPosterZoom> {
double lastScale = 1.0;
@override
Widget build(BuildContext context) {
return GestureDetector(
onScaleStart: (details) {
lastScale = 1;
},
onScaleUpdate: (details) {
final difference = details.scale - lastScale;
if (ref.watch(clientSettingsProvider.select((value) => value.pinchPosterZoom))) {
widget.scaleDifference?.call(difference);
}
lastScale = details.scale;
},
child: widget.child,
);
}
}

View file

@ -0,0 +1,43 @@
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/widgets/shared/fladder_slider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class PosterSizeWidget extends ConsumerWidget {
final Color? iconColor;
final double width;
const PosterSizeWidget({this.width = 150, this.iconColor, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (ref.watch(clientSettingsProvider.select((value) => value.pinchPosterZoom))) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Tooltip(
message: 'Set poster size',
child: IconButton(
onPressed: () =>
ref.read(clientSettingsProvider.notifier).update((current) => current.copyWith(posterSize: 1)),
icon: Icon(Icons.photo_size_select_large_rounded),
color: iconColor ?? Theme.of(context).colorScheme.onSurface,
),
),
SizedBox(
width: width,
child: FladderSlider(
value: ref.watch(clientSettingsProvider.select((value) => value.posterSize)),
min: 0.5,
divisions: 12,
max: 1.5,
onChanged: (value) =>
ref.read(clientSettingsProvider.notifier).update((current) => current.copyWith(posterSize: value)),
),
),
],
);
} else {
return Container();
}
}
}

View file

@ -0,0 +1,181 @@
import 'dart:async';
import 'package:async/async.dart';
import 'package:ficonsax/ficonsax.dart';
import 'package:fladder/providers/settings/photo_view_settings_provider.dart';
import 'package:fladder/util/simple_duration_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:square_progress_indicator/square_progress_indicator.dart';
class RestarableTimerController {
late Duration _steps = const Duration(milliseconds: 32);
RestartableTimer? _timer;
late Duration _duration = const Duration(seconds: 1);
late Function() _onTimeout;
late Duration _timeLeft = _duration;
set setTimeLeft(Duration value) {
_timeLeftController.add(value);
_timeLeft = value;
}
final StreamController<Duration> _timeLeftController = StreamController<Duration>.broadcast();
final StreamController<bool> _isActiveController = StreamController<bool>.broadcast();
RestarableTimerController(Duration duration, Duration steps, Function() onTimeout) {
_steps = steps;
_duration = duration;
_onTimeout = onTimeout;
}
void playPause() {
if (_timer?.isActive == true) {
cancel();
} else {
play();
}
}
void play() {
_timer?.cancel();
_timer = _startTimer();
_isActiveController.add(_timer?.isActive ?? true);
}
RestartableTimer _startTimer() {
return RestartableTimer(
_steps,
() {
if (_timeLeft < _steps) {
setTimeLeft = _duration;
_onTimeout.call();
} else {
setTimeLeft = _timeLeft - _steps;
}
_timer?.reset();
},
);
}
bool get timerIsActive => _timer?.isActive ?? false;
Stream<bool> get isActive => _isActiveController.stream;
Stream<Duration> get timeLeft => _timeLeftController.stream;
void setDuration(Duration value) => {
_duration = value,
};
void cancel() {
_timer?.cancel();
_timer = null;
_isActiveController.add(false);
}
void reset() {
setTimeLeft = _duration;
_timer?.reset();
}
void dispose() => _timer?.cancel();
}
class ProgressFloatingButton extends ConsumerStatefulWidget {
final RestarableTimerController? controller;
final Function()? onTimeOut;
const ProgressFloatingButton({this.controller, this.onTimeOut, super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _ProgressFloatingButtonState();
}
class _ProgressFloatingButtonState extends ConsumerState<ProgressFloatingButton> {
late RestarableTimerController timer;
late Duration timeLeft = timer._duration;
late bool isActive = false;
List<StreamSubscription> subscriptions = [];
@override
void initState() {
super.initState();
timer = widget.controller ??
RestarableTimerController(
const Duration(seconds: 1),
const Duration(milliseconds: 32),
widget.onTimeOut ?? () {},
);
subscriptions.addAll([
timer.timeLeft.listen((event) => setState(() => timeLeft = event)),
timer.isActive.listen((event) => setState(() => isActive = event))
]);
}
@override
void dispose() {
timer.cancel();
for (var element in subscriptions) {
element.cancel();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onDoubleTap: () {
HapticFeedback.vibrate();
setState(() {
timer.reset();
});
},
onLongPress: () async {
HapticFeedback.vibrate();
final newTimer =
await showSimpleDurationPicker(context: context, initialValue: timer._duration, showNever: false);
if (newTimer != null) {
setState(() {
ref.read(photoViewSettingsProvider.notifier).update((state) => state.copyWith(timer: newTimer));
timer.setDuration(newTimer);
});
}
},
child: FloatingActionButton(
onPressed: isActive ? timer.cancel : timer.play,
child: Stack(
fit: StackFit.expand,
alignment: Alignment.center,
children: [
SquareProgressIndicator(
color: Theme.of(context).colorScheme.onSecondaryContainer,
borderRadius: 6,
strokeWidth: 4,
value: timeLeft.inMilliseconds / timer._duration.inMilliseconds,
),
Icon(isActive ? IconsaxBold.pause : IconsaxBold.play)
],
),
),
);
}
}
class CustomTrackShape extends RoundedRectSliderTrackShape {
@override
Rect getPreferredRect({
required RenderBox parentBox,
Offset offset = Offset.zero,
required SliderThemeData sliderTheme,
bool isEnabled = false,
bool isDiscrete = false,
}) {
final trackHeight = sliderTheme.trackHeight;
final trackLeft = offset.dx;
final trackTop = offset.dy + (parentBox.size.height - trackHeight!) / 2;
final trackWidth = parentBox.size.width;
return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight);
}
}

View file

@ -0,0 +1,83 @@
import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/util/refresh_state.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class PullToRefresh extends ConsumerStatefulWidget {
final GlobalKey<RefreshIndicatorState>? refreshKey;
final double? displacement;
final bool refreshOnStart;
final bool autoFocus;
final bool contextRefresh;
final Future<void> Function()? onRefresh;
final Widget child;
const PullToRefresh({
required this.child,
this.displacement,
this.autoFocus = true,
this.refreshOnStart = true,
this.contextRefresh = true,
required this.onRefresh,
this.refreshKey,
super.key,
});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _PullToRefreshState();
}
class _PullToRefreshState extends ConsumerState<PullToRefresh> {
final GlobalKey<RefreshIndicatorState> _refreshIndicatorKey = GlobalKey<RefreshIndicatorState>();
final FocusNode focusNode = FocusNode();
GlobalKey<RefreshIndicatorState> get refreshKey {
return (widget.refreshKey ?? _refreshIndicatorKey);
}
@override
void initState() {
super.initState();
if (widget.refreshOnStart) {
Future.microtask(
() => refreshKey.currentState?.show(),
);
}
}
@override
Widget build(BuildContext context) {
if ((AdaptiveLayout.of(context).isDesktop || kIsWeb) && widget.autoFocus) {
focusNode.requestFocus();
}
return RefreshState(
refreshKey: refreshKey,
refreshAble: widget.contextRefresh,
child: Focus(
focusNode: focusNode,
autofocus: true,
onKeyEvent: (node, event) {
if (event is KeyDownEvent) {
if (event.logicalKey == LogicalKeyboardKey.f5) {
refreshKey.currentState?.show();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
return KeyEventResult.ignored;
},
child: widget.onRefresh != null
? RefreshIndicator.adaptive(
displacement: widget.displacement ?? 80 + MediaQuery.of(context).viewPadding.top,
key: refreshKey,
onRefresh: widget.onRefresh!,
color: Theme.of(context).colorScheme.onPrimaryContainer,
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: widget.child,
)
: widget.child,
),
);
}
}

View file

@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
enum ScrollState {
top,
middle,
bottom,
}
class ScrollStatePosition extends ConsumerStatefulWidget {
final ScrollController? controller;
final Widget Function(ScrollState state) positionBuilder;
const ScrollStatePosition({
this.controller,
required this.positionBuilder,
super.key,
});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _ScrollStatePositionState();
}
class _ScrollStatePositionState extends ConsumerState<ScrollStatePosition> {
late final scrollController = widget.controller ?? ScrollController();
ScrollState scrollState = ScrollState.top;
@override
void initState() {
super.initState();
scrollController.addListener(listen);
}
@override
void dispose() {
scrollController.removeListener(listen);
super.dispose();
}
void listen() {
if (scrollController.offset < scrollController.position.maxScrollExtent) {
if (scrollController.position.atEdge) {
bool isTop = scrollController.position.pixels == 0;
if (isTop) {
setState(() {
scrollState = ScrollState.top;
});
print('At the top');
} else {
setState(() {
scrollState = ScrollState.bottom;
});
}
} else {
setState(() {
scrollState = ScrollState.middle;
});
}
}
}
@override
Widget build(BuildContext context) {
return widget.positionBuilder(scrollState);
}
}

View file

@ -0,0 +1,103 @@
import 'dart:async';
import 'dart:developer';
import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/util/refresh_state.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class SelectableIconButton extends ConsumerStatefulWidget {
final FutureOr<dynamic> Function() onPressed;
final String? label;
final IconData icon;
final IconData? selectedIcon;
final bool selected;
const SelectableIconButton({
required this.onPressed,
required this.selected,
required this.icon,
this.selectedIcon,
this.label,
super.key,
});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _SelectableIconButtonState();
}
class _SelectableIconButtonState extends ConsumerState<SelectableIconButton> {
bool loading = false;
@override
Widget build(BuildContext context) {
const duration = Duration(milliseconds: 250);
const iconSize = 24.0;
return Tooltip(
message: widget.label ?? "",
child: ElevatedButton(
style: ButtonStyle(
backgroundColor: widget.selected ? WidgetStatePropertyAll(Theme.of(context).colorScheme.primary) : null,
foregroundColor: widget.selected ? WidgetStatePropertyAll(Theme.of(context).colorScheme.onPrimary) : null,
padding: const WidgetStatePropertyAll(EdgeInsets.zero),
),
onPressed: loading
? null
: () async {
setState(() => loading = true);
try {
await widget.onPressed();
if (context.mounted) await context.refreshData();
} catch (e) {
log(e.toString());
} finally {
setState(() => loading = false);
}
},
child: Padding(
padding: EdgeInsets.symmetric(vertical: 10, horizontal: widget.label != null ? 18 : 0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.label != null) ...{
Text(
widget.label.toString(),
),
const SizedBox(width: 10),
},
AnimatedFadeSize(
duration: duration,
child: loading
? Opacity(
opacity: 0.75,
child: SizedBox(
width: iconSize,
height: iconSize,
child: CircularProgressIndicator(
strokeCap: StrokeCap.round,
color: widget.selected
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.primary,
),
),
)
: !widget.selected
? Opacity(
opacity: 0.65,
child: Icon(
key: const Key("selected-off"),
widget.icon,
size: iconSize,
),
)
: Icon(
key: const Key("selected-on"),
widget.selectedIcon,
size: iconSize,
),
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
class AppBarShape extends OutlinedBorder {
@override
OutlinedBorder copyWith({BorderSide? side}) => this; //todo
@override
Path getInnerPath(Rect rect, {TextDirection? textDirection}) {
Path path = Path()
..fillType = PathFillType.evenOdd
..addRect(rect)
..addRRect(RRect.fromRectAndCorners(
Rect.fromLTWH(rect.left, rect.bottom - 14, rect.width, 14),
topLeft: Radius.circular(14),
topRight: Radius.circular(14),
));
return path;
}
@override
Path getOuterPath(Rect rect, {TextDirection? textDirection}) {
return getInnerPath(rect, textDirection: textDirection);
}
@override
void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {
/// create shader linear gradient
canvas.drawPath(getInnerPath(rect), Paint()..color = Colors.transparent);
}
@override
ShapeBorder scale(double t) => this;
}
class BottomBarShape extends OutlinedBorder {
@override
OutlinedBorder copyWith({BorderSide? side}) => this; //todo
@override
Path getInnerPath(Rect rect, {TextDirection? textDirection}) {
Path path = Path()
..fillType = PathFillType.evenOdd
..addRect(rect)
..addRRect(RRect.fromRectAndCorners(
Rect.fromLTWH(rect.left, rect.top, rect.width, 14),
bottomLeft: Radius.circular(14),
bottomRight: Radius.circular(14),
));
return path;
}
@override
Path getOuterPath(Rect rect, {TextDirection? textDirection}) {
return getInnerPath(rect, textDirection: textDirection);
}
@override
void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {
/// create shader linear gradient
canvas.drawPath(getInnerPath(rect), Paint()..color = Colors.transparent);
}
@override
ShapeBorder scale(double t) => this;
}

View file

@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
class SpacedListTile extends StatelessWidget {
final Widget title;
final Widget? content;
final Function()? onTap;
const SpacedListTile({required this.title, this.content, this.onTap, super.key});
@override
Widget build(BuildContext context) {
return ListTile(
title: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(flex: 1, child: title),
if (content != null)
Flexible(
flex: 1,
child: content!,
),
],
),
onTap: onTap,
);
}
}

View file

@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class StatusCard extends ConsumerWidget {
final Color? color;
final Widget child;
const StatusCard({this.color, required this.child, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Padding(
padding: EdgeInsets.all(5),
child: SizedBox(
width: 33,
height: 33,
child: Card(
elevation: 10,
surfaceTintColor: color,
shadowColor: color != null ? Colors.transparent : null,
child: IconTheme(
data: IconThemeData(
color: color,
),
child: Center(child: child),
),
),
),
);
}
}

View file

@ -0,0 +1,126 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:fladder/models/items/trick_play_model.dart';
class TrickplayImage extends ConsumerStatefulWidget {
final TrickPlayModel trickplay;
final Duration? position;
const TrickplayImage(this.trickplay, {this.position, super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _TrickplayImageState();
}
class _TrickplayImageState extends ConsumerState<TrickplayImage> {
ui.Image? image;
late TrickPlayModel model = widget.trickplay;
late Duration time = widget.position ?? Duration.zero;
late Offset currentOffset = Offset(0, 0);
String? currentUrl;
@override
void initState() {
super.initState();
loadImage();
}
@override
void didUpdateWidget(covariant TrickplayImage oldWidget) {
if (oldWidget.position?.inMilliseconds != widget.position?.inMilliseconds) {
time = widget.position ?? Duration.zero;
model = widget.trickplay;
loadImage();
}
super.didUpdateWidget(oldWidget);
}
@override
Widget build(BuildContext context) {
return Container(
child: image != null
? CustomPaint(
painter: TilledPainter(image!, currentOffset, widget.trickplay),
)
: Container(
color: Colors.purple,
),
);
}
Future<void> loadImage() async {
if (model.images.isEmpty) return;
final newUrl = model.getTile(time);
currentOffset = model.offset(time);
if (newUrl != currentUrl) {
currentUrl = newUrl;
final tempUrl = currentUrl;
if (tempUrl == null) return;
if (tempUrl.startsWith('http')) {
await loadNetworkImage(tempUrl);
} else {
await loadFileImage(tempUrl);
}
}
}
Future<void> loadNetworkImage(String url) async {
final http.Response response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
final Uint8List bytes = response.bodyBytes;
final ui.Codec codec = await ui.instantiateImageCodec(bytes);
final ui.FrameInfo frameInfo = await codec.getNextFrame();
setState(() {
image = frameInfo.image;
});
} else {
throw Exception('Failed to load network image');
}
}
Future<void> loadFileImage(String path) async {
final Uint8List bytes = await File(path).readAsBytes();
final ui.Codec codec = await ui.instantiateImageCodec(bytes);
final ui.FrameInfo frameInfo = await codec.getNextFrame();
setState(() {
image = frameInfo.image;
});
}
}
class TilledPainter extends CustomPainter {
final ui.Image image;
final Offset offset;
final TrickPlayModel model;
TilledPainter(this.image, this.offset, this.model);
@override
void paint(Canvas canvas, Size size) {
// Define the source rectangle from the image
Rect srcRect = Rect.fromLTWH(
offset.dx,
offset.dy,
model.width.toDouble(),
model.height.toDouble(),
); // Adjust these values to control the part of the image to display
// Define the destination rectangle on the canvas
Rect dstRect = Rect.fromLTWH(0, 0, size.width, size.height);
// Draw the image part onto the canvas
canvas.drawImageRect(image, srcRect, dstRect, Paint());
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return false;
}
}