chore: Improved performance for some widgets (#525)

Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
PartyDonut 2025-10-10 15:54:17 +02:00 committed by GitHub
parent 10bd34bb20
commit 07972ea5ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 589 additions and 545 deletions

View file

@ -72,8 +72,6 @@ class SettingsListTile extends StatelessWidget {
constraints: const BoxConstraints( constraints: const BoxConstraints(
minHeight: 50, minHeight: 50,
), ),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Row( child: Row(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
@ -99,13 +97,13 @@ class SettingsListTile extends StatelessWidget {
child: label, child: label,
), ),
if (subLabel != null) if (subLabel != null)
Opacity( Material(
opacity: 0.65,
child: Material(
color: Colors.transparent, color: Colors.transparent,
textStyle: Theme.of(context).textTheme.labelLarge?.copyWith(color: contentColor), textStyle: Theme.of(context).textTheme.labelLarge?.copyWith(
child: subLabel, color:
(contentColor ?? Theme.of(context).colorScheme.onSurface).withValues(alpha: 0.65),
), ),
child: subLabel,
), ),
], ],
), ),
@ -123,7 +121,6 @@ class SettingsListTile extends StatelessWidget {
), ),
), ),
), ),
),
); );
} }
} }

View file

@ -104,15 +104,29 @@ class _DetailScaffoldState extends ConsumerState<DetailScaffold> {
final minHeight = 450.0.clamp(0, size.height).toDouble(); final minHeight = 450.0.clamp(0, size.height).toDouble();
final maxHeight = size.height - 10; final maxHeight = size.height - 10;
final sideBarPadding = AdaptiveLayout.of(context).sideBarWidth; final sideBarPadding = AdaptiveLayout.of(context).sideBarWidth;
return Theme( final newColorScheme = dominantColor != null
data: Theme.of(context).copyWith(
colorScheme: dominantColor != null
? ColorScheme.fromSeed( ? ColorScheme.fromSeed(
seedColor: dominantColor!, seedColor: dominantColor!,
brightness: Theme.brightnessOf(context), brightness: Theme.brightnessOf(context),
dynamicSchemeVariant: ref.watch(clientSettingsProvider.select((value) => value.schemeVariant)), dynamicSchemeVariant: ref.watch(clientSettingsProvider.select((value) => value.schemeVariant)),
) )
: null, : null;
final amoledBlack = ref.watch(clientSettingsProvider.select((value) => value.amoledBlack));
final amoledOverwrite = amoledBlack ? Colors.black : null;
return Theme(
data: Theme.of(context)
.copyWith(
colorScheme: newColorScheme,
)
.copyWith(
scaffoldBackgroundColor: amoledOverwrite,
cardColor: amoledOverwrite,
canvasColor: amoledOverwrite,
colorScheme: newColorScheme?.copyWith(
surface: amoledOverwrite,
surfaceContainerHighest: amoledOverwrite,
surfaceContainerLow: amoledOverwrite,
),
), ),
child: Builder(builder: (context) { child: Builder(builder: (context) {
return PullToRefresh( return PullToRefresh(

View file

@ -5,13 +5,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/screens/shared/media/banner_play_button.dart'; import 'package:fladder/screens/shared/media/banner_play_button.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/fladder_image.dart'; import 'package:fladder/util/fladder_image.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart'; import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
import 'package:fladder/util/list_padding.dart'; import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/themes_data.dart'; import 'package:fladder/util/themes_data.dart';
import 'package:fladder/widgets/shared/ensure_visible.dart';
import 'package:fladder/widgets/shared/item_actions.dart'; import 'package:fladder/widgets/shared/item_actions.dart';
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart'; import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
@ -64,67 +65,14 @@ class _CarouselBannerState extends ConsumerState<CarouselBanner> {
itemExtent: itemExtent, itemExtent: itemExtent,
children: [ children: [
...widget.items.mapIndexed( ...widget.items.mapIndexed(
(index, item) => LayoutBuilder(builder: (context, constraints) { (index, item) => LayoutBuilder(
builder: (context, constraints) {
final opacity = (constraints.maxWidth / maxExtent); final opacity = (constraints.maxWidth / maxExtent);
return Stack( return FocusButton(
clipBehavior: Clip.none,
children: [
FladderImage(image: item.bannerImage),
Opacity(
opacity: opacity.clamp(0, 1),
child: Stack(
children: [
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomLeft,
end: Alignment.topCenter,
colors: [
ThemesData.of(context)
.dark
.colorScheme
.primaryContainer
.withValues(alpha: 0.85),
Colors.transparent,
],
),
),
),
),
],
),
),
Align(
alignment: Alignment.bottomLeft,
child: Padding(
padding: const EdgeInsets.all(16.0).copyWith(right: constraints.maxWidth * 0.2),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 2,
softWrap: item.title.length > 25,
overflow: TextOverflow.fade,
style:
Theme.of(context).textTheme.headlineMedium?.copyWith(color: Colors.white),
),
if (item.label(context) != null || item.subText != null)
Text(
item.label(context) ?? item.subText ?? "",
maxLines: 2,
softWrap: false,
overflow: TextOverflow.fade,
style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white),
),
].addInBetween(const SizedBox(height: 4)),
),
),
),
FlatButton(
onTap: () => widget.items[index].navigateTo(context), onTap: () => widget.items[index].navigateTo(context),
onFocusChanged: (hover) {
context.ensureVisible();
},
onLongPress: AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer onLongPress: AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer
? null ? null
: () { : () {
@ -155,6 +103,57 @@ class _CarouselBannerState extends ConsumerState<CarouselBanner> {
items: poster.generateActions(context, ref).popupMenuItems(useIcons: true), items: poster.generateActions(context, ref).popupMenuItems(useIcons: true),
); );
}, },
child: Stack(
children: [
FladderImage(image: item.bannerImage),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomLeft,
end: Alignment.topCenter,
colors: [
ThemesData.of(context)
.dark
.colorScheme
.primaryContainer
.withValues(alpha: opacity.clamp(0, 1)),
Colors.transparent,
],
),
),
),
Align(
alignment: Alignment.bottomLeft,
child: Padding(
padding: const EdgeInsets.all(16.0).copyWith(right: constraints.maxWidth * 0.2),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 2,
softWrap: item.title.length > 25,
overflow: TextOverflow.fade,
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(color: Colors.white),
),
if (item.label(context) != null || item.subText != null)
Text(
item.label(context) ?? item.subText ?? "",
maxLines: 2,
softWrap: false,
overflow: TextOverflow.fade,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: Colors.white),
),
].addInBetween(const SizedBox(height: 4)),
),
),
), ),
ExcludeFocus( ExcludeFocus(
child: BannerPlayButton(item: widget.items[index]), child: BannerPlayButton(item: widget.items[index]),
@ -166,17 +165,21 @@ class _CarouselBannerState extends ConsumerState<CarouselBanner> {
color: Colors.white.withValues(alpha: 0.1), color: Colors.white.withValues(alpha: 0.1),
width: 1.0, width: 1.0,
), ),
borderRadius: border), borderRadius: border,
),
), ),
), ),
], ],
),
); );
}), },
),
) )
], ],
), ),
if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer) if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer)
AnimatedOpacity( ExcludeFocus(
child: AnimatedOpacity(
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
opacity: showControls ? 1 : 0, opacity: showControls ? 1 : 0,
child: IgnorePointer( child: IgnorePointer(
@ -211,6 +214,7 @@ class _CarouselBannerState extends ConsumerState<CarouselBanner> {
), ),
), ),
), ),
),
], ],
), ),
); );

View file

@ -20,6 +20,7 @@ class NextUpEpisode extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final alreadyPlayed = nextEpisode.userData.played; final alreadyPlayed = nextEpisode.userData.played;
final episodeSummary = nextEpisode.overview.summary.maxLength(limitTo: 250); final episodeSummary = nextEpisode.overview.summary.maxLength(limitTo: 250);
final style = Theme.of(context).textTheme.titleMedium;
return Column( return Column(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -27,11 +28,10 @@ class NextUpEpisode extends ConsumerWidget {
StickyHeaderText( StickyHeaderText(
label: alreadyPlayed ? context.localized.reWatch : context.localized.nextUp, label: alreadyPlayed ? context.localized.reWatch : context.localized.nextUp,
), ),
Opacity( SelectableText(
opacity: 0.75,
child: SelectableText(
nextEpisode.seasonEpisodeLabelFull(context), nextEpisode.seasonEpisodeLabelFull(context),
style: Theme.of(context).textTheme.titleMedium, style: style?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.75),
), ),
), ),
SelectableText( SelectableText(

View file

@ -84,6 +84,50 @@ class _PosterImageState extends ConsumerState<PosterImage> {
return Hero( return Hero(
tag: tag, tag: tag,
child: FocusButton(
onTap: () => pressedWidget(context),
onFocusChanged: widget.onFocusChanged,
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),
),
);
},
onSecondaryTapDown: (details) async {
Offset localPosition = details.globalPosition;
RelativeRect position =
RelativeRect.fromLTRB(localPosition.dx, 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),
);
},
child: Card( child: Card(
elevation: 6, elevation: 6,
color: Theme.of(context).colorScheme.secondaryContainer, color: Theme.of(context).colorScheme.secondaryContainer,
@ -201,7 +245,8 @@ class _PosterImageState extends ConsumerState<PosterImage> {
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Text( child: Text(
widget.poster.title.maxLength(limitTo: 25), widget.poster.title.maxLength(limitTo: 25),
style: Theme.of(context).textTheme.labelLarge?.copyWith(fontSize: 20, fontWeight: FontWeight.bold), style:
Theme.of(context).textTheme.labelLarge?.copyWith(fontSize: 20, fontWeight: FontWeight.bold),
), ),
), ),
), ),
@ -272,50 +317,9 @@ class _PosterImageState extends ConsumerState<PosterImage> {
), ),
) )
}, },
FocusButton( ],
onTap: () => pressedWidget(context), ),
onFocusChanged: widget.onFocusChanged,
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),
), ),
);
},
onSecondaryTapDown: (details) async {
Offset localPosition = details.globalPosition;
RelativeRect position =
RelativeRect.fromLTRB(localPosition.dx, 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),
);
},
overlays: [ overlays: [
//Poster Button //Poster Button
if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer) ...[ if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer) ...[
@ -360,9 +364,6 @@ class _PosterImageState extends ConsumerState<PosterImage> {
], ],
], ],
), ),
],
),
),
); );
} }
} }

View file

@ -8,6 +8,7 @@ class PosterPlaceholder extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.75);
return Stack( return Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
@ -15,7 +16,10 @@ class PosterPlaceholder extends StatelessWidget {
alignment: Alignment.topRight, alignment: Alignment.topRight,
child: Padding( child: Padding(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.all(12.0),
child: Opacity(opacity: 0.5, child: Icon(item.type.icon)), child: Icon(
item.type.icon,
color: color.withValues(alpha: 0.5),
),
), ),
), ),
Padding( Padding(
@ -34,15 +38,14 @@ class PosterPlaceholder extends StatelessWidget {
softWrap: true, softWrap: true,
), ),
if (item.label(context) != null) ...[ if (item.label(context) != null) ...[
Opacity( Text(
opacity: 0.75,
child: Text(
item.label(context)!, item.label(context)!,
maxLines: 2, maxLines: 2,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleSmall, style: Theme.of(context).textTheme.titleSmall?.copyWith(
softWrap: true, color: color.withValues(alpha: 0.75),
), ),
softWrap: true,
), ),
], ],
], ],

View file

@ -38,6 +38,9 @@ class EpisodeDetailsList extends ConsumerWidget {
((AdaptiveLayout.poster(context).gridRatio * 2) * ((AdaptiveLayout.poster(context).gridRatio * 2) *
ref.watch(clientSettingsProvider.select((value) => value.posterSize))); ref.watch(clientSettingsProvider.select((value) => value.posterSize)));
final decimals = size - size.toInt(); final decimals = size - size.toInt();
final textStyle = Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.65),
);
return AnimatedSwitcher( return AnimatedSwitcher(
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
child: switch (viewType) { child: switch (viewType) {
@ -73,20 +76,14 @@ class EpisodeDetailsList extends ConsumerWidget {
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Opacity( SelectableText(
opacity: 0.65,
child: SelectableText(
episode.seasonEpisodeLabel(context), episode.seasonEpisodeLabel(context),
style: Theme.of(context).textTheme.titleMedium, style: textStyle,
),
), ),
if (episode.overview.runTime != null) if (episode.overview.runTime != null)
Opacity( SelectableText(
opacity: 0.65,
child: SelectableText(
" - ${episode.overview.runTime!.humanize!}", " - ${episode.overview.runTime!.humanize!}",
style: Theme.of(context).textTheme.titleMedium, style: textStyle,
),
), ),
], ],
), ),

View file

@ -149,14 +149,11 @@ class PosterListItem extends ConsumerWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
if ((poster.subText ?? poster.subTextShort(context))?.isNotEmpty == true) if ((poster.subText ?? poster.subTextShort(context))?.isNotEmpty == true)
Opacity( Text(
opacity: 0.45,
child: Text(
poster.subText ?? poster.subTextShort(context) ?? "", poster.subText ?? poster.subTextShort(context) ?? "",
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
),
Row( Row(
children: [ children: [
if (subTitle != null) ...[ if (subTitle != null) ...[

View file

@ -96,11 +96,8 @@ class PosterWidget extends ConsumerWidget {
children: [ children: [
if (subTitle != null) ...[ if (subTitle != null) ...[
Flexible( Flexible(
child: Opacity(
opacity: opacity,
child: subTitle!, child: subTitle!,
), ),
),
], ],
if (poster.subText?.isNotEmpty ?? false) if (poster.subText?.isNotEmpty ?? false)
Flexible( Flexible(

View file

@ -200,7 +200,7 @@ class _OutlinedTextFieldState extends ConsumerState<OutlinedTextField> {
child: KeyboardListener( child: KeyboardListener(
focusNode: _wrapperFocus, focusNode: _wrapperFocus,
onKeyEvent: (KeyEvent event) { onKeyEvent: (KeyEvent event) {
if (keyboardFocus) return; if (keyboardFocus || AdaptiveLayout.inputDeviceOf(context) != InputDevice.dPad) return;
if (event is KeyDownEvent && acceptKeys.contains(event.logicalKey)) { if (event is KeyDownEvent && acceptKeys.contains(event.logicalKey)) {
if (_textFocus.hasFocus) { if (_textFocus.hasFocus) {
_wrapperFocus.requestFocus(); _wrapperFocus.requestFocus();

View file

@ -5,7 +5,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/account_model.dart'; import 'package:fladder/models/account_model.dart';
import 'package:fladder/screens/shared/flat_button.dart'; import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/theme.dart';
import 'package:fladder/util/string_extensions.dart'; import 'package:fladder/util/string_extensions.dart';
class UserIcon extends ConsumerWidget { class UserIcon extends ConsumerWidget {
@ -18,7 +17,7 @@ class UserIcon extends ConsumerWidget {
const UserIcon({ const UserIcon({
this.size = const Size(50, 50), this.size = const Size(50, 50),
this.labelStyle, this.labelStyle,
this.cornerRadius = 5, this.cornerRadius = 16,
this.onTap, this.onTap,
this.onLongPress, this.onLongPress,
required this.user, required this.user,
@ -46,26 +45,24 @@ class UserIcon extends ConsumerWidget {
tag: Key(user?.id ?? "empty-user-avatar"), tag: Key(user?.id ?? "empty-user-avatar"),
child: AspectRatio( child: AspectRatio(
aspectRatio: 1, aspectRatio: 1,
child: Card( child: Container(
elevation: 0, decoration: BoxDecoration(
surfaceTintColor: Colors.transparent, borderRadius: BorderRadius.circular(cornerRadius),
color: Colors.transparent, ),
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
child: SizedBox.fromSize( child: SizedBox.fromSize(
size: size, size: size,
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
fit: StackFit.expand,
children: [ children: [
ClipRRect( CachedNetworkImage(
borderRadius: FladderTheme.smallShape.borderRadius,
child: CachedNetworkImage(
imageUrl: user?.avatar ?? "", imageUrl: user?.avatar ?? "",
progressIndicatorBuilder: (context, url, progress) => placeHolder(), progressIndicatorBuilder: (context, url, progress) => placeHolder(),
errorWidget: (context, url, error) => placeHolder(), errorWidget: (context, url, error) => placeHolder(),
memCacheHeight: 128, memCacheHeight: 128,
fit: BoxFit.cover, fit: BoxFit.cover,
), ),
),
FlatButton( FlatButton(
onTap: onTap, onTap: onTap,
onLongPress: onLongPress, onLongPress: onLongPress,

View file

@ -175,8 +175,8 @@ class FocusButtonState extends State<FocusButton> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.primaryContainer .surfaceContainerLowest
.withValues(alpha: widget.darkOverlay ? 0.1 : 0), .withValues(alpha: widget.darkOverlay ? 0.35 : 0),
border: Border.all(width: 3, color: Theme.of(context).colorScheme.onPrimaryContainer), border: Border.all(width: 3, color: Theme.of(context).colorScheme.onPrimaryContainer),
borderRadius: widget.borderRadius ?? FladderTheme.smallShape.borderRadius, borderRadius: widget.borderRadius ?? FladderTheme.smallShape.borderRadius,
), ),

View file

@ -38,7 +38,7 @@ class SettingsUserIcon extends ConsumerWidget {
children: [ children: [
UserIcon( UserIcon(
user: user, user: user,
cornerRadius: 200, cornerRadius: 8,
), ),
if (hasNewUpdate) if (hasNewUpdate)
Transform.translate( Transform.translate(

View file

@ -103,15 +103,15 @@ class _SideNavigationBarState extends ConsumerState<SideNavigationBar> {
if (expandedSideBar) ...[ if (expandedSideBar) ...[
Expanded(child: Text(context.localized.navigation)), Expanded(child: Text(context.localized.navigation)),
], ],
Opacity( IconButton(
opacity: largeBar && expandedSideBar ? 0.65 : 1.0,
child: IconButton(
onPressed: !largeBar onPressed: !largeBar
? () => widget.scaffoldKey.currentState?.openDrawer() ? () => widget.scaffoldKey.currentState?.openDrawer()
: () => setState(() => expandedSideBar = !expandedSideBar), : () => setState(() => expandedSideBar = !expandedSideBar),
icon: Icon( icon: Icon(
largeBar && expandedSideBar ? IconsaxPlusLinear.sidebar_left : IconsaxPlusLinear.menu, largeBar && expandedSideBar ? IconsaxPlusLinear.sidebar_left : IconsaxPlusLinear.menu,
), ),
color: Theme.of(context).colorScheme.onSurface.withValues(
alpha: largeBar && expandedSideBar ? 0.65 : 1,
), ),
) )
], ],

View file

@ -28,19 +28,20 @@ class _ClickableTextState extends ConsumerState<ClickableText> {
bool hovering = false; bool hovering = false;
Widget _textWidget(bool showDecoration) { Widget _textWidget(bool showDecoration) {
return Opacity( final color =
opacity: widget.opacity, (showDecoration ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.onSurface).withValues(
child: Text( alpha: widget.opacity,
);
return Text(
widget.text, widget.text,
maxLines: widget.maxLines, maxLines: widget.maxLines,
overflow: widget.overflow, overflow: widget.overflow,
style: widget.style?.copyWith( style: widget.style?.copyWith(
color: showDecoration ? Theme.of(context).colorScheme.primary : null, color: color,
decoration: showDecoration ? TextDecoration.underline : TextDecoration.none, decoration: showDecoration ? TextDecoration.underline : TextDecoration.none,
decorationColor: showDecoration ? Theme.of(context).colorScheme.primary : null, decorationColor: color,
decorationThickness: 3, decorationThickness: 3,
), ),
),
); );
} }

View file

@ -53,21 +53,30 @@ class HorizontalList<T> extends ConsumerStatefulWidget {
ConsumerState<ConsumerStatefulWidget> createState() => _HorizontalListState(); ConsumerState<ConsumerStatefulWidget> createState() => _HorizontalListState();
} }
class _HorizontalListState extends ConsumerState<HorizontalList> { class _HorizontalListState extends ConsumerState<HorizontalList> with TickerProviderStateMixin {
final FocusNode parentNode = FocusNode(); final FocusNode parentNode = FocusNode();
FocusNode? lastFocused; FocusNode? lastFocused;
final GlobalKey _firstItemKey = GlobalKey(); final GlobalKey _firstItemKey = GlobalKey();
final GlobalKey _listViewKey = GlobalKey();
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
final contentPadding = 8.0; final contentPadding = 8.0;
double? contentWidth; double? contentWidth;
double? _firstItemWidth; double? _firstItemWidth;
AnimationController? _scrollAnimation;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_measureFirstItem(); _measureFirstItem();
} }
@override
void dispose() {
_scrollAnimation?.dispose();
super.dispose();
}
void _measureFirstItem() { void _measureFirstItem() {
if (_firstItemWidth != null) return; if (_firstItemWidth != null) return;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@ -87,16 +96,34 @@ class _HorizontalListState extends ConsumerState<HorizontalList> {
} }
Future<void> _scrollToPosition(int index) async { Future<void> _scrollToPosition(int index) async {
if (_firstItemWidth == null) return; if (_firstItemWidth == null || !_scrollController.hasClients) return;
final offset = index * (_firstItemWidth! + contentPadding); final target = (index * (_firstItemWidth! + contentPadding)).clamp(0, _scrollController.position.maxScrollExtent);
final clamped = math.min(offset, _scrollController.position.maxScrollExtent);
await _scrollController.animateTo( // Cancel any ongoing animation
clamped, _scrollAnimation?.stop();
duration: const Duration(milliseconds: 250),
curve: Curves.fastOutSlowIn, final controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 125),
); );
_scrollAnimation = controller;
final tween = Tween<double>(
begin: _scrollController.offset,
end: target.toDouble(),
);
controller.addListener(() {
if (_scrollController.hasClients) {
_scrollController.jumpTo(tween.evaluate(controller));
}
});
controller.forward().whenComplete(() {
if (_scrollAnimation == controller) _scrollAnimation = null;
});
} }
void _scrollToStart() { void _scrollToStart() {
@ -146,11 +173,10 @@ class _HorizontalListState extends ConsumerState<HorizontalList> {
if (widget.subtext != null) if (widget.subtext != null)
Flexible( Flexible(
child: ExcludeFocus( child: ExcludeFocus(
child: Opacity(
opacity: 0.5,
child: Text( child: Text(
widget.subtext!, widget.subtext!,
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
), ),
), ),
), ),
@ -223,11 +249,16 @@ class _HorizontalListState extends ConsumerState<HorizontalList> {
if (currentNode != null) { if (currentNode != null) {
lastFocused = currentNode; lastFocused = currentNode;
final correctIndex = _getCorrectIndexForNode(currentNode);
if (widget.onFocused != null) { if (widget.onFocused != null) {
widget.onFocused!(nodesOnSameRow.indexOf(currentNode)); if (correctIndex != -1) {
widget.onFocused!(correctIndex);
}
} else { } else {
context.ensureVisible(); context.ensureVisible();
} }
currentNode.requestFocus(); currentNode.requestFocus();
} }
} }
@ -244,25 +275,17 @@ class _HorizontalListState extends ConsumerState<HorizontalList> {
throttle: Throttler(duration: const Duration(milliseconds: 100)), throttle: Throttler(duration: const Duration(milliseconds: 100)),
onFocused: (node) { onFocused: (node) {
lastFocused = node; lastFocused = node;
final nodesOnSameRow = _nodesInRow(parentNode); final correctIndex = _getCorrectIndexForNode(node);
if (widget.onFocused != null) { if (correctIndex != -1) {
widget.onFocused?.call(nodesOnSameRow.indexOf(node)); widget.onFocused?.call(correctIndex);
} _scrollToPosition(correctIndex);
final nodeContext = node.context!;
final renderObject = nodeContext.findRenderObject();
if (renderObject != null) {
final position = _scrollController.position;
position.ensureVisible(
renderObject,
alignment: _calcAlignmentWithPadding(nodeContext),
duration: const Duration(milliseconds: 175),
curve: Curves.fastOutSlowIn,
);
} }
}, },
), ),
child: ListView.separated( child: ListView.separated(
key: _listViewKey,
controller: _scrollController, controller: _scrollController,
clipBehavior: Clip.none,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: widget.contentPadding, padding: widget.contentPadding,
itemBuilder: (context, index) => index == widget.items.length itemBuilder: (context, index) => index == widget.items.length
@ -286,10 +309,24 @@ class _HorizontalListState extends ConsumerState<HorizontalList> {
); );
} }
double _calcAlignmentWithPadding(BuildContext context) { int _getCorrectIndexForNode(FocusNode node) {
final viewportWidth = _scrollController.position.viewportDimension; if (!mounted || _firstItemWidth == null || !_scrollController.hasClients || node.context == null) return -1;
final double leftPadding = widget.contentPadding.left + (contentPadding * 2);
return leftPadding / viewportWidth; final scrollableContext = _listViewKey.currentContext;
if (scrollableContext == null || !scrollableContext.mounted) return -1;
final scrollableBox = scrollableContext.findRenderObject() as RenderBox?;
final itemBox = node.context!.findRenderObject() as RenderBox?;
if (scrollableBox == null || itemBox == null) return -1;
final dx = itemBox.localToGlobal(Offset.zero, ancestor: scrollableBox).dx;
final totalItemWidth = _firstItemWidth! + contentPadding;
final offset = dx + _scrollController.offset - widget.contentPadding.left;
final index = ((offset + totalItemWidth / 2) ~/ totalItemWidth).clamp(0, widget.items.length - 1);
return index;
} }
} }
@ -344,12 +381,11 @@ class HorizontalRailFocus extends WidgetOrderTraversalPolicy {
@override @override
bool inDirection(FocusNode currentNode, TraversalDirection direction) { bool inDirection(FocusNode currentNode, TraversalDirection direction) {
if (throttle?.canRun() == false) return true;
final rowNodes = _nodesInRow(parentNode); final rowNodes = _nodesInRow(parentNode);
final index = rowNodes.indexOf(currentNode); final index = rowNodes.indexOf(currentNode);
if (direction == TraversalDirection.left) { if (direction == TraversalDirection.left) {
if (throttle?.canRun() == false) return true;
if (index > 0) { if (index > 0) {
final target = rowNodes[index - 1]; final target = rowNodes[index - 1];
target.requestFocus(); target.requestFocus();