TV: improved support for Licenses

This commit is contained in:
Thibault Deckers 2023-03-16 19:35:35 +01:00
parent bd658bd3db
commit 298637a3c6
5 changed files with 376 additions and 3 deletions

View file

@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased]
### Added
- TV: improved support for Licenses
### Fixed
- Viewer: playing video from app content provider

View file

@ -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<Licenses> {
// 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(),
),
),
),

View file

@ -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 apps 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<TvLicensePage> createState() => _TvLicensePageState();
}
class _TvLicensePageState extends State<TvLicensePage> {
final FocusNode _railFocusNode = FocusNode();
final ScrollController _detailsScrollController = ScrollController();
final ValueNotifier<int> _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<int>(
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<LicenseEntry> licenses = <LicenseEntry>[];
final Map<String, List<int>> packageLicenseBindings = <String, List<int>>{};
final List<String> packages = <String>[];
// 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] = <int>[];
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<LicenseEntry> licenseEntries;
final ScrollController? scrollController;
@override
_PackageLicensePageState createState() => _PackageLicensePageState();
}
class _PackageLicensePageState extends State<_PackageLicensePage> {
@override
void initState() {
super.initState();
_initLicenses();
}
final List<Widget> _licenses = <Widget>[];
bool _loaded = false;
Future<void> _initLicenses() async {
for (final LicenseEntry license in widget.licenseEntries) {
if (!mounted) {
return;
}
final List<LicenseParagraph> paragraphs = await SchedulerBinding.instance.scheduleTask<List<LicenseParagraph>>(
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<Widget> listWidgets = <Widget>[
..._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: <Widget>[
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: <Widget>[
Text(title, style: theme.titleLarge?.copyWith(color: color)),
Text(subtitle, style: theme.titleSmall?.copyWith(color: color)),
],
);
}
}

View file

@ -97,8 +97,15 @@ class _SettingsPageState extends State<SettingsPage> 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(),
),
),
],
),

View file

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