TV: improved support for Licenses
This commit is contained in:
parent
bd658bd3db
commit
298637a3c6
5 changed files with 376 additions and 3 deletions
|
@ -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
|
||||
|
|
|
@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
357
lib/widgets/about/tv_license_page.dart
Normal file
357
lib/widgets/about/tv_license_page.dart
Normal 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 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<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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
@ -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`
|
||||
#
|
||||
|
|
Loading…
Reference in a new issue