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

@ -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(),
],
),
),
);