import 'dart:developer' show Flow, Timeline; 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 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); 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( 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 : theme.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 { 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 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 { assert(paragraph.indent >= 0); _licenses.add(Padding( padding: EdgeInsetsDirectional.only(top: 8.0, 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 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: title, subtitle: subtitle, theme: theme.useMaterial3 ? theme.textTheme : theme.primaryTextTheme, titleTextStyle: theme.appBarTheme.titleTextStyle, foregroundColor: theme.appBarTheme.foregroundColor, ), ), 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( primary: true, padding: padding, children: listWidgets, ), ), ), ), ), ), ), ); } else { page = CustomScrollView( controller: widget.scrollController, slivers: [ SliverAppBar( automaticallyImplyLeading: false, pinned: true, backgroundColor: theme.cardColor, 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: [ 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;