tv: improved about page
This commit is contained in:
parent
a63590f5ad
commit
a70881c902
12 changed files with 729 additions and 534 deletions
50
lib/widgets/about/about_mobile_page.dart
Normal file
50
lib/widgets/about/about_mobile_page.dart
Normal file
|
@ -0,0 +1,50 @@
|
|||
import 'package:aves/widgets/about/app_ref.dart';
|
||||
import 'package:aves/widgets/about/bug_report.dart';
|
||||
import 'package:aves/widgets/about/credits.dart';
|
||||
import 'package:aves/widgets/about/licenses.dart';
|
||||
import 'package:aves/widgets/about/translators.dart';
|
||||
import 'package:aves/widgets/common/basic/insets.dart';
|
||||
import 'package:aves/widgets/common/basic/scaffold.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AboutMobilePage extends StatelessWidget {
|
||||
const AboutMobilePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AvesScaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.aboutPageTitle),
|
||||
),
|
||||
body: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate(
|
||||
[
|
||||
const AppReference(),
|
||||
const Divider(),
|
||||
const BugReport(),
|
||||
const Divider(),
|
||||
const AboutCredits(),
|
||||
const Divider(),
|
||||
const AboutTranslators(),
|
||||
const Divider(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Licenses(),
|
||||
const BottomPaddingSliver(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,18 +1,7 @@
|
|||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/widgets/about/app_ref.dart';
|
||||
import 'package:aves/widgets/about/bug_report.dart';
|
||||
import 'package:aves/widgets/about/credits.dart';
|
||||
import 'package:aves/widgets/about/licenses.dart';
|
||||
import 'package:aves/widgets/about/translators.dart';
|
||||
import 'package:aves/widgets/common/basic/insets.dart';
|
||||
import 'package:aves/widgets/common/basic/scaffold.dart';
|
||||
import 'package:aves/widgets/common/basic/tv_edge_focus.dart';
|
||||
import 'package:aves/widgets/common/behaviour/pop/scope.dart';
|
||||
import 'package:aves/widgets/common/behaviour/pop/tv_navigation.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/navigation/tv_rail.dart';
|
||||
import 'package:aves/widgets/about/about_mobile_page.dart';
|
||||
import 'package:aves/widgets/about/about_tv_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class AboutPage extends StatelessWidget {
|
||||
static const routeName = '/about';
|
||||
|
@ -21,66 +10,10 @@ class AboutPage extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appBarTitle = Text(context.l10n.aboutPageTitle);
|
||||
final useTvLayout = settings.useTvLayout;
|
||||
final body = CustomScrollView(
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate(
|
||||
[
|
||||
const TvEdgeFocus(),
|
||||
const AppReference(),
|
||||
if (!settings.useTvLayout) ...[
|
||||
const Divider(),
|
||||
const BugReport(),
|
||||
],
|
||||
const Divider(),
|
||||
const AboutCredits(),
|
||||
const Divider(),
|
||||
const AboutTranslators(),
|
||||
const Divider(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Licenses(),
|
||||
const BottomPaddingSliver(),
|
||||
],
|
||||
);
|
||||
|
||||
if (useTvLayout) {
|
||||
return AvesScaffold(
|
||||
body: AvesPopScope(
|
||||
handlers: const [TvNavigationPopHandler.pop],
|
||||
child: Row(
|
||||
children: [
|
||||
TvRail(
|
||||
controller: context.read<TvRailController>(),
|
||||
),
|
||||
Expanded(
|
||||
child: DirectionalSafeArea(
|
||||
start: false,
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (settings.useTvLayout) {
|
||||
return const AboutTvPage();
|
||||
} else {
|
||||
return AvesScaffold(
|
||||
appBar: AppBar(
|
||||
title: appBarTitle,
|
||||
),
|
||||
body: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
);
|
||||
return const AboutMobilePage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
223
lib/widgets/about/about_tv_page.dart
Normal file
223
lib/widgets/about/about_tv_page.dart
Normal file
|
@ -0,0 +1,223 @@
|
|||
import 'package:aves/widgets/about/app_ref.dart';
|
||||
import 'package:aves/widgets/about/credits.dart';
|
||||
import 'package:aves/widgets/about/translators.dart';
|
||||
import 'package:aves/widgets/about/tv_license_page.dart';
|
||||
import 'package:aves/widgets/common/basic/insets.dart';
|
||||
import 'package:aves/widgets/common/basic/scaffold.dart';
|
||||
import 'package:aves/widgets/common/behaviour/pop/scope.dart';
|
||||
import 'package:aves/widgets/common/behaviour/pop/tv_navigation.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/identity/buttons/outlined_button.dart';
|
||||
import 'package:aves/widgets/navigation/tv_rail.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class AboutTvPage extends StatelessWidget {
|
||||
const AboutTvPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AvesScaffold(
|
||||
body: AvesPopScope(
|
||||
handlers: const [TvNavigationPopHandler.pop],
|
||||
child: Row(
|
||||
children: [
|
||||
TvRail(
|
||||
controller: context.read<TvRailController>(),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
DirectionalSafeArea(
|
||||
start: false,
|
||||
bottom: false,
|
||||
child: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
title: Text(context.l10n.aboutPageTitle),
|
||||
elevation: 0,
|
||||
primary: false,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeLeft: true,
|
||||
removeTop: true,
|
||||
removeRight: true,
|
||||
removeBottom: true,
|
||||
child: const _Content(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Content extends StatefulWidget {
|
||||
const _Content({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_Content> createState() => _ContentState();
|
||||
}
|
||||
|
||||
enum _Section { links, credits, translators, licenses }
|
||||
|
||||
class _ContentState extends State<_Content> {
|
||||
final FocusNode _railFocusNode = FocusNode();
|
||||
final ValueNotifier<int> _railIndexNotifier = ValueNotifier(0);
|
||||
late Future<PackageInfo> _packageInfoLoader;
|
||||
|
||||
static const double railWidth = 256;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_packageInfoLoader = PackageInfo.fromPlatform();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_railIndexNotifier.dispose();
|
||||
_railFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: _railIndexNotifier,
|
||||
builder: (context, selectedIndex, child) {
|
||||
final rail = Focus(
|
||||
focusNode: _railFocusNode,
|
||||
skipTraversal: true,
|
||||
canRequestFocus: false,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints.loose(const Size.fromWidth(railWidth)),
|
||||
child: Center(
|
||||
child: ListView.builder(
|
||||
itemBuilder: (context, index) {
|
||||
final isSelected = index == selectedIndex;
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
return ListTile(
|
||||
title: DefaultTextStyle(
|
||||
style: theme.textTheme.bodyLarge!.copyWith(
|
||||
color: isSelected ? colors.primary : colors.onSurface.withOpacity(0.64),
|
||||
),
|
||||
child: _getTitle(_Section.values[index]),
|
||||
),
|
||||
selected: isSelected,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 24),
|
||||
onTap: () => _railIndexNotifier.value = index,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(123)),
|
||||
),
|
||||
// tileColor: theme.scaffoldBackgroundColor,
|
||||
);
|
||||
},
|
||||
itemCount: _Section.values.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
rail,
|
||||
Expanded(
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeLeft: !context.isRtl,
|
||||
removeRight: context.isRtl,
|
||||
child: _getBody(_Section.values[selectedIndex]),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getTitle(_Section key) {
|
||||
switch (key) {
|
||||
case _Section.links:
|
||||
return FutureBuilder<PackageInfo>(
|
||||
future: _packageInfoLoader,
|
||||
builder: (context, snapshot) {
|
||||
return Text('${context.l10n.appName} ${snapshot.data?.version}');
|
||||
},
|
||||
);
|
||||
case _Section.credits:
|
||||
return Text(context.l10n.aboutCreditsSectionTitle);
|
||||
case _Section.translators:
|
||||
return Text(context.l10n.aboutTranslatorsSectionTitle);
|
||||
case _Section.licenses:
|
||||
return Text(context.l10n.aboutLicensesSectionTitle);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _getBody(_Section key) {
|
||||
switch (key) {
|
||||
case _Section.links:
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: AppReference.buildLinks(context)
|
||||
.map((v) => Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: v,
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
case _Section.credits:
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: AboutCredits.buildBody(context),
|
||||
);
|
||||
case _Section.translators:
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: AboutTranslators.buildBody(context),
|
||||
);
|
||||
case _Section.licenses:
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.l10n.aboutLicensesBanner),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: AvesOutlinedButton(
|
||||
label: context.l10n.aboutLicensesShowAllButtonLabel,
|
||||
onPressed: () => Navigator.maybeOf(context)?.push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) {
|
||||
final theme = Theme.of(context);
|
||||
final listTileTheme = theme.listTileTheme;
|
||||
return Theme(
|
||||
data: theme.copyWith(
|
||||
listTileTheme: listTileTheme.copyWith(
|
||||
tileColor: theme.scaffoldBackgroundColor,
|
||||
),
|
||||
),
|
||||
child: const TvLicensePage(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -15,6 +15,45 @@ class AppReference extends StatefulWidget {
|
|||
|
||||
@override
|
||||
State<AppReference> createState() => _AppReferenceState();
|
||||
|
||||
static List<Widget> buildLinks(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return [
|
||||
const LinkChip(
|
||||
leading: Icon(
|
||||
AIcons.github,
|
||||
size: 24,
|
||||
),
|
||||
text: 'GitHub',
|
||||
urlString: AppReference.avesGithub,
|
||||
),
|
||||
LinkChip(
|
||||
leading: const Icon(
|
||||
AIcons.legal,
|
||||
size: 22,
|
||||
),
|
||||
text: l10n.aboutLinkLicense,
|
||||
urlString: '${AppReference.avesGithub}/blob/main/LICENSE',
|
||||
),
|
||||
LinkChip(
|
||||
leading: const Icon(
|
||||
AIcons.privacy,
|
||||
size: 22,
|
||||
),
|
||||
text: l10n.aboutLinkPolicy,
|
||||
onTap: () => _goToPolicyPage(context),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static void _goToPolicyPage(BuildContext context) {
|
||||
Navigator.maybeOf(context)?.push(
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: PolicyPage.routeName),
|
||||
builder: (context) => const PolicyPage(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AppReferenceState extends State<AppReference> {
|
||||
|
@ -40,7 +79,12 @@ class _AppReferenceState extends State<AppReference> {
|
|||
children: [
|
||||
_buildAvesLine(),
|
||||
const SizedBox(height: 16),
|
||||
_buildLinks(),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 16,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: AppReference.buildLinks(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
@ -66,48 +110,4 @@ class _AppReferenceState extends State<AppReference> {
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLinks() {
|
||||
final l10n = context.l10n;
|
||||
return Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 16,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
const LinkChip(
|
||||
leading: Icon(
|
||||
AIcons.github,
|
||||
size: 24,
|
||||
),
|
||||
text: 'GitHub',
|
||||
urlString: AppReference.avesGithub,
|
||||
),
|
||||
LinkChip(
|
||||
leading: const Icon(
|
||||
AIcons.legal,
|
||||
size: 22,
|
||||
),
|
||||
text: l10n.aboutLinkLicense,
|
||||
urlString: '${AppReference.avesGithub}/blob/main/LICENSE',
|
||||
),
|
||||
LinkChip(
|
||||
leading: const Icon(
|
||||
AIcons.privacy,
|
||||
size: 22,
|
||||
),
|
||||
text: l10n.aboutLinkPolicy,
|
||||
onTap: _goToPolicyPage,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _goToPolicyPage() {
|
||||
Navigator.maybeOf(context)?.push(
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: PolicyPage.routeName),
|
||||
builder: (context) => const PolicyPage(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,33 +8,37 @@ class AboutCredits extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AboutSectionTitle(text: l10n.aboutCreditsSectionTitle),
|
||||
AboutSectionTitle(text: context.l10n.aboutCreditsSectionTitle),
|
||||
const SizedBox(height: 8),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: l10n.aboutCreditsWorldAtlas1),
|
||||
const WidgetSpan(
|
||||
child: LinkChip(
|
||||
text: 'World Atlas',
|
||||
urlString: 'https://github.com/topojson/world-atlas',
|
||||
textStyle: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
),
|
||||
TextSpan(text: l10n.aboutCreditsWorldAtlas2),
|
||||
],
|
||||
),
|
||||
),
|
||||
buildBody(context),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget buildBody(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: l10n.aboutCreditsWorldAtlas1),
|
||||
const WidgetSpan(
|
||||
child: LinkChip(
|
||||
text: 'World Atlas',
|
||||
urlString: 'https://github.com/topojson/world-atlas',
|
||||
textStyle: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
),
|
||||
TextSpan(text: l10n.aboutCreditsWorldAtlas2),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
import 'package:aves/app_flavor.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/app/dependencies.dart';
|
||||
import 'package:aves/ref/brand_colors.dart';
|
||||
import 'package:aves/theme/colors.dart';
|
||||
import 'package:aves/model/app/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';
|
||||
|
@ -52,32 +50,30 @@ class _LicensesState extends State<Licenses> {
|
|||
[
|
||||
_buildHeader(),
|
||||
const SizedBox(height: 16),
|
||||
if (!settings.useTvLayout) ...[
|
||||
AvesExpansionTile(
|
||||
title: context.l10n.aboutLicensesAndroidLibrariesSectionTitle,
|
||||
highlightColor: colors.fromBrandColor(BrandColors.android),
|
||||
expandedNotifier: _expandedNotifier,
|
||||
children: _platform.map((package) => LicenseRow(package: package)).toList(),
|
||||
),
|
||||
AvesExpansionTile(
|
||||
title: context.l10n.aboutLicensesFlutterPluginsSectionTitle,
|
||||
highlightColor: colors.fromBrandColor(BrandColors.flutter),
|
||||
expandedNotifier: _expandedNotifier,
|
||||
children: _flutterPlugins.map((package) => LicenseRow(package: package)).toList(),
|
||||
),
|
||||
AvesExpansionTile(
|
||||
title: context.l10n.aboutLicensesFlutterPackagesSectionTitle,
|
||||
highlightColor: colors.fromBrandColor(BrandColors.flutter),
|
||||
expandedNotifier: _expandedNotifier,
|
||||
children: _flutterPackages.map((package) => LicenseRow(package: package)).toList(),
|
||||
),
|
||||
AvesExpansionTile(
|
||||
title: context.l10n.aboutLicensesDartPackagesSectionTitle,
|
||||
highlightColor: colors.fromBrandColor(BrandColors.flutter),
|
||||
expandedNotifier: _expandedNotifier,
|
||||
children: _dartPackages.map((package) => LicenseRow(package: package)).toList(),
|
||||
),
|
||||
],
|
||||
AvesExpansionTile(
|
||||
title: context.l10n.aboutLicensesAndroidLibrariesSectionTitle,
|
||||
highlightColor: colors.fromBrandColor(BrandColors.android),
|
||||
expandedNotifier: _expandedNotifier,
|
||||
children: _platform.map((package) => _LicenseRow(package: package)).toList(),
|
||||
),
|
||||
AvesExpansionTile(
|
||||
title: context.l10n.aboutLicensesFlutterPluginsSectionTitle,
|
||||
highlightColor: colors.fromBrandColor(BrandColors.flutter),
|
||||
expandedNotifier: _expandedNotifier,
|
||||
children: _flutterPlugins.map((package) => _LicenseRow(package: package)).toList(),
|
||||
),
|
||||
AvesExpansionTile(
|
||||
title: context.l10n.aboutLicensesFlutterPackagesSectionTitle,
|
||||
highlightColor: colors.fromBrandColor(BrandColors.flutter),
|
||||
expandedNotifier: _expandedNotifier,
|
||||
children: _flutterPackages.map((package) => _LicenseRow(package: package)).toList(),
|
||||
),
|
||||
AvesExpansionTile(
|
||||
title: context.l10n.aboutLicensesDartPackagesSectionTitle,
|
||||
highlightColor: colors.fromBrandColor(BrandColors.flutter),
|
||||
expandedNotifier: _expandedNotifier,
|
||||
children: _dartPackages.map((package) => _LicenseRow(package: package)).toList(),
|
||||
),
|
||||
Center(
|
||||
child: AvesOutlinedButton(
|
||||
label: context.l10n.aboutLicensesShowAllButtonLabel,
|
||||
|
@ -85,10 +81,10 @@ class _LicensesState extends State<Licenses> {
|
|||
MaterialPageRoute(
|
||||
builder: (context) => Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
// as of Flutter v1.22.4, `cardColor` is used as a background color by `LicensePage`
|
||||
// as of Flutter v3.7.8, `cardColor` is used as a background color by `LicensePage`
|
||||
cardColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
),
|
||||
child: settings.useTvLayout ? const TvLicensePage() : const LicensePage(),
|
||||
child: const LicensePage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -116,11 +112,10 @@ class _LicensesState extends State<Licenses> {
|
|||
}
|
||||
}
|
||||
|
||||
class LicenseRow extends StatelessWidget {
|
||||
class _LicenseRow extends StatelessWidget {
|
||||
final Dependency package;
|
||||
|
||||
const LicenseRow({
|
||||
super.key,
|
||||
const _LicenseRow({
|
||||
required this.package,
|
||||
});
|
||||
|
||||
|
|
|
@ -20,15 +20,19 @@ class AboutTranslators extends StatelessWidget {
|
|||
children: [
|
||||
AboutSectionTitle(text: context.l10n.aboutTranslatorsSectionTitle),
|
||||
const SizedBox(height: 8),
|
||||
_RandomTextSpanHighlighter(
|
||||
spans: Contributors.translators.map((v) => v.name).toList(),
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
buildBody(context),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget buildBody(BuildContext context) {
|
||||
return _RandomTextSpanHighlighter(
|
||||
spans: Contributors.translators.map((v) => v.name).toList(),
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RandomTextSpanHighlighter extends StatefulWidget {
|
||||
|
|
|
@ -24,6 +24,8 @@ class _TvLicensePageState extends State<TvLicensePage> {
|
|||
final ScrollController _detailsScrollController = ScrollController();
|
||||
final ValueNotifier<int> _railIndexNotifier = ValueNotifier(0);
|
||||
|
||||
static const double railWidth = 256;
|
||||
|
||||
final Future<_LicenseData> licenses = LicenseRegistry.licenses
|
||||
.fold<_LicenseData>(
|
||||
_LicenseData(),
|
||||
|
@ -65,14 +67,15 @@ class _TvLicensePageState extends State<TvLicensePage> {
|
|||
skipTraversal: true,
|
||||
canRequestFocus: false,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints.loose(const Size.fromWidth(300)),
|
||||
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.of(context).highlightColor : Theme.of(context).cardColor,
|
||||
color: isSelected ? theme.highlightColor : theme.cardColor,
|
||||
child: ListTile(
|
||||
title: Text(packageName),
|
||||
subtitle: Text(MaterialLocalizations.of(context).licensesPackageDetailText(bindings.length)),
|
||||
|
|
189
lib/widgets/settings/settings_mobile_page.dart
Normal file
189
lib/widgets/settings/settings_mobile_page.dart
Normal file
|
@ -0,0 +1,189 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/model/actions/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/app_bar/app_bar_title.dart';
|
||||
import 'package:aves/widgets/common/basic/font_size_icon_theme.dart';
|
||||
import 'package:aves/widgets/common/basic/insets.dart';
|
||||
import 'package:aves/widgets/common/basic/popup/menu_row.dart';
|
||||
import 'package:aves/widgets/common/basic/scaffold.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/search/route.dart';
|
||||
import 'package:aves/widgets/settings/app_export/items.dart';
|
||||
import 'package:aves/widgets/settings/app_export/selection_dialog.dart';
|
||||
import 'package:aves/widgets/settings/settings_page.dart';
|
||||
import 'package:aves/widgets/settings/settings_search.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class SettingsMobilePage extends StatefulWidget {
|
||||
const SettingsMobilePage({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsMobilePage> createState() => _SettingsMobilePageState();
|
||||
}
|
||||
|
||||
class _SettingsMobilePageState extends State<SettingsMobilePage> with FeedbackMixin {
|
||||
final ValueNotifier<String?> _expandedNotifier = ValueNotifier(null);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_expandedNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AvesScaffold(
|
||||
appBar: AppBar(
|
||||
title: InteractiveAppBarTitle(
|
||||
onTap: () => _goToSearch(context),
|
||||
child: Text(context.l10n.settingsPageTitle),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(AIcons.search),
|
||||
onPressed: () => _goToSearch(context),
|
||||
tooltip: MaterialLocalizations.of(context).searchFieldLabel,
|
||||
),
|
||||
PopupMenuButton<SettingsAction>(
|
||||
itemBuilder: (context) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
value: SettingsAction.export,
|
||||
child: MenuRow(text: context.l10n.settingsActionExport, icon: const Icon(AIcons.fileExport)),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: SettingsAction.import,
|
||||
child: MenuRow(text: context.l10n.settingsActionImport, icon: const Icon(AIcons.fileImport)),
|
||||
),
|
||||
];
|
||||
},
|
||||
onSelected: (action) async {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
|
||||
_onActionSelected(action);
|
||||
},
|
||||
),
|
||||
].map((v) => FontSizeIconTheme(child: v)).toList(),
|
||||
),
|
||||
body: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: AnimationLimiter(
|
||||
child: SettingsListView(
|
||||
children: SettingsPage.sections.map((v) => v.build(context, _expandedNotifier)).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static const String exportVersionKey = 'version';
|
||||
static const int exportVersion = 1;
|
||||
|
||||
void _onActionSelected(SettingsAction action) async {
|
||||
final source = context.read<CollectionSource>();
|
||||
switch (action) {
|
||||
case SettingsAction.export:
|
||||
final toExport = await showDialog<Set<AppExportItem>>(
|
||||
context: context,
|
||||
builder: (context) => AppExportItemSelectionDialog(
|
||||
title: context.l10n.settingsActionExportDialogTitle,
|
||||
),
|
||||
);
|
||||
if (toExport == null || toExport.isEmpty) return;
|
||||
|
||||
final allMap = Map.fromEntries(toExport.map((v) {
|
||||
final jsonMap = v.export(source);
|
||||
return jsonMap != null ? MapEntry(v.name, jsonMap) : null;
|
||||
}).whereNotNull());
|
||||
allMap[exportVersionKey] = exportVersion;
|
||||
final allJsonString = jsonEncode(allMap);
|
||||
|
||||
final success = await storageService.createFile(
|
||||
'aves-settings-${DateFormat('yyyyMMdd_HHmmss').format(DateTime.now())}.json',
|
||||
MimeTypes.json,
|
||||
Uint8List.fromList(utf8.encode(allJsonString)),
|
||||
);
|
||||
if (success != null) {
|
||||
if (success) {
|
||||
showFeedback(context, context.l10n.genericSuccessFeedback);
|
||||
} else {
|
||||
showFeedback(context, context.l10n.genericFailureFeedback);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case SettingsAction.import:
|
||||
// specifying the JSON MIME type to restrict openable files is correct in theory,
|
||||
// but older devices (e.g. SM-P580, API 27) that do not recognize JSON files as such would filter them out
|
||||
final bytes = await storageService.openFile();
|
||||
if (bytes.isNotEmpty) {
|
||||
try {
|
||||
final allJsonString = utf8.decode(bytes);
|
||||
final allJsonMap = jsonDecode(allJsonString);
|
||||
|
||||
final version = allJsonMap[exportVersionKey];
|
||||
final importable = <AppExportItem, dynamic>{};
|
||||
if (version == null) {
|
||||
// backward compatibility before versioning
|
||||
importable[AppExportItem.settings] = allJsonMap;
|
||||
} else {
|
||||
if (allJsonMap is! Map) {
|
||||
debugPrint('failed to import app json=$allJsonMap');
|
||||
showFeedback(context, context.l10n.genericFailureFeedback);
|
||||
return;
|
||||
}
|
||||
allJsonMap.keys.where((v) => v != exportVersionKey).forEach((k) {
|
||||
try {
|
||||
importable[AppExportItem.values.byName(k)] = allJsonMap[k];
|
||||
} catch (error, stack) {
|
||||
debugPrint('failed to identify import app item=$k with error=$error\n$stack');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
final toImport = await showDialog<Set<AppExportItem>>(
|
||||
context: context,
|
||||
builder: (context) => AppExportItemSelectionDialog(
|
||||
title: context.l10n.settingsActionImportDialogTitle,
|
||||
selectableItems: importable.keys.toSet(),
|
||||
),
|
||||
);
|
||||
if (toImport == null || toImport.isEmpty) return;
|
||||
|
||||
await Future.forEach<AppExportItem>(toImport, (item) async {
|
||||
return item.import(importable[item], source);
|
||||
});
|
||||
showFeedback(context, context.l10n.genericSuccessFeedback);
|
||||
} catch (error) {
|
||||
debugPrint('failed to import app json, error=$error');
|
||||
showFeedback(context, context.l10n.genericFailureFeedback);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _goToSearch(BuildContext context) {
|
||||
Navigator.maybeOf(context)?.push(
|
||||
SearchPageRoute(
|
||||
delegate: SettingsSearchDelegate(
|
||||
searchFieldLabel: context.l10n.settingsSearchFieldLabel,
|
||||
sections: SettingsPage.sections,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,58 +1,27 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/model/actions/settings.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/app_bar/app_bar_title.dart';
|
||||
import 'package:aves/widgets/common/basic/font_size_icon_theme.dart';
|
||||
import 'package:aves/widgets/common/basic/insets.dart';
|
||||
import 'package:aves/widgets/common/basic/popup/menu_row.dart';
|
||||
import 'package:aves/widgets/common/basic/scaffold.dart';
|
||||
import 'package:aves/widgets/common/behaviour/pop/scope.dart';
|
||||
import 'package:aves/widgets/common/behaviour/pop/tv_navigation.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/extensions/media_query.dart';
|
||||
import 'package:aves/widgets/common/search/route.dart';
|
||||
import 'package:aves/widgets/navigation/tv_rail.dart';
|
||||
import 'package:aves/widgets/settings/accessibility/accessibility.dart';
|
||||
import 'package:aves/widgets/settings/app_export/items.dart';
|
||||
import 'package:aves/widgets/settings/app_export/selection_dialog.dart';
|
||||
import 'package:aves/widgets/settings/display/display.dart';
|
||||
import 'package:aves/widgets/settings/language/language.dart';
|
||||
import 'package:aves/widgets/settings/navigation/navigation.dart';
|
||||
import 'package:aves/widgets/settings/privacy/privacy.dart';
|
||||
import 'package:aves/widgets/settings/settings_definition.dart';
|
||||
import 'package:aves/widgets/settings/settings_search.dart';
|
||||
import 'package:aves/widgets/settings/settings_mobile_page.dart';
|
||||
import 'package:aves/widgets/settings/settings_tv_page.dart';
|
||||
import 'package:aves/widgets/settings/thumbnails/thumbnails.dart';
|
||||
import 'package:aves/widgets/settings/video/video.dart';
|
||||
import 'package:aves/widgets/settings/viewer/viewer.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
class SettingsPage extends StatelessWidget {
|
||||
static const routeName = '/settings';
|
||||
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsPage> createState() => _SettingsPageState();
|
||||
}
|
||||
|
||||
class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
|
||||
final ValueNotifier<String?> _expandedNotifier = ValueNotifier(null);
|
||||
final ValueNotifier<int> _tvSelectedIndexNotifier = ValueNotifier(0);
|
||||
|
||||
static final List<SettingsSection> sections = [
|
||||
NavigationSection(),
|
||||
ThumbnailsSection(),
|
||||
|
@ -64,207 +33,22 @@ class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
|
|||
LanguageSection(),
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_expandedNotifier.dispose();
|
||||
_tvSelectedIndexNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appBarTitle = Text(context.l10n.settingsPageTitle);
|
||||
|
||||
if (settings.useTvLayout) {
|
||||
return AvesScaffold(
|
||||
body: AvesPopScope(
|
||||
handlers: const [TvNavigationPopHandler.pop],
|
||||
child: Row(
|
||||
children: [
|
||||
TvRail(
|
||||
controller: context.read<TvRailController>(),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
DirectionalSafeArea(
|
||||
start: false,
|
||||
bottom: false,
|
||||
child: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
title: appBarTitle,
|
||||
elevation: 0,
|
||||
primary: false,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeLeft: true,
|
||||
removeTop: true,
|
||||
removeRight: true,
|
||||
removeBottom: true,
|
||||
child: const _TvRail(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
return const SettingsTvPage();
|
||||
} else {
|
||||
return AvesScaffold(
|
||||
appBar: AppBar(
|
||||
title: InteractiveAppBarTitle(
|
||||
onTap: () => _goToSearch(context),
|
||||
child: appBarTitle,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(AIcons.search),
|
||||
onPressed: () => _goToSearch(context),
|
||||
tooltip: MaterialLocalizations.of(context).searchFieldLabel,
|
||||
),
|
||||
PopupMenuButton<SettingsAction>(
|
||||
itemBuilder: (context) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
value: SettingsAction.export,
|
||||
child: MenuRow(text: context.l10n.settingsActionExport, icon: const Icon(AIcons.fileExport)),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: SettingsAction.import,
|
||||
child: MenuRow(text: context.l10n.settingsActionImport, icon: const Icon(AIcons.fileImport)),
|
||||
),
|
||||
];
|
||||
},
|
||||
onSelected: (action) async {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
|
||||
_onActionSelected(action);
|
||||
},
|
||||
),
|
||||
].map((v) => FontSizeIconTheme(child: v)).toList(),
|
||||
),
|
||||
body: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: AnimationLimiter(
|
||||
child: _SettingsListView(
|
||||
children: sections.map((v) => v.build(context, _expandedNotifier)).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return const SettingsMobilePage();
|
||||
}
|
||||
}
|
||||
|
||||
static const String exportVersionKey = 'version';
|
||||
static const int exportVersion = 1;
|
||||
|
||||
void _onActionSelected(SettingsAction action) async {
|
||||
final source = context.read<CollectionSource>();
|
||||
switch (action) {
|
||||
case SettingsAction.export:
|
||||
final toExport = await showDialog<Set<AppExportItem>>(
|
||||
context: context,
|
||||
builder: (context) => AppExportItemSelectionDialog(
|
||||
title: context.l10n.settingsActionExportDialogTitle,
|
||||
),
|
||||
);
|
||||
if (toExport == null || toExport.isEmpty) return;
|
||||
|
||||
final allMap = Map.fromEntries(toExport.map((v) {
|
||||
final jsonMap = v.export(source);
|
||||
return jsonMap != null ? MapEntry(v.name, jsonMap) : null;
|
||||
}).whereNotNull());
|
||||
allMap[exportVersionKey] = exportVersion;
|
||||
final allJsonString = jsonEncode(allMap);
|
||||
|
||||
final success = await storageService.createFile(
|
||||
'aves-settings-${DateFormat('yyyyMMdd_HHmmss').format(DateTime.now())}.json',
|
||||
MimeTypes.json,
|
||||
Uint8List.fromList(utf8.encode(allJsonString)),
|
||||
);
|
||||
if (success != null) {
|
||||
if (success) {
|
||||
showFeedback(context, context.l10n.genericSuccessFeedback);
|
||||
} else {
|
||||
showFeedback(context, context.l10n.genericFailureFeedback);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case SettingsAction.import:
|
||||
// specifying the JSON MIME type to restrict openable files is correct in theory,
|
||||
// but older devices (e.g. SM-P580, API 27) that do not recognize JSON files as such would filter them out
|
||||
final bytes = await storageService.openFile();
|
||||
if (bytes.isNotEmpty) {
|
||||
try {
|
||||
final allJsonString = utf8.decode(bytes);
|
||||
final allJsonMap = jsonDecode(allJsonString);
|
||||
|
||||
final version = allJsonMap[exportVersionKey];
|
||||
final importable = <AppExportItem, dynamic>{};
|
||||
if (version == null) {
|
||||
// backward compatibility before versioning
|
||||
importable[AppExportItem.settings] = allJsonMap;
|
||||
} else {
|
||||
if (allJsonMap is! Map) {
|
||||
debugPrint('failed to import app json=$allJsonMap');
|
||||
showFeedback(context, context.l10n.genericFailureFeedback);
|
||||
return;
|
||||
}
|
||||
allJsonMap.keys.where((v) => v != exportVersionKey).forEach((k) {
|
||||
try {
|
||||
importable[AppExportItem.values.byName(k)] = allJsonMap[k];
|
||||
} catch (error, stack) {
|
||||
debugPrint('failed to identify import app item=$k with error=$error\n$stack');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
final toImport = await showDialog<Set<AppExportItem>>(
|
||||
context: context,
|
||||
builder: (context) => AppExportItemSelectionDialog(
|
||||
title: context.l10n.settingsActionImportDialogTitle,
|
||||
selectableItems: importable.keys.toSet(),
|
||||
),
|
||||
);
|
||||
if (toImport == null || toImport.isEmpty) return;
|
||||
|
||||
await Future.forEach<AppExportItem>(toImport, (item) async {
|
||||
return item.import(importable[item], source);
|
||||
});
|
||||
showFeedback(context, context.l10n.genericSuccessFeedback);
|
||||
} catch (error) {
|
||||
debugPrint('failed to import app json, error=$error');
|
||||
showFeedback(context, context.l10n.genericFailureFeedback);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _goToSearch(BuildContext context) {
|
||||
Navigator.maybeOf(context)?.push(
|
||||
SearchPageRoute(
|
||||
delegate: SettingsSearchDelegate(
|
||||
searchFieldLabel: context.l10n.settingsSearchFieldLabel,
|
||||
sections: sections,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsListView extends StatelessWidget {
|
||||
class SettingsListView extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
|
||||
const _SettingsListView({
|
||||
const SettingsListView({
|
||||
super.key,
|
||||
required this.children,
|
||||
});
|
||||
|
@ -302,90 +86,3 @@ class _SettingsListView extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsSectionBody extends StatelessWidget {
|
||||
final Future<List<SettingsTile>> loader;
|
||||
|
||||
const _SettingsSectionBody({required this.loader});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<SettingsTile>>(
|
||||
future: loader,
|
||||
builder: (context, snapshot) {
|
||||
final tiles = snapshot.data;
|
||||
if (tiles == null) return const SizedBox();
|
||||
|
||||
return _SettingsListView(
|
||||
key: ValueKey(loader),
|
||||
children: tiles.map((v) => v.build(context)).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TvRail extends StatefulWidget {
|
||||
const _TvRail();
|
||||
|
||||
@override
|
||||
State<_TvRail> createState() => _TvRailState();
|
||||
}
|
||||
|
||||
class _TvRailState extends State<_TvRail> {
|
||||
final ValueNotifier<int> _indexNotifier = ValueNotifier(0);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_indexNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
static final List<SettingsSection> sections = _SettingsPageState.sections;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: _indexNotifier,
|
||||
builder: (context, selectedIndex, child) {
|
||||
final rail = NavigationRail(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
extended: true,
|
||||
destinations: sections
|
||||
.map((section) => NavigationRailDestination(
|
||||
icon: section.icon(context),
|
||||
label: Text(section.title(context)),
|
||||
))
|
||||
.toList(),
|
||||
selectedIndex: selectedIndex,
|
||||
onDestinationSelected: (index) => _indexNotifier.value = index,
|
||||
minExtendedWidth: TvRail.minExtendedWidth,
|
||||
);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Row(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: IntrinsicHeight(child: rail),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeLeft: !context.isRtl,
|
||||
removeRight: context.isRtl,
|
||||
child: _SettingsSectionBody(
|
||||
loader: Future.value(sections[selectedIndex].tiles(context)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
144
lib/widgets/settings/settings_tv_page.dart
Normal file
144
lib/widgets/settings/settings_tv_page.dart
Normal file
|
@ -0,0 +1,144 @@
|
|||
import 'package:aves/widgets/common/basic/insets.dart';
|
||||
import 'package:aves/widgets/common/basic/scaffold.dart';
|
||||
import 'package:aves/widgets/common/behaviour/pop/scope.dart';
|
||||
import 'package:aves/widgets/common/behaviour/pop/tv_navigation.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/navigation/tv_rail.dart';
|
||||
import 'package:aves/widgets/settings/settings_definition.dart';
|
||||
import 'package:aves/widgets/settings/settings_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class SettingsTvPage extends StatelessWidget {
|
||||
const SettingsTvPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AvesScaffold(
|
||||
body: AvesPopScope(
|
||||
handlers: const [TvNavigationPopHandler.pop],
|
||||
child: Row(
|
||||
children: [
|
||||
TvRail(
|
||||
controller: context.read<TvRailController>(),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
DirectionalSafeArea(
|
||||
start: false,
|
||||
bottom: false,
|
||||
child: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
title: Text(context.l10n.settingsPageTitle),
|
||||
elevation: 0,
|
||||
primary: false,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeLeft: true,
|
||||
removeTop: true,
|
||||
removeRight: true,
|
||||
removeBottom: true,
|
||||
child: const _Content(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Content extends StatefulWidget {
|
||||
const _Content();
|
||||
|
||||
@override
|
||||
State<_Content> createState() => _ContentState();
|
||||
}
|
||||
|
||||
class _ContentState extends State<_Content> {
|
||||
final ValueNotifier<int> _indexNotifier = ValueNotifier(0);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_indexNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
static final List<SettingsSection> sections = SettingsPage.sections;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: _indexNotifier,
|
||||
builder: (context, selectedIndex, child) {
|
||||
final rail = NavigationRail(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
extended: true,
|
||||
destinations: sections
|
||||
.map((section) => NavigationRailDestination(
|
||||
icon: section.icon(context),
|
||||
label: Text(section.title(context)),
|
||||
))
|
||||
.toList(),
|
||||
selectedIndex: selectedIndex,
|
||||
onDestinationSelected: (index) => _indexNotifier.value = index,
|
||||
minExtendedWidth: TvRail.minExtendedWidth,
|
||||
);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Row(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: IntrinsicHeight(child: rail),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeLeft: !context.isRtl,
|
||||
removeRight: context.isRtl,
|
||||
child: _Section(
|
||||
loader: Future.value(sections[selectedIndex].tiles(context)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Section extends StatelessWidget {
|
||||
final Future<List<SettingsTile>> loader;
|
||||
|
||||
const _Section({required this.loader});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<SettingsTile>>(
|
||||
future: loader,
|
||||
builder: (context, snapshot) {
|
||||
final tiles = snapshot.data;
|
||||
if (tiles == null) return const SizedBox();
|
||||
|
||||
return SettingsListView(
|
||||
key: ValueKey(loader),
|
||||
children: tiles.map((v) => v.build(context)).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1280,30 +1280,9 @@
|
|||
"tagPlaceholderState"
|
||||
],
|
||||
|
||||
"es": [
|
||||
"chipActionShowCountryStates",
|
||||
"viewerActionLock",
|
||||
"viewerActionUnlock",
|
||||
"statePageTitle",
|
||||
"stateEmpty",
|
||||
"searchStatesSectionTitle",
|
||||
"settingsCollectionBurstPatternsTile",
|
||||
"settingsCollectionBurstPatternsNone",
|
||||
"statsTopStatesSectionTitle",
|
||||
"tagPlaceholderState"
|
||||
],
|
||||
|
||||
"eu": [
|
||||
"chipActionShowCountryStates",
|
||||
"viewerActionLock",
|
||||
"viewerActionUnlock",
|
||||
"statePageTitle",
|
||||
"stateEmpty",
|
||||
"searchStatesSectionTitle",
|
||||
"settingsCollectionBurstPatternsTile",
|
||||
"settingsCollectionBurstPatternsNone",
|
||||
"statsTopStatesSectionTitle",
|
||||
"tagPlaceholderState"
|
||||
"statsTopStatesSectionTitle"
|
||||
],
|
||||
|
||||
"fa": [
|
||||
|
@ -1790,17 +1769,6 @@
|
|||
"filePickerUseThisFolder"
|
||||
],
|
||||
|
||||
"fr": [
|
||||
"chipActionShowCountryStates",
|
||||
"viewerActionLock",
|
||||
"viewerActionUnlock",
|
||||
"statePageTitle",
|
||||
"stateEmpty",
|
||||
"searchStatesSectionTitle",
|
||||
"statsTopStatesSectionTitle",
|
||||
"tagPlaceholderState"
|
||||
],
|
||||
|
||||
"gl": [
|
||||
"columnCount",
|
||||
"chipActionGoToPlacePage",
|
||||
|
@ -3594,8 +3562,6 @@
|
|||
"statePageTitle",
|
||||
"stateEmpty",
|
||||
"searchStatesSectionTitle",
|
||||
"settingsCollectionBurstPatternsTile",
|
||||
"settingsCollectionBurstPatternsNone",
|
||||
"statsTopStatesSectionTitle",
|
||||
"tagPlaceholderState"
|
||||
],
|
||||
|
@ -3676,17 +3642,6 @@
|
|||
"tagPlaceholderState"
|
||||
],
|
||||
|
||||
"ko": [
|
||||
"chipActionShowCountryStates",
|
||||
"viewerActionLock",
|
||||
"viewerActionUnlock",
|
||||
"statePageTitle",
|
||||
"stateEmpty",
|
||||
"searchStatesSectionTitle",
|
||||
"statsTopStatesSectionTitle",
|
||||
"tagPlaceholderState"
|
||||
],
|
||||
|
||||
"lt": [
|
||||
"columnCount",
|
||||
"chipActionGoToPlacePage",
|
||||
|
@ -4170,8 +4125,6 @@
|
|||
"statePageTitle",
|
||||
"stateEmpty",
|
||||
"searchStatesSectionTitle",
|
||||
"settingsCollectionBurstPatternsTile",
|
||||
"settingsCollectionBurstPatternsNone",
|
||||
"statsTopStatesSectionTitle",
|
||||
"tagPlaceholderState"
|
||||
],
|
||||
|
|
Loading…
Reference in a new issue