aves/lib/widgets/settings/settings_page.dart
2022-05-24 15:34:24 +09:00

235 lines
9.2 KiB
Dart

import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:aves/model/actions/settings_actions.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_title.dart';
import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/search/route.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/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 {
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);
static final List<SettingsSection> sections = [
NavigationSection(),
ThumbnailsSection(),
ViewerSection(),
VideoSection(),
PrivacySection(),
AccessibilitySection(),
DisplaySection(),
LanguageSection(),
];
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final durations = context.watch<DurationsData>();
return MediaQueryDataProvider(
child: Scaffold(
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,
),
MenuIconTheme(
child: 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);
},
),
),
],
),
body: GestureAreaProtectorStack(
child: SafeArea(
bottom: false,
child: Theme(
data: theme.copyWith(
textTheme: theme.textTheme.copyWith(
// dense style font for tile subtitles, without modifying title font
bodyText2: const TextStyle(fontSize: 12),
),
),
child: AnimationLimiter(
child: Selector<MediaQueryData, double>(
selector: (context, mq) => max(mq.effectiveBottomPadding, mq.systemGestureInsets.bottom),
builder: (context, mqPaddingBottom, child) {
return ListView(
padding: const EdgeInsets.all(8) + EdgeInsets.only(bottom: mqPaddingBottom),
children: AnimationConfiguration.toStaggeredList(
duration: durations.staggeredAnimation,
delay: durations.staggeredAnimationDelay * timeDilation,
childAnimationBuilder: (child) => SlideAnimation(
verticalOffset: 50.0,
child: FadeInAnimation(
child: child,
),
),
children: 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.settingsActionExport,
),
);
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) {
// backwards 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.settingsActionImport,
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.push(
context,
SearchPageRoute(
delegate: SettingsSearchDelegate(
searchFieldLabel: context.l10n.settingsSearchFieldLabel,
sections: sections,
),
),
);
}
}