feat: Android TV support (#503)

Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
PartyDonut 2025-09-28 21:07:49 +02:00 committed by GitHub
parent 7ab8c015b9
commit c299492d6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
168 changed files with 12019 additions and 3073 deletions

View file

@ -1,11 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/providers/settings/book_viewer_settings_provider.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/string_extensions.dart';
import 'package:fladder/widgets/shared/enum_selection.dart';
import 'package:fladder/widgets/shared/fladder_slider.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
import 'package:fladder/widgets/shared/modal_side_sheet.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
Future<void> showBookViewerSettings(
BuildContext context,
@ -80,10 +83,9 @@ class BookViewerSettingsScreen extends ConsumerWidget {
label: const Text("Read direction"),
current: settings.readDirection.name.toUpperCaseSplit(),
itemBuilder: (context) => ReadDirection.values
.map((value) => PopupMenuItem(
value: value,
child: Text(value.name.toUpperCaseSplit()),
onTap: () => ref
.map((value) => ItemActionButton(
label: Text(value.name.toUpperCaseSplit()),
action: () => ref
.read(bookViewerSettingsProvider.notifier)
.update((state) => state.copyWith(readDirection: value)),
))
@ -102,10 +104,9 @@ class BookViewerSettingsScreen extends ConsumerWidget {
label: const Text("Init zoom"),
current: settings.initZoomState.name.toUpperCaseSplit(),
itemBuilder: (context) => InitZoomState.values
.map((value) => PopupMenuItem(
value: value,
child: Text(value.name.toUpperCaseSplit()),
onTap: () => ref
.map((value) => ItemActionButton(
label: Text(value.name.toUpperCaseSplit()),
action: () => ref
.read(bookViewerSettingsProvider.notifier)
.update((state) => state.copyWith(initZoomState: value)),
))

View file

@ -9,6 +9,7 @@ import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/string_extensions.dart';
import 'package:fladder/widgets/shared/enum_selection.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
final _selectedWarningProvider = StateProvider<ErrorType?>((ref) => null);
@ -41,16 +42,14 @@ class CrashScreen extends ConsumerWidget {
EnumBox(
current: selectedType == null ? context.localized.all : selectedType.name.capitalize(),
itemBuilder: (context) => [
PopupMenuItem(
value: null,
child: Text(context.localized.all),
onTap: () => ref.read(_selectedWarningProvider.notifier).update((state) => null),
ItemActionButton(
label: Text(context.localized.all),
action: () => ref.read(_selectedWarningProvider.notifier).update((state) => null),
),
...ErrorType.values.map(
(entry) => PopupMenuItem(
value: entry,
child: Text(entry.name.capitalize()),
onTap: () => ref.read(_selectedWarningProvider.notifier).update((state) => entry),
(entry) => ItemActionButton(
label: Text(entry.name.capitalize()),
action: () => ref.read(_selectedWarningProvider.notifier).update((state) => entry),
),
)
],

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.enums.swagger.dart';
@ -23,6 +24,7 @@ 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';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/sliver_list_padding.dart';
@ -45,6 +47,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
late final Timer _timer;
final GlobalKey<RefreshIndicatorState> _refreshIndicatorKey = GlobalKey<RefreshIndicatorState>();
final textController = TextEditingController();
ItemBaseModel? selectedPoster;
@override
void initState() {
super.initState();
@ -70,6 +76,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
@override
Widget build(BuildContext context) {
final padding = AdaptiveLayout.adaptivePadding(context);
final bannerType = ref.watch(homeSettingsProvider.select((value) => value.homeBanner));
final dashboardData = ref.watch(dashboardProvider);
final views = ref.watch(viewsProvider);
@ -87,10 +94,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
HomeCarouselSettings.cont => allResume,
};
final viewSize = AdaptiveLayout.viewSizeOf(context);
return MediaQuery.removeViewInsets(
context: context,
child: NestedScaffold(
background: BackgroundImage(items: [...homeCarouselItems, ...dashboardData.nextUp, ...allResume]),
background: BackgroundImage(
items: selectedPoster != null
? [selectedPoster!]
: [...homeCarouselItems, ...dashboardData.nextUp, ...allResume]),
body: PullToRefresh(
refreshKey: _refreshIndicatorKey,
displacement: 80 + MediaQuery.of(context).viewPadding.top,
@ -101,8 +113,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
controller: AdaptiveLayout.scrollOf(context, HomeTabs.dashboard),
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
const DefaultSliverTopBadding(),
if (AdaptiveLayout.viewSizeOf(context) == ViewSize.phone)
if (bannerType != HomeBanner.detailedBanner) const DefaultSliverTopBadding(),
if (viewSize == ViewSize.phone)
NestedSliverAppBar(
route: LibrarySearchRoute(),
parent: context,
@ -114,7 +126,16 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
context,
horizontalPadding: 0,
),
child: HomeBannerWidget(posters: homeCarouselItems),
child: HomeBannerWidget(
posters: homeCarouselItems,
onSelect: (selected) {
// if (selectedPoster != selected) {
// setState(() {
// selectedPoster = selected;
// });
// }
},
),
),
),
},
@ -130,80 +151,84 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
...[
if (resumeVideo.isNotEmpty &&
(homeSettings.nextUp == HomeNextUp.cont || homeSettings.nextUp == HomeNextUp.separate))
SliverToBoxAdapter(
child: PosterRow(
contentPadding: padding,
label: context.localized.dashboardContinueWatching,
posters: resumeVideo,
),
PosterRow(
contentPadding: padding,
label: context.localized.dashboardContinueWatching,
posters: resumeVideo,
),
if (resumeAudio.isNotEmpty &&
(homeSettings.nextUp == HomeNextUp.cont || homeSettings.nextUp == HomeNextUp.separate))
SliverToBoxAdapter(
child: PosterRow(
contentPadding: padding,
label: context.localized.dashboardContinueListening,
posters: resumeAudio,
),
PosterRow(
contentPadding: padding,
label: context.localized.dashboardContinueListening,
posters: resumeAudio,
),
if (resumeBooks.isNotEmpty &&
(homeSettings.nextUp == HomeNextUp.cont || homeSettings.nextUp == HomeNextUp.separate))
SliverToBoxAdapter(
child: PosterRow(
contentPadding: padding,
label: context.localized.dashboardContinueReading,
posters: resumeBooks,
),
PosterRow(
contentPadding: padding,
label: context.localized.dashboardContinueReading,
posters: resumeBooks,
),
if (dashboardData.nextUp.isNotEmpty &&
(homeSettings.nextUp == HomeNextUp.nextUp || homeSettings.nextUp == HomeNextUp.separate))
SliverToBoxAdapter(
child: PosterRow(
contentPadding: padding,
label: context.localized.nextUp,
posters: dashboardData.nextUp,
),
PosterRow(
contentPadding: padding,
label: context.localized.nextUp,
posters: dashboardData.nextUp,
),
if ([...allResume, ...dashboardData.nextUp].isNotEmpty && homeSettings.nextUp == HomeNextUp.combined)
SliverToBoxAdapter(
child: PosterRow(
contentPadding: padding,
label: context.localized.dashboardContinue,
posters: [...allResume, ...dashboardData.nextUp],
PosterRow(
contentPadding: padding,
label: context.localized.dashboardContinue,
posters: [...allResume, ...dashboardData.nextUp],
),
...views.dashboardViews.where((element) => element.recentlyAdded.isNotEmpty).map(
(view) => PosterRow(
contentPadding: padding,
label: context.localized.dashboardRecentlyAdded(view.name),
collectionAspectRatio: view.collectionType.aspectRatio,
onLabelClick: () => context.router.push(
LibrarySearchRoute(
viewModelId: view.id,
types: switch (view.collectionType) {
CollectionType.tvshows => {
FladderItemType.episode: true,
},
_ => {},
},
sortingOptions: switch (view.collectionType) {
CollectionType.books ||
CollectionType.boxsets ||
CollectionType.folders ||
CollectionType.music =>
SortingOptions.dateLastContentAdded,
_ => SortingOptions.dateAdded,
},
sortOrder: SortingOrder.descending,
recursive: true,
),
),
posters: view.recentlyAdded,
),
),
]
.nonNulls
.toList()
.mapIndexed(
(index, child) => SliverToBoxAdapter(
child: FocusProvider(
autoFocus: bannerType != HomeBanner.detailedBanner ? index == 0 : false,
child: child,
),
),
)
.toList()
.addInBetween(
const SliverToBoxAdapter(
child: SizedBox(height: 16),
),
),
...views.dashboardViews
.where((element) => element.recentlyAdded.isNotEmpty)
.map((view) => SliverToBoxAdapter(
child: PosterRow(
contentPadding: padding,
label: context.localized.dashboardRecentlyAdded(view.name),
collectionAspectRatio: view.collectionType.aspectRatio,
onLabelClick: () => context.router.push(
LibrarySearchRoute(
viewModelId: view.id,
types: switch (view.collectionType) {
CollectionType.tvshows => {
FladderItemType.episode: true,
},
_ => {},
},
sortingOptions: switch (view.collectionType) {
CollectionType.books ||
CollectionType.boxsets ||
CollectionType.folders ||
CollectionType.music =>
SortingOptions.dateLastContentAdded,
_ => SortingOptions.dateAdded,
},
sortOrder: SortingOrder.descending,
recursive: true,
),
),
posters: view.recentlyAdded,
),
)),
].nonNulls.toList().addInBetween(const SliverToBoxAdapter(child: SizedBox(height: 16))),
const DefautlSliverBottomPadding(),
],
),

View file

@ -5,17 +5,26 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/settings/home_settings_model.dart';
import 'package:fladder/providers/settings/home_settings_provider.dart';
import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/screens/shared/media/carousel_banner.dart';
import 'package:fladder/screens/shared/media/detailed_banner.dart';
import 'package:fladder/screens/shared/media/media_banner.dart';
class HomeBannerWidget extends ConsumerWidget {
final List<ItemBaseModel> posters;
const HomeBannerWidget({required this.posters, super.key});
final Function(ItemBaseModel selected) onSelect;
const HomeBannerWidget({
required this.posters,
required this.onSelect,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bannerType = ref.watch(homeSettingsProvider.select((value) => value.homeBanner));
final maxHeight = (MediaQuery.sizeOf(context).shortestSide * 0.6).clamp(125.0, 375.0);
return switch (bannerType) {
HomeBanner.carousel => Column(
mainAxisSize: MainAxisSize.min,
@ -34,6 +43,12 @@ class HomeBannerWidget extends ConsumerWidget {
maxHeight: maxHeight,
),
),
HomeBanner.detailedBanner => AnimatedFadeSize(
child: DetailedBanner(
posters: posters,
onSelect: onSelect,
),
),
_ => const SizedBox.shrink(),
};
}

View file

@ -5,6 +5,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/items/media_streams_model.dart';
import 'package:fladder/screens/details_screens/components/label_title_item.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/shared/enum_selection.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
class MediaStreamInformation extends ConsumerWidget {
final MediaStreamsModel mediaStream;
@ -30,15 +32,13 @@ class MediaStreamInformation extends ConsumerWidget {
label: Text(context.localized.version),
current: mediaStream.currentVersionStream?.name ?? "",
itemBuilder: (context) => mediaStream.versionStreams
.map((e) => PopupMenuItem(
value: e,
padding: EdgeInsets.zero,
child: textWidget(
.map((e) => ItemActionButton(
selected: mediaStream.currentVersionStream == e,
label: textWidget(
context,
selected: mediaStream.currentVersionStream == e,
label: e.name,
),
onTap: () => onVersionIndexChanged?.call(e.index),
action: () => onVersionIndexChanged?.call(e.index),
))
.toList(),
),
@ -48,10 +48,8 @@ class MediaStreamInformation extends ConsumerWidget {
current: (mediaStream.videoStreams.first).prettyName,
itemBuilder: (context) => mediaStream.videoStreams
.map(
(e) => PopupMenuItem(
value: e,
padding: EdgeInsets.zero,
child: Text(e.prettyName),
(e) => ItemActionButton(
label: Text(e.prettyName),
),
)
.toList(),
@ -62,12 +60,13 @@ class MediaStreamInformation extends ConsumerWidget {
current: mediaStream.currentAudioStream?.displayTitle ?? "",
itemBuilder: (context) => [AudioStreamModel.no(), ...mediaStream.audioStreams]
.map(
(e) => PopupMenuItem(
value: e,
padding: EdgeInsets.zero,
child: textWidget(context,
selected: mediaStream.currentAudioStream?.index == e.index, label: e.displayTitle),
onTap: () => onAudioIndexChanged?.call(e.index),
(e) => ItemActionButton(
selected: mediaStream.currentAudioStream?.index == e.index,
label: textWidget(
context,
label: e.displayTitle,
),
action: () => onAudioIndexChanged?.call(e.index),
),
)
.toList(),
@ -78,12 +77,13 @@ class MediaStreamInformation extends ConsumerWidget {
current: mediaStream.currentSubStream?.displayTitle ?? "",
itemBuilder: (context) => [SubStreamModel.no(), ...mediaStream.subStreams]
.map(
(e) => PopupMenuItem(
value: e,
padding: EdgeInsets.zero,
child: textWidget(context,
selected: mediaStream.currentSubStream?.index == e.index, label: e.displayTitle),
onTap: () => onSubIndexChanged?.call(e.index),
(e) => ItemActionButton(
selected: mediaStream.currentSubStream?.index == e.index,
label: textWidget(
context,
label: e.displayTitle,
),
action: () => onSubIndexChanged?.call(e.index),
),
)
.toList(),
@ -92,22 +92,12 @@ class MediaStreamInformation extends ConsumerWidget {
);
}
Widget textWidget(BuildContext context, {required bool selected, required String label}) {
return Container(
height: kMinInteractiveDimension,
width: double.maxFinite,
color: selected ? Theme.of(context).colorScheme.primary : null,
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
label,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: selected ? Theme.of(context).colorScheme.onPrimary : null,
fontWeight: FontWeight.bold,
),
),
),
Widget textWidget(BuildContext context, {required String label}) {
return Text(
label,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
),
);
}
}
@ -115,7 +105,7 @@ class MediaStreamInformation extends ConsumerWidget {
class _StreamOptionSelect<T> extends StatelessWidget {
final Text label;
final String current;
final List<PopupMenuEntry<T>> Function(BuildContext context) itemBuilder;
final List<ItemAction> Function(BuildContext context) itemBuilder;
const _StreamOptionSelect({
required this.label,
required this.current,
@ -124,47 +114,14 @@ class _StreamOptionSelect<T> extends StatelessWidget {
@override
Widget build(BuildContext context) {
final textStyle = Theme.of(context).textTheme.titleMedium;
const padding = EdgeInsets.all(6);
final itemList = itemBuilder(context);
return LabelTitleItem(
title: label,
content: Flexible(
child: PopupMenuButton(
tooltip: '',
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
enabled: itemList.length > 1,
itemBuilder: itemBuilder,
enableFeedback: false,
menuPadding: const EdgeInsets.symmetric(vertical: 16),
padding: padding,
child: Padding(
padding: padding,
child: Material(
textStyle: textStyle?.copyWith(
fontWeight: FontWeight.bold,
color: itemList.length > 1 ? Theme.of(context).colorScheme.primary : 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.primary,
)
],
),
),
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: LabelTitleItem(
title: label,
content: Flexible(
child: EnumBox(
current: current,
itemBuilder: itemBuilder,
),
),
),

View file

@ -18,8 +18,10 @@ class OverviewHeader extends ConsumerWidget {
final EdgeInsets? padding;
final String? subTitle;
final String? originalTitle;
final Alignment logoAlignment;
final Function()? onTitleClicked;
final int? productionYear;
final String? summary;
final Duration? runTime;
final String? officialRating;
final double? communityRating;
@ -32,8 +34,10 @@ class OverviewHeader extends ConsumerWidget {
this.padding,
this.subTitle,
this.originalTitle,
this.logoAlignment = Alignment.bottomCenter,
this.onTitleClicked,
this.productionYear,
this.summary,
this.runTime,
this.officialRating,
this.communityRating,
@ -68,83 +72,101 @@ class OverviewHeader extends ConsumerWidget {
crossAxisAlignment: crossAlignment,
mainAxisSize: MainAxisSize.min,
children: [
MediaHeader(
name: name,
logo: image?.logo,
onTap: onTitleClicked,
),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: crossAlignment,
children: [
if (subTitle != null)
Flexible(
child: SelectableText(
subTitle ?? "",
textAlign: TextAlign.center,
style: mainStyle,
),
),
if (name.toLowerCase() != originalTitle?.toLowerCase() && originalTitle != null)
SelectableText(
originalTitle.toString(),
textAlign: TextAlign.center,
style: subStyle,
),
].addInBetween(const SizedBox(height: 4)),
),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: crossAlignment,
children: [
Wrap(
spacing: 8,
runSpacing: 8,
direction: Axis.horizontal,
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (officialRating != null)
ChipButton(
label: officialRating.toString(),
),
if (productionYear != null)
SelectableText(
productionYear.toString(),
textAlign: TextAlign.center,
style: subStyle,
),
if (runTime != null && (runTime?.inSeconds ?? 0) > 1)
SelectableText(
runTime.humanize.toString(),
textAlign: TextAlign.center,
style: subStyle,
),
if (communityRating != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.star_rate_rounded,
color: Theme.of(context).colorScheme.primary,
),
Text(
communityRating?.toStringAsFixed(2) ?? "",
style: subStyle,
),
],
),
].addInBetween(CircleAvatar(
radius: 3,
backgroundColor: Theme.of(context).colorScheme.onSurface,
)),
Flexible(
child: ExcludeFocus(
child: MediaHeader(
name: name,
logo: image?.logo,
onTap: onTitleClicked,
alignment: logoAlignment,
),
if (genres.isNotEmpty)
Genres(
genres: genres.take(6).toList(),
),
),
ExcludeFocus(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: crossAlignment,
children: [
if (subTitle != null)
Flexible(
child: SelectableText(
subTitle ?? "",
textAlign: TextAlign.center,
style: mainStyle,
),
),
if (name.toLowerCase() != originalTitle?.toLowerCase() && originalTitle != null)
SelectableText(
originalTitle.toString(),
textAlign: TextAlign.center,
style: subStyle,
),
].addInBetween(const SizedBox(height: 4)),
),
),
ExcludeFocus(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: crossAlignment,
children: [
Wrap(
spacing: 8,
runSpacing: 8,
direction: Axis.horizontal,
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (officialRating != null)
ChipButton(
label: officialRating.toString(),
),
if (productionYear != null)
SelectableText(
productionYear.toString(),
textAlign: TextAlign.center,
style: subStyle,
),
if (runTime != null && (runTime?.inSeconds ?? 0) > 1)
SelectableText(
runTime.humanize.toString(),
textAlign: TextAlign.center,
style: subStyle,
),
if (communityRating != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.star_rate_rounded,
color: Theme.of(context).colorScheme.primary,
),
Text(
communityRating?.toStringAsFixed(2) ?? "",
style: subStyle,
),
],
),
].addInBetween(CircleAvatar(
radius: 3,
backgroundColor: Theme.of(context).colorScheme.onSurface,
)),
),
].addInBetween(const SizedBox(height: 10)),
if (summary?.isNotEmpty == true)
Flexible(
child: Text(
summary ?? "",
style: Theme.of(context).textTheme.titleLarge,
overflow: TextOverflow.ellipsis,
maxLines: 3,
),
),
if (genres.isNotEmpty)
Genres(
genres: genres.take(6).toList(),
),
].addInBetween(const SizedBox(height: 10)),
),
),
if (centerButtons != null) centerButtons!,
].addInBetween(const SizedBox(height: 21)),

View file

@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/providers/items/episode_details_provider.dart';
@ -25,6 +25,8 @@ import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/people_extension.dart';
import 'package:fladder/util/router_extension.dart';
import 'package:fladder/util/widget_extensions.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
import 'package:fladder/widgets/shared/selectable_icon_button.dart';
class EpisodeDetailScreen extends ConsumerStatefulWidget {
@ -77,19 +79,62 @@ class _ItemDetailScreenState extends ConsumerState<EpisodeDetailScreen> {
OverviewHeader(
name: details.series?.name ?? "",
image: seasonDetails.images,
centerButtons: episodeDetails.playAble
? MediaPlayButton(
item: episodeDetails,
onPressed: () async {
await details.episode.play(context, ref);
ref.read(providerInstance.notifier).fetchDetails(widget.item);
},
onLongPressed: () async {
await details.episode.play(context, ref, showPlaybackOption: true);
ref.read(providerInstance.notifier).fetchDetails(widget.item);
},
)
: null,
centerButtons: Wrap(
spacing: 8,
runSpacing: 8,
alignment: wrapAlignment,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
episodeDetails.playAble
? MediaPlayButton(
item: episodeDetails,
onPressed: () async {
await details.episode.play(context, ref);
ref.read(providerInstance.notifier).fetchDetails(widget.item);
},
onLongPressed: () async {
await details.episode.play(context, ref, showPlaybackOption: true);
ref.read(providerInstance.notifier).fetchDetails(widget.item);
},
)
: null,
SelectableIconButton(
onPressed: () async {
await ref
.read(userProvider.notifier)
.setAsFavorite(!(episodeDetails.userData.isFavourite), episodeDetails.id);
},
selected: episodeDetails.userData.isFavourite,
selectedIcon: IconsaxPlusBold.heart,
icon: IconsaxPlusLinear.heart,
),
SelectableIconButton(
onPressed: () async {
await ref
.read(userProvider.notifier)
.markAsPlayed(!(episodeDetails.userData.played), episodeDetails.id);
},
selected: episodeDetails.userData.played,
selectedIcon: IconsaxPlusBold.tick_circle,
icon: IconsaxPlusLinear.tick_circle,
),
SelectableIconButton(
onPressed: () async {
await showBottomSheetPill(
context: context,
content: (context, scrollController) => ListView(
controller: scrollController,
shrinkWrap: true,
children:
episodeDetails.generateActions(context, ref).listTileItems(context, useIcons: true),
),
);
},
selected: false,
icon: IconsaxPlusLinear.more,
),
].nonNulls.toList().addPadding(const EdgeInsets.symmetric(horizontal: 6)),
),
padding: padding,
subTitle: details.episode?.detailedName(context),
originalTitle: details.series?.originalTitle,
@ -101,34 +146,6 @@ class _ItemDetailScreenState extends ConsumerState<EpisodeDetailScreen> {
officialRating: details.series?.overview.parentalRating,
communityRating: details.series?.overview.communityRating,
),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: wrapAlignment,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SelectableIconButton(
onPressed: () async {
await ref
.read(userProvider.notifier)
.setAsFavorite(!(episodeDetails.userData.isFavourite), episodeDetails.id);
},
selected: episodeDetails.userData.isFavourite,
selectedIcon: IconsaxPlusBold.heart,
icon: IconsaxPlusLinear.heart,
),
SelectableIconButton(
onPressed: () async {
await ref
.read(userProvider.notifier)
.markAsPlayed(!(episodeDetails.userData.played), episodeDetails.id);
},
selected: episodeDetails.userData.played,
selectedIcon: IconsaxPlusBold.tick_circle,
icon: IconsaxPlusLinear.tick_circle,
),
].addPadding(const EdgeInsets.symmetric(horizontal: 6)),
).padding(padding),
if (details.episode?.mediaStreams != null)
Padding(
padding: padding,

View file

@ -22,6 +22,8 @@ import 'package:fladder/util/item_base_model/play_item_helpers.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/router_extension.dart';
import 'package:fladder/util/widget_extensions.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
import 'package:fladder/widgets/shared/selectable_icon_button.dart';
class MovieDetailScreen extends ConsumerStatefulWidget {
@ -71,23 +73,63 @@ class _ItemDetailScreenState extends ConsumerState<MovieDetailScreen> {
name: details.name,
image: details.images,
padding: padding,
centerButtons: MediaPlayButton(
item: details,
onLongPressed: () async {
await details.play(
context,
ref,
showPlaybackOption: true,
);
ref.read(providerInstance.notifier).fetchDetails(widget.item);
},
onPressed: () async {
await details.play(
context,
ref,
);
ref.read(providerInstance.notifier).fetchDetails(widget.item);
},
centerButtons: Wrap(
spacing: 8,
runSpacing: 8,
alignment: wrapAlignment,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
MediaPlayButton(
item: details,
onLongPressed: () async {
await details.play(
context,
ref,
showPlaybackOption: true,
);
ref.read(providerInstance.notifier).fetchDetails(widget.item);
},
onPressed: () async {
await details.play(
context,
ref,
);
ref.read(providerInstance.notifier).fetchDetails(widget.item);
},
),
SelectableIconButton(
onPressed: () async {
await ref
.read(userProvider.notifier)
.setAsFavorite(!details.userData.isFavourite, details.id);
},
selected: details.userData.isFavourite,
selectedIcon: IconsaxPlusBold.heart,
icon: IconsaxPlusLinear.heart,
),
SelectableIconButton(
onPressed: () async {
await ref.read(userProvider.notifier).markAsPlayed(!details.userData.played, details.id);
},
selected: details.userData.played,
selectedIcon: IconsaxPlusBold.tick_circle,
icon: IconsaxPlusLinear.tick_circle,
),
SelectableIconButton(
onPressed: () async {
await showBottomSheetPill(
context: context,
content: (context, scrollController) => ListView(
controller: scrollController,
shrinkWrap: true,
children: details.generateActions(context, ref).listTileItems(context, useIcons: true),
),
);
},
selected: false,
icon: IconsaxPlusLinear.more,
),
],
),
originalTitle: details.originalTitle,
productionYear: details.overview.productionYear,
@ -97,32 +139,6 @@ class _ItemDetailScreenState extends ConsumerState<MovieDetailScreen> {
officialRating: details.overview.parentalRating,
communityRating: details.overview.communityRating,
),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: wrapAlignment,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SelectableIconButton(
onPressed: () async {
await ref
.read(userProvider.notifier)
.setAsFavorite(!details.userData.isFavourite, details.id);
},
selected: details.userData.isFavourite,
selectedIcon: IconsaxPlusBold.heart,
icon: IconsaxPlusLinear.heart,
),
SelectableIconButton(
onPressed: () async {
await ref.read(userProvider.notifier).markAsPlayed(!details.userData.played, details.id);
},
selected: details.userData.played,
selectedIcon: IconsaxPlusBold.tick_circle,
icon: IconsaxPlusLinear.tick_circle,
),
],
).padding(padding),
if (details.mediaStreams.isNotEmpty)
MediaStreamInformation(
onVersionIndexChanged: (index) {

View file

@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/series_model.dart';
@ -25,6 +25,8 @@ import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/router_extension.dart';
import 'package:fladder/util/widget_extensions.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
import 'package:fladder/widgets/shared/selectable_icon_button.dart';
class SeriesDetailScreen extends ConsumerStatefulWidget {
@ -74,20 +76,60 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
OverviewHeader(
name: details.name,
image: details.images,
centerButtons: MediaPlayButton(
item: details.nextUp,
onPressed: details.nextUp != null
? () async {
await details.nextUp.play(context, ref);
ref.read(providerId.notifier).fetchDetails(widget.item);
}
: null,
onLongPressed: details.nextUp != null
? () async {
await details.nextUp.play(context, ref, showPlaybackOption: true);
ref.read(providerId.notifier).fetchDetails(widget.item);
}
: null,
centerButtons: Wrap(
spacing: 8,
runSpacing: 8,
alignment: wrapAlignment,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
MediaPlayButton(
item: details.nextUp,
onPressed: details.nextUp != null
? () async {
await details.nextUp.play(context, ref);
ref.read(providerId.notifier).fetchDetails(widget.item);
}
: null,
onLongPressed: details.nextUp != null
? () async {
await details.nextUp.play(context, ref, showPlaybackOption: true);
ref.read(providerId.notifier).fetchDetails(widget.item);
}
: null,
),
SelectableIconButton(
onPressed: () async {
await ref
.read(userProvider.notifier)
.setAsFavorite(!details.userData.isFavourite, details.id);
},
selected: details.userData.isFavourite,
selectedIcon: IconsaxPlusBold.heart,
icon: IconsaxPlusLinear.heart,
),
SelectableIconButton(
onPressed: () async {
await ref.read(userProvider.notifier).markAsPlayed(!details.userData.played, details.id);
},
selected: details.userData.played,
selectedIcon: IconsaxPlusBold.tick_circle,
icon: IconsaxPlusLinear.tick_circle,
),
SelectableIconButton(
onPressed: () async {
await showBottomSheetPill(
context: context,
content: (context, scrollController) => ListView(
controller: scrollController,
shrinkWrap: true,
children: details.generateActions(context, ref).listTileItems(context, useIcons: true),
),
);
},
selected: false,
icon: IconsaxPlusLinear.more,
),
],
),
padding: padding,
originalTitle: details.originalTitle,
@ -98,32 +140,6 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
genres: details.overview.genreItems,
communityRating: details.overview.communityRating,
),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: wrapAlignment,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SelectableIconButton(
onPressed: () async {
await ref
.read(userProvider.notifier)
.setAsFavorite(!details.userData.isFavourite, details.id);
},
selected: details.userData.isFavourite,
selectedIcon: IconsaxPlusBold.heart,
icon: IconsaxPlusLinear.heart,
),
SelectableIconButton(
onPressed: () async {
await ref.read(userProvider.notifier).markAsPlayed(!details.userData.played, details.id);
},
selected: details.userData.played,
selectedIcon: IconsaxPlusBold.tick_circle,
icon: IconsaxPlusLinear.tick_circle,
),
],
).padding(padding),
if (details.nextUp != null)
NextUpEpisode(
nextEpisode: details.nextUp!,

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/library_filter_model.dart';
@ -12,6 +13,7 @@ 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';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/sliver_list_padding.dart';
import 'package:fladder/widgets/navigation_scaffold/components/background_image.dart';
@ -54,9 +56,9 @@ class FavouritesScreen extends ConsumerWidget {
],
),
),
...favourites.favourites.entries.where((element) => element.value.isNotEmpty).map(
(e) => SliverToBoxAdapter(
child: PosterRow(
...[
...favourites.favourites.entries.where((element) => element.value.isNotEmpty).map(
(e) => PosterRow(
contentPadding: padding,
onLabelClick: () => context.pushRoute(
LibrarySearchRoute().withFilter(
@ -71,15 +73,17 @@ class FavouritesScreen extends ConsumerWidget {
posters: e.value,
),
),
),
if (favourites.people.isNotEmpty)
SliverToBoxAdapter(
child: PosterRow(
if (favourites.people.isNotEmpty)
PosterRow(
contentPadding: padding,
label: context.localized.actor(favourites.people.length),
posters: favourites.people,
),
].mapIndexed(
(index, e) => SliverToBoxAdapter(
child: FocusProvider(hasFocus: false, autoFocus: index == 0, child: e),
),
),
const DefautlSliverBottomPadding(),
],
),

View file

@ -13,13 +13,13 @@ import 'package:fladder/providers/library_screen_provider.dart';
import 'package:fladder/routes/auto_router.gr.dart';
import 'package:fladder/screens/home_screen.dart';
import 'package:fladder/screens/metadata/refresh_metadata.dart';
import 'package:fladder/screens/shared/flat_button.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';
import 'package:fladder/theme.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/fladder_image.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/sliver_list_padding.dart';
import 'package:fladder/widgets/navigation_scaffold/components/background_image.dart';
@ -233,9 +233,10 @@ class LibraryRow extends ConsumerWidget {
label: context.localized.library(views.length),
items: views,
height: 155,
autoFocus: true,
startIndex: selectedView != null ? views.indexOf(selectedView!) : null,
contentPadding: padding,
itemBuilder: (context, index) {
itemBuilder: (context, index, selected) {
final view = views[index];
final isSelected = selectedView == view;
final List<ItemActionButton> viewActions = [
@ -250,25 +251,26 @@ class LibraryRow extends ConsumerWidget {
action: () => showRefreshPopup(context, view.id, view.name),
)
];
return FlatButton(
onTap: isSelected ? null : () => onSelected?.call(view),
onLongPress: () => context.pushRoute(LibrarySearchRoute(viewModelId: view.id)),
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: viewActions.popupMenuItems(useIcons: true),
);
},
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
FocusButton(
key: Key(view.id),
onTap: isSelected ? null : () => onSelected?.call(view),
onLongPress: () => context.pushRoute(LibrarySearchRoute(viewModelId: view.id)),
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: viewActions.popupMenuItems(useIcons: true),
);
},
child: Container(
decoration: BoxDecoration(
borderRadius: FladderTheme.defaultShape.borderRadius,
),
@ -294,15 +296,15 @@ class LibraryRow extends ConsumerWidget {
),
),
),
Text(
view.name,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.start,
)
],
),
),
Text(
view.name,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.start,
)
],
);
},
);

View file

@ -352,49 +352,27 @@ class _LibrarySearchScreenState extends ConsumerState<LibrarySearchScreen> {
child: Tooltip(
message: librarySearchResults.nestedCurrentItem?.type.label(context) ??
context.localized.library(1),
child: InkWell(
onTapUp: (details) async {
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) {
double left = details.globalPosition.dx;
double top = details.globalPosition.dy;
await showMenu(
context: context,
position: RelativeRect.fromLTRB(left, top, 40, 100),
items: <PopupMenuEntry>[
PopupMenuItem(
child: Text(librarySearchResults.nestedCurrentItem?.type.label(context) ??
context.localized.library(0))),
itemCountWidget.toPopupMenuItem(useIcons: true),
refreshAction.toPopupMenuItem(useIcons: true),
itemViewAction.toPopupMenuItem(useIcons: true),
child: IconButton(
onPressed: () async {
await showBottomSheetPill(
context: context,
content: (context, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children: [
itemCountWidget.toListItem(context, useIcons: true),
refreshAction.toListItem(context, useIcons: true),
itemViewAction.toListItem(context, useIcons: true),
if (librarySearchResults.views.hasEnabled == true)
showSavedFiltersDialogue.toPopupMenuItem(useIcons: true),
if (itemActions.isNotEmpty) ItemActionDivider().toPopupMenuItem(),
...itemActions.popupMenuItems(useIcons: true),
showSavedFiltersDialogue.toListItem(context, useIcons: true),
if (itemActions.isNotEmpty) ItemActionDivider().toListItem(context),
...itemActions.listTileItems(context, useIcons: true),
],
elevation: 8.0,
);
} else {
await showBottomSheetPill(
context: context,
content: (context, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children: [
itemCountWidget.toListItem(context, useIcons: true),
refreshAction.toListItem(context, useIcons: true),
itemViewAction.toListItem(context, useIcons: true),
if (librarySearchResults.views.hasEnabled == true)
showSavedFiltersDialogue.toPopupMenuItem(useIcons: true),
if (itemActions.isNotEmpty) ItemActionDivider().toListItem(context),
...itemActions.listTileItems(context, useIcons: true),
],
),
);
}
),
);
},
child: Padding(
padding: const EdgeInsets.all(12),
icon: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
isFavorite
? librarySearchResults.nestedCurrentItem?.type.selectedicon

View file

@ -149,16 +149,19 @@ class _LibraryFilterChipsState extends ConsumerState<LibraryFilterChips> {
),
];
return Row(
spacing: 4,
children: chips.mapIndexed(
(index, element) {
final position = index == 0
? PositionContext.first
: (index == chips.length - 1 ? PositionContext.last : PositionContext.middle);
return PositionProvider(position: position, child: element);
},
).toList(),
return FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(),
child: Row(
spacing: 4,
children: chips.mapIndexed(
(index, element) {
final position = index == 0
? PositionContext.first
: (index == chips.length - 1 ? PositionContext.last : PositionContext.middle);
return PositionProvider(position: position, child: element);
},
).toList(),
),
);
}

View file

@ -23,11 +23,14 @@ import 'package:fladder/routes/auto_router.gr.dart';
import 'package:fladder/screens/shared/media/poster_list_item.dart';
import 'package:fladder/screens/shared/media/poster_widget.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/refresh_state.dart';
import 'package:fladder/util/string_extensions.dart';
import 'package:fladder/util/theme_extensions.dart';
import 'package:fladder/widgets/shared/ensure_visible.dart';
import 'package:fladder/widgets/shared/grid_focus_traveler.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
final libraryViewTypeProvider = StateProvider<LibraryViewTypes>((ref) {
@ -63,12 +66,12 @@ class LibraryViews extends ConsumerWidget {
padding: const EdgeInsets.symmetric(horizontal: 4),
sliver: SliverAnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: _getWidget(ref, context),
child: _getWidget(context, ref),
),
);
}
Widget _getWidget(WidgetRef ref, BuildContext context) {
Widget _getWidget(BuildContext context, WidgetRef ref) {
final selected = ref.watch(librarySearchProvider(key!).select((value) => value.selectedPosters));
final posterSizeMultiplier = ref.watch(clientSettingsProvider.select((value) => value.posterSize));
final libraryProvider = ref.read(librarySearchProvider(key!).notifier);
@ -111,21 +114,24 @@ class LibraryViews extends ConsumerWidget {
switch (ref.watch(libraryViewTypeProvider)) {
case LibraryViewTypes.grid:
Widget createGrid(List<ItemBaseModel> items) {
return SliverGrid.builder(
final width = MediaQuery.of(context).size.width;
final cellWidth = (width / posterSize).floorToDouble();
final crossAxisCount = ((width / cellWidth).floor()).clamp(2, 10);
return GridFocusTraveler(
itemCount: items.length,
crossAxisCount: crossAxisCount,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: posterSize.clamp(2, double.maxFinite).toInt(),
mainAxisSpacing: (8 * decimal) + 8,
crossAxisSpacing: (8 * decimal) + 8,
crossAxisCount: crossAxisCount,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: items.getMostCommonType.aspectRatio,
),
itemCount: items.length,
itemBuilder: (context, index) {
itemBuilder: (other, selectedIndex, index) {
final item = items[index];
return PosterWidget(
key: Key(item.id),
poster: item,
maxLines: 2,
heroTag: true,
subTitle: item.subTitle(sortingOptions),
excludeActions: excludeActions,
otherActions: otherActions(item),
@ -134,6 +140,11 @@ class LibraryViews extends ConsumerWidget {
onItemRemoved: (oldItem) => libraryProvider.removeFromPosters([oldItem.id]),
onItemUpdated: (newItem) => libraryProvider.updateItem(newItem),
onPressed: (action, item) async => onItemPressed(action, key, item, ref, context),
onFocusChanged: (focus) {
if (focus) {
other.ensureVisible();
}
},
);
},
);
@ -165,16 +176,19 @@ class LibraryViews extends ConsumerWidget {
itemCount: items.length,
itemBuilder: (context, index) {
final poster = items[index];
return PosterListItem(
poster: poster,
selected: selected.contains(poster),
excludeActions: excludeActions,
otherActions: otherActions(poster),
subTitle: poster.subTitle(sortingOptions),
onUserDataChanged: (id, newData) => libraryProvider.updateUserData(id, newData),
onItemRemoved: (oldItem) => libraryProvider.removeFromPosters([oldItem.id]),
onItemUpdated: (newItem) => libraryProvider.updateItem(newItem),
onPressed: (action, item) async => onItemPressed(action, key, item, ref, context),
return FocusProvider(
autoFocus: index == 0,
child: PosterListItem(
poster: poster,
selected: selected.contains(poster),
excludeActions: excludeActions,
otherActions: otherActions(poster),
subTitle: poster.subTitle(sortingOptions),
onUserDataChanged: (id, newData) => libraryProvider.updateUserData(id, newData),
onItemRemoved: (oldItem) => libraryProvider.removeFromPosters([oldItem.id]),
onItemUpdated: (newItem) => libraryProvider.updateItem(newItem),
onPressed: (action, item) async => onItemPressed(action, key, item, ref, context),
),
);
},
);
@ -228,7 +242,6 @@ class LibraryViews extends ConsumerWidget {
aspectRatio: item.primaryRatio,
selected: selected.contains(item),
inlineTitle: true,
heroTag: true,
subTitle: item.subTitle(sortingOptions),
excludeActions: excludeActions,
otherActions: otherActions(group[index]),
@ -257,7 +270,6 @@ class LibraryViews extends ConsumerWidget {
aspectRatio: item.primaryRatio,
selected: selected.contains(item),
inlineTitle: true,
heroTag: true,
excludeActions: excludeActions,
otherActions: otherActions(item),
subTitle: item.subTitle(sortingOptions),

View file

@ -7,6 +7,7 @@ import 'package:page_transition/page_transition.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/providers/library_search_provider.dart';
import 'package:fladder/screens/shared/outlined_text_field.dart';
import 'package:fladder/theme.dart';
import 'package:fladder/util/debouncer.dart';
import 'package:fladder/util/fladder_image.dart';
@ -87,7 +88,7 @@ class _SearchBarState extends ConsumerState<SuggestionSearchBar> {
),
child: child,
),
builder: (context, controller, focusNode) => TextField(
builder: (context, controller, focusNode) => OutlinedTextField(
focusNode: focusNode,
controller: controller,
onSubmitted: (value) {
@ -99,6 +100,7 @@ class _SearchBarState extends ConsumerState<SuggestionSearchBar> {
isEmpty = value.isEmpty;
});
},
placeHolder: widget.title ?? "${context.localized.search}...",
decoration: InputDecoration(
hintText: widget.title ?? "${context.localized.search}...",
prefixIcon: const Icon(IconsaxPlusLinear.search_normal),

View file

@ -0,0 +1,140 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:async/async.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/auth_provider.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/localization_helper.dart';
Future<void> openLoginCodeDialog(
BuildContext context, {
required QuickConnectResult quickConnectInfo,
required Function(BuildContext context, String secret) onAuthenticated,
}) {
return showDialog(
context: context,
builder: (context) => LoginCodeDialog(
quickConnectInfo: quickConnectInfo,
onAuthenticated: onAuthenticated,
),
);
}
class LoginCodeDialog extends ConsumerStatefulWidget {
final QuickConnectResult quickConnectInfo;
final Function(BuildContext context, String secret) onAuthenticated;
const LoginCodeDialog({
required this.quickConnectInfo,
required this.onAuthenticated,
super.key,
});
@override
ConsumerState<LoginCodeDialog> createState() => _LoginCodeDialogState();
}
class _LoginCodeDialogState extends ConsumerState<LoginCodeDialog> {
late QuickConnectResult quickConnectInfo = widget.quickConnectInfo;
RestartableTimer? timer;
@override
void initState() {
super.initState();
createTimer();
}
@override
void dispose() {
timer?.cancel();
super.dispose();
}
void createTimer() {
timer?.cancel();
timer = RestartableTimer(const Duration(seconds: 1), () async {
final result = await ref.read(jellyApiProvider).quickConnectConnectGet(
secret: quickConnectInfo.secret,
);
final newSecret = result.body?.secret;
if (result.isSuccessful && result.body?.authenticated == true && newSecret != null) {
widget.onAuthenticated.call(context, newSecret);
} else {
timer?.reset();
}
});
}
@override
Widget build(BuildContext context) {
final code = quickConnectInfo.code;
final serverName = ref.watch(authProvider.select((value) => value.serverLoginModel?.tempCredentials.serverName));
return Dialog(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 16,
children: [
Text(
serverName?.isNotEmpty == true
? "${context.localized.quickConnectTitle} - $serverName"
: context.localized.quickConnectTitle,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const Divider(),
ListView(
shrinkWrap: true,
children: [
if (code != null) ...[
Text(
context.localized.quickConnectEnterCodeDescription,
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
IntrinsicWidth(
child: Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
code,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
wordSpacing: 8,
letterSpacing: 8,
),
textAlign: TextAlign.center,
semanticsLabel: code,
),
),
),
),
],
FilledButton(
onPressed: () async {
final response = await ref.read(jellyApiProvider).quickConnectInitiate();
if (response.isSuccessful && response.body != null) {
setState(() {
quickConnectInfo = response.body!;
});
createTimer();
}
},
child: Text(
context.localized.refresh,
),
)
].addInBetween(const SizedBox(height: 16)),
)
],
),
),
);
}
}

View file

@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
@ -7,25 +5,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/account_model.dart';
import 'package:fladder/models/login_screen_model.dart';
import 'package:fladder/providers/auth_provider.dart';
import 'package:fladder/providers/shared_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/routes/auto_router.gr.dart';
import 'package:fladder/screens/login/lock_screen.dart';
import 'package:fladder/screens/login/login_edit_user.dart';
import 'package:fladder/screens/login/login_screen_credentials.dart';
import 'package:fladder/screens/login/login_user_grid.dart';
import 'package:fladder/screens/login/widgets/discover_servers_widget.dart';
import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/screens/shared/fladder_logo.dart';
import 'package:fladder/screens/shared/fladder_snackbar.dart';
import 'package:fladder/screens/shared/outlined_text_field.dart';
import 'package:fladder/screens/shared/passcode_input.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/auth_service.dart';
import 'package:fladder/util/fladder_config.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/string_extensions.dart';
import 'package:fladder/widgets/navigation_scaffold/components/fladder_app_bar.dart';
@RoutePage()
@ -37,373 +24,85 @@ class LoginScreen extends ConsumerStatefulWidget {
}
class _LoginPageState extends ConsumerState<LoginScreen> {
List<AccountModel> users = const [];
bool loading = false;
String? invalidUrl = "";
bool startCheckingForErrors = false;
bool addingNewUser = false;
bool editingUsers = false;
late final TextEditingController serverTextController = TextEditingController(text: '');
final usernameController = TextEditingController();
final passwordController = TextEditingController();
final FocusNode focusNode = FocusNode();
void startAddingNewUser() {
setState(() {
addingNewUser = true;
editingUsers = false;
});
if (FladderConfig.baseUrl != null) {
serverTextController.text = FladderConfig.baseUrl!;
_parseUrl(FladderConfig.baseUrl!);
retrieveListOfUsers();
}
}
bool editUsersMode = false;
@override
void initState() {
super.initState();
Future.microtask(() {
ref.read(userProvider.notifier).clear();
final currentAccounts = ref.read(authProvider.notifier).getSavedAccounts();
addingNewUser = currentAccounts.isEmpty;
ref.read(lockScreenActiveProvider.notifier).update((state) => true);
if (FladderConfig.baseUrl != null) {
serverTextController.text = FladderConfig.baseUrl!;
_parseUrl(FladderConfig.baseUrl!);
retrieveListOfUsers();
}
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(authProvider.notifier).initModel(context);
});
}
@override
Widget build(BuildContext context) {
final loggedInUsers = ref.watch(authProvider.select((value) => value.accounts));
final authLoading = ref.watch(authProvider.select((value) => value.loading));
final screen = ref.watch(authProvider.select((value) => value.screen));
final accounts = ref.watch(authProvider.select((value) => value.accounts));
return Scaffold(
appBar: const FladderAppBar(),
floatingActionButton: !addingNewUser
? Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (!AdaptiveLayout.of(context).isDesktop)
FloatingActionButton(
key: const Key("edit_button"),
heroTag: "edit_button",
backgroundColor: editingUsers ? Theme.of(context).colorScheme.errorContainer : null,
child: const Icon(IconsaxPlusLinear.edit_2),
onPressed: () => setState(() => editingUsers = !editingUsers),
),
FloatingActionButton(
key: const Key("new_button"),
heroTag: "new_button",
child: const Icon(IconsaxPlusLinear.add_square),
onPressed: startAddingNewUser,
),
].addInBetween(const SizedBox(width: 16)),
)
: null,
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 900),
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32),
extendBody: true,
extendBodyBehindAppBar: true,
floatingActionButton: switch (screen) {
LoginScreenType.users => Row(
mainAxisAlignment: MainAxisAlignment.end,
spacing: 16,
children: [
const Center(
child: FladderLogo(),
if (!AdaptiveLayout.of(context).isDesktop)
FloatingActionButton(
key: const Key("edit_user_button"),
heroTag: "edit_user_button",
backgroundColor: editUsersMode ? Theme.of(context).colorScheme.errorContainer : null,
child: const Icon(IconsaxPlusLinear.edit_2),
onPressed: () => setState(() => editUsersMode = !editUsersMode),
),
FloatingActionButton(
key: const Key("new_user_button"),
heroTag: "new_user_button",
child: const Icon(IconsaxPlusLinear.add_square),
onPressed: () => ref.read(authProvider.notifier).addNewUser(),
),
AnimatedFadeSize(
child: addingNewUser
? addUserFields(loggedInUsers, authLoading)
: Column(
key: UniqueKey(),
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
LoginUserGrid(
users: loggedInUsers,
editMode: editingUsers,
onPressed: (user) async => tapLoggedInAccount(user),
onLongPress: (user) => openUserEditDialogue(context, user),
),
],
),
),
].addPadding(const EdgeInsets.symmetric(vertical: 16)),
],
),
_ => null,
},
body: Center(
child: ListView(
shrinkWrap: true,
padding: MediaQuery.paddingOf(context).add(const EdgeInsetsGeometry.all(16)),
children: [
const FladderLogo(),
const SizedBox(height: 24),
AnimatedFadeSize(
child: switch (screen) {
LoginScreenType.login || LoginScreenType.code => const LoginScreenCredentials(),
_ => LoginUserGrid(
users: accounts,
editMode: editUsersMode,
onPressed: (user) => tapLoggedInAccount(context, user, ref),
onLongPress: (user) => openUserEditDialogue(context, user),
),
},
)
],
),
),
);
}
void _parseUrl(String url) {
setState(() {
ref.read(authProvider.notifier).setServer("");
users = [];
if (url.isEmpty) {
invalidUrl = "";
return;
}
if (!Uri.parse(url).isAbsolute) {
invalidUrl = context.localized.invalidUrl;
return;
}
if (!url.startsWith('https://') && !url.startsWith('http://')) {
invalidUrl = context.localized.invalidUrlDesc;
return;
}
invalidUrl = null;
if (invalidUrl == null) {
ref.read(authProvider.notifier).setServer(url.rtrim('/'));
}
});
}
void openUserEditDialogue(BuildContext context, AccountModel user) {
showDialog(
context: context,
builder: (context) => LoginEditUser(
user: user,
onTapServer: (value) {
setState(() {
_parseUrl(value);
serverTextController.text = value;
startAddingNewUser();
});
ref.read(authProvider.notifier).setServer(value);
Navigator.of(context).pop();
},
),
);
}
void tapLoggedInAccount(AccountModel user) async {
switch (user.authMethod) {
case Authentication.autoLogin:
handleLogin(user);
break;
case Authentication.biometrics:
final authenticated = await AuthService.authenticateUser(context, user);
if (authenticated) {
handleLogin(user);
}
break;
case Authentication.passcode:
if (context.mounted) {
showPassCodeDialog(context, (newPin) {
if (newPin == user.localPin) {
handleLogin(user);
} else {
fladderSnackbar(context, title: context.localized.incorrectPinTryAgain);
}
});
}
break;
case Authentication.none:
handleLogin(user);
break;
}
}
Future<void> handleLogin(AccountModel user) async {
await ref.read(authProvider.notifier).switchUser();
await ref.read(sharedUtilityProvider).updateAccountInfo(user.copyWith(
lastUsed: DateTime.now(),
));
ref.read(userProvider.notifier).updateUser(user.copyWith(lastUsed: DateTime.now()));
loggedInGoToHome();
}
void loggedInGoToHome() {
ref.read(lockScreenActiveProvider.notifier).update((state) => false);
if (context.mounted) {
context.router.replaceAll([const DashboardRoute()]);
}
}
Future<Null> Function()? get enterCredentialsTryLogin => emptyFields()
? null
: () async {
serverTextController.text = serverTextController.text.rtrim('/');
ref.read(authProvider.notifier).setServer(serverTextController.text.rtrim('/'));
final response = await ref.read(authProvider.notifier).authenticateByName(
usernameController.text,
passwordController.text,
);
if (response?.isSuccessful == false) {
fladderSnackbar(context,
title:
"(${response?.base.statusCode}) ${response?.base.reasonPhrase ?? context.localized.somethingWentWrongPasswordCheck}");
} else if (response?.body != null) {
loggedInGoToHome();
}
};
bool emptyFields() => usernameController.text.isEmpty;
void retrieveListOfUsers() async {
serverTextController.text = serverTextController.text.rtrim('/');
ref.read(authProvider.notifier).setServer(serverTextController.text);
setState(() => loading = true);
final response = await ref.read(authProvider.notifier).getPublicUsers();
if ((response == null || response.isSuccessful == false) && context.mounted) {
fladderSnackbar(context, title: response?.base.reasonPhrase ?? context.localized.unableToConnectHost);
setState(() => startCheckingForErrors = true);
}
if (response?.body?.isEmpty == true) {
await Future.delayed(const Duration(seconds: 1));
}
setState(() {
users = response?.body ?? [];
loading = false;
});
}
Widget addUserFields(List<AccountModel> accounts, bool authLoading) {
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (accounts.isNotEmpty)
Padding(
padding: const EdgeInsets.all(8.0),
child: IconButton.filledTonal(
onPressed: () {
setState(() {
addingNewUser = false;
loading = false;
startCheckingForErrors = false;
serverTextController.text = "";
usernameController.text = "";
passwordController.text = "";
invalidUrl = "";
});
ref.read(authProvider.notifier).setServer("");
},
icon: const Icon(
IconsaxPlusLinear.arrow_left_2,
),
),
),
if (FladderConfig.baseUrl == null) ...[
Flexible(
child: OutlinedTextField(
controller: serverTextController,
onChanged: _parseUrl,
onSubmitted: (value) => retrieveListOfUsers(),
autoFillHints: const [AutofillHints.url],
keyboardType: TextInputType.url,
autocorrect: false,
textInputAction: TextInputAction.go,
label: context.localized.server,
errorText: (invalidUrl == null || serverTextController.text.isEmpty || !startCheckingForErrors)
? null
: invalidUrl,
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Tooltip(
message: context.localized.retrievePublicListOfUsers,
waitDuration: const Duration(seconds: 1),
child: IconButton.filled(
onPressed: () => retrieveListOfUsers(),
icon: const Icon(
IconsaxPlusLinear.refresh,
),
),
),
),
],
],
),
AnimatedFadeSize(
child: invalidUrl == null
? Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (loading || users.isNotEmpty)
AnimatedFadeSize(
duration: const Duration(milliseconds: 250),
child: loading
? CircularProgressIndicator(key: UniqueKey(), strokeCap: StrokeCap.round)
: LoginUserGrid(
users: users,
onPressed: (value) {
usernameController.text = value.name;
passwordController.text = "";
focusNode.requestFocus();
setState(() {});
},
),
),
AutofillGroup(
child: Column(
children: [
OutlinedTextField(
controller: usernameController,
autoFillHints: const [AutofillHints.username],
textInputAction: TextInputAction.next,
autocorrect: false,
onChanged: (value) => setState(() {}),
label: context.localized.userName,
),
OutlinedTextField(
controller: passwordController,
autoFillHints: const [AutofillHints.password],
keyboardType: TextInputType.visiblePassword,
focusNode: focusNode,
autocorrect: false,
textInputAction: TextInputAction.send,
onSubmitted: (value) => enterCredentialsTryLogin?.call(),
onChanged: (value) => setState(() {}),
label: context.localized.password,
),
FilledButton(
onPressed: enterCredentialsTryLogin,
child: authLoading
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
color: Theme.of(context).colorScheme.inversePrimary,
strokeCap: StrokeCap.round),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(context.localized.login),
const SizedBox(width: 8),
const Icon(IconsaxPlusBold.send_1),
],
),
),
].addPadding(const EdgeInsets.symmetric(vertical: 4)),
),
),
].addPadding(const EdgeInsets.symmetric(vertical: 10)),
)
: DiscoverServersWidget(
serverCredentials: accounts.map((e) => e.credentials).toList(),
onPressed: (server) {
serverTextController.text = server.address;
_parseUrl(server.address);
retrieveListOfUsers();
},
),
)
].addPadding(const EdgeInsets.symmetric(vertical: 8)),
);
}
}

View file

@ -0,0 +1,315 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/account_model.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/auth_provider.dart';
import 'package:fladder/providers/shared_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/routes/auto_router.gr.dart';
import 'package:fladder/screens/login/lock_screen.dart';
import 'package:fladder/screens/login/login_code_dialog.dart';
import 'package:fladder/screens/login/login_user_grid.dart';
import 'package:fladder/screens/login/widgets/discover_servers_widget.dart';
import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/screens/shared/fladder_snackbar.dart';
import 'package:fladder/screens/shared/outlined_text_field.dart';
import 'package:fladder/screens/shared/passcode_input.dart';
import 'package:fladder/util/auth_service.dart';
import 'package:fladder/util/localization_helper.dart';
class LoginScreenCredentials extends ConsumerStatefulWidget {
const LoginScreenCredentials({super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _LoginScreenCredentialsState();
}
class _LoginScreenCredentialsState extends ConsumerState<LoginScreenCredentials> {
late final TextEditingController serverTextController = TextEditingController(text: '');
final usernameController = TextEditingController();
final passwordController = TextEditingController();
final FocusNode focusNode = FocusNode();
bool loggingIn = false;
@override
Widget build(BuildContext context) {
final existingUsers = ref.watch(authProvider.select((value) => value.accounts));
final otherCredentials = existingUsers.map((e) => e.credentials).toList();
final serverCredentials = ref.watch(authProvider.select((value) => value.serverLoginModel));
final users = serverCredentials?.accounts ?? [];
final provider = ref.read(authProvider.notifier);
final loading = ref.watch(authProvider.select((value) => value.loading));
final hasBaseUrl = ref.watch(authProvider.select((value) => value.hasBaseUrl));
final urlError = ref.watch(authProvider.select((value) => value.errorMessage));
final hasQuickConnect = ref.watch(authProvider.select((value) => value.serverLoginModel?.hasQuickConnect ?? false));
ref.listen(
authProvider.select((value) => value.serverLoginModel),
(previous, next) {
if (next?.tempCredentials.server.isNotEmpty == true) {
serverTextController.text = next?.tempCredentials.server ?? "";
}
},
);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 16,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 8,
children: [
if (existingUsers.isNotEmpty)
IconButton.filledTonal(
onPressed: () => provider.goUserSelect(),
iconSize: 28,
icon: const Icon(
IconsaxPlusLinear.arrow_left_2,
),
),
if (!hasBaseUrl)
Flexible(
child: OutlinedTextField(
controller: serverTextController,
onChanged: (value) => provider.tryParseUrl(value),
onSubmitted: (value) => provider.setServer(value),
autoFillHints: const [AutofillHints.url],
keyboardType: TextInputType.url,
autocorrect: false,
textInputAction: TextInputAction.go,
label: context.localized.server,
errorText: urlError,
),
),
Tooltip(
message: context.localized.retrievePublicListOfUsers,
waitDuration: const Duration(seconds: 1),
child: IconButton.filled(
onPressed: () => provider.setServer(serverTextController.text),
iconSize: 28,
icon: const Icon(
IconsaxPlusLinear.refresh,
),
),
),
],
),
if (serverCredentials == null)
DiscoverServersWidget(
serverCredentials: otherCredentials,
onPressed: (info) => provider.setServer(info.address),
)
else ...[
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 16,
children: [
if (loading || users.isNotEmpty)
AnimatedFadeSize(
duration: const Duration(milliseconds: 250),
child: loading
? CircularProgressIndicator(key: UniqueKey(), strokeCap: StrokeCap.round)
: LoginUserGrid(
users: users,
onPressed: (value) {
usernameController.text = value.name;
passwordController.text = "";
focusNode.requestFocus();
setState(() {});
},
),
),
AutofillGroup(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8,
children: [
Flexible(
child: OutlinedTextField(
controller: usernameController,
autoFillHints: const [AutofillHints.username],
textInputAction: TextInputAction.next,
autocorrect: false,
onChanged: (value) => setState(() {}),
label: context.localized.userName,
),
),
Flexible(
child: OutlinedTextField(
controller: passwordController,
autoFillHints: const [AutofillHints.password],
keyboardType: TextInputType.visiblePassword,
focusNode: focusNode,
autocorrect: false,
textInputAction: TextInputAction.send,
onSubmitted: (value) => enterCredentialsTryLogin?.call(),
onChanged: (value) => setState(() {}),
label: context.localized.password,
),
),
const Divider(
indent: 32,
endIndent: 32,
),
FilledButton(
onPressed: enterCredentialsTryLogin,
child: loggingIn
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
color: Theme.of(context).colorScheme.inversePrimary, strokeCap: StrokeCap.round),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(context.localized.login),
const SizedBox(width: 8),
const Icon(IconsaxPlusBold.send_1),
],
),
),
if (hasQuickConnect)
FilledButton(
onPressed: () async {
final result = await ref.read(jellyApiProvider).quickConnectInitiate();
if (result.body != null) {
await openLoginCodeDialog(
context,
quickConnectInfo: result.body!,
onAuthenticated: (context, secret) async {
context.pop();
if (secret.isNotEmpty) {
await loginUsingSecret(secret);
}
},
);
} else {
fladderSnackbar(context, title: context.localized.quickConnectPostFailed);
}
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(context.localized.quickConnectLoginUsingCode),
const SizedBox(width: 8),
const Icon(IconsaxPlusBold.scan_barcode),
],
),
),
],
),
),
if (serverCredentials.serverMessage?.isEmpty == false) ...[
const Divider(),
Text(
serverCredentials.serverMessage ?? "",
style: Theme.of(context).textTheme.titleLarge,
),
],
],
),
],
],
);
}
Future<void> Function()? get enterCredentialsTryLogin => emptyFields() ? null : () => loginUsingCredentials();
Future<void> loginUsingCredentials() async {
setState(() {
loggingIn = true;
});
final response = await ref.read(authProvider.notifier).authenticateByName(
usernameController.text,
passwordController.text,
);
if (response?.isSuccessful == false) {
fladderSnackbar(context,
title:
"(${response?.base.statusCode}) ${response?.base.reasonPhrase ?? context.localized.somethingWentWrongPasswordCheck}");
} else if (response?.body != null) {
loggedInGoToHome(context, ref);
}
setState(() {
loggingIn = false;
});
}
Future<void> loginUsingSecret(String secret) async {
setState(() {
loggingIn = true;
});
final response = await ref.read(authProvider.notifier).authenticateUsingSecret(secret);
if (response?.isSuccessful == false) {
fladderSnackbar(context,
title:
"(${response?.base.statusCode}) ${response?.base.reasonPhrase ?? context.localized.somethingWentWrongPasswordCheck}");
} else if (response?.body != null) {
loggedInGoToHome(context, ref);
}
setState(() {
loggingIn = false;
});
}
bool emptyFields() => usernameController.text.isEmpty;
}
void loggedInGoToHome(BuildContext context, WidgetRef ref) {
ref.read(lockScreenActiveProvider.notifier).update((state) => false);
if (context.mounted) {
context.router.replaceAll([const DashboardRoute()]);
}
}
Future<void> _handleLogin(BuildContext context, AccountModel user, WidgetRef ref) async {
await ref.read(authProvider.notifier).switchUser();
await ref.read(sharedUtilityProvider).updateAccountInfo(user.copyWith(
lastUsed: DateTime.now(),
));
ref.read(userProvider.notifier).updateUser(user.copyWith(lastUsed: DateTime.now()));
loggedInGoToHome(context, ref);
}
void tapLoggedInAccount(BuildContext context, AccountModel user, WidgetRef ref) async {
Future<void> loginFunction() => _handleLogin(context, user, ref);
switch (user.authMethod) {
case Authentication.autoLogin:
loginFunction();
break;
case Authentication.biometrics:
final authenticated = await AuthService.authenticateUser(context, user);
if (authenticated) {
loginFunction();
}
break;
case Authentication.passcode:
if (context.mounted) {
showPassCodeDialog(context, (newPin) {
if (newPin == user.localPin) {
loginFunction();
} else {
fladderSnackbar(context, title: context.localized.incorrectPinTryAgain);
}
});
}
break;
case Authentication.none:
loginFunction();
break;
}
}

View file

@ -6,9 +6,9 @@ import 'package:reorderable_grid/reorderable_grid.dart';
import 'package:fladder/models/account_model.dart';
import 'package:fladder/providers/auth_provider.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/screens/shared/user_icon.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/list_padding.dart';
class LoginUserGrid extends ConsumerWidget {
@ -21,127 +21,118 @@ class LoginUserGrid extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final mainAxisExtent = 175.0;
final maxCount = (MediaQuery.of(context).size.width ~/ mainAxisExtent).clamp(1, 3);
final maxCount = (MediaQuery.of(context).size.width / mainAxisExtent).floor().clamp(1, 3);
return ReorderableGridView.builder(
onReorder: (oldIndex, newIndex) => ref.read(authProvider.notifier).reOrderUsers(oldIndex, newIndex),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
autoScroll: true,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: users.length == 1 ? 1 : maxCount,
mainAxisSpacing: 24,
crossAxisSpacing: 24,
mainAxisExtent: mainAxisExtent,
),
itemCount: users.length,
itemBuilder: (context, index) {
final user = users[index];
return FlatButton(
key: Key(user.id),
onTap: () => editMode ? onLongPress?.call(user) : onPressed?.call(user),
onLongPress:
AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer ? () => onLongPress?.call(user) : null,
child: _CardHolder(
content: Stack(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: UserIcon(
labelStyle: Theme.of(context).textTheme.headlineMedium,
user: user,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
final crossAxisCount = users.length == 1 ? 1 : maxCount;
final neededWidth = crossAxisCount * mainAxisExtent + (crossAxisCount - 1) * 24.0;
return SizedBox(
width: neededWidth,
child: ReorderableGridView.builder(
onReorder: (oldIndex, newIndex) => ref.read(authProvider.notifier).reOrderUsers(oldIndex, newIndex),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
autoScroll: true,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: (users.length == 1 ? 1 : maxCount).toInt(),
mainAxisSpacing: 24,
crossAxisSpacing: 24,
mainAxisExtent: mainAxisExtent,
),
itemCount: users.length,
itemBuilder: (context, index) {
final user = users[index];
return Center(
key: Key(user.id),
child: AspectRatio(
aspectRatio: 1.0,
child: FocusButton(
onTap: () => editMode ? onLongPress?.call(user) : onPressed?.call(user),
onLongPress: switch (AdaptiveLayout.inputDeviceOf(context)) {
InputDevice.dpad || InputDevice.pointer => () => onLongPress?.call(user),
InputDevice.touch => null,
},
darkOverlay: false,
child: Stack(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
user.authMethod.icon,
size: 18,
),
const SizedBox(width: 4),
Flexible(
child: Text(
user.name,
maxLines: 2,
softWrap: true,
)),
],
),
if (user.credentials.serverName.isNotEmpty)
Opacity(
opacity: 0.75,
child: Row(
child: UserIcon(
labelStyle: Theme.of(context).textTheme.headlineMedium,
user: user,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
const Icon(
IconsaxPlusBold.driver_2,
size: 14,
Icon(
user.authMethod.icon,
size: 18,
),
const SizedBox(width: 4),
Flexible(
child: Text(
user.credentials.serverName,
maxLines: 2,
softWrap: true,
),
),
child: Text(
user.name,
maxLines: 2,
softWrap: true,
)),
],
),
)
].addInBetween(const SizedBox(width: 4, height: 4)),
),
),
if (editMode)
Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
color: Theme.of(context).colorScheme.errorContainer,
child: const Padding(
padding: EdgeInsets.all(8.0),
child: Icon(
IconsaxPlusBold.edit_2,
size: 14,
if (user.credentials.serverName.isNotEmpty)
Opacity(
opacity: 0.75,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
const Icon(
IconsaxPlusBold.driver_2,
size: 14,
),
const SizedBox(width: 4),
Flexible(
child: Text(
user.credentials.serverName,
maxLines: 2,
softWrap: true,
),
),
],
),
)
].addInBetween(const SizedBox(width: 4, height: 4)),
),
),
if (editMode)
Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
color: Theme.of(context).colorScheme.errorContainer,
child: const Padding(
padding: EdgeInsets.all(8.0),
child: Icon(
IconsaxPlusBold.edit_2,
size: 14,
),
),
),
),
),
),
),
],
],
),
),
),
),
);
},
);
}
}
class _CardHolder extends StatelessWidget {
final Widget content;
const _CardHolder({
required this.content,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 1,
shadowColor: Colors.transparent,
clipBehavior: Clip.hardEdge,
margin: EdgeInsets.zero,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 150, maxWidth: 150),
child: content,
);
},
),
);
}

View file

@ -1,8 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/credentials_model.dart';
import 'package:fladder/providers/discovery_provider.dart';
@ -37,6 +37,7 @@ class DiscoverServersWidget extends ConsumerWidget {
return ListView(
padding: const EdgeInsets.all(6),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
if (existingServers.isNotEmpty) ...[
Row(
@ -123,8 +124,8 @@ class _ServerInfoCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Card(
child: InkWell(
onTap: () => onPressed(server),
child: TextButton(
onPressed: () => onPressed(server),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
child: Row(

View file

@ -1,5 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:intl/intl.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/items/item_shared_models.dart';
import 'package:fladder/providers/edit_item_provider.dart';
@ -12,10 +18,7 @@ import 'package:fladder/util/list_extensions.dart';
import 'package:fladder/util/string_extensions.dart';
import 'package:fladder/widgets/shared/adaptive_date_picker.dart';
import 'package:fladder/widgets/shared/enum_selection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
class EditFields extends ConsumerStatefulWidget {
final Map<String, dynamic> fields;
@ -63,14 +66,14 @@ class _EditGeneralState extends ConsumerState<EditFields> {
trailing: EnumBox(
current: map.entries.firstWhereOrNull((element) => element.value == true)?.key ?? "",
itemBuilder: (context) => [
PopupMenuItem(
child: const Text(""),
onTap: () => ref.read(editItemProvider.notifier).updateField(MapEntry(e.key, "")),
ItemActionButton(
label: const Text(""),
action: () => ref.read(editItemProvider.notifier).updateField(MapEntry(e.key, "")),
),
...map.entries.map(
(mapEntry) => PopupMenuItem(
child: Text(mapEntry.key),
onTap: () => ref
(mapEntry) => ItemActionButton(
label: Text(mapEntry.key),
action: () => ref
.read(editItemProvider.notifier)
.updateField(MapEntry(e.key, mapEntry.key)),
),
@ -240,9 +243,9 @@ class _EditGeneralState extends ConsumerState<EditFields> {
.whereNot(
(element) => element == PersonKind.swaggerGeneratedUnknown)
.map(
(entry) => PopupMenuItem(
child: Text(entry.name.toUpperCaseSplit()),
onTap: () {
(entry) => ItemActionButton(
label: Text(entry.name.toUpperCaseSplit()),
action: () {
setState(() {
personType = entry;
});
@ -570,9 +573,9 @@ class _EditGeneralState extends ConsumerState<EditFields> {
current: (e.value as DisplayOrder).value.toUpperCaseSplit(),
itemBuilder: (context) => DisplayOrder.values
.map(
(mapEntry) => PopupMenuItem(
child: Text(mapEntry.value.toUpperCaseSplit()),
onTap: () => ref
(mapEntry) => ItemActionButton(
label: Text(mapEntry.value.toUpperCaseSplit()),
action: () => ref
.read(editItemProvider.notifier)
.updateField(MapEntry(e.key, mapEntry.value)),
),
@ -594,9 +597,9 @@ class _EditGeneralState extends ConsumerState<EditFields> {
current: (e.value as ShowStatus).value,
itemBuilder: (context) => ShowStatus.values
.map(
(mapEntry) => PopupMenuItem(
child: Text(mapEntry.value),
onTap: () => ref
(mapEntry) => ItemActionButton(
label: Text(mapEntry.value),
action: () => ref
.read(editItemProvider.notifier)
.updateField(MapEntry(e.key, mapEntry.value)),
),

View file

@ -9,6 +9,7 @@ import 'package:fladder/screens/shared/fladder_snackbar.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/shared/enum_selection.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
Future<void> showRefreshPopup(BuildContext context, String itemId, String itemName) async {
return showDialog(
@ -69,10 +70,9 @@ class _RefreshPopupDialogState extends ConsumerState<RefreshPopupDialog> {
child: EnumBox(
current: refreshMode.label(context),
itemBuilder: (context) => MetadataRefresh.values
.map((value) => PopupMenuItem(
value: value,
child: Text(value.label(context)),
onTap: () => setState(() {
.map((value) => ItemActionButton(
label: Text(value.label(context)),
action: () => setState(() {
refreshMode = value;
}),
))

View file

@ -2,8 +2,8 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:path/path.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:window_manager/window_manager.dart';
@ -35,6 +35,7 @@ class _SimpleVideoPlayerState extends ConsumerState<SimpleVideoPlayer> with Wind
late final BasePlayer player = switch (ref.read(videoPlayerSettingsProvider.select((value) => value.wantedPlayer))) {
PlayerOptions.libMDK => LibMDK(),
PlayerOptions.libMPV => LibMPV(),
_ => LibMDK(),
};
late String videoUrl = "";
@ -102,7 +103,7 @@ class _SimpleVideoPlayerState extends ConsumerState<SimpleVideoPlayer> with Wind
duration = event.duration;
});
}));
await player.open(videoUrl, !ref.watch(photoViewSettingsProvider).autoPlay);
await player.loadVideo(videoUrl, !ref.watch(photoViewSettingsProvider).autoPlay);
await player.setVolume(ref.watch(photoViewSettingsProvider.select((value) => value.mute)) ? 0 : 100);
await player.loop(ref.watch(photoViewSettingsProvider.select((value) => value.repeat)));
}

View file

@ -10,6 +10,7 @@ import 'package:fladder/screens/settings/widgets/settings_label_divider.dart';
import 'package:fladder/screens/settings/widgets/settings_list_group.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/shared/enum_selection.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
List<Widget> buildClientSettingsDashboard(BuildContext context, WidgetRef ref) {
final clientSettings = ref.watch(clientSettingsProvider);
@ -28,10 +29,9 @@ List<Widget> buildClientSettingsDashboard(BuildContext context, WidgetRef ref) {
),
itemBuilder: (context) => HomeBanner.values
.map(
(entry) => PopupMenuItem(
value: entry,
child: Text(entry.label(context)),
onTap: () =>
(entry) => ItemActionButton(
label: Text(entry.label(context)),
action: () =>
ref.read(homeSettingsProvider.notifier).update((context) => context.copyWith(homeBanner: entry)),
),
)
@ -48,10 +48,9 @@ List<Widget> buildClientSettingsDashboard(BuildContext context, WidgetRef ref) {
),
itemBuilder: (context) => HomeCarouselSettings.values
.map(
(entry) => PopupMenuItem(
value: entry,
child: Text(entry.label(context)),
onTap: () => ref
(entry) => ItemActionButton(
label: Text(entry.label(context)),
action: () => ref
.read(homeSettingsProvider.notifier)
.update((context) => context.copyWith(carouselSettings: entry)),
),
@ -70,10 +69,9 @@ List<Widget> buildClientSettingsDashboard(BuildContext context, WidgetRef ref) {
),
itemBuilder: (context) => HomeNextUp.values
.map(
(entry) => PopupMenuItem(
value: entry,
child: Text(entry.label(context)),
onTap: () =>
(entry) => ItemActionButton(
label: Text(entry.label(context)),
action: () =>
ref.read(homeSettingsProvider.notifier).update((context) => context.copyWith(nextUp: entry)),
),
)

View file

@ -89,6 +89,21 @@ List<Widget> buildClientSettingsDownload(BuildContext context, WidgetRef ref, Fu
return SettingsListTile(
label: Text(context.localized.downloadsSyncedData),
subLabel: Text(data.byteFormat ?? ""),
onTap: () {
showDefaultAlertDialog(
context,
context.localized.downloadsClearTitle,
context.localized.downloadsClearDesc,
(context) async {
await ref.read(syncProvider.notifier).removeAllSyncedData();
setState(() {});
Navigator.of(context).pop();
},
context.localized.clear,
(context) => Navigator.of(context).pop(),
context.localized.cancel,
);
},
trailing: FilledButton(
onPressed: () {
showDefaultAlertDialog(
@ -123,21 +138,22 @@ List<Widget> buildClientSettingsDownload(BuildContext context, WidgetRef ref, Fu
label: Text(context.localized.maxConcurrentDownloadsTitle),
subLabel: Text(context.localized.maxConcurrentDownloadsDesc),
trailing: SizedBox(
width: 100,
child: IntInputField(
controller: TextEditingController(text: clientSettings.maxConcurrentDownloads.toString()),
onSubmitted: (value) {
if (value != null) {
ref.read(clientSettingsProvider.notifier).update(
(current) => current.copyWith(
maxConcurrentDownloads: value,
),
);
width: 150,
child: IntInputField(
controller: TextEditingController(text: clientSettings.maxConcurrentDownloads.toString()),
onSubmitted: (value) {
if (value != null) {
ref.read(clientSettingsProvider.notifier).update(
(current) => current.copyWith(
maxConcurrentDownloads: value,
),
);
ref.read(backgroundDownloaderProvider.notifier).setMaxConcurrent(value);
}
},
)),
ref.read(backgroundDownloaderProvider.notifier).setMaxConcurrent(value);
}
},
),
),
),
]),
const SizedBox(height: 12),

View file

@ -13,6 +13,7 @@ import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/shared/enum_selection.dart';
import 'package:fladder/widgets/shared/fladder_slider.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
List<Widget> buildClientSettingsVisual(
BuildContext context,
@ -41,16 +42,15 @@ List<Widget> buildClientSettingsVisual(
itemBuilder: (context) {
return [
...AppLocalizations.supportedLocales.map(
(entry) => PopupMenuItem(
value: entry,
child: Localizations.override(
(entry) => ItemActionButton(
label: Localizations.override(
context: context,
locale: entry,
child: Builder(builder: (context) {
return Text("${context.localized.nativeName} (${entry.toDisplayCode()})");
}),
),
onTap: () => ref
action: () => ref
.read(clientSettingsProvider.notifier)
.update((state) => state.copyWith(selectedLocale: entry)),
),
@ -95,10 +95,9 @@ List<Widget> buildClientSettingsVisual(
current: clientSettings.backgroundImage.label(context),
itemBuilder: (context) => BackgroundType.values
.map(
(e) => PopupMenuItem(
value: e,
child: Text(e.label(context)),
onTap: () =>
(e) => ItemActionButton(
label: Text(e.label(context)),
action: () =>
ref.read(clientSettingsProvider.notifier).update((cb) => cb.copyWith(backgroundImage: e)),
),
)

View file

@ -94,6 +94,15 @@ class _ClientSettingsPageState extends ConsumerState<ClientSettingsPage> {
...buildClientSettingsAdvanced(context, ref),
if (kDebugMode) ...[
const SizedBox(height: 64),
SettingsListTile(
label: const Text(
"Clear cache",
),
contentColor: Theme.of(context).colorScheme.error,
onTap: () {
PaintingBinding.instance.imageCache.clear();
},
),
SettingsListTile(
label: Text(
context.localized.clearAllSettings,

View file

@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/items/media_segments_model.dart';
import 'package:fladder/models/settings/video_player_settings.dart';
import 'package:fladder/providers/arguments_provider.dart';
import 'package:fladder/providers/connectivity_provider.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/providers/user_provider.dart';
@ -27,6 +28,7 @@ import 'package:fladder/util/bitrate_helper.dart';
import 'package:fladder/util/box_fit_extension.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/shared/enum_selection.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
@RoutePage()
class PlayerSettingsPage extends ConsumerStatefulWidget {
@ -81,10 +83,9 @@ class _PlayerSettingsPageState extends ConsumerState<PlayerSettingsPage> {
current: videoSettings.videoFit.label(context),
itemBuilder: (context) => BoxFit.values
.map(
(entry) => PopupMenuItem(
value: entry,
child: Text(entry.label(context)),
onTap: () => ref.read(videoPlayerSettingsProvider.notifier).setFitType(entry),
(entry) => ItemActionButton(
label: Text(entry.label(context)),
action: () => ref.read(videoPlayerSettingsProvider.notifier).setFitType(entry),
),
)
.toList(),
@ -102,10 +103,9 @@ class _PlayerSettingsPageState extends ConsumerState<PlayerSettingsPage> {
),
itemBuilder: (context) => Bitrate.values
.map(
(entry) => PopupMenuItem(
value: entry,
child: Text(entry.label(context)),
onTap: () => ref.read(videoPlayerSettingsProvider.notifier).state =
(entry) => ItemActionButton(
label: Text(entry.label(context)),
action: () => ref.read(videoPlayerSettingsProvider.notifier).state =
videoSettings.copyWith(maxHomeBitrate: entry),
),
)
@ -124,10 +124,9 @@ class _PlayerSettingsPageState extends ConsumerState<PlayerSettingsPage> {
),
itemBuilder: (context) => Bitrate.values
.map(
(entry) => PopupMenuItem(
value: entry,
child: Text(entry.label(context)),
onTap: () => ref.read(videoPlayerSettingsProvider.notifier).state =
(entry) => ItemActionButton(
label: Text(entry.label(context)),
action: () => ref.read(videoPlayerSettingsProvider.notifier).state =
videoSettings.copyWith(maxInternetBitrate: entry),
),
)
@ -153,10 +152,9 @@ class _PlayerSettingsPageState extends ConsumerState<PlayerSettingsPage> {
current: entry.value.label(context),
itemBuilder: (context) => SegmentSkip.values
.map(
(value) => PopupMenuItem(
value: value,
child: Text(value.label(context)),
onTap: () {
(value) => ItemActionButton(
label: Text(value.label(context)),
action: () {
final newEntries = videoSettings.segmentSkipSettings.map(
(key, currentValue) => MapEntry(key, key == entry.key ? value : currentValue));
ref.read(videoPlayerSettingsProvider.notifier).state =
@ -264,145 +262,151 @@ class _PlayerSettingsPageState extends ConsumerState<PlayerSettingsPage> {
),
]),
const SizedBox(height: 12),
...settingsListGroup(context, SettingsLabelDivider(label: context.localized.advanced), [
if (PlayerOptions.available.length != 1)
SettingsListTile(
label: Text(context.localized.playerSettingsBackendTitle),
subLabel: Text(context.localized.playerSettingsBackendDesc),
trailing: Builder(builder: (context) {
final wantedPlayer = videoSettings.wantedPlayer;
final currentPlayer = videoSettings.playerOptions;
return EnumBox(
current: currentPlayer == null
? "${context.localized.defaultLabel} (${PlayerOptions.platformDefaults.label(context)})"
: wantedPlayer.label(context),
itemBuilder: (context) => [
PopupMenuItem(
value: null,
child:
Text("${context.localized.defaultLabel} (${PlayerOptions.platformDefaults.label(context)})"),
onTap: () => ref.read(videoPlayerSettingsProvider.notifier).state =
videoSettings.copyWith(playerOptions: null),
),
...PlayerOptions.available.map(
(entry) => PopupMenuItem(
value: entry,
child: Text(entry.label(context)),
onTap: () => ref.read(videoPlayerSettingsProvider.notifier).state =
videoSettings.copyWith(playerOptions: entry),
),
)
],
);
}),
),
AnimatedFadeSize(
child: switch (videoSettings.wantedPlayer) {
PlayerOptions.libMPV => Column(
children: [
SettingsListTile(
label: Text(context.localized.settingsPlayerVideoHWAccelTitle),
subLabel: Text(context.localized.settingsPlayerVideoHWAccelDesc),
onTap: () => provider.setHardwareAccel(!videoSettings.hardwareAccel),
trailing: Switch(
value: videoSettings.hardwareAccel,
onChanged: (value) => provider.setHardwareAccel(value),
),
),
if (!kIsWeb)
SettingsListTile(
label: Text(context.localized.settingsPlayerNativeLibassAccelTitle),
subLabel: Text(context.localized.settingsPlayerNativeLibassAccelDesc),
onTap: () => provider.setUseLibass(!videoSettings.useLibass),
trailing: Switch(
value: videoSettings.useLibass,
onChanged: (value) => provider.setUseLibass(value),
...settingsListGroup(
context,
SettingsLabelDivider(label: context.localized.advanced),
[
if (!ref.read(argumentsStateProvider).leanBackMode) ...[
if (PlayerOptions.available.length != 1)
SettingsListTile(
label: Text(context.localized.playerSettingsBackendTitle),
subLabel: Text(context.localized.playerSettingsBackendDesc),
trailing: Builder(builder: (context) {
final wantedPlayer = videoSettings.wantedPlayer;
final currentPlayer = videoSettings.playerOptions;
return EnumBox(
current: currentPlayer == null
? "${context.localized.defaultLabel} (${PlayerOptions.platformDefaults.label(context)})"
: wantedPlayer.label(context),
itemBuilder: (context) => [
ItemActionButton(
label: Text(
"${context.localized.defaultLabel} (${PlayerOptions.platformDefaults.label(context)})"),
action: () => ref.read(videoPlayerSettingsProvider.notifier).state =
videoSettings.copyWith(playerOptions: null),
),
),
if (!videoSettings.useLibass)
SettingsListTile(
label: Text(context.localized.settingsPlayerCustomSubtitlesTitle),
subLabel: Text(context.localized.settingsPlayerCustomSubtitlesDesc),
onTap: videoSettings.useLibass
? null
: () {
showDialog(
context: context,
barrierDismissible: true,
useSafeArea: false,
builder: (context) => const SubtitleEditor(),
);
},
),
AnimatedFadeSize(
child: videoSettings.useLibass && videoSettings.hardwareAccel && Platform.isAndroid
? SettingsMessageBox(
context.localized.settingsPlayerMobileWarning,
messageType: MessageType.warning,
)
: Container(),
),
SettingsListTile(
label: Text(context.localized.settingsPlayerBufferSizeTitle),
subLabel: Text(context.localized.settingsPlayerBufferSizeDesc),
trailing: SizedBox(
width: 70,
child: IntInputField(
suffix: 'MB',
controller: TextEditingController(text: videoSettings.bufferSize.toString()),
onSubmitted: (value) {
if (value != null) {
provider.setBufferSize(value);
}
},
)),
),
],
...PlayerOptions.available.map(
(entry) => ItemActionButton(
label: Text(entry.label(context)),
action: () => ref.read(videoPlayerSettingsProvider.notifier).state =
videoSettings.copyWith(playerOptions: entry),
),
)
],
);
}),
),
_ => SettingsMessageBox(
messageType: MessageType.info,
"${context.localized.noVideoPlayerOptions}\n${context.localized.mdkExperimental}")
},
),
Column(
children: [
SettingsListTile(
label: Text(context.localized.settingsAutoNextTitle),
subLabel: Text(context.localized.settingsAutoNextDesc),
trailing: EnumBox(
current: ref.watch(
videoPlayerSettingsProvider.select(
(value) => value.nextVideoType.label(context),
),
),
itemBuilder: (context) => AutoNextType.values
.map(
(entry) => PopupMenuItem(
value: entry,
child: Text(entry.label(context)),
onTap: () => ref.read(videoPlayerSettingsProvider.notifier).state =
videoSettings.copyWith(nextVideoType: entry),
),
)
.toList(),
),
),
AnimatedFadeSize(
child: switch (ref.watch(videoPlayerSettingsProvider.select((value) => value.nextVideoType))) {
AutoNextType.smart => SettingsMessageBox(AutoNextType.smart.desc(context)),
AutoNextType.static => SettingsMessageBox(AutoNextType.static.desc(context)),
_ => const SizedBox.shrink(),
child: switch (videoSettings.wantedPlayer) {
PlayerOptions.libMPV => Column(
children: [
SettingsListTile(
label: Text(context.localized.settingsPlayerVideoHWAccelTitle),
subLabel: Text(context.localized.settingsPlayerVideoHWAccelDesc),
onTap: () => provider.setHardwareAccel(!videoSettings.hardwareAccel),
trailing: Switch(
value: videoSettings.hardwareAccel,
onChanged: (value) => provider.setHardwareAccel(value),
),
),
if (!kIsWeb)
SettingsListTile(
label: Text(context.localized.settingsPlayerNativeLibassAccelTitle),
subLabel: Text(context.localized.settingsPlayerNativeLibassAccelDesc),
onTap: () => provider.setUseLibass(!videoSettings.useLibass),
trailing: Switch(
value: videoSettings.useLibass,
onChanged: (value) => provider.setUseLibass(value),
),
),
if (!videoSettings.useLibass)
SettingsListTile(
label: Text(context.localized.settingsPlayerCustomSubtitlesTitle),
subLabel: Text(context.localized.settingsPlayerCustomSubtitlesDesc),
onTap: videoSettings.useLibass
? null
: () {
showDialog(
context: context,
barrierDismissible: true,
useSafeArea: false,
builder: (context) => const SubtitleEditor(),
);
},
),
AnimatedFadeSize(
child: videoSettings.useLibass && videoSettings.hardwareAccel && Platform.isAndroid
? SettingsMessageBox(
context.localized.settingsPlayerMobileWarning,
messageType: MessageType.warning,
)
: Container(),
),
SettingsListTile(
label: Text(context.localized.settingsPlayerBufferSizeTitle),
subLabel: Text(context.localized.settingsPlayerBufferSizeDesc),
trailing: SizedBox(
width: 70,
child: IntInputField(
suffix: 'MB',
controller: TextEditingController(text: videoSettings.bufferSize.toString()),
onSubmitted: (value) {
if (value != null) {
provider.setBufferSize(value);
}
},
)),
),
],
),
PlayerOptions.libMDK => SettingsMessageBox(
messageType: MessageType.info,
"${context.localized.noVideoPlayerOptions}\n${context.localized.mdkExperimental}"),
_ => const SizedBox.shrink()
},
),
],
),
if (!AdaptiveLayout.of(context).isDesktop && !kIsWeb)
SettingsListTile(
label: Text(context.localized.playerSettingsOrientationTitle),
subLabel: Text(context.localized.playerSettingsOrientationDesc),
onTap: () => showOrientationOptions(context, ref),
),
]),
if (videoSettings.wantedPlayer != PlayerOptions.nativePlayer) ...[
Column(
children: [
SettingsListTile(
label: Text(context.localized.settingsAutoNextTitle),
subLabel: Text(context.localized.settingsAutoNextDesc),
trailing: EnumBox(
current: ref.watch(
videoPlayerSettingsProvider.select(
(value) => value.nextVideoType.label(context),
),
),
itemBuilder: (context) => AutoNextType.values
.map(
(entry) => ItemActionButton(
label: Text(entry.label(context)),
action: () => ref.read(videoPlayerSettingsProvider.notifier).state =
videoSettings.copyWith(nextVideoType: entry),
),
)
.toList(),
),
),
AnimatedFadeSize(
child: switch (ref.watch(videoPlayerSettingsProvider.select((value) => value.nextVideoType))) {
AutoNextType.smart => SettingsMessageBox(AutoNextType.smart.desc(context)),
AutoNextType.static => SettingsMessageBox(AutoNextType.static.desc(context)),
_ => const SizedBox.shrink(),
},
),
],
),
if (!AdaptiveLayout.of(context).isDesktop && !kIsWeb && !ref.read(argumentsStateProvider).htpcMode)
SettingsListTile(
label: Text(context.localized.playerSettingsOrientationTitle),
subLabel: Text(context.localized.playerSettingsOrientationDesc),
onTap: () => showOrientationOptions(context, ref),
),
],
],
),
],
);
}

View file

@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/screens/login/widgets/login_icon.dart';
import 'package:fladder/screens/shared/outlined_text_field.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
Future<void> openQuickConnectDialog(
BuildContext context,
@ -30,18 +31,28 @@ class _QuickConnectDialogState extends ConsumerState<QuickConnectDialog> {
Widget build(BuildContext context) {
final user = ref.watch(userProvider);
return AlertDialog(
title: Text(context.localized.quickConnectTitle),
title: Text(
context.localized.quickConnectTitle,
textAlign: TextAlign.center,
),
backgroundColor: Theme.of(context).colorScheme.surface,
scrollable: true,
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 12,
children: [
Text(context.localized.quickConnectAction),
Text(
context.localized.quickConnectAction,
textAlign: TextAlign.center,
),
if (user != null) SizedBox(child: LoginIcon(user: user)),
Flexible(
child: OutlinedTextField(
label: context.localized.code,
controller: controller,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.go,
onChanged: (value) {
if (value.isNotEmpty) {
setState(() {
@ -50,6 +61,7 @@ class _QuickConnectDialogState extends ConsumerState<QuickConnectDialog> {
});
}
},
onSubmitted: (value) => tryLogin(),
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
@ -58,50 +70,24 @@ class _QuickConnectDialogState extends ConsumerState<QuickConnectDialog> {
child: error != null || success != null
? Card(
key: Key(context.localized.error),
color: success == null ? Theme.of(context).colorScheme.errorContainer : Theme.of(context).colorScheme.surfaceContainer,
color: success == null
? Theme.of(context).colorScheme.errorContainer
: Theme.of(context).colorScheme.surfaceContainer,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
success ?? error ?? "",
style: TextStyle(
color:
success == null ? Theme.of(context).colorScheme.onErrorContainer : Theme.of(context).colorScheme.onSurface),
color: success == null
? Theme.of(context).colorScheme.onErrorContainer
: Theme.of(context).colorScheme.onSurface),
),
),
)
: null,
),
ElevatedButton(
onPressed: loading
? null
: () async {
setState(() {
error = null;
loading = true;
});
final response = await ref.read(userProvider.notifier).quickConnect(controller.text);
if (response.isSuccessful) {
setState(
() {
error = null;
success = context.localized.loggedIn;
},
);
await Future.delayed(const Duration(seconds: 2));
Navigator.of(context).pop();
} else {
if (controller.text.isEmpty) {
error = context.localized.quickConnectInputACode;
} else {
error = context.localized.quickConnectWrongCode;
}
}
loading = false;
setState(
() {},
);
controller.text = "";
},
FilledButton(
onPressed: loading ? null : () => tryLogin(),
child: loading
? const SizedBox.square(
child: CircularProgressIndicator(),
@ -109,8 +95,37 @@ class _QuickConnectDialogState extends ConsumerState<QuickConnectDialog> {
)
: Text(context.localized.login),
)
].addInBetween(const SizedBox(height: 16)),
],
),
);
}
Future<void> tryLogin() async {
setState(() {
error = null;
loading = true;
});
final response = await ref.read(userProvider.notifier).quickConnect(controller.text);
if (response.isSuccessful) {
setState(
() {
error = null;
success = context.localized.loggedIn;
},
);
await Future.delayed(const Duration(seconds: 2));
Navigator.of(context).pop();
} else {
if (controller.text.isEmpty) {
error = context.localized.quickConnectInputACode;
} else {
error = context.localized.quickConnectWrongCode;
}
}
loading = false;
setState(
() {},
);
controller.text = "";
}
}

View file

@ -1,12 +1,14 @@
import 'package:flutter/material.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/widgets/shared/ensure_visible.dart';
class SettingsListTile extends StatelessWidget {
final Widget label;
final Widget? subLabel;
final Widget? trailing;
final bool selected;
final bool autoFocus;
final IconData? icon;
final Widget? leading;
final Color? contentColor;
@ -16,6 +18,7 @@ class SettingsListTile extends StatelessWidget {
this.subLabel,
this.trailing,
this.selected = false,
this.autoFocus = false,
this.leading,
this.icon,
this.contentColor,
@ -52,6 +55,12 @@ class SettingsListTile extends StatelessWidget {
margin: EdgeInsets.zero,
child: FlatButton(
onTap: onTap,
autoFocus: autoFocus,
onFocusChange: (value) {
if (value) {
context.ensureVisible();
}
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
@ -66,6 +75,7 @@ class SettingsListTile extends StatelessWidget {
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.start,
children: [
DefaultTextStyle.merge(
@ -85,7 +95,7 @@ class SettingsListTile extends StatelessWidget {
children: [
Material(
color: Colors.transparent,
textStyle: Theme.of(context).textTheme.titleLarge,
textStyle: Theme.of(context).textTheme.titleLarge?.copyWith(color: contentColor),
child: label,
),
if (subLabel != null)
@ -93,7 +103,7 @@ class SettingsListTile extends StatelessWidget {
opacity: 0.65,
child: Material(
color: Colors.transparent,
textStyle: Theme.of(context).textTheme.labelLarge,
textStyle: Theme.of(context).textTheme.labelLarge?.copyWith(color: contentColor),
child: subLabel,
),
),
@ -101,9 +111,12 @@ class SettingsListTile extends StatelessWidget {
),
),
if (trailing != null)
Padding(
padding: const EdgeInsets.only(left: 16),
child: trailing,
ExcludeFocusTraversal(
excluding: onTap != null,
child: Padding(
padding: const EdgeInsets.only(left: 16),
child: trailing,
),
)
],
),

View file

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/providers/arguments_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/screens/shared/user_icon.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
@ -76,7 +77,7 @@ class SettingsScaffold extends ConsumerWidget {
padding: MediaQuery.paddingOf(context).copyWith(bottom: 0),
child: Row(
children: [
if (showBackButtonNested)
if (showBackButtonNested && !ref.read(argumentsStateProvider).htpcMode)
BackButton(
onPressed: () => backAction(context),
)

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -13,6 +14,7 @@ import 'package:fladder/routes/auto_router.gr.dart';
import 'package:fladder/screens/settings/quick_connect_window.dart';
import 'package:fladder/screens/settings/settings_list_tile.dart';
import 'package:fladder/screens/settings/settings_scaffold.dart';
import 'package:fladder/screens/shared/default_alert_dialog.dart';
import 'package:fladder/screens/shared/fladder_icon.dart';
import 'package:fladder/screens/shared/fladder_snackbar.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
@ -55,9 +57,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(flex: 1, child: _leftPane(context)),
Expanded(flex: 2, child: _leftPane(context)),
Expanded(
flex: 2,
flex: 3,
child: content,
),
],
@ -88,6 +90,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
return IconsaxPlusLinear.monitor;
case ViewSize.desktop:
return IconsaxPlusLinear.monitor;
case ViewSize.television:
return IconsaxPlusLinear.mirroring_screen;
}
}
@ -129,6 +133,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
SettingsListTile(
label: Text(context.localized.settingsClientTitle),
subLabel: Text(context.localized.settingsClientDesc),
autoFocus: true,
selected: containsRoute(const ClientSettingsRoute()),
icon: deviceIcon,
onTap: () => navigateTo(const ClientSettingsRoute()),
@ -171,83 +176,81 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
label: Text(context.localized.exitFladderTitle),
icon: IconsaxPlusLinear.close_square,
onTap: () async {
final manager = WindowManager.instance;
if (await manager.isClosable()) {
manager.close();
} else {
fladderSnackbar(context, title: context.localized.somethingWentWrong);
}
showDefaultAlertDialog(
context,
context.localized.exitFladderTitle,
context.localized.exitFladderDesc,
(context) async {
if (AdaptiveLayout.of(context).isDesktop) {
final manager = WindowManager.instance;
if (await manager.isClosable()) {
manager.close();
} else {
fladderSnackbar(context, title: context.localized.somethingWentWrong);
}
} else {
SystemNavigator.pop();
}
},
context.localized.close,
(context) => context.pop(),
context.localized.cancel,
);
},
),
],
],
floatingActionButton: Padding(
padding: EdgeInsets.symmetric(horizontal: MediaQuery.paddingOf(context).horizontal),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Spacer(),
FloatingActionButton(
key: Key(context.localized.switchUser),
tooltip: context.localized.switchUser,
onPressed: () async {
await ref.read(userProvider.notifier).logoutUser();
context.router.replaceAll([const LoginRoute()]);
},
child: const Icon(
IconsaxPlusLinear.arrow_swap_horizontal,
),
),
const SizedBox(width: 16),
FloatingActionButton(
heroTag: context.localized.logout,
key: Key(context.localized.logout),
tooltip: context.localized.logout,
backgroundColor: Theme.of(context).colorScheme.errorContainer,
onPressed: () {
final user = ref.read(userProvider);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(context.localized.logoutUserPopupTitle(user?.name ?? "")),
scrollable: true,
content: Text(
context.localized.logoutUserPopupContent(user?.name ?? "", user?.server ?? ""),
),
actions: [
ElevatedButton(
onPressed: () => Navigator.pop(context),
child: Text(context.localized.cancel),
),
ElevatedButton(
style: ElevatedButton.styleFrom().copyWith(
iconColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onErrorContainer),
foregroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onErrorContainer),
backgroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.errorContainer),
),
onPressed: () async {
await ref.read(authProvider.notifier).logOutUser();
if (context.mounted) {
context.router.replaceAll([const LoginRoute()]);
}
},
child: Text(context.localized.logout),
),
],
),
);
},
child: Icon(
IconsaxPlusLinear.logout,
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
],
),
const FractionallySizedBox(
widthFactor: 0.25,
child: Divider(),
),
),
SettingsListTile(
label: Text(context.localized.switchUser),
icon: IconsaxPlusLinear.arrow_swap_horizontal,
contentColor: Colors.greenAccent,
onTap: () async {
await ref.read(userProvider.notifier).logoutUser();
context.router.replaceAll([const LoginRoute()]);
},
),
SettingsListTile(
label: Text(context.localized.logout),
icon: IconsaxPlusLinear.logout,
contentColor: Theme.of(context).colorScheme.error,
onTap: () {
final user = ref.read(userProvider);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(context.localized.logoutUserPopupTitle(user?.name ?? "")),
scrollable: true,
content: Text(
context.localized.logoutUserPopupContent(user?.name ?? "", user?.server ?? ""),
),
actions: [
ElevatedButton(
onPressed: () => Navigator.pop(context),
child: Text(context.localized.cancel),
),
ElevatedButton(
style: ElevatedButton.styleFrom().copyWith(
iconColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onErrorContainer),
foregroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onErrorContainer),
backgroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.errorContainer),
),
onPressed: () async {
await ref.read(authProvider.notifier).logOutUser();
if (context.mounted) {
context.router.replaceAll([const LoginRoute()]);
}
},
child: Text(context.localized.logout),
),
],
),
);
},
),
],
),
),
);

View file

@ -2,6 +2,10 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/providers/arguments_provider.dart';
Future<void> showDefaultAlertDialog(
BuildContext context,
String title,
@ -18,9 +22,12 @@ Future<void> showDefaultAlertDialog(
content: content != null ? Text(content) : null,
actions: [
if (decline != null)
ElevatedButton(
onPressed: () => decline.call(context),
child: Text(declineTitle),
Consumer(
builder: (context, ref, child) => ElevatedButton(
autofocus: ref.read(argumentsStateProvider).htpcMode,
onPressed: () => decline.call(context),
child: Text(declineTitle),
),
),
if (accept != null)
ElevatedButton(

View file

@ -44,167 +44,171 @@ class _DefaultTitleBarState extends ConsumerState<DefaultTitleBar> with WindowLi
final isOffline = ref.watch(connectivityStatusProvider.select((value) => value == ConnectionState.offline));
final surfaceColor = theme.colorScheme.surface;
return MouseRegion(
onEnter: (event) => setState(() => hovering = true),
onExit: (event) => setState(() => hovering = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: isOffline
? [
theme.colorScheme.errorContainer.withValues(alpha: 0.8),
theme.colorScheme.errorContainer.withValues(alpha: 0.25),
]
: [
surfaceColor.withValues(alpha: hovering ? 0.7 : 0),
surfaceColor.withValues(alpha: 0),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
)),
height: widget.height,
child: kIsWeb
? const SizedBox.shrink()
: Stack(
fit: StackFit.expand,
children: [
switch (AdaptiveLayout.of(context).platform) {
TargetPlatform.android || TargetPlatform.iOS => SizedBox(height: MediaQuery.paddingOf(context).top),
TargetPlatform.windows || TargetPlatform.linux => Container(
child: Row(
children: [
Expanded(
child: Container(
color: Colors.black.withValues(alpha: 0),
child: DragToMoveArea(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.max,
children: [
Container(
padding: const EdgeInsets.only(left: 16),
child: DefaultTextStyle(
style: TextStyle(
color: iconColor,
fontSize: 14,
return ExcludeFocus(
child: MouseRegion(
onEnter: (event) => setState(() => hovering = true),
onExit: (event) => setState(() => hovering = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: isOffline
? [
theme.colorScheme.errorContainer.withValues(alpha: 0.8),
theme.colorScheme.errorContainer.withValues(alpha: 0.25),
]
: [
surfaceColor.withValues(alpha: hovering ? 0.7 : 0),
surfaceColor.withValues(alpha: 0),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
)),
height: widget.height,
child: kIsWeb
? const SizedBox.shrink()
: Stack(
fit: StackFit.expand,
children: [
switch (AdaptiveLayout.of(context).platform) {
TargetPlatform.android ||
TargetPlatform.iOS =>
SizedBox(height: MediaQuery.paddingOf(context).top),
TargetPlatform.windows || TargetPlatform.linux => Container(
child: Row(
children: [
Expanded(
child: Container(
color: Colors.black.withValues(alpha: 0),
child: DragToMoveArea(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.max,
children: [
Container(
padding: const EdgeInsets.only(left: 16),
child: DefaultTextStyle(
style: TextStyle(
color: iconColor,
fontSize: 14,
),
child: Text(widget.label ?? ""),
),
child: Text(widget.label ?? ""),
),
),
],
],
),
),
),
),
),
Container(
decoration: BoxDecoration(boxShadow: [
BoxShadow(
color: surfaceColor.withValues(alpha: isOffline ? 0 : 0.5),
blurRadius: 32,
spreadRadius: 10,
offset: const Offset(8, -6),
),
]),
child: Row(
children: [
FutureBuilder<List<bool>>(future: Future.microtask(() async {
final isMinimized = await windowManager.isMinimized();
return [isMinimized];
}), builder: (context, snapshot) {
final isMinimized = snapshot.data?.firstOrNull ?? false;
return IconButton(
Container(
decoration: BoxDecoration(boxShadow: [
BoxShadow(
color: surfaceColor.withValues(alpha: isOffline ? 0 : 0.5),
blurRadius: 32,
spreadRadius: 10,
offset: const Offset(8, -6),
),
]),
child: Row(
children: [
FutureBuilder<List<bool>>(future: Future.microtask(() async {
final isMinimized = await windowManager.isMinimized();
return [isMinimized];
}), builder: (context, snapshot) {
final isMinimized = snapshot.data?.firstOrNull ?? false;
return IconButton(
style: IconButton.styleFrom(
hoverColor: brightness == Brightness.light
? Colors.black.withValues(alpha: 0.1)
: Colors.white.withValues(alpha: 0.2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2))),
onPressed: () async {
fullScreenHelper.closeFullScreen(ref);
if (isMinimized) {
windowManager.restore();
} else {
windowManager.minimize();
}
},
icon: Transform.translate(
offset: const Offset(0, -2),
child: Icon(
Icons.minimize_rounded,
color: iconColor,
size: 20,
),
),
);
}),
FutureBuilder<List<bool>>(
future: Future.microtask(() async {
final isMaximized = await windowManager.isMaximized();
return [isMaximized];
}),
builder: (BuildContext context, AsyncSnapshot<List<bool>> snapshot) {
final maximized = snapshot.data?.firstOrNull ?? false;
return IconButton(
style: IconButton.styleFrom(
hoverColor: brightness == Brightness.light
? Colors.black.withValues(alpha: 0.1)
: Colors.white.withValues(alpha: 0.2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)),
),
onPressed: () async {
fullScreenHelper.closeFullScreen(ref);
if (maximized) {
await windowManager.unmaximize();
return;
}
if (!maximized) {
await windowManager.maximize();
} else {
await windowManager.unmaximize();
}
},
icon: Transform.translate(
offset: const Offset(0, 0),
child: Icon(
maximized ? Icons.maximize_rounded : Icons.crop_square_rounded,
color: iconColor,
size: 19,
),
),
);
},
),
IconButton(
style: IconButton.styleFrom(
hoverColor: brightness == Brightness.light
? Colors.black.withValues(alpha: 0.1)
: Colors.white.withValues(alpha: 0.2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2))),
hoverColor: Colors.red,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(2),
),
),
onPressed: () async {
fullScreenHelper.closeFullScreen(ref);
if (isMinimized) {
windowManager.restore();
} else {
windowManager.minimize();
}
windowManager.close();
},
icon: Transform.translate(
offset: const Offset(0, -2),
child: Icon(
Icons.minimize_rounded,
Icons.close_rounded,
color: iconColor,
size: 20,
size: 23,
),
),
);
}),
FutureBuilder<List<bool>>(
future: Future.microtask(() async {
final isMaximized = await windowManager.isMaximized();
return [isMaximized];
}),
builder: (BuildContext context, AsyncSnapshot<List<bool>> snapshot) {
final maximized = snapshot.data?.firstOrNull ?? false;
return IconButton(
style: IconButton.styleFrom(
hoverColor: brightness == Brightness.light
? Colors.black.withValues(alpha: 0.1)
: Colors.white.withValues(alpha: 0.2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)),
),
onPressed: () async {
fullScreenHelper.closeFullScreen(ref);
if (maximized) {
await windowManager.unmaximize();
return;
}
if (!maximized) {
await windowManager.maximize();
} else {
await windowManager.unmaximize();
}
},
icon: Transform.translate(
offset: const Offset(0, 0),
child: Icon(
maximized ? Icons.maximize_rounded : Icons.crop_square_rounded,
color: iconColor,
size: 19,
),
),
);
},
),
IconButton(
style: IconButton.styleFrom(
hoverColor: Colors.red,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(2),
),
),
onPressed: () async {
windowManager.close();
},
icon: Transform.translate(
offset: const Offset(0, -2),
child: Icon(
Icons.close_rounded,
color: iconColor,
size: 23,
),
),
),
],
],
),
),
),
],
],
),
),
),
TargetPlatform.macOS => const SizedBox.shrink(),
_ => Text(widget.label ?? "Fladder"),
},
const OfflineBanner()
],
),
TargetPlatform.macOS => const SizedBox.shrink(),
_ => Text(widget.label ?? "Fladder"),
},
const OfflineBanner()
],
),
),
),
);
}

View file

@ -63,17 +63,20 @@ class _DetailScaffoldState extends ConsumerState<DetailScaffold> {
@override
Widget build(BuildContext context) {
final padding = EdgeInsets.symmetric(horizontal: MediaQuery.sizeOf(context).width / 25);
final size = MediaQuery.sizeOf(context);
final padding = EdgeInsets.symmetric(horizontal: size.width / 25);
final backGroundColor = Theme.of(context).colorScheme.surface.withValues(alpha: 0.8);
final minHeight = 450.0.clamp(0, MediaQuery.sizeOf(context).height).toDouble();
final maxHeight = MediaQuery.sizeOf(context).height - 10;
final minHeight = 450.0.clamp(0, size.height).toDouble();
final maxHeight = size.height - 10;
final sideBarPadding = AdaptiveLayout.of(context).sideBarWidth;
return PullToRefresh(
onRefresh: () async {
await widget.onRefresh?.call();
setState(() {
if (widget.backDrops?.backDrop?.contains(backgroundImage) == true) {
backgroundImage = widget.backDrops?.randomBackDrop;
if (context.mounted) {
if (widget.backDrops?.backDrop?.contains(backgroundImage) == true) {
backgroundImage = widget.backDrops?.randomBackDrop;
}
}
});
},
@ -89,7 +92,7 @@ class _DetailScaffoldState extends ConsumerState<DetailScaffold> {
children: [
SizedBox(
height: maxHeight,
width: MediaQuery.sizeOf(context).width,
width: size.width,
child: FladderImage(
image: backgroundImage,
blurOnly: true,
@ -120,14 +123,19 @@ class _DetailScaffoldState extends ConsumerState<DetailScaffold> {
maxHeight: maxHeight.clamp(minHeight, 2500) - 20,
),
child: FadeInImage(
placeholder: backgroundImage!.imageProvider,
placeholder: ResizeImage(
backgroundImage!.imageProvider,
height: maxHeight ~/ 1.5,
),
placeholderColor: Colors.transparent,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
placeholderFit: BoxFit.cover,
excludeFromSemantics: true,
placeholderFilterQuality: FilterQuality.low,
image: backgroundImage!.imageProvider,
image: ResizeImage(
backgroundImage!.imageProvider,
height: maxHeight ~/ 1.5,
),
),
),
),
@ -151,8 +159,8 @@ class _DetailScaffoldState extends ConsumerState<DetailScaffold> {
),
),
Container(
height: MediaQuery.sizeOf(context).height,
width: MediaQuery.sizeOf(context).width,
height: size.height,
width: size.width,
color: widget.backgroundColor,
),
Padding(
@ -160,141 +168,148 @@ class _DetailScaffoldState extends ConsumerState<DetailScaffold> {
bottom: 0,
top: MediaQuery.of(context).padding.top,
),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: MediaQuery.sizeOf(context).height,
maxWidth: MediaQuery.sizeOf(context).width,
child: FocusScope(
autofocus: true,
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: size.height,
maxWidth: size.width,
),
child: widget.content(
padding.copyWith(
left: sideBarPadding + 25 + MediaQuery.paddingOf(context).left,
),
),
),
child: widget.content(padding.copyWith(
left: sideBarPadding + 25 + MediaQuery.paddingOf(context).left,
)),
),
),
],
),
),
//Top row buttons
IconTheme(
data: IconThemeData(color: Theme.of(context).colorScheme.onSurface),
child: Padding(
padding: MediaQuery.paddingOf(context)
.copyWith(left: sideBarPadding + MediaQuery.paddingOf(context).left)
.add(
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
child: Row(
children: [
IconButton.filledTonal(
style: IconButton.styleFrom(
backgroundColor: backGroundColor,
if (AdaptiveLayout.of(context).viewSize < ViewSize.desktop)
IconTheme(
data: IconThemeData(color: Theme.of(context).colorScheme.onSurface),
child: Padding(
padding: MediaQuery.paddingOf(context)
.copyWith(left: sideBarPadding + MediaQuery.paddingOf(context).left)
.add(
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
onPressed: () => context.router.popBack(),
icon: Padding(
padding: EdgeInsets.all(AdaptiveLayout.of(context).inputDevice == InputDevice.pointer ? 0 : 4),
child: const BackButtonIcon(),
),
),
const Spacer(),
AnimatedSize(
duration: const Duration(milliseconds: 250),
child: Container(
decoration:
BoxDecoration(color: backGroundColor, borderRadius: FladderTheme.defaultShape.borderRadius),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.item != null) ...[
ref.watch(syncedItemProvider(widget.item)).when(
error: (error, stackTrace) => const SizedBox.shrink(),
data: (syncedItem) {
if (syncedItem == null &&
ref.read(userProvider.select(
(value) => value?.canDownload ?? false,
)) &&
widget.item?.syncAble == true) {
return IconButton(
onPressed: () =>
ref.read(syncProvider.notifier).addSyncItem(context, widget.item!),
icon: const Icon(
IconsaxPlusLinear.arrow_down_2,
),
);
} else if (syncedItem != null) {
return IconButton(
onPressed: () => showSyncItemDetails(context, syncedItem, ref),
icon: SyncButton(item: widget.item!, syncedItem: syncedItem),
);
}
return const SizedBox.shrink();
},
loading: () => const SizedBox.shrink(),
),
Builder(
builder: (context) {
final newActions = widget.actions?.call(context);
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) {
return PopupMenuButton(
tooltip: context.localized.moreOptions,
enabled: newActions?.isNotEmpty == true,
icon: Icon(
widget.item!.type.icon,
color: Theme.of(context).colorScheme.onSurface,
),
itemBuilder: (context) => newActions?.popupMenuItems(useIcons: true) ?? [],
);
} else {
return IconButton(
onPressed: () => showBottomSheetPill(
context: context,
content: (context, scrollController) => ListView(
controller: scrollController,
shrinkWrap: true,
children: newActions?.listTileItems(context, useIcons: true) ?? [],
),
),
icon: Icon(
widget.item!.type.icon,
),
);
}
},
),
],
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer)
Builder(
builder: (context) => Tooltip(
message: context.localized.refresh,
child: IconButton(
onPressed: () => context.refreshData(),
icon: const Icon(IconsaxPlusLinear.refresh),
),
),
),
if (AdaptiveLayout.layoutModeOf(context) == LayoutMode.single ||
AdaptiveLayout.viewSizeOf(context) == ViewSize.phone)
Container(
margin: const EdgeInsets.symmetric(horizontal: 6),
child: const SizedBox(
height: 30,
width: 30,
child: SettingsUserIcon(),
),
),
if (AdaptiveLayout.layoutModeOf(context) == LayoutMode.single)
Tooltip(
message: context.localized.home,
child: IconButton(
onPressed: () => context.navigateTo(const DashboardRoute()),
icon: const Icon(IconsaxPlusLinear.home),
)),
],
child: Row(
children: [
IconButton.filledTonal(
style: IconButton.styleFrom(
backgroundColor: backGroundColor,
),
onPressed: () => context.router.popBack(),
icon: Padding(
padding:
EdgeInsets.all(AdaptiveLayout.of(context).inputDevice == InputDevice.pointer ? 0 : 4),
child: const BackButtonIcon(),
),
),
),
],
const Spacer(),
AnimatedSize(
duration: const Duration(milliseconds: 250),
child: Container(
decoration: BoxDecoration(
color: backGroundColor, borderRadius: FladderTheme.defaultShape.borderRadius),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.item != null) ...[
ref.watch(syncedItemProvider(widget.item)).when(
error: (error, stackTrace) => const SizedBox.shrink(),
data: (syncedItem) {
if (syncedItem == null &&
ref.read(userProvider.select(
(value) => value?.canDownload ?? false,
)) &&
widget.item?.syncAble == true) {
return IconButton(
onPressed: () =>
ref.read(syncProvider.notifier).addSyncItem(context, widget.item!),
icon: const Icon(
IconsaxPlusLinear.arrow_down_2,
),
);
} else if (syncedItem != null) {
return IconButton(
onPressed: () => showSyncItemDetails(context, syncedItem, ref),
icon: SyncButton(item: widget.item!, syncedItem: syncedItem),
);
}
return const SizedBox.shrink();
},
loading: () => const SizedBox.shrink(),
),
Builder(
builder: (context) {
final newActions = widget.actions?.call(context);
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) {
return PopupMenuButton(
tooltip: context.localized.moreOptions,
enabled: newActions?.isNotEmpty == true,
icon: Icon(
widget.item!.type.icon,
color: Theme.of(context).colorScheme.onSurface,
),
itemBuilder: (context) => newActions?.popupMenuItems(useIcons: true) ?? [],
);
} else {
return IconButton(
onPressed: () => showBottomSheetPill(
context: context,
content: (context, scrollController) => ListView(
controller: scrollController,
shrinkWrap: true,
children: newActions?.listTileItems(context, useIcons: true) ?? [],
),
),
icon: Icon(
widget.item!.type.icon,
),
);
}
},
),
],
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer)
Builder(
builder: (context) => Tooltip(
message: context.localized.refresh,
child: IconButton(
onPressed: () => context.refreshData(),
icon: const Icon(IconsaxPlusLinear.refresh),
),
),
),
if (AdaptiveLayout.layoutModeOf(context) == LayoutMode.single ||
AdaptiveLayout.viewSizeOf(context) == ViewSize.phone)
Container(
margin: const EdgeInsets.symmetric(horizontal: 6),
child: const SizedBox(
height: 30,
width: 30,
child: SettingsUserIcon(),
),
),
if (AdaptiveLayout.layoutModeOf(context) == LayoutMode.single)
Tooltip(
message: context.localized.home,
child: IconButton(
onPressed: () => context.navigateTo(const DashboardRoute()),
icon: const Icon(IconsaxPlusLinear.home),
)),
],
),
),
),
],
),
),
),
),
],
),
),

View file

@ -6,6 +6,9 @@ import 'package:fladder/theme.dart';
class FlatButton extends ConsumerWidget {
final Widget? child;
final bool autoFocus;
final FocusNode? focusNode;
final Function(bool value)? onFocusChange;
final Function()? onTap;
final Function()? onLongPress;
final Function()? onDoubleTap;
@ -17,6 +20,9 @@ class FlatButton extends ConsumerWidget {
final Clip clipBehavior;
const FlatButton({
this.child,
this.onFocusChange,
this.focusNode,
this.autoFocus = false,
this.onTap,
this.onLongPress,
this.onDoubleTap,
@ -47,8 +53,11 @@ class FlatButton extends ConsumerWidget {
borderRadius: borderRadiusGeometry ?? FladderTheme.defaultShape.borderRadius,
elevation: 0,
child: InkWell(
autofocus: autoFocus,
focusNode: focusNode,
onTap: onTap,
onLongPress: onLongPress,
onFocusChange: onFocusChange,
onDoubleTap: onDoubleTap,
onSecondaryTapDown: onSecondaryTapDown,
borderRadius: borderRadiusGeometry ?? BorderRadius.circular(10),

View file

@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/screens/shared/outlined_text_field.dart';
class IntInputField extends ConsumerWidget {
final int? value;
final TextEditingController? controller;
@ -19,25 +22,17 @@ class IntInputField extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Card(
color: Theme.of(context).colorScheme.secondaryContainer.withValues(alpha: 0.25),
elevation: 0,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: TextField(
controller: controller ?? TextEditingController(text: (value ?? 0).toString()),
keyboardType: const TextInputType.numberWithOptions(decimal: false, signed: false),
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
textInputAction: TextInputAction.done,
onSubmitted: (value) => onSubmitted?.call(int.tryParse(value)),
textAlign: TextAlign.center,
decoration: InputDecoration(
contentPadding: const EdgeInsets.all(0),
hintText: placeHolder,
suffixText: suffix,
border: InputBorder.none,
),
),
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: OutlinedTextField(
controller: controller ?? TextEditingController(text: (value ?? 0).toString()),
keyboardType: const TextInputType.numberWithOptions(decimal: false, signed: false),
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
textInputAction: TextInputAction.done,
onSubmitted: (value) => onSubmitted?.call(int.tryParse(value)),
textAlign: TextAlign.center,
suffix: suffix,
placeHolder: placeHolder,
),
);
}

View file

@ -156,7 +156,9 @@ class _CarouselBannerState extends ConsumerState<CarouselBanner> {
);
},
),
BannerPlayButton(item: widget.items[index]),
ExcludeFocus(
child: BannerPlayButton(item: widget.items[index]),
),
IgnorePointer(
child: Container(
decoration: BoxDecoration(

View file

@ -4,9 +4,8 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/items/chapters_model.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/disable_keypad_focus.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/humanize_duration.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/shared/horizontal_list.dart';
@ -25,7 +24,7 @@ class ChapterRow extends ConsumerWidget {
label: context.localized.chapter(chapters.length),
height: AdaptiveLayout.poster(context).size / 1.75,
items: chapters,
itemBuilder: (context, index) {
itemBuilder: (context, index, selected) {
final chapter = chapters[index];
List<ItemAction> generateActions() {
return [
@ -34,16 +33,39 @@ class ChapterRow extends ConsumerWidget {
];
}
return AspectRatio(
aspectRatio: 1.75,
child: Card(
return FocusButton(
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: generateActions().popupMenuItems(),
);
},
onLongPress: () {
showBottomSheetPill(
context: context,
content: (context, scrollController) {
return ListView(
shrinkWrap: true,
controller: scrollController,
children: [
...generateActions().listTileItems(context),
],
);
},
);
},
child: AspectRatio(
aspectRatio: 1.75,
child: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: CachedNetworkImage(
imageUrl: chapter.imageUrl,
fit: BoxFit.cover,
),
CachedNetworkImage(
imageUrl: chapter.imageUrl,
fit: BoxFit.cover,
),
Align(
alignment: Alignment.bottomLeft,
@ -64,49 +86,25 @@ class ChapterRow extends ConsumerWidget {
),
),
),
FlatButton(
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: generateActions().popupMenuItems(),
);
},
onLongPress: () {
showBottomSheetPill(
context: context,
content: (context, scrollController) {
return ListView(
shrinkWrap: true,
controller: scrollController,
children: [
...generateActions().listTileItems(context),
],
);
},
);
},
),
if (AdaptiveLayout.of(context).isDesktop)
DisableFocus(
child: Align(
alignment: Alignment.bottomRight,
child: PopupMenuButton(
tooltip: context.localized.options,
icon: const Icon(
Icons.more_vert,
color: Colors.white,
),
itemBuilder: (context) => generateActions().popupMenuItems(),
),
),
),
],
),
),
overlays: [
if (AdaptiveLayout.of(context).isDesktop)
ExcludeFocus(
child: Align(
alignment: Alignment.bottomRight,
child: PopupMenuButton(
tooltip: context.localized.options,
icon: const Icon(
Icons.more_vert,
color: Colors.white,
),
itemBuilder: (context) => generateActions().popupMenuItems(),
),
),
)
],
);
},
contentPadding: contentPadding,

View file

@ -9,10 +9,12 @@ class MediaHeader extends ConsumerWidget {
final String name;
final ImageData? logo;
final Function()? onTap;
final Alignment alignment;
const MediaHeader({
required this.name,
required this.logo,
this.onTap,
this.alignment = Alignment.bottomCenter,
super.key,
});
@ -48,7 +50,7 @@ class MediaHeader extends ConsumerWidget {
? FladderImage(
image: logo,
disableBlur: true,
alignment: Alignment.bottomCenter,
alignment: alignment,
imageErrorBuilder: (context, object, stack) => textWidget,
placeHolder: const SizedBox(height: 0),
fit: BoxFit.contain,

View file

@ -1,15 +1,19 @@
import 'package:flutter/material.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/providers/arguments_provider.dart';
import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/theme.dart';
import 'package:fladder/widgets/shared/ensure_visible.dart';
class MediaPlayButton extends ConsumerWidget {
final ItemBaseModel? item;
final Function()? onPressed;
final Function()? onLongPressed;
final VoidCallback? onPressed;
final VoidCallback? onLongPressed;
const MediaPlayButton({
required this.item,
this.onPressed,
@ -19,66 +23,110 @@ class MediaPlayButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final resume = (item?.progress ?? 0) > 0;
Widget buttonBuilder(bool resume, ButtonStyle? style, Color? textColor) {
return ElevatedButton(
onPressed: onPressed,
onLongPress: onLongPressed,
style: style,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Text(
item?.playButtonLabel(context) ?? "",
maxLines: 2,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: textColor,
),
),
final progress = (item?.progress ?? 0) / 100.0;
final radius = FladderTheme.defaultShape.borderRadius;
Widget buttonTitle(Color contentColor) {
return Padding(
padding: const EdgeInsets.all(10.0),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Text(
item?.playButtonLabel(context) ?? "",
maxLines: 2,
overflow: TextOverflow.clip,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: contentColor,
),
),
const SizedBox(width: 4),
const Icon(
IconsaxPlusBold.play,
),
],
),
),
const SizedBox(width: 4),
Icon(
IconsaxPlusBold.play,
color: contentColor,
),
],
),
);
}
return AnimatedFadeSize(
duration: const Duration(milliseconds: 250),
child: onPressed != null
? Stack(
children: [
buttonBuilder(resume, null, null),
IgnorePointer(
child: ClipRect(
child: Align(
alignment: Alignment.centerLeft,
widthFactor: (item?.progress ?? 0) / 100,
child: buttonBuilder(
resume,
ButtonStyle(
backgroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.primary),
foregroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onPrimary),
iconColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onPrimary),
child: onPressed == null
? const SizedBox.shrink(key: ValueKey('empty'))
: TextButton(
onPressed: onPressed,
onLongPress: onLongPressed,
autofocus: ref.read(argumentsStateProvider).htpcMode,
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
),
onFocusChange: (value) {
if (value) {
context.ensureVisible(
alignment: 1.0,
);
}
},
child: Padding(
padding: const EdgeInsets.all(2.0),
child: Stack(
alignment: Alignment.center,
children: [
// Progress background
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
boxShadow: [
BoxShadow(
blurRadius: 8.0,
offset: const Offset(0, 2),
color: Colors.black.withValues(alpha: 0.3),
)
],
borderRadius: radius,
),
Theme.of(context).colorScheme.onPrimary,
),
),
),
// Button content
buttonTitle(Theme.of(context).colorScheme.primary),
Positioned.fill(
child: ClipRect(
clipper: _ProgressClipper(
progress,
),
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: radius,
),
child: buttonTitle(Theme.of(context).colorScheme.onPrimary),
),
),
),
],
),
],
)
: Container(
key: UniqueKey(),
),
),
);
}
}
class _ProgressClipper extends CustomClipper<Rect> {
final double progress;
_ProgressClipper(this.progress);
@override
Rect getClip(Size size) {
final w = (progress.clamp(0.0, 1.0) * size.width);
return Rect.fromLTWH(0, 0, w, size.height);
}
@override
bool shouldReclip(covariant _ProgressClipper old) => old.progress != progress;
}

View file

@ -1,4 +1,3 @@
import 'package:fladder/models/items/movie_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -7,14 +6,14 @@ import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/book_model.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/item_shared_models.dart';
import 'package:fladder/models/items/movie_model.dart';
import 'package:fladder/models/items/photos_model.dart';
import 'package:fladder/models/items/series_model.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/screens/shared/media/components/poster_placeholder.dart';
import 'package:fladder/theme.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/disable_keypad_focus.dart';
import 'package:fladder/util/fladder_image.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/humanize_duration.dart';
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
import 'package:fladder/util/localization_helper.dart';
@ -26,7 +25,6 @@ import 'package:fladder/widgets/shared/status_card.dart';
class PosterImage extends ConsumerStatefulWidget {
final ItemBaseModel poster;
final bool heroTag;
final bool? selected;
final ValueChanged<bool>? playVideo;
final bool inlineTitle;
@ -36,9 +34,11 @@ class PosterImage extends ConsumerStatefulWidget {
final Function(ItemBaseModel newItem)? onItemUpdated;
final Function(ItemBaseModel oldItem)? onItemRemoved;
final Function(Function() action, ItemBaseModel item)? onPressed;
final bool primaryPosters;
final Function(bool focus)? onFocusChanged;
const PosterImage({
required this.poster,
this.heroTag = false,
this.selected,
this.playVideo,
this.inlineTitle = false,
@ -48,6 +48,8 @@ class PosterImage extends ConsumerStatefulWidget {
this.otherActions = const [],
this.onPressed,
this.onUserDataChanged,
this.primaryPosters = false,
this.onFocusChanged,
super.key,
});
@ -56,15 +58,8 @@ class PosterImage extends ConsumerStatefulWidget {
}
class _PosterImageState extends ConsumerState<PosterImage> {
late String currentTag = widget.heroTag == true ? widget.poster.id : UniqueKey().toString();
bool hover = false;
final tag = UniqueKey();
void pressedWidget(BuildContext context) async {
if (widget.heroTag == false) {
setState(() {
currentTag = widget.poster.id;
});
}
if (widget.onPressed != null) {
widget.onPressed?.call(() async {
await navigateToDetails();
@ -78,7 +73,7 @@ class _PosterImageState extends ConsumerState<PosterImage> {
}
Future<void> navigateToDetails() async {
await widget.poster.navigateTo(context, ref: ref);
await widget.poster.navigateTo(context, ref: ref, tag: tag);
}
final posterRadius = FladderTheme.smallShape.borderRadius;
@ -87,302 +82,268 @@ class _PosterImageState extends ConsumerState<PosterImage> {
Widget build(BuildContext context) {
final poster = widget.poster;
final padding = const EdgeInsets.all(5);
return Hero(
tag: currentTag,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (event) => setState(() => hover = true),
onExit: (event) => setState(() => hover = false),
child: Card(
elevation: 6,
color: Theme.of(context).colorScheme.secondaryContainer,
shape: RoundedRectangleBorder(
side: BorderSide(
width: 1.0,
color: Colors.white.withValues(alpha: 0.10),
),
borderRadius: posterRadius,
tag: tag,
child: Card(
elevation: 6,
color: Theme.of(context).colorScheme.secondaryContainer,
shape: RoundedRectangleBorder(
side: BorderSide(
width: 1.0,
color: Colors.white.withValues(alpha: 0.10),
),
child: Stack(
fit: StackFit.expand,
children: [
FladderImage(
image: widget.poster.getPosters?.primary ?? widget.poster.getPosters?.backDrop?.lastOrNull,
placeHolder: PosterPlaceholder(item: widget.poster),
),
if (poster.userData.progress > 0 && widget.poster.type == FladderItemType.book)
Align(
alignment: Alignment.topLeft,
child: Padding(
padding: padding,
child: Card(
child: Padding(
padding: const EdgeInsets.all(5.5),
child: Text(
context.localized.page((widget.poster as BookModel).currentPage),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
fontSize: 12,
),
),
borderRadius: posterRadius,
),
child: Stack(
fit: StackFit.expand,
children: [
FladderImage(
image: widget.primaryPosters
? widget.poster.images?.primary
: widget.poster.getPosters?.primary ?? widget.poster.getPosters?.backDrop?.lastOrNull,
placeHolder: PosterPlaceholder(item: widget.poster),
),
if (poster.userData.progress > 0 && widget.poster.type == FladderItemType.book)
Align(
alignment: Alignment.topLeft,
child: Padding(
padding: padding,
child: Card(
child: Padding(
padding: const EdgeInsets.all(5.5),
child: Text(
context.localized.page((widget.poster as BookModel).currentPage),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
fontSize: 12,
),
),
),
),
),
if (widget.selected == true)
Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.15),
border: Border.all(width: 3, color: Theme.of(context).colorScheme.primary),
borderRadius: posterRadius,
),
clipBehavior: Clip.hardEdge,
child: Stack(
alignment: Alignment.topCenter,
children: [
Container(
color: Theme.of(context).colorScheme.primary,
width: double.infinity,
child: Padding(
padding: const EdgeInsets.all(2),
child: Text(
widget.poster.name,
maxLines: 2,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(color: Theme.of(context).colorScheme.onPrimary, fontWeight: FontWeight.bold),
),
),
)
],
),
),
if (widget.selected == true)
Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.15),
border: Border.all(width: 3, color: Theme.of(context).colorScheme.primary),
borderRadius: posterRadius,
),
Align(
alignment: Alignment.bottomCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
clipBehavior: Clip.hardEdge,
child: Stack(
alignment: Alignment.topCenter,
children: [
if (widget.poster.userData.isFavourite)
const Row(
children: [
StatusCard(
color: Colors.red,
child: Icon(
IconsaxPlusBold.heart,
size: 21,
color: Colors.red,
),
),
],
),
if ((poster.userData.progress > 0 && poster.userData.progress < 100) &&
widget.poster.type != FladderItemType.book) ...{
const SizedBox(
height: 4,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 3).copyWith(bottom: 3).add(padding),
child: Card(
color: Colors.transparent,
elevation: 3,
shadowColor: Colors.transparent,
child: LinearProgressIndicator(
minHeight: 7.5,
backgroundColor: Theme.of(context).colorScheme.onPrimary.withValues(alpha: 0.5),
value: poster.userData.progress / 100,
borderRadius: BorderRadius.circular(2),
),
Container(
color: Theme.of(context).colorScheme.primary,
width: double.infinity,
child: Padding(
padding: const EdgeInsets.all(2),
child: Text(
widget.poster.name,
maxLines: 2,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(color: Theme.of(context).colorScheme.onPrimary, fontWeight: FontWeight.bold),
),
),
},
)
],
),
),
if (widget.inlineTitle)
IgnorePointer(
child: Align(
alignment: Alignment.topLeft,
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(
widget.poster.title.maxLength(limitTo: 25),
style:
Theme.of(context).textTheme.labelLarge?.copyWith(fontSize: 20, fontWeight: FontWeight.bold),
Align(
alignment: Alignment.bottomCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.poster.userData.isFavourite)
const Row(
children: [
StatusCard(
color: Colors.red,
child: Icon(
IconsaxPlusBold.heart,
size: 21,
color: Colors.red,
),
),
],
),
if ((poster.userData.progress > 0 && poster.userData.progress < 100) &&
widget.poster.type != FladderItemType.book) ...{
const SizedBox(
height: 4,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 3).copyWith(bottom: 3).add(padding),
child: Card(
color: Colors.transparent,
elevation: 3,
shadowColor: Colors.transparent,
child: LinearProgressIndicator(
minHeight: 7.5,
backgroundColor: Theme.of(context).colorScheme.onPrimary.withValues(alpha: 0.5),
value: poster.userData.progress / 100,
borderRadius: BorderRadius.circular(2),
),
),
),
},
],
),
),
if (widget.inlineTitle)
Align(
alignment: Alignment.topLeft,
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(
widget.poster.title.maxLength(limitTo: 25),
style: Theme.of(context).textTheme.labelLarge?.copyWith(fontSize: 20, fontWeight: FontWeight.bold),
),
),
if ((widget.poster.unPlayedItemCount != null && widget.poster is SeriesModel)
|| (widget.poster is MovieModel && !widget.poster.unWatched))
IgnorePointer(
child: Align(
alignment: Alignment.topRight,
child: StatusCard(
color: Theme.of(context).colorScheme.primary,
useFittedBox: widget.poster.unPlayedItemCount != 0,
child: Padding(
padding: const EdgeInsets.all(6),
child: widget.poster.unPlayedItemCount != 0
? Container(
constraints: const BoxConstraints(minWidth: 16),
child: Text(
widget.poster.userData.unPlayedItemCount.toString(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
overflow: TextOverflow.visible,
fontSize: 14,
),
),
)
: Icon(
Icons.check_rounded,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
),
),
),
),
if (widget.poster.overview.runTime != null &&
((widget.poster is PhotoModel) &&
(widget.poster as PhotoModel).internalType == FladderItemType.video)) ...{
Align(
),
if ((widget.poster.unPlayedItemCount != null && widget.poster is SeriesModel) ||
(widget.poster is MovieModel && !widget.poster.unWatched))
IgnorePointer(
child: Align(
alignment: Alignment.topRight,
child: Padding(
padding: padding,
child: Card(
elevation: 5,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
widget.poster.overview.runTime.humanizeSmall ?? "",
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
child: StatusCard(
color: Theme.of(context).colorScheme.primary,
useFittedBox: widget.poster.unPlayedItemCount != 0,
child: Padding(
padding: const EdgeInsets.all(6),
child: widget.poster.unPlayedItemCount != 0
? Container(
constraints: const BoxConstraints(minWidth: 16),
child: Text(
widget.poster.userData.unPlayedItemCount.toString(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
overflow: TextOverflow.visible,
fontSize: 14,
),
),
)
: Icon(
Icons.check_rounded,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 2),
Icon(
Icons.play_arrow_rounded,
color: Theme.of(context).colorScheme.onSurface,
),
],
),
),
),
),
),
if (widget.poster.overview.runTime != null &&
((widget.poster is PhotoModel) &&
(widget.poster as PhotoModel).internalType == FladderItemType.video)) ...{
Align(
alignment: Alignment.topRight,
child: Padding(
padding: padding,
child: Card(
elevation: 5,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
widget.poster.overview.runTime.humanizeSmall ?? "",
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(width: 2),
Icon(
Icons.play_arrow_rounded,
color: Theme.of(context).colorScheme.onSurface,
),
],
),
),
),
)
},
//Desktop overlay
if (AdaptiveLayout.of(context).inputDevice != InputDevice.touch &&
widget.poster.type != FladderItemType.person)
AnimatedOpacity(
opacity: hover ? 1 : 0,
duration: const Duration(milliseconds: 125),
child: Stack(
fit: StackFit.expand,
children: [
//Hover color overlay
Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
border: Border.all(width: 3, color: Theme.of(context).colorScheme.primary),
borderRadius: posterRadius,
)),
//Poster Button
Focus(
onFocusChange: (value) => setState(() => hover = value),
child: FlatButton(
onTap: () => pressedWidget(context),
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),
);
},
),
),
//Play Button
if (widget.poster.playAble)
DisableFocus(
child: Align(
alignment: Alignment.center,
child: IconButton.filledTonal(
onPressed: () => widget.playVideo?.call(false),
icon: const Icon(
IconsaxPlusBold.play,
size: 32,
),
),
),
),
DisableFocus(
child: Align(
alignment: Alignment.bottomRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
PopupMenuButton(
tooltip: "Options",
icon: const Icon(
Icons.more_vert,
color: Colors.white,
),
itemBuilder: (context) => widget.poster
.generateActions(
context,
ref,
exclude: widget.excludeActions,
otherActions: widget.otherActions,
onUserDataChanged: widget.onUserDataChanged,
onDeleteSuccesFully: widget.onItemRemoved,
onItemUpdated: widget.onItemUpdated,
)
.popupMenuItems(useIcons: true),
),
],
),
),
),
],
),
)
},
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),
),
)
else
Material(
color: Colors.transparent,
child: InkWell(
onTap: () => pressedWidget(context),
onLongPress: () {
showBottomSheetPill(
context: context,
item: widget.poster,
content: (scrollContext, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children: widget.poster
);
},
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: [
//Poster Button
if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer) ...[
// Play Button
if (widget.poster.playAble)
Align(
alignment: Alignment.center,
child: IconButton.filledTonal(
onPressed: () => widget.playVideo?.call(false),
icon: const Icon(
IconsaxPlusBold.play,
size: 32,
),
),
),
Align(
alignment: Alignment.bottomRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
PopupMenuButton(
tooltip: "Options",
icon: const Icon(
Icons.more_vert,
color: Colors.white,
),
itemBuilder: (context) => widget.poster
.generateActions(
context,
ref,
@ -392,14 +353,15 @@ class _PosterImageState extends ConsumerState<PosterImage> {
onDeleteSuccesFully: widget.onItemRemoved,
onItemUpdated: widget.onItemUpdated,
)
.listTileItems(scrollContext, useIcons: true),
.popupMenuItems(useIcons: true),
),
);
},
],
),
),
),
],
),
],
],
),
],
),
),
);

View file

@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/screens/details_screens/components/overview_header.dart';
import 'package:fladder/screens/shared/media/poster_row.dart';
import 'package:fladder/util/fladder_image.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/shared/ensure_visible.dart';
class DetailedBanner extends ConsumerStatefulWidget {
final List<ItemBaseModel> posters;
final Function(ItemBaseModel selected) onSelect;
const DetailedBanner({
required this.posters,
required this.onSelect,
super.key,
});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _DetailedBannerState();
}
class _DetailedBannerState extends ConsumerState<DetailedBanner> {
late ItemBaseModel selectedPoster = widget.posters.first;
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final color = Theme.of(context).colorScheme.surface;
final stops = [0.05, 0.35, 0.65, 0.95];
return Column(
children: [
SizedBox(
width: double.infinity,
height: size.height * 0.50,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
color.withValues(alpha: 0.85),
color.withValues(alpha: 0.75),
color.withValues(alpha: 0),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Stack(
children: [
ExcludeFocus(
child: Align(
alignment: Alignment.topRight,
child: AspectRatio(
aspectRatio: 1.7,
child: ShaderMask(
shaderCallback: (Rect bounds) {
return LinearGradient(
colors: [
Colors.white,
Colors.white,
Colors.white,
Colors.white.withAlpha(0),
],
stops: stops,
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
).createShader(bounds);
},
child: ShaderMask(
shaderCallback: (Rect bounds) {
return LinearGradient(
colors: [
Colors.white.withAlpha(0),
Colors.white,
Colors.white,
Colors.white,
],
stops: stops,
begin: Alignment.centerLeft,
end: Alignment.centerRight,
).createShader(bounds);
},
child: FladderImage(
image: selectedPoster.images?.primary,
),
),
),
),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: FractionallySizedBox(
widthFactor: 0.5,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
spacing: 16,
children: [
Flexible(
child: OverviewHeader(
name: selectedPoster.parentBaseModel.name,
subTitle: selectedPoster.label(context),
image: selectedPoster.getPosters,
logoAlignment: Alignment.centerLeft,
summary: selectedPoster.overview.summary,
productionYear: selectedPoster.overview.productionYear,
runTime: selectedPoster.overview.runTime,
genres: selectedPoster.overview.genreItems,
studios: selectedPoster.overview.studios,
officialRating: selectedPoster.overview.parentalRating,
communityRating: selectedPoster.overview.communityRating,
),
),
SizedBox(
height: size.height * 0.05,
)
],
),
),
),
],
),
),
),
FocusProvider(
autoFocus: true,
child: PosterRow(
key: const Key("detailed-banner-row"),
primaryPosters: true,
label: context.localized.nextUp,
posters: widget.posters,
onFocused: (poster) {
context.ensureVisible(
alignment: 1.0,
);
setState(() {
selectedPoster = poster;
});
widget.onSelect(poster);
},
),
)
],
);
}
}

View file

@ -6,11 +6,10 @@ import 'package:fladder/models/items/episode_model.dart';
import 'package:fladder/models/syncing/sync_item.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/sync/sync_provider_helpers.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/screens/syncing/sync_button.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/disable_keypad_focus.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/localization_helper.dart';
import 'package:fladder/widgets/shared/clickable_text.dart';
@ -64,14 +63,14 @@ class _EpisodePosterState extends ConsumerState<EpisodePosters> {
EnumBox(
current: selectedSeason != null ? "${context.localized.season(1)} $selectedSeason" : context.localized.all,
itemBuilder: (context) => [
PopupMenuItem(
child: Text(context.localized.all),
onTap: () => setState(() => selectedSeason = null),
ItemActionButton(
label: Text(context.localized.all),
action: () => setState(() => selectedSeason = null),
),
...episodesBySeason.entries.map(
(e) => PopupMenuItem(
child: Text("${context.localized.season(1)} ${e.key}"),
onTap: () {
(e) => ItemActionButton(
label: Text("${context.localized.season(1)} ${e.key}"),
action: () {
setState(() => selectedSeason = e.key);
},
),
@ -84,7 +83,7 @@ class _EpisodePosterState extends ConsumerState<EpisodePosters> {
contentPadding: widget.contentPadding,
startIndex: indexOfCurrent,
items: episodes,
itemBuilder: (context, index) {
itemBuilder: (context, index, selected) {
final episode = episodes[index];
final isCurrentEpisode = index == indexOfCurrent;
return EpisodePoster(
@ -164,14 +163,43 @@ class EpisodePoster extends ConsumerWidget {
child: Stack(
fit: StackFit.expand,
children: [
FladderImage(
image: !episodeAvailable ? episode.parentImages?.primary : episode.images?.primary,
placeHolder: placeHolder,
blurOnly: !episodeAvailable
? true
: ref.watch(clientSettingsProvider.select((value) => value.blurUpcomingEpisodes))
? blur
: false,
FocusButton(
onTap: onTap,
onLongPress: onLongPress,
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: actions.popupMenuItems(useIcons: true));
},
child: FladderImage(
image: !episodeAvailable ? episode.parentImages?.primary : episode.images?.primary,
placeHolder: placeHolder,
blurOnly: !episodeAvailable
? true
: ref.watch(clientSettingsProvider.select((value) => value.blurUpcomingEpisodes))
? blur
: false,
decodeHeight: 250,
),
overlays: [
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer && actions.isNotEmpty)
ExcludeFocus(
child: Align(
alignment: Alignment.bottomRight,
child: PopupMenuButton(
tooltip: context.localized.options,
icon: const Icon(
Icons.more_vert,
color: Colors.white,
),
itemBuilder: (context) => actions.popupMenuItems(useIcons: true),
),
),
),
],
),
if (!episodeAvailable)
Align(
@ -236,36 +264,6 @@ class EpisodePoster extends ConsumerWidget {
value: episode.userData.progress / 100,
),
),
LayoutBuilder(
builder: (context, constraints) {
return FlatButton(
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: actions.popupMenuItems(useIcons: true));
},
onTap: onTap,
onLongPress: onLongPress,
);
},
),
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer && actions.isNotEmpty)
DisableFocus(
child: Align(
alignment: Alignment.bottomRight,
child: PopupMenuButton(
tooltip: context.localized.options,
icon: const Icon(
Icons.more_vert,
color: Colors.white,
),
itemBuilder: (context) => actions.popupMenuItems(useIcons: true),
),
),
),
],
),
),

View file

@ -5,10 +5,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.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/util/adaptive_layout/adaptive_layout.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/list_padding.dart';
import 'package:fladder/util/themes_data.dart';
@ -99,9 +99,7 @@ class _MediaBannerState extends ConsumerState<MediaBanner> {
surfaceTintColor: overlayColor,
color: overlayColor,
child: MouseRegion(
onEnter: (event) => setState(() => showControls = true),
onHover: (event) => timer.reset(),
onExit: (event) => setState(() => showControls = false),
child: Stack(
fit: StackFit.expand,
children: [
@ -146,56 +144,52 @@ class _MediaBannerState extends ConsumerState<MediaBanner> {
],
),
),
child: Stack(
children: [
SizedBox(
width: double.infinity,
height: double.infinity,
child: Padding(
padding: const EdgeInsets.all(1),
child: FladderImage(
fit: BoxFit.cover,
image: currentItem.bannerImage,
),
child: FocusButton(
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,
onSecondaryTapDown: AdaptiveLayout.of(context).inputDevice == InputDevice.touch
? null
: (details) async {
Offset localPosition = details.globalPosition;
RelativeRect position = RelativeRect.fromLTRB(
localPosition.dx - 320, localPosition.dy, localPosition.dx, localPosition.dy);
final poster = currentItem;
await showMenu(
context: context,
position: position,
items: poster.generateActions(context, ref).popupMenuItems(useIcons: true),
);
},
child: SizedBox(
width: double.infinity,
height: double.infinity,
child: Padding(
padding: const EdgeInsets.all(1),
child: FladderImage(
fit: BoxFit.cover,
image: currentItem.bannerImage,
),
),
FlatButton(
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,
onSecondaryTapDown: AdaptiveLayout.of(context).inputDevice == InputDevice.touch
? null
: (details) async {
Offset localPosition = details.globalPosition;
RelativeRect position = RelativeRect.fromLTRB(localPosition.dx - 320,
localPosition.dy, localPosition.dx, localPosition.dy);
final poster = currentItem;
await showMenu(
context: context,
position: position,
items: poster.generateActions(context, ref).popupMenuItems(useIcons: true),
);
},
),
],
),
),
),
),

View file

@ -6,9 +6,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.enums.swagger.dart';
import 'package:fladder/models/items/item_shared_models.dart';
import 'package:fladder/screens/details_screens/person_detail_screen.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/fladder_image.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/string_extensions.dart';
import 'package:fladder/widgets/shared/clickable_text.dart';
@ -47,7 +47,7 @@ class PeopleRow extends ConsumerWidget {
height: AdaptiveLayout.poster(context).size * 0.9,
contentPadding: contentPadding,
items: people,
itemBuilder: (context, index) {
itemBuilder: (context, index, selected) {
final person = people[index];
return AspectRatio(
aspectRatio: 0.6,
@ -63,19 +63,13 @@ class PeopleRow extends ConsumerWidget {
transitionType: ContainerTransitionType.fadeThrough,
openColor: Colors.transparent,
tappable: false,
closedBuilder: (context, action) => Stack(
children: [
Positioned.fill(
child: Card(
child: FladderImage(
image: person.image,
placeHolder: placeHolder(person.name),
fit: BoxFit.cover,
),
),
),
FlatButton(onTap: () => action()),
],
closedBuilder: (context, action) => FocusButton(
onTap: () => action(),
child: FladderImage(
image: person.image,
placeHolder: placeHolder(person.name),
fit: BoxFit.cover,
),
),
openBuilder: (context, action) => PersonDetailScreen(
person: person,

View file

@ -9,10 +9,12 @@ import 'package:fladder/models/items/item_shared_models.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.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/list_padding.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/shared/clickable_text.dart';
import 'package:fladder/widgets/shared/ensure_visible.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
@ -65,8 +67,13 @@ class PosterListItem extends ConsumerWidget {
color: Theme.of(context).colorScheme.primary.withValues(alpha: selected == true ? 0.25 : 0),
borderRadius: BorderRadius.circular(6),
),
child: InkWell(
child: FocusButton(
onTap: () => pressedWidget(context),
onFocusChanged: (focus) {
if (focus) {
context.ensureVisible();
}
},
onSecondaryTapDown: (details) async {
Offset localPosition = details.globalPosition;
RelativeRect position =

View file

@ -3,53 +3,56 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/providers/arguments_provider.dart';
import 'package:fladder/screens/shared/media/poster_widget.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
import 'package:fladder/widgets/shared/ensure_visible.dart';
import 'package:fladder/widgets/shared/horizontal_list.dart';
class PosterRow extends ConsumerStatefulWidget {
class PosterRow extends ConsumerWidget {
final List<ItemBaseModel> posters;
final String label;
final double? collectionAspectRatio;
final Function()? onLabelClick;
final EdgeInsets contentPadding;
final Function(ItemBaseModel focused)? onFocused;
final bool primaryPosters;
const PosterRow({
required this.posters,
this.contentPadding = const EdgeInsets.symmetric(horizontal: 16),
required this.label,
this.collectionAspectRatio,
this.onLabelClick,
this.onFocused,
this.primaryPosters = false,
super.key,
});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _PosterRowState();
}
class _PosterRowState extends ConsumerState<PosterRow> {
late final controller = ScrollController();
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final dominantRatio = widget.collectionAspectRatio ?? widget.posters.getMostCommonType.aspectRatio;
Widget build(BuildContext context, WidgetRef ref) {
final dominantRatio = primaryPosters ? 1.2 : collectionAspectRatio ?? posters.getMostCommonType.aspectRatio;
return HorizontalList(
contentPadding: widget.contentPadding,
label: widget.label,
onLabelClick: widget.onLabelClick,
contentPadding: contentPadding,
label: label,
autoFocus: ref.read(argumentsStateProvider).htpcMode ? FocusProvider.autoFocusOf(context) : false,
onLabelClick: onLabelClick,
dominantRatio: dominantRatio,
items: widget.posters,
itemBuilder: (context, index) {
final poster = widget.posters[index];
items: posters,
onFocused: (index) {
if (onFocused != null) {
onFocused?.call(posters[index]);
} else {
context.ensureVisible();
}
},
itemBuilder: (context, index, selected) {
final poster = posters[index];
return PosterWidget(
key: Key(poster.id),
poster: poster,
aspectRatio: dominantRatio,
key: Key(poster.id),
primaryPosters: primaryPosters,
);
},
);

View file

@ -15,7 +15,6 @@ class PosterWidget extends ConsumerWidget {
final ItemBaseModel poster;
final Widget? subTitle;
final bool? selected;
final bool? heroTag;
final int maxLines;
final double? aspectRatio;
final bool inlineTitle;
@ -26,22 +25,27 @@ class PosterWidget extends ConsumerWidget {
final Function(ItemBaseModel newItem)? onItemUpdated;
final Function(ItemBaseModel oldItem)? onItemRemoved;
final Function(VoidCallback action, ItemBaseModel item)? onPressed;
const PosterWidget(
{required this.poster,
this.subTitle,
this.maxLines = 3,
this.selected,
this.heroTag,
this.aspectRatio,
this.inlineTitle = false,
this.underTitle = true,
this.excludeActions = const {},
this.otherActions = const [],
this.onUserDataChanged,
this.onItemUpdated,
this.onItemRemoved,
this.onPressed,
super.key});
final bool primaryPosters;
final Function(bool focus)? onFocusChanged;
const PosterWidget({
required this.poster,
this.subTitle,
this.maxLines = 3,
this.selected,
this.aspectRatio,
this.inlineTitle = false,
this.underTitle = true,
this.excludeActions = const {},
this.otherActions = const [],
this.onUserDataChanged,
this.onItemUpdated,
this.onItemRemoved,
this.onPressed,
this.primaryPosters = false,
this.onFocusChanged,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@ -54,7 +58,6 @@ class PosterWidget extends ConsumerWidget {
Expanded(
child: PosterImage(
poster: poster,
heroTag: heroTag ?? false,
selected: selected,
playVideo: (value) async => await poster.play(context, ref),
inlineTitle: inlineTitle,
@ -64,67 +67,71 @@ class PosterWidget extends ConsumerWidget {
onItemRemoved: onItemRemoved,
onItemUpdated: onItemUpdated,
onPressed: onPressed,
primaryPosters: primaryPosters,
onFocusChanged: onFocusChanged,
),
),
if (!inlineTitle && underTitle)
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Flexible(
child: ClickableText(
onTap: AdaptiveLayout.viewSizeOf(context) != ViewSize.phone
? () => poster.parentBaseModel.navigateTo(context)
: null,
text: poster.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
ExcludeFocus(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Flexible(
child: ClickableText(
onTap: AdaptiveLayout.viewSizeOf(context) != ViewSize.phone
? () => poster.parentBaseModel.navigateTo(context)
: null,
text: poster.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (subTitle != null) ...[
Flexible(
child: Opacity(
opacity: opacity,
child: subTitle!,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (subTitle != null) ...[
Flexible(
child: Opacity(
opacity: opacity,
child: subTitle!,
),
),
],
if (poster.subText?.isNotEmpty ?? false)
Flexible(
child: ClickableText(
opacity: opacity,
text: poster.subText ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
),
)
else
Flexible(
child: ClickableText(
opacity: opacity,
text: poster.subTextShort(context) ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
),
),
),
],
if (poster.subText?.isNotEmpty ?? false)
Flexible(
child: ClickableText(
opacity: opacity,
text: poster.subText ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
),
)
else
Flexible(
child: ClickableText(
opacity: opacity,
text: poster.subTextShort(context) ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
),
),
],
),
Flexible(
child: ClickableText(
opacity: opacity,
text: poster.subText?.isNotEmpty ?? false ? poster.subTextShort(context) ?? "" : "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
),
),
].take(maxLines).toList(),
Flexible(
child: ClickableText(
opacity: opacity,
text: poster.subText?.isNotEmpty ?? false ? poster.subTextShort(context) ?? "" : "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
),
),
].take(maxLines).toList(),
),
),
],
),

View file

@ -4,11 +4,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/items/season_model.dart';
import 'package:fladder/providers/sync/sync_provider_helpers.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/screens/syncing/sync_button.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/disable_keypad_focus.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/localization_helper.dart';
import 'package:fladder/widgets/shared/clickable_text.dart';
@ -39,6 +38,7 @@ class SeasonsRow extends ConsumerWidget {
itemBuilder: (
context,
index,
selected,
) {
final season = (seasons ?? [])[index];
return SeasonPoster(
@ -141,46 +141,46 @@ class SeasonPoster extends ConsumerWidget {
],
),
),
LayoutBuilder(
builder: (context, constraints) {
return FlatButton(
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: season.generateActions(context, ref).popupMenuItems(useIcons: true));
},
onTap: () => onSeasonPressed?.call(season),
onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.touch
? () {
showBottomSheetPill(
context: context,
content: (context, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children:
season.generateActions(context, ref).listTileItems(context, useIcons: true),
),
);
}
: null,
);
},
),
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer)
DisableFocus(
child: Align(
alignment: Alignment.bottomRight,
child: PopupMenuButton(
tooltip: context.localized.options,
icon: const Icon(Icons.more_vert, color: Colors.white),
itemBuilder: (context) => season.generateActions(context, ref).popupMenuItems(useIcons: true),
),
),
Positioned.fill(
child: FocusButton(
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: season.generateActions(context, ref).popupMenuItems(useIcons: true));
},
onTap: () => onSeasonPressed?.call(season),
onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.touch
? () {
showBottomSheetPill(
context: context,
content: (context, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children: season.generateActions(context, ref).listTileItems(context, useIcons: true),
),
);
}
: null,
overlays: [
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer)
ExcludeFocus(
child: Align(
alignment: Alignment.bottomRight,
child: PopupMenuButton(
tooltip: context.localized.options,
icon: const Icon(Icons.more_vert, color: Colors.white),
itemBuilder: (context) =>
season.generateActions(context, ref).popupMenuItems(useIcons: true),
),
),
),
],
),
),
],
),
),

View file

@ -3,8 +3,11 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/providers/arguments_provider.dart';
import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/theme.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/widgets/shared/ensure_visible.dart';
class OutlinedTextField extends ConsumerStatefulWidget {
final String? label;
@ -24,6 +27,9 @@ class OutlinedTextField extends ConsumerStatefulWidget {
final TextAlign textAlign;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final InputDecoration? decoration;
final String? placeHolder;
final String? suffix;
final String? errorText;
final bool? enabled;
@ -46,6 +52,9 @@ class OutlinedTextField extends ConsumerStatefulWidget {
this.keyboardType,
this.textInputAction,
this.errorText,
this.placeHolder,
this.decoration,
this.suffix,
this.enabled,
super.key,
});
@ -55,7 +64,26 @@ class OutlinedTextField extends ConsumerStatefulWidget {
}
class _OutlinedTextFieldState extends ConsumerState<OutlinedTextField> {
late FocusNode focusNode = widget.focusNode ?? FocusNode();
late final FocusNode _textFocus = widget.focusNode ?? FocusNode();
late final FocusNode _wrapperFocus = FocusNode()
..addListener(() {
setState(() {
hasFocus = _wrapperFocus.hasFocus;
if (hasFocus) {
context.ensureVisible();
}
});
});
bool hasFocus = false;
@override
void dispose() {
_textFocus.dispose();
_wrapperFocus.dispose();
super.dispose();
}
bool _obscureText = true;
void _toggle() {
setState(() {
@ -65,101 +93,103 @@ class _OutlinedTextFieldState extends ConsumerState<OutlinedTextField> {
Color getColor() {
if (widget.errorText != null) return Theme.of(context).colorScheme.errorContainer;
return Theme.of(context).colorScheme.secondaryContainer.withValues(alpha: 0.25);
return Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.35);
}
@override
Widget build(BuildContext context) {
final isPasswordField = widget.keyboardType == TextInputType.visiblePassword;
final leanBackMode = ref.watch(argumentsStateProvider).leanBackMode;
if (widget.autoFocus) {
focusNode.requestFocus();
if (leanBackMode) {
_wrapperFocus.requestFocus();
} else {
_textFocus.requestFocus();
}
}
focusNode.addListener(
() {},
);
return Column(
children: [
Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
decoration: BoxDecoration(
color: widget.fillColor ?? getColor(),
borderRadius: FladderTheme.defaultShape.borderRadius,
),
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 175),
decoration: BoxDecoration(
color: widget.decoration == null ? widget.fillColor ?? getColor() : null,
borderRadius: FladderTheme.smallShape.borderRadius,
border: BoxBorder.all(
width: 2,
color: hasFocus ? Theme.of(context).colorScheme.primaryFixed : Colors.transparent,
),
IgnorePointer(
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: IgnorePointer(
ignoring: widget.enabled == false,
child: TextField(
controller: widget.controller,
onChanged: widget.onChanged,
focusNode: focusNode,
onTap: widget.onTap,
autofillHints: widget.autoFillHints,
keyboardType: widget.keyboardType,
autocorrect: widget.autocorrect,
onSubmitted: widget.onSubmitted,
textInputAction: widget.textInputAction,
obscureText: isPasswordField ? _obscureText : false,
style: widget.style,
maxLines: widget.maxLines,
inputFormatters: widget.inputFormatters,
textAlign: widget.textAlign,
decoration: InputDecoration(
border: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0),
width: widget.borderWidth,
),
child: KeyboardListener(
focusNode: _wrapperFocus,
onKeyEvent: (KeyEvent event) {
if (event is KeyUpEvent && acceptKeys.contains(event.logicalKey)) {
if (_textFocus.hasFocus) {
_textFocus.unfocus();
_wrapperFocus.requestFocus();
} else if (_wrapperFocus.hasFocus) {
_textFocus.requestFocus();
}
}
},
child: ExcludeFocusTraversal(
child: TextField(
controller: widget.controller,
onChanged: widget.onChanged,
focusNode: _textFocus,
onTap: widget.onTap,
autofillHints: widget.autoFillHints,
keyboardType: widget.keyboardType,
autocorrect: widget.autocorrect,
onSubmitted: widget.onSubmitted != null
? (value) {
widget.onSubmitted?.call(value);
Future.microtask(() async {
await Future.delayed(const Duration(milliseconds: 125));
_wrapperFocus.requestFocus();
});
}
: null,
textInputAction: widget.textInputAction,
obscureText: isPasswordField ? _obscureText : false,
style: widget.style,
maxLines: widget.maxLines,
inputFormatters: widget.inputFormatters,
textAlign: widget.textAlign,
canRequestFocus: true,
decoration: widget.decoration ??
InputDecoration(
border: InputBorder.none,
filled: widget.fillColor != null,
fillColor: widget.fillColor,
labelText: widget.label,
suffix: widget.suffix != null
? Padding(
padding: const EdgeInsets.only(right: 6),
child: Text(widget.suffix!),
)
: null,
hintText: widget.placeHolder,
// errorText: widget.errorText,
suffixIcon: isPasswordField
? InkWell(
onTap: _toggle,
borderRadius: BorderRadius.circular(5),
child: Icon(
_obscureText ? Icons.visibility : Icons.visibility_off,
size: 16.0,
),
)
: null,
),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0),
width: widget.borderWidth,
),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0),
width: widget.borderWidth,
),
),
errorBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0),
width: widget.borderWidth,
),
),
focusedErrorBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0),
width: widget.borderWidth,
),
),
filled: widget.fillColor != null,
fillColor: widget.fillColor,
labelText: widget.label,
// errorText: widget.errorText,
suffixIcon: isPasswordField
? InkWell(
onTap: _toggle,
borderRadius: BorderRadius.circular(5),
child: Icon(
_obscureText ? Icons.visibility : Icons.visibility_off,
size: 16.0,
),
)
: null,
),
),
),
],
),
),
AnimatedFadeSize(
child: widget.errorText != null

View file

@ -4,8 +4,6 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/util/input_handler.dart';
import 'package:fladder/util/list_padding.dart';
class PassCodeInput extends ConsumerStatefulWidget {
final ValueChanged<String> passCode;
@ -20,6 +18,18 @@ class _PassCodeInputState extends ConsumerState<PassCodeInput> {
final passCodeLength = 4;
var currentPasscode = "";
@override
void initState() {
super.initState();
HardwareKeyboard.instance.addHandler(_onKey);
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_onKey);
super.dispose();
}
bool _onKey(KeyEvent value) {
if (value is KeyDownEvent) {
final keyInt = int.tryParse(value.logicalKey.keyLabel);
@ -37,70 +47,68 @@ class _PassCodeInputState extends ConsumerState<PassCodeInput> {
@override
Widget build(BuildContext context) {
return InputHandler(
onKeyEvent: (node, event) => _onKey(event) ? KeyEventResult.handled : KeyEventResult.ignored,
child: AlertDialog(
scrollable: true,
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(
passCodeLength,
(index) => Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: SizedBox(
height: iconSize * 1.2,
width: iconSize * 1.2,
child: Card(
child: Transform.translate(
offset: const Offset(0, 5),
child: AnimatedFadeSize(
child: Text(
currentPasscode.length > index ? "*" : "",
style: Theme.of(context).textTheme.displayLarge?.copyWith(fontSize: 60),
),
return AlertDialog(
scrollable: true,
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8,
children: [
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(
passCodeLength,
(index) => Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: SizedBox(
height: iconSize * 1.2,
width: iconSize * 1.2,
child: Card(
child: Transform.translate(
offset: const Offset(0, 5),
child: AnimatedFadeSize(
child: Text(
currentPasscode.length > index ? "*" : "",
style: Theme.of(context).textTheme.displayLarge?.copyWith(fontSize: 60),
),
),
),
),
),
),
).toList(),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.of([1, 2, 3]).map((e) => passCodeNumber(e)).toList(),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.of([4, 5, 6]).map((e) => passCodeNumber(e)).toList(),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.of([7, 8, 9]).map((e) => passCodeNumber(e)).toList(),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
backSpaceButton,
passCodeNumber(0),
clearAllButton,
],
)
].addPadding(const EdgeInsets.symmetric(vertical: 8)),
),
),
).toList(),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.of([1, 2, 3]).map((e) => passCodeNumber(e)).toList(),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.of([4, 5, 6]).map((e) => passCodeNumber(e)).toList(),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.of([7, 8, 9]).map((e) => passCodeNumber(e)).toList(),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
backSpaceButton,
passCodeNumber(0),
clearAllButton,
],
)
],
),
);
}
Widget passCodeNumber(int value) {
return IconButton.filledTonal(
onPressed: () async {
onPressed: () {
addToPassCode(value.toString());
},
icon: Container(
@ -138,6 +146,7 @@ class _PassCodeInputState extends ConsumerState<PassCodeInput> {
Widget get clearAllButton {
return IconButton.filled(
autofocus: true,
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.errorContainer),
iconColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onErrorContainer),

View file

@ -59,6 +59,8 @@ class UserIcon extends ConsumerWidget {
imageUrl: user?.avatar ?? "",
progressIndicatorBuilder: (context, url, progress) => placeHolder(),
errorWidget: (context, url, error) => placeHolder(),
memCacheHeight: 128,
fit: BoxFit.cover,
),
FlatButton(
onTap: onTap,

View file

@ -11,153 +11,144 @@ import 'package:fladder/screens/shared/default_alert_dialog.dart';
import 'package:fladder/screens/syncing/sync_item_details.dart';
import 'package:fladder/screens/syncing/sync_widgets.dart';
import 'package:fladder/screens/syncing/widgets/sync_progress_builder.dart';
import 'package:fladder/screens/syncing/widgets/sync_status_overlay.dart';
import 'package:fladder/util/fladder_image.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/size_formatting.dart';
class SyncListItem extends ConsumerStatefulWidget {
class SyncListItem extends ConsumerWidget {
final SyncedItem syncedItem;
const SyncListItem({required this.syncedItem, super.key});
const SyncListItem({
required this.syncedItem,
super.key,
});
@override
ConsumerState<ConsumerStatefulWidget> createState() => SyncListItemState();
}
class SyncListItemState extends ConsumerState<SyncListItem> {
@override
Widget build(BuildContext context) {
final syncedItem = widget.syncedItem;
Widget build(BuildContext context, WidgetRef ref) {
final baseItem = syncedItem.itemModel;
print(FocusManager.instance.primaryFocus);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: SyncStatusOverlay(
syncedItem: syncedItem,
child: Card(
elevation: 1,
color: Theme.of(context).colorScheme.surfaceDim,
shadowColor: Colors.transparent,
child: Dismissible(
background: Container(
color: Theme.of(context).colorScheme.errorContainer,
child: const Padding(
padding: EdgeInsets.all(8.0),
child: Row(
children: [Icon(IconsaxPlusBold.trash)],
),
child: Card(
elevation: 1,
color: Theme.of(context).colorScheme.surfaceDim,
shadowColor: Colors.transparent,
child: Dismissible(
key: Key(syncedItem.id),
background: Container(
color: Theme.of(context).colorScheme.errorContainer,
child: const Padding(
padding: EdgeInsets.all(8.0),
child: Row(
children: [Icon(IconsaxPlusBold.trash)],
),
),
key: Key(syncedItem.id),
direction: DismissDirection.startToEnd,
confirmDismiss: (direction) async {
await showDefaultAlertDialog(
context,
context.localized.deleteItem(baseItem?.detailedName(context) ?? ""),
context.localized.syncDeletePopupPermanent,
(context) async {
ref.read(syncProvider.notifier).removeSync(context, syncedItem);
Navigator.of(context).pop();
return true;
},
context.localized.delete,
(context) async {
Navigator.of(context).pop();
},
context.localized.cancel);
return false;
},
child: LayoutBuilder(
builder: (context, constraints) {
return IntrinsicHeight(
child: InkWell(
onTap: () => baseItem?.navigateTo(context),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 16,
children: [
ConstrainedBox(
constraints: BoxConstraints(maxHeight: 125, maxWidth: constraints.maxWidth * 0.2),
child: Card(
child: AspectRatio(
aspectRatio: baseItem?.primaryRatio ?? 1.0,
child: FladderImage(
image: baseItem?.getPosters?.primary,
fit: BoxFit.cover,
)),
),
),
Expanded(
child: FutureBuilder(
future: ref.read(syncProvider.notifier).getNestedChildren(syncedItem),
builder: (context, asyncSnapshot) {
final nestedChildren = asyncSnapshot.data ?? [];
return SyncProgressBuilder(
item: syncedItem,
children: nestedChildren,
builder: (context, combinedStream) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
Flexible(
child: Text(
baseItem?.detailedName(context) ?? "",
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
),
Flexible(
child: SyncSubtitle(
syncItem: syncedItem,
children: nestedChildren,
),
),
Flexible(
child: Consumer(
builder: (context, ref, child) => SyncLabel(
label: context.localized.totalSize(
ref.watch(syncSizeProvider(syncedItem, nestedChildren)).byteFormat ??
'--'),
status: combinedStream?.status ?? TaskStatus.notFound,
),
),
),
if (combinedStream != null && combinedStream.hasDownload == true)
SyncProgressBar(item: syncedItem, task: combinedStream)
],
);
},
);
},
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Card(
elevation: 0,
shadowColor: Colors.transparent,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Text(baseItem != null ? baseItem.type.label(context) : ""),
)),
IconButton(
onPressed: () => showSyncItemDetails(context, syncedItem, ref),
icon: const Icon(IconsaxPlusLinear.more_square),
),
],
),
],
),
direction: DismissDirection.startToEnd,
confirmDismiss: (direction) async {
await showDefaultAlertDialog(
context,
context.localized.deleteItem(baseItem?.detailedName(context) ?? ""),
context.localized.syncDeletePopupPermanent,
(context) async {
ref.read(syncProvider.notifier).removeSync(context, syncedItem);
Navigator.of(context).pop();
return true;
},
context.localized.delete,
(context) async {
Navigator.of(context).pop();
},
context.localized.cancel);
return false;
},
child: FocusButton(
onTap: () => baseItem?.navigateTo(context),
onLongPress: () => showSyncItemDetails(context, syncedItem, ref),
child: ExcludeFocus(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 16,
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 125, maxWidth: 512),
child: Card(
child: AspectRatio(
aspectRatio: baseItem?.primaryRatio ?? 1.0,
child: FladderImage(
image: baseItem?.getPosters?.primary,
fit: BoxFit.cover,
)),
),
),
),
);
},
Expanded(
child: FutureBuilder(
future: ref.read(syncProvider.notifier).getNestedChildren(syncedItem),
builder: (context, asyncSnapshot) {
final nestedChildren = asyncSnapshot.data ?? [];
return SyncProgressBuilder(
item: syncedItem,
children: nestedChildren,
builder: (context, combinedStream) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
Flexible(
child: Text(
baseItem?.detailedName(context) ?? "",
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
),
Flexible(
child: SyncSubtitle(
syncItem: syncedItem,
children: nestedChildren,
),
),
Flexible(
child: Consumer(
builder: (context, ref, child) => SyncLabel(
label: context.localized.totalSize(
ref.watch(syncSizeProvider(syncedItem, nestedChildren)).byteFormat ?? '--'),
status: combinedStream?.status ?? TaskStatus.notFound,
),
),
),
if (combinedStream != null && combinedStream.hasDownload == true)
SyncProgressBar(item: syncedItem, task: combinedStream)
],
);
},
);
},
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Card(
elevation: 0,
shadowColor: Colors.transparent,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Text(baseItem != null ? baseItem.type.label(context) : ""),
)),
IconButton(
onPressed: () => showSyncItemDetails(context, syncedItem, ref),
icon: const Icon(IconsaxPlusLinear.more_square),
),
],
),
],
),
),
),
),
),

View file

@ -5,7 +5,6 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:fladder/routes/auto_router.gr.dart';
import 'package:fladder/screens/home_screen.dart';
@ -13,10 +12,10 @@ import 'package:fladder/screens/shared/nested_scaffold.dart';
import 'package:fladder/screens/shared/nested_sliver_appbar.dart';
import 'package:fladder/screens/syncing/sync_list_item.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/sliver_list_padding.dart';
import 'package:fladder/widgets/navigation_scaffold/components/background_image.dart';
import 'package:fladder/widgets/shared/pinch_poster_zoom.dart';
import 'package:fladder/widgets/shared/pull_to_refresh.dart';
@RoutePage()
@ -38,82 +37,82 @@ class _SyncedScreenState extends ConsumerState<SyncedScreen> {
onRefresh: () => ref.read(syncProvider.notifier).refresh(),
child: NestedScaffold(
background: BackgroundImage(images: items.map((value) => value.images).nonNulls.toList()),
body: PinchPosterZoom(
scaleDifference: (difference) => ref.read(clientSettingsProvider.notifier).addPosterSize(difference / 2),
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
controller: AdaptiveLayout.scrollOf(context, HomeTabs.sync),
slivers: [
if (AdaptiveLayout.viewSizeOf(context) == ViewSize.phone)
NestedSliverAppBar(
parent: context,
route: LibrarySearchRoute(),
)
else
const DefaultSliverTopBadding(),
if (kDebugMode)
SliverToBoxAdapter(
child: Padding(
padding: padding,
child: Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
runAlignment: WrapAlignment.center,
spacing: 12,
children: [
ElevatedButton(
onPressed: () => ref.read(syncProvider.notifier).viewDatabase(context),
child: const Text("View Database"),
),
ElevatedButton(
onPressed: () => ref.read(syncProvider.notifier).removeAllSyncedData(),
child: const Text("Clear drift database"),
),
],
),
),
),
if (items.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: padding,
child: Text(
context.localized.syncedItems,
style: Theme.of(context).textTheme.titleLarge,
),
),
),
SliverPadding(
body: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
controller: AdaptiveLayout.scrollOf(context, HomeTabs.sync),
slivers: [
if (AdaptiveLayout.viewSizeOf(context) == ViewSize.phone)
NestedSliverAppBar(
parent: context,
route: LibrarySearchRoute(),
)
else
const DefaultSliverTopBadding(),
if (kDebugMode)
SliverToBoxAdapter(
child: Padding(
padding: padding,
sliver: SliverList.builder(
itemBuilder: (context, index) {
final item = items[index];
return SyncListItem(syncedItem: item);
},
itemCount: items.length,
),
),
] else ...[
SliverFillRemaining(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
child: Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
runAlignment: WrapAlignment.center,
spacing: 12,
children: [
Text(
context.localized.noItemsSynced,
style: Theme.of(context).textTheme.titleMedium,
ElevatedButton(
onPressed: () => ref.read(syncProvider.notifier).viewDatabase(context),
child: const Text("View Database"),
),
ElevatedButton(
onPressed: () => ref.read(syncProvider.notifier).removeAllSyncedData(),
child: const Text("Clear drift database"),
),
const SizedBox(width: 16),
const Icon(
IconsaxPlusLinear.cloud_cross,
)
],
),
)
],
const DefautlSliverBottomPadding(),
),
),
if (items.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: padding,
child: Text(
context.localized.syncedItems,
style: Theme.of(context).textTheme.titleLarge,
),
),
),
SliverPadding(
padding: padding,
sliver: SliverList.builder(
itemBuilder: (context, index) {
final item = items[index];
return FocusProvider(
autoFocus: index == 0,
child: SyncListItem(syncedItem: item),
);
},
itemCount: items.length,
),
),
] else ...[
SliverFillRemaining(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.localized.noItemsSynced,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(width: 16),
const Icon(
IconsaxPlusLinear.cloud_cross,
)
],
),
)
],
),
const DefautlSliverBottomPadding(),
],
),
),
);

View file

@ -45,7 +45,7 @@ class VideoPlayerChapters extends ConsumerWidget {
startIndex: chapters.indexOf(currentChapter ?? chapters.first),
contentPadding: const EdgeInsets.symmetric(horizontal: 32),
items: chapters.toList(),
itemBuilder: (context, index) {
itemBuilder: (context, index, selected) {
final chapter = chapters[index];
final isCurrent = chapter == currentChapter;
return Card(

View file

@ -3,8 +3,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:collection/collection.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/episode_model.dart';
@ -31,6 +31,7 @@ import 'package:fladder/util/refresh_state.dart';
import 'package:fladder/util/string_extensions.dart';
import 'package:fladder/widgets/shared/enum_selection.dart';
import 'package:fladder/widgets/shared/fladder_slider.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
import 'package:fladder/widgets/shared/spaced_list_tile.dart';
@ -153,10 +154,9 @@ class _VideoOptionsMobileState extends ConsumerState<VideoOptions> {
label: Text(context.localized.scale),
current: videoSettings.videoFit.name.toUpperCaseSplit(),
itemBuilder: (context) => BoxFit.values
.map((value) => PopupMenuItem(
value: value,
child: Text(value.name.toUpperCaseSplit()),
onTap: () => ref.read(videoPlayerSettingsProvider.notifier).setFitType(value),
.map((value) => ItemActionButton(
label: Text(value.name.toUpperCaseSplit()),
action: () => ref.read(videoPlayerSettingsProvider.notifier).setFitType(value),
))
.toList(),
),