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]
|
## <a id="unreleased"></a>[Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- TV: improved support for Licenses
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Viewer: playing video from app content provider
|
- 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/theme/colors.dart';
|
||||||
import 'package:aves/utils/dependencies.dart';
|
import 'package:aves/utils/dependencies.dart';
|
||||||
import 'package:aves/widgets/about/title.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/basic/link_chip.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.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`
|
// as of Flutter v1.22.4, `cardColor` is used as a background color by `LicensePage`
|
||||||
cardColor: Theme.of(context).scaffoldBackgroundColor,
|
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,
|
primary: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Expanded(
|
Expanded(
|
||||||
child: _TvRail(),
|
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`
|
# `OutputBuffer` in `/services/common/output_buffer.dart`
|
||||||
# adapts from Flutter v3.3.3 `_OutputBuffer` in `/foundation/consolidate_response.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`
|
# `OverlaySnackBar` in `/widgets/common/action_mixins/overlay_snack_bar.dart`
|
||||||
# adapts from Flutter v3.3.3 `SnackBar` in `/material/snack_bar.dart`
|
# adapts from Flutter v3.3.3 `SnackBar` in `/material/snack_bar.dart`
|
||||||
#
|
#
|
||||||
|
|
Loading…
Reference in a new issue