feature: Add new home carousel (#58)

## Pull Request Description

This adds a new home carousel better suited for mobile
The old one is still available

## Checklist

- [x] If a new package was added, did you ensure it works for all
supported platforms? Is the package also well maintained?
- [x] Did you add localization for any text? If yes, did you sort the
.arb file using ```arb_utils sort <INPUT_FILE>```?
- [x] Check that any changes are related to the issue at hand.

Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
PartyDonut 2024-10-21 22:24:59 +02:00 committed by GitHub
parent 2a2502147a
commit d572884e61
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1696 additions and 393 deletions

View file

@ -16,7 +16,7 @@ import 'package:fladder/providers/settings/home_settings_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/providers/views_provider.dart';
import 'package:fladder/routes/auto_router.gr.dart';
import 'package:fladder/screens/shared/media/carousel_banner.dart';
import 'package:fladder/screens/dashboard/top_posters_row.dart';
import 'package:fladder/screens/shared/media/poster_row.dart';
import 'package:fladder/screens/shared/nested_scaffold.dart';
import 'package:fladder/screens/shared/nested_sliver_appbar.dart';
@ -104,20 +104,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
SliverToBoxAdapter(
child: Transform.translate(
offset: Offset(0, AdaptiveLayout.layoutOf(context) == LayoutState.phone ? -14 : 0),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: AdaptiveLayout.of(context).isDesktop ? 350 : 275,
maxHeight: (MediaQuery.sizeOf(context).height * 0.25).clamp(400, double.infinity)),
child: AspectRatio(
aspectRatio: 1.6,
child: SizedBox(
width: MediaQuery.of(context).size.width,
child: CarouselBanner(
items: homeCarouselItems,
),
),
),
),
child: TopPostersRow(posters: homeCarouselItems),
),
),
} else if (AdaptiveLayout.of(context).isDesktop)

View file

@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/settings/client_settings_model.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/screens/shared/media/carousel_banner.dart';
import 'package:fladder/screens/shared/media/media_banner.dart';
class TopPostersRow extends ConsumerWidget {
final List<ItemBaseModel> posters;
const TopPostersRow({required this.posters, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bannerType = ref.watch(clientSettingsProvider.select((value) => value.homeBanner));
final maxHeight = (MediaQuery.sizeOf(context).shortestSide * 0.6).clamp(125.0, 350.0);
return switch (bannerType) {
HomeBanner.carousel => Column(
mainAxisSize: MainAxisSize.min,
children: [
CarouselBanner(
items: posters,
maxHeight: maxHeight,
),
const SizedBox(height: 8)
],
),
HomeBanner.banner => MediaBanner(
items: posters,
maxHeight: maxHeight,
)
};
}
}

View file

@ -7,6 +7,7 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/settings/client_settings_model.dart';
import 'package:fladder/models/settings/home_settings_model.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/settings/home_settings_provider.dart';
@ -186,6 +187,28 @@ class _ClientSettingsPageState extends ConsumerState<ClientSettingsPage> {
.toList(),
),
),
SettingsListTile(
label: Text(context.localized.settingsHomeBannerTitle),
subLabel: Text(context.localized.settingsHomeBannerDescription),
trailing: EnumBox(
current: ref.watch(
clientSettingsProvider.select(
(value) => value.homeBanner.label(context),
),
),
itemBuilder: (context) => HomeBanner.values
.map(
(entry) => PopupMenuItem(
value: entry,
child: Text(entry.label(context)),
onTap: () => ref
.read(clientSettingsProvider.notifier)
.update((context) => context.copyWith(homeBanner: entry)),
),
)
.toList(),
),
),
SettingsListTile(
label: Text(context.localized.settingsHomeNextUpTitle),
subLabel: Text(context.localized.settingsHomeNextUpDesc),

View file

@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
@ -45,7 +44,7 @@ class SettingsScaffold extends ConsumerWidget {
leading: context.router.backButton(),
flexibleSpace: FlexibleSpaceBar(
titlePadding: const EdgeInsets.symmetric(horizontal: 16)
.add(EdgeInsets.only(left: padding.left, right: padding.right)),
.add(EdgeInsets.only(left: padding.left, right: padding.right, bottom: 4)),
title: Row(
children: [
Text(label, style: Theme.of(context).textTheme.headlineLarge),
@ -75,8 +74,7 @@ class SettingsScaffold extends ConsumerWidget {
),
),
SliverPadding(
padding: MediaQuery.paddingOf(context)
.copyWith(top: AdaptiveLayout.of(context).isDesktop || kIsWeb ? 0 : null),
padding: MediaQuery.paddingOf(context).copyWith(top: AdaptiveLayout.of(context).isDesktop ? 0 : 8),
sliver: SliverList(
delegate: SliverChildListDelegate(items),
),

View file

@ -1,26 +1,25 @@
import 'package:async/async.dart';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:ficonsax/ficonsax.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/fladder_carousel.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;
final double maxHeight;
const CarouselBanner({
this.controller,
required this.items,
this.maxHeight = 250,
super.key,
});
@ -29,350 +28,120 @@ class CarouselBanner extends ConsumerStatefulWidget {
}
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(const 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: const 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: const Duration(milliseconds: 125),
opacity: dragOpacity.abs(),
child: AnimatedSwitcher(
duration: const 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,
),
),
),
),
),
return ConstrainedBox(
constraints: BoxConstraints(maxHeight: widget.maxHeight),
child: LayoutBuilder(
builder: (context, constraints) {
final maxExtent = (constraints.maxHeight * 2.1).clamp(250.0, MediaQuery.sizeOf(context).shortestSide * 0.75);
final border = BorderRadius.circular(18);
return FladderCarousel(
shape: RoundedRectangleBorder(borderRadius: border),
onTap: (index) => widget.items[index].navigateTo(context),
onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.pointer
? null
: (index) {
final poster = widget.items[index];
showBottomSheetPill(
context: context,
item: poster,
content: (scrollContext, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children: poster.generateActions(context, ref).listTileItems(scrollContext, useIcons: true),
),
),
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(const SizedBox(height: 6)),
),
);
},
onSecondaryTap: AdaptiveLayout.of(context).inputDevice == InputDevice.touch
? null
: (details) async {
Offset localPosition = details.$2.globalPosition;
RelativeRect position = RelativeRect.fromLTRB(
localPosition.dx - 320, localPosition.dy, localPosition.dx, localPosition.dy);
final poster = widget.items[details.$1];
await showMenu(
context: context,
position: position,
items: poster.generateActions(context, ref).popupMenuItems(useIcons: true),
);
},
itemExtent: maxExtent,
children: [
...widget.items.mapIndexed(
(index, e) => LayoutBuilder(builder: (context, constraints) {
final opacity = (constraints.maxWidth / maxExtent);
return Stack(
clipBehavior: Clip.none,
children: [
FladderImage(image: e.bannerImage),
AnimatedOpacity(
duration: const Duration(milliseconds: 250),
opacity: opacity.clamp(0, 1),
child: Stack(
children: [
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomLeft,
end: Alignment.topCenter,
colors: [
Theme.of(context).colorScheme.primaryContainer.withOpacity(0.75),
Colors.transparent,
],
),
),
Wrap(
runSpacing: 6,
spacing: 6,
children: [
if (currentItem.playAble)
MediaPlayButton(
item: currentItem,
onPressed: () async {
await currentItem.play(
context,
ref,
);
},
),
],
),
].addInBetween(const SizedBox(height: 16)),
),
),
),
],
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: AnimatedOpacity(
opacity: showControls ? 1 : 0,
duration: const Duration(milliseconds: 250),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton.filledTonal(
onPressed: () => nextSlide(),
icon: const Icon(IconsaxOutline.arrow_right_3),
)
],
),
),
),
],
),
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer)
),
Align(
alignment: Alignment.bottomRight,
alignment: Alignment.bottomLeft,
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),
),
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
e.title,
maxLines: 2,
softWrap: e.title.length > 25,
textWidthBasis: TextWidthBasis.parent,
overflow: TextOverflow.fade,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(color: Colors.white),
),
if (e.label(context) != null)
Text(
e.label(context)!,
maxLines: 2,
softWrap: false,
overflow: TextOverflow.fade,
style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white),
),
].addInBetween(const SizedBox(height: 4)),
),
),
),
],
),
),
),
),
),
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: const 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),
Container(
decoration: BoxDecoration(
border: Border.all(
color: Colors.white.withOpacity(0.1),
width: 1.0,
),
),
),
borderRadius: border),
),
),
],
);
}).toList(),
),
),
),
)
],
}),
)
],
);
},
),
);
}
void animateToTarget(int nextIndex) {
int step = currentPage < nextIndex ? 1 : -1;
void updateItem(int item) {
Future.delayed(Duration(milliseconds: 64 ~/ ((currentPage - nextIndex).abs() / 3)), () {
setState(() {
currentPage = item;
});
if (currentPage != nextIndex) {
updateItem(item + step);
}
});
timer.reset();
}
updateItem(currentPage + step);
}
}

View file

@ -0,0 +1,370 @@
import 'package:flutter/material.dart';
import 'package:async/async.dart';
import 'package:collection/collection.dart';
import 'package:ficonsax/ficonsax.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/movie_model.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/themes_data.dart';
import 'package:fladder/widgets/shared/fladder_carousel.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
class MediaBanner extends ConsumerStatefulWidget {
final PageController? controller;
final List<ItemBaseModel> items;
final double maxHeight;
const MediaBanner({
this.controller,
required this.items,
this.maxHeight = 250,
super.key,
});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _MediaBannerState();
}
class _MediaBannerState extends ConsumerState<MediaBanner> {
bool showControls = false;
bool interacting = false;
int currentPage = 0;
double dragOffset = 0;
double dragIntensity = 1;
double slidePosition = 1;
late final RestartableTimer timer = RestartableTimer(const 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();
}
final controller = FladderCarouselController();
@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: [
ConstrainedBox(
constraints: BoxConstraints(maxHeight: widget.maxHeight),
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;
final poster = currentItem;
showBottomSheetPill(
context: context,
item: poster,
content: (scrollContext, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children: poster.generateActions(context, ref).listTileItems(scrollContext, 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: const 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: const Duration(milliseconds: 125),
opacity: dragOpacity.abs(),
child: AnimatedSwitcher(
duration: const 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: 2,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
shadows: shadows,
color: Colors.white,
),
),
),
if (currentItem.label(context) != null && currentItem is! MovieModel)
Flexible(
child: Text(
currentItem.label(context)!,
maxLines: 2,
style: Theme.of(context).textTheme.titleMedium?.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: 2,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
shadows: shadows,
color: Colors.white.withOpacity(0.75),
),
),
),
].addInBetween(const SizedBox(height: 6)),
),
),
),
].addInBetween(const SizedBox(height: 16)),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: AnimatedOpacity(
opacity: showControls ? 1 : 0,
duration: const Duration(milliseconds: 250),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton.filledTonal(
onPressed: () => nextSlide(),
icon: const 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.only(top: 4),
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: const 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);
}
}