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`
#