tv: improved about page

This commit is contained in:
Thibault Deckers 2023-03-27 19:29:57 +02:00
parent a63590f5ad
commit a70881c902
12 changed files with 729 additions and 534 deletions

View 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(),
],
),
),
),
);
}
}

View file

@ -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();
}
}
}

View 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(),
);
},
),
),
),
),
],
),
);
}
}
}

View file

@ -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(),
),
);
}
}

View file

@ -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),
],
),
);
}
}

View file

@ -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,
});

View file

@ -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 {

View file

@ -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)),

View 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,
),
),
);
}
}

View file

@ -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)),
),
),
),
],
);
},
);
},
);
}
}

View 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(),
);
},
);
}
}

View file

@ -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"
],