aves/lib/widgets/about/tv_license_page.dart
Thibault Deckers 0f1d8ec760 explorer fixes
2024-06-26 22:13:27 +02:00

396 lines
14 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:developer' show Flow, Timeline;
import 'package:aves/theme/themes.dart';
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' hide Flow;
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);
static const double railWidth = 256;
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(railWidth)),
child: ListView.builder(
itemBuilder: (context, index) {
final packageName = packages[index];
final bindings = data.packageLicenseBindings[packageName]!;
final isSelected = index == selectedIndex;
final theme = Theme.of(context);
return Ink(
color: isSelected ? theme.highlightColor : Themes.firstLayerColor(context),
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 {
int debugFlowId = -1;
assert(() {
final Flow flow = Flow.begin();
Timeline.timeSync('_initLicenses()', () {}, flow: flow);
debugFlowId = flow.id;
return true;
}());
for (final LicenseEntry license in widget.licenseEntries) {
if (!mounted) return;
assert(() {
Timeline.timeSync('_initLicenses()', () {}, flow: Flow.step(debugFlowId));
return true;
}());
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),
child: Divider(),
));
for (final LicenseParagraph paragraph in paragraphs) {
if (paragraph.indent == LicenseParagraph.centeredIndent) {
_licenses.add(Padding(
padding: const EdgeInsets.only(top: 16),
child: Text(
paragraph.text,
style: const TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
));
} else {
assert(paragraph.indent >= 0);
_licenses.add(Padding(
padding: EdgeInsetsDirectional.only(top: 8, start: 16.0 * paragraph.indent),
child: Text(paragraph.text),
));
}
}
});
}
setState(() {
_loaded = true;
});
assert(() {
Timeline.timeSync('Build scheduled', () {}, flow: Flow.end(debugFlowId));
return true;
}());
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasMaterialLocalizations(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);
final double pad = _getGutterSize(context);
final 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),
child: Center(
child: CircularProgressIndicator(),
),
),
];
final Widget page;
if (widget.scrollController == null) {
page = Scaffold(
appBar: AppBar(
title: _PackageLicensePageTitle(
title: title,
subtitle: subtitle,
theme: theme.useMaterial3 ? theme.textTheme : theme.primaryTextTheme,
titleTextStyle: theme.appBarTheme.titleTextStyle,
foregroundColor: theme.appBarTheme.foregroundColor,
),
),
body: Center(
child: Material(
color: Themes.firstLayerColor(context),
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(
primary: true,
padding: padding,
children: listWidgets,
),
),
),
),
),
),
),
);
} else {
page = CustomScrollView(
controller: widget.scrollController,
slivers: <Widget>[
SliverAppBar(
automaticallyImplyLeading: false,
pinned: true,
backgroundColor: Themes.firstLayerColor(context),
title: _PackageLicensePageTitle(
title: title,
subtitle: subtitle,
theme: theme.textTheme,
titleTextStyle: theme.textTheme.titleLarge,
),
),
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({
required this.title,
required this.subtitle,
required this.theme,
this.titleTextStyle,
this.foregroundColor,
});
final String title;
final String subtitle;
final TextTheme theme;
final TextStyle? titleTextStyle;
final Color? foregroundColor;
@override
Widget build(BuildContext context) {
final TextStyle? effectiveTitleTextStyle = titleTextStyle ?? theme.titleLarge;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(title, style: effectiveTitleTextStyle?.copyWith(color: foregroundColor)),
Text(subtitle, style: theme.titleSmall?.copyWith(color: foregroundColor)),
],
);
}
}
const int _materialGutterThreshold = 720;
const double _wideGutterSize = 24.0;
const double _narrowGutterSize = 12.0;
double _getGutterSize(BuildContext context) => MediaQuery.sizeOf(context).width >= _materialGutterThreshold ? _wideGutterSize : _narrowGutterSize;