From 298637a3c644b13f5a6210f44d989f2dff65ccb9 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 16 Mar 2023 19:35:35 +0100 Subject: [PATCH] TV: improved support for Licenses --- CHANGELOG.md | 4 + lib/widgets/about/licenses.dart | 3 +- lib/widgets/about/tv_license_page.dart | 357 ++++++++++++++++++++++++ lib/widgets/settings/settings_page.dart | 11 +- pubspec.yaml | 4 + 5 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 lib/widgets/about/tv_license_page.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f47c44c1..9f97ffd6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- TV: improved support for Licenses + ### Fixed - Viewer: playing video from app content provider diff --git a/lib/widgets/about/licenses.dart b/lib/widgets/about/licenses.dart index ac1f1b1f1..e3d476d58 100644 --- a/lib/widgets/about/licenses.dart +++ b/lib/widgets/about/licenses.dart @@ -4,6 +4,7 @@ import 'package:aves/ref/brand_colors.dart'; import 'package:aves/theme/colors.dart'; import 'package:aves/utils/dependencies.dart'; import 'package:aves/widgets/about/title.dart'; +import 'package:aves/widgets/about/tv_license_page.dart'; import 'package:aves/widgets/common/basic/link_chip.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; @@ -87,7 +88,7 @@ class _LicensesState extends State { // as of Flutter v1.22.4, `cardColor` is used as a background color by `LicensePage` cardColor: Theme.of(context).scaffoldBackgroundColor, ), - child: const LicensePage(), + child: settings.useTvLayout ? const TvLicensePage() : const LicensePage(), ), ), ), diff --git a/lib/widgets/about/tv_license_page.dart b/lib/widgets/about/tv_license_page.dart new file mode 100644 index 000000000..abd54b1c8 --- /dev/null +++ b/lib/widgets/about/tv_license_page.dart @@ -0,0 +1,357 @@ +import 'package:aves/widgets/common/basic/scaffold.dart'; +import 'package:aves/widgets/common/behaviour/intents.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; + +// as of Flutter v3.7.7, `LicensePage` is not designed for Android TV +// and gets rejected from Google Play review: +// ``` +// Your app’s text is cut off at the edge of the screen. +// Apps should not display any text or functionality that is partially cut off by the edges of the screen. +// For example, your app (version code 94) in the "Show All Licenses" section text is cut off from the bottom of the screen. +// ``` +class TvLicensePage extends StatefulWidget { + const TvLicensePage({super.key}); + + @override + State createState() => _TvLicensePageState(); +} + +class _TvLicensePageState extends State { + final FocusNode _railFocusNode = FocusNode(); + final ScrollController _detailsScrollController = ScrollController(); + final ValueNotifier _railIndexNotifier = ValueNotifier(0); + + final Future<_LicenseData> licenses = LicenseRegistry.licenses + .fold<_LicenseData>( + _LicenseData(), + (prev, license) => prev..addLicense(license), + ) + .then((licenseData) => licenseData..sortPackages()); + + @override + void dispose() { + _railIndexNotifier.dispose(); + _railFocusNode.dispose(); + _detailsScrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AvesScaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + title: Text(MaterialLocalizations.of(context).licensesPageTitle), + ), + body: ValueListenableBuilder( + valueListenable: _railIndexNotifier, + builder: (context, selectedIndex, child) { + return FutureBuilder<_LicenseData>( + future: licenses, + builder: (context, snapshot) { + final data = snapshot.data; + if (data == null) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + final packages = data.packages; + final rail = Focus( + focusNode: _railFocusNode, + skipTraversal: true, + canRequestFocus: false, + child: ConstrainedBox( + constraints: BoxConstraints.loose(const Size.fromWidth(300)), + child: ListView.builder( + itemBuilder: (context, index) { + final packageName = packages[index]; + final bindings = data.packageLicenseBindings[packageName]!; + final isSelected = index == selectedIndex; + return Ink( + color: isSelected ? Theme.of(context).highlightColor : Theme.of(context).cardColor, + child: ListTile( + title: Text(packageName), + subtitle: Text(MaterialLocalizations.of(context).licensesPackageDetailText(bindings.length)), + selected: isSelected, + onTap: () => _railIndexNotifier.value = index, + ), + ); + }, + itemCount: packages.length, + ), + ), + ); + + final packageName = packages[selectedIndex]; + final bindings = data.packageLicenseBindings[packageName]!; + + return SafeArea( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(width: 16), + rail, + Expanded( + child: FocusableActionDetector( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.arrowUp): ScrollIntent(direction: AxisDirection.up, type: ScrollIncrementType.page), + SingleActivator(LogicalKeyboardKey.arrowDown): ScrollIntent(direction: AxisDirection.down, type: ScrollIncrementType.page), + }, + actions: { + ScrollIntent: ScrollControllerAction(scrollController: _detailsScrollController), + }, + child: KeyedSubtree( + key: Key(packageName), + child: _PackageLicensePage( + packageName: packageName, + licenseEntries: bindings.map((i) => data.licenses[i]).toList(growable: false), + scrollController: _detailsScrollController, + ), + ), + ), + ), + ], + ), + ); + }, + ); + }, + ), + ); + } +} + +// adapted from Flutter `_LicenseData` in `/material/about.dart` +class _LicenseData { + final List licenses = []; + final Map> packageLicenseBindings = >{}; + final List packages = []; + + // Special treatment for the first package since it should be the package + // for delivered application. + String? firstPackage; + + void addLicense(LicenseEntry entry) { + // Before the license can be added, we must first record the packages to + // which it belongs. + for (final String package in entry.packages) { + _addPackage(package); + // Bind this license to the package using the next index value. This + // creates a contract that this license must be inserted at this same + // index value. + packageLicenseBindings[package]!.add(licenses.length); + } + licenses.add(entry); // Completion of the contract above. + } + + /// Add a package and initialize package license binding. This is a no-op if + /// the package has been seen before. + void _addPackage(String package) { + if (!packageLicenseBindings.containsKey(package)) { + packageLicenseBindings[package] = []; + firstPackage ??= package; + packages.add(package); + } + } + + /// Sort the packages using some comparison method, or by the default manner, + /// which is to put the application package first, followed by every other + /// package in case-insensitive alphabetical order. + void sortPackages([int Function(String a, String b)? compare]) { + packages.sort(compare ?? + (a, b) { + // Based on how LicenseRegistry currently behaves, the first package + // returned is the end user application license. This should be + // presented first in the list. So here we make sure that first package + // remains at the front regardless of alphabetical sorting. + if (a == firstPackage) { + return -1; + } + if (b == firstPackage) { + return 1; + } + return a.toLowerCase().compareTo(b.toLowerCase()); + }); + } +} + +// adapted from Flutter `_PackageLicensePage` in `/material/about.dart` +class _PackageLicensePage extends StatefulWidget { + const _PackageLicensePage({ + required this.packageName, + required this.licenseEntries, + required this.scrollController, + }); + + final String packageName; + final List licenseEntries; + final ScrollController? scrollController; + + @override + _PackageLicensePageState createState() => _PackageLicensePageState(); +} + +class _PackageLicensePageState extends State<_PackageLicensePage> { + @override + void initState() { + super.initState(); + _initLicenses(); + } + + final List _licenses = []; + bool _loaded = false; + + Future _initLicenses() async { + for (final LicenseEntry license in widget.licenseEntries) { + if (!mounted) { + return; + } + final List paragraphs = await SchedulerBinding.instance.scheduleTask>( + license.paragraphs.toList, + Priority.animation, + debugLabel: 'License', + ); + if (!mounted) { + return; + } + setState(() { + _licenses.add(const Padding( + padding: EdgeInsets.all(18.0), + child: Divider(), + )); + for (final LicenseParagraph paragraph in paragraphs) { + if (paragraph.indent == LicenseParagraph.centeredIndent) { + _licenses.add(Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Text( + paragraph.text, + style: const TextStyle(fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + )); + } else { + _licenses.add(Padding( + padding: EdgeInsetsDirectional.only(top: 8.0, start: 16.0 * paragraph.indent), + child: Text(paragraph.text), + )); + } + } + }); + } + setState(() { + _loaded = true; + }); + } + + @override + Widget build(BuildContext context) { + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final ThemeData theme = Theme.of(context); + final String title = widget.packageName; + final String subtitle = localizations.licensesPackageDetailText(widget.licenseEntries.length); + const double pad = 24; + const EdgeInsets padding = EdgeInsets.only(left: pad, right: pad, bottom: pad); + final List listWidgets = [ + ..._licenses, + if (!_loaded) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24.0), + child: Center( + child: CircularProgressIndicator(), + ), + ), + ]; + + final Widget page; + if (widget.scrollController == null) { + page = Scaffold( + appBar: AppBar( + title: _PackageLicensePageTitle( + title, + subtitle, + theme.primaryTextTheme, + ), + ), + body: Center( + child: Material( + color: theme.cardColor, + elevation: 4.0, + child: Container( + constraints: BoxConstraints.loose(const Size.fromWidth(600.0)), + child: Localizations.override( + locale: const Locale('en', 'US'), + context: context, + child: ScrollConfiguration( + // A Scrollbar is built-in below. + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: Scrollbar( + child: ListView(padding: padding, children: listWidgets), + ), + ), + ), + ), + ), + ), + ); + } else { + page = CustomScrollView( + controller: widget.scrollController, + slivers: [ + SliverAppBar( + automaticallyImplyLeading: false, + pinned: true, + backgroundColor: theme.cardColor, + title: _PackageLicensePageTitle(title, subtitle, theme.textTheme), + ), + SliverPadding( + padding: padding, + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => Localizations.override( + locale: const Locale('en', 'US'), + context: context, + child: listWidgets[index], + ), + childCount: listWidgets.length, + ), + ), + ), + ], + ); + } + return DefaultTextStyle( + style: theme.textTheme.bodySmall!, + child: page, + ); + } +} + +class _PackageLicensePageTitle extends StatelessWidget { + const _PackageLicensePageTitle( + this.title, + this.subtitle, + this.theme, + ); + + final String title; + final String subtitle; + final TextTheme theme; + + @override + Widget build(BuildContext context) { + final Color? color = Theme.of(context).appBarTheme.foregroundColor; + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.titleLarge?.copyWith(color: color)), + Text(subtitle, style: theme.titleSmall?.copyWith(color: color)), + ], + ); + } +} diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index cdb23c642..c917ffcde 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -97,8 +97,15 @@ class _SettingsPageState extends State with FeedbackMixin { primary: false, ), ), - const Expanded( - child: _TvRail(), + Expanded( + child: MediaQuery.removePadding( + context: context, + removeLeft: true, + removeTop: true, + removeRight: true, + removeBottom: true, + child: const _TvRail(), + ), ), ], ), diff --git a/pubspec.yaml b/pubspec.yaml index 5357ab73f..22289c7d3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -163,6 +163,10 @@ flutter: # `OutputBuffer` in `/services/common/output_buffer.dart` # adapts from Flutter v3.3.3 `_OutputBuffer` in `/foundation/consolidate_response.dart` # +# `TvLicensePage` in `/widgets/about/tv_license_page.dart` +# adapts from Flutter v3.7.7 `_LicenseData` in `/material/about.dart` +# and `_PackageLicensePage` in `/material/about.dart` +# # `OverlaySnackBar` in `/widgets/common/action_mixins/overlay_snack_bar.dart` # adapts from Flutter v3.3.3 `SnackBar` in `/material/snack_bar.dart` #