Refactor and combine platform specific settings pages.

This commit is contained in:
Dain Nilsson 2023-03-03 16:09:40 +01:00
parent 1c6b11ada7
commit 7c0814a8e0
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
11 changed files with 530 additions and 456 deletions

View File

@ -15,27 +15,16 @@
*/
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:yubico_authenticator/android/init.dart';
import 'package:yubico_authenticator/android/keys.dart' as android_keys;
import 'package:yubico_authenticator/android/qr_scanner/qr_scanner_view.dart';
import 'package:yubico_authenticator/android/preferences.dart';
import 'package:yubico_authenticator/app/views/device_avatar.dart';
import 'package:yubico_authenticator/app/views/keys.dart' as app_keys;
import '../test_util.dart';
void _setShowBetaDialogPref(bool value) async {
SharedPreferences.setMockInitialValues({betaDialogPrefName: value});
}
Future<void> startUp(WidgetTester tester,
[Map<dynamic, dynamic> startUpParams = const {}]) async {
// on Android disable Beta welcome dialog
// we need to do it before we pump the app
var betaDlgEnabled = startUpParams['dlg.beta.enabled'] ?? false;
_setShowBetaDialogPref(betaDlgEnabled);
await tester.pumpWidget(await initialize());
// only wait for yubikey connection when needed

View File

@ -16,27 +16,20 @@
import 'package:flutter/material.dart';
import 'models.dart';
const _prefix = 'android.keys';
const betaDialogView = Key('$_prefix.beta_dialog');
const nfcTapSetting = Key('$_prefix.nfc_tap');
const nfcKeyboardLayoutSetting = Key('$_prefix.nfc_keyboard_layout');
const nfcBypassTouchSetting = Key('$_prefix.nfc_bypass_touch');
const nfcSilenceSoundsSettings = Key('$_prefix.nfc_silence_sounds');
const usbOpenApp = Key('$_prefix.usb_open_app');
const themeModeSetting = Key('$_prefix.theme_mode');
const okButton = Key('$_prefix.ok');
const manualEntryButton = Key('$_prefix.manual_entry');
const launchTapAction = Key('$_prefix.tap_action_launch');
const copyTapAction = Key('$_prefix.tap_action_copy');
const bothTapAction = Key('$_prefix.tap_action_both');
const themeModeSystem = Key('$_prefix.theme_mode_system');
const themeModeLight = Key('$_prefix.theme_mode_light');
const themeModeDark = Key('$_prefix.theme_mode_dark');
const nfcBypassTouchSetting = Key('$_prefix.nfc_bypass_touch');
const nfcSilenceSoundsSettings = Key('$_prefix.nfc_silence_sounds');
const usbOpenApp = Key('$_prefix.usb_open_app');
const nfcTapSetting = Key('$_prefix.nfc_tap');
Key nfcTapOption(NfcTapAction action) =>
Key('$_prefix.tap_action.${action.name}');
const nfcKeyboardLayoutSetting = Key('$_prefix.nfc_keyboard_layout');
Key keyboardLayoutOption(String name) => Key('$_prefix.keyboard_layout.$name');

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Yubico.
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -14,12 +14,21 @@
* limitations under the License.
*/
// shared preferences keys
const betaDialogPrefName = 'prefBetaDialogShouldBeShown';
const prefNfcOpenApp = 'prefNfcOpenApp';
const prefNfcBypassTouch = 'prefNfcBypassTouch';
const prefNfcSilenceSounds = 'prefNfcSilenceSounds';
const prefNfcCopyOtp = 'prefNfcCopyOtp';
const prefClipKbdLayout = 'prefClipKbdLayout';
const prefUsbOpenApp = 'prefUsbOpenApp';
const prefTheme = 'APP_STATE_THEME';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
enum NfcTapAction {
launch,
copy,
both;
String getDescription(AppLocalizations l10n) {
switch (this) {
case NfcTapAction.launch:
return l10n.l_launch_ya;
case NfcTapAction.copy:
return l10n.l_copy_otp_clipboard;
case NfcTapAction.both:
return l10n.l_launch_and_copy_otp;
}
}
}

View File

@ -17,11 +17,14 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../app/models.dart';
import '../app/state.dart';
import '../core/state.dart';
import 'app_methods.dart';
import 'devices.dart';
import 'models.dart';
const _contextChannel = MethodChannel('android.state.appContext');
@ -63,7 +66,7 @@ class _AndroidClipboard extends AppClipboard {
}
class NfcStateNotifier extends StateNotifier<bool> {
NfcStateNotifier(): super(false);
NfcStateNotifier() : super(false);
void setNfcEnabled(bool value) {
state = value;
@ -74,9 +77,8 @@ final androidSdkVersionProvider = Provider<int>((ref) => -1);
final androidNfcSupportProvider = Provider<bool>((ref) => false);
final androidNfcStateProvider = StateNotifierProvider<NfcStateNotifier, bool>((ref) =>
NfcStateNotifier()
);
final androidNfcStateProvider =
StateNotifierProvider<NfcStateNotifier, bool>((ref) => NfcStateNotifier());
final androidSupportedThemesProvider = StateProvider<List<ThemeMode>>((ref) {
if (ref.read(androidSdkVersionProvider) < 29) {
@ -124,3 +126,114 @@ class AndroidCurrentDeviceNotifier extends CurrentDeviceNotifier {
state = device;
}
}
final androidNfcTapActionProvider =
StateNotifierProvider<NfcTapActionNotifier, NfcTapAction>(
(ref) => NfcTapActionNotifier(ref.watch(prefProvider)));
class NfcTapActionNotifier extends StateNotifier<NfcTapAction> {
static const _prefNfcOpenApp = 'prefNfcOpenApp';
static const _prefNfcCopyOtp = 'prefNfcCopyOtp';
final SharedPreferences _prefs;
NfcTapActionNotifier._(this._prefs, super._state);
factory NfcTapActionNotifier(SharedPreferences prefs) {
final launchApp = prefs.getBool(_prefNfcOpenApp) ?? true;
final copyOtp = prefs.getBool(_prefNfcCopyOtp) ?? false;
final NfcTapAction action;
if (launchApp && copyOtp) {
action = NfcTapAction.both;
} else if (copyOtp) {
action = NfcTapAction.copy;
} else {
// This is the default value if both are false.
action = NfcTapAction.launch;
}
return NfcTapActionNotifier._(prefs, action);
}
Future<void> setTapAction(NfcTapAction value) async {
if (state != value) {
state = value;
await _prefs.setBool(_prefNfcOpenApp, value != NfcTapAction.copy);
await _prefs.setBool(_prefNfcCopyOtp, value != NfcTapAction.launch);
}
}
}
// TODO: Get these from Android
final androidNfcSupportedKbdLayoutsProvider =
Provider<List<String>>((ref) => ['US', 'DE', 'DE-CH']);
final androidNfcKbdLayoutProvider =
StateNotifierProvider<NfcKbdLayoutNotifier, String>(
(ref) => NfcKbdLayoutNotifier(ref.watch(prefProvider)));
class NfcKbdLayoutNotifier extends StateNotifier<String> {
static const String _defaultClipKbdLayout = 'US';
static const _prefClipKbdLayout = 'prefClipKbdLayout';
final SharedPreferences _prefs;
NfcKbdLayoutNotifier(this._prefs)
: super(_prefs.getString(_prefClipKbdLayout) ?? _defaultClipKbdLayout);
Future<void> setKeyboardLayout(String value) async {
if (state != value) {
state = value;
await _prefs.setString(_prefClipKbdLayout, value);
}
}
}
final androidNfcBypassTouchProvider =
StateNotifierProvider<NfcBypassTouchNotifier, bool>(
(ref) => NfcBypassTouchNotifier(ref.watch(prefProvider)));
class NfcBypassTouchNotifier extends StateNotifier<bool> {
static const _prefNfcBypassTouch = 'prefNfcBypassTouch';
final SharedPreferences _prefs;
NfcBypassTouchNotifier(this._prefs)
: super(_prefs.getBool(_prefNfcBypassTouch) ?? false);
Future<void> setNfcBypassTouch(bool value) async {
if (state != value) {
state = value;
await _prefs.setBool(_prefNfcBypassTouch, value);
}
}
}
final androidNfcSilenceSoundsProvider =
StateNotifierProvider<NfcSilenceSoundsNotifier, bool>(
(ref) => NfcSilenceSoundsNotifier(ref.watch(prefProvider)));
class NfcSilenceSoundsNotifier extends StateNotifier<bool> {
static const _prefNfcSilenceSounds = 'prefNfcSilenceSounds';
final SharedPreferences _prefs;
NfcSilenceSoundsNotifier(this._prefs)
: super(_prefs.getBool(_prefNfcSilenceSounds) ?? false);
Future<void> setNfcSilenceSounds(bool value) async {
if (state != value) {
state = value;
await _prefs.setBool(_prefNfcSilenceSounds, value);
}
}
}
final androidUsbLaunchAppProvider =
StateNotifierProvider<UsbLaunchAppNotifier, bool>(
(ref) => UsbLaunchAppNotifier(ref.watch(prefProvider)));
class UsbLaunchAppNotifier extends StateNotifier<bool> {
static const _prefUsbOpenApp = 'prefUsbOpenApp';
final SharedPreferences _prefs;
UsbLaunchAppNotifier(this._prefs)
: super(_prefs.getBool(_prefUsbOpenApp) ?? false);
Future<void> setUsbLaunchApp(bool value) async {
if (state != value) {
state = value;
await _prefs.setBool(_prefUsbOpenApp, value);
}
}
}

View File

@ -1,283 +0,0 @@
/*
* Copyright (C) 2022 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../app/state.dart';
import '../../core/state.dart';
import '../../widgets/list_title.dart';
import '../../widgets/responsive_dialog.dart';
import '../keys.dart' as keys;
import '../preferences.dart';
// TODO: Get these from Android
const List<String> _keyboardLayouts = ['US', 'DE', 'DE-CH'];
const String _defaultClipKbdLayout = 'US';
enum _TapAction {
launch,
copy,
both;
String getDescription(AppLocalizations l10n) {
switch (this) {
case _TapAction.launch:
return l10n.l_launch_ya;
case _TapAction.copy:
return l10n.l_copy_otp_clipboard;
case _TapAction.both:
return l10n.l_launch_and_copy_otp;
}
}
Key get key {
switch (this) {
case _TapAction.launch:
return keys.launchTapAction;
case _TapAction.copy:
return keys.copyTapAction;
case _TapAction.both:
return keys.bothTapAction;
}
}
static _TapAction load(SharedPreferences prefs) {
final launchApp = prefs.getBool(prefNfcOpenApp) ?? true;
final copyOtp = prefs.getBool(prefNfcCopyOtp) ?? false;
if (launchApp && copyOtp) {
return both;
}
if (copyOtp) {
return copy;
}
// This is the default value if both are false.
return launch;
}
void save(SharedPreferences prefs) {
prefs.setBool(prefNfcOpenApp, this != copy);
prefs.setBool(prefNfcCopyOtp, this != launch);
}
}
extension on ThemeMode {
String getDisplayName(AppLocalizations l10n) {
switch (this) {
case ThemeMode.system:
return l10n.s_system_default;
case ThemeMode.light:
return l10n.s_light_mode;
case ThemeMode.dark:
return l10n.s_dark_mode;
}
}
}
class AndroidSettingsPage extends ConsumerStatefulWidget {
const AndroidSettingsPage({super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_AndroidSettingsPageState();
}
class _AndroidSettingsPageState extends ConsumerState<AndroidSettingsPage> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final prefs = ref.watch(prefProvider);
final tapAction = _TapAction.load(prefs);
final clipKbdLayout =
prefs.getString(prefClipKbdLayout) ?? _defaultClipKbdLayout;
final nfcBypassTouch = prefs.getBool(prefNfcBypassTouch) ?? false;
final nfcSilenceSounds = prefs.getBool(prefNfcSilenceSounds) ?? false;
final usbOpenApp = prefs.getBool(prefUsbOpenApp) ?? false;
final themeMode = ref.watch(themeModeProvider);
final theme = Theme.of(context);
return ResponsiveDialog(
title: Text(l10n.s_settings),
child: Theme(
// Make the headers use the primary color to pop a bit.
// Once M3 is implemented this will probably not be needed.
data: theme.copyWith(
textTheme: theme.textTheme.copyWith(
labelLarge: theme.textTheme.labelLarge
?.copyWith(color: theme.colorScheme.primary)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTitle(l10n.s_nfc_options),
ListTile(
title: Text(l10n.l_on_yk_nfc_tap),
subtitle: Text(tapAction.getDescription(l10n)),
key: keys.nfcTapSetting,
onTap: () async {
final newTapAction = await _selectTapAction(context, tapAction);
setState(() {
newTapAction.save(prefs);
});
},
),
ListTile(
title: Text(l10n.l_kbd_layout_for_static),
subtitle: Text(clipKbdLayout),
key: keys.nfcKeyboardLayoutSetting,
enabled: tapAction != _TapAction.launch,
onTap: () async {
var newValue = await _selectKbdLayout(context, clipKbdLayout);
if (newValue != clipKbdLayout) {
setState(() {
prefs.setString(prefClipKbdLayout, newValue);
});
}
},
),
SwitchListTile(
title: Text(l10n.l_bypass_touch_requirement),
subtitle: Text(nfcBypassTouch
? l10n.l_bypass_touch_requirement_on
: l10n.l_bypass_touch_requirement_off),
value: nfcBypassTouch,
key: keys.nfcBypassTouchSetting,
onChanged: (value) {
setState(() {
prefs.setBool(prefNfcBypassTouch, value);
});
}),
SwitchListTile(
title: Text(l10n.s_silence_nfc_sounds),
subtitle: Text(nfcSilenceSounds
? l10n.l_silence_nfc_sounds_on
: l10n.l_silence_nfc_sounds_off),
value: nfcSilenceSounds,
key: keys.nfcSilenceSoundsSettings,
onChanged: (value) {
setState(() {
prefs.setBool(prefNfcSilenceSounds, value);
});
}),
ListTitle(l10n.s_usb_options),
SwitchListTile(
title: Text(l10n.l_launch_app_on_usb),
subtitle: Text(usbOpenApp
? l10n.l_launch_app_on_usb_on
: l10n.l_launch_app_on_usb_off),
value: usbOpenApp,
key: keys.usbOpenApp,
onChanged: (value) {
setState(() {
prefs.setBool(prefUsbOpenApp, value);
});
}),
ListTitle(l10n.s_appearance),
ListTile(
title: Text(l10n.s_app_theme),
subtitle: Text(themeMode.getDisplayName(l10n)),
key: keys.themeModeSetting,
onTap: () async {
final newMode = await _selectAppearance(
ref.read(supportedThemesProvider), context, themeMode);
ref.read(themeModeProvider.notifier).setThemeMode(newMode);
},
),
],
),
),
);
}
Future<_TapAction> _selectTapAction(
BuildContext context, _TapAction tapAction) async =>
await showDialog<_TapAction>(
context: context,
builder: (BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SimpleDialog(
title: Text(l10n.l_on_yk_nfc_tap),
children: _TapAction.values
.map(
(e) => RadioListTile<_TapAction>(
title: Text(e.getDescription(l10n)),
key: e.key,
value: e,
groupValue: tapAction,
toggleable: true,
onChanged: (mode) {
Navigator.pop(context, e);
}),
)
.toList(),
);
}) ??
_TapAction.launch;
Future<String> _selectKbdLayout(
BuildContext context, String currentKbdLayout) async =>
await showDialog<String>(
context: context,
builder: (BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SimpleDialog(
title: Text(l10n.s_choose_kbd_layout),
children: _keyboardLayouts
.map(
(e) => RadioListTile<String>(
title: Text(e),
value: e,
key: keys.keyboardLayoutOption(e),
toggleable: true,
groupValue: currentKbdLayout,
onChanged: (mode) {
Navigator.pop(context, e);
}),
)
.toList(),
);
}) ??
_defaultClipKbdLayout;
Future<ThemeMode> _selectAppearance(List<ThemeMode> supportedThemes,
BuildContext context, ThemeMode themeMode) async =>
await showDialog<ThemeMode>(
context: context,
builder: (BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SimpleDialog(
title: Text(l10n.s_choose_app_theme),
children: supportedThemes
.map((e) => RadioListTile(
title: Text(e.getDisplayName(l10n)),
value: e,
key: Key('android.keys.theme_mode_${e.name}'),
groupValue: themeMode,
toggleable: true,
onChanged: (mode) {
Navigator.pop(context, e);
},
))
.toList(),
);
}) ??
themeMode;
}

View File

@ -0,0 +1,187 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state.dart';
import '../models.dart';
import '../keys.dart' as keys;
class NfcTapActionView extends ConsumerWidget {
const NfcTapActionView({super.key});
Future<NfcTapAction?> _selectTapAction(
BuildContext context, NfcTapAction tapAction) async =>
await showDialog<NfcTapAction>(
context: context,
builder: (BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SimpleDialog(
title: Text(l10n.l_on_yk_nfc_tap),
children: NfcTapAction.values
.map(
(e) => RadioListTile<NfcTapAction>(
title: Text(e.getDescription(l10n)),
key: keys.nfcTapOption(e),
value: e,
groupValue: tapAction,
toggleable: true,
onChanged: (mode) {
Navigator.pop(context, e);
}),
)
.toList(),
);
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final tapAction = ref.watch(androidNfcTapActionProvider);
return ListTile(
title: Text(l10n.l_on_yk_nfc_tap),
subtitle: Text(tapAction.getDescription(l10n)),
key: keys.nfcTapSetting,
onTap: () async {
final newTapAction = await _selectTapAction(context, tapAction);
if (newTapAction != null) {
await ref
.read(androidNfcTapActionProvider.notifier)
.setTapAction(newTapAction);
}
},
);
}
}
class NfcKbdLayoutView extends ConsumerWidget {
const NfcKbdLayoutView({super.key});
Future<String?> _selectKbdLayout(BuildContext context, List<String> available,
String currentKbdLayout) async =>
await showDialog<String>(
context: context,
builder: (BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SimpleDialog(
title: Text(l10n.s_choose_kbd_layout),
children: available
.map(
(e) => RadioListTile<String>(
title: Text(e),
value: e,
key: keys.keyboardLayoutOption(e),
toggleable: true,
groupValue: currentKbdLayout,
onChanged: (mode) {
Navigator.pop(context, e);
}),
)
.toList(),
);
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final tapAction = ref.watch(androidNfcTapActionProvider);
final clipKbdLayout = ref.watch(androidNfcKbdLayoutProvider);
return ListTile(
title: Text(l10n.l_kbd_layout_for_static),
subtitle: Text(clipKbdLayout),
key: keys.nfcKeyboardLayoutSetting,
enabled: tapAction != NfcTapAction.launch,
onTap: () async {
final newValue = await _selectKbdLayout(
context,
ref.watch(androidNfcSupportedKbdLayoutsProvider),
clipKbdLayout,
);
if (newValue != null) {
await ref
.read(androidNfcKbdLayoutProvider.notifier)
.setKeyboardLayout(newValue);
}
},
);
}
}
class NfcBypassTouchView extends ConsumerWidget {
const NfcBypassTouchView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final nfcBypassTouch = ref.watch(androidNfcBypassTouchProvider);
return SwitchListTile(
title: Text(l10n.l_bypass_touch_requirement),
subtitle: Text(nfcBypassTouch
? l10n.l_bypass_touch_requirement_on
: l10n.l_bypass_touch_requirement_off),
value: nfcBypassTouch,
key: keys.nfcBypassTouchSetting,
onChanged: (value) {
ref
.read(androidNfcBypassTouchProvider.notifier)
.setNfcBypassTouch(value);
});
}
}
class NfcSilenceSoundsView extends ConsumerWidget {
const NfcSilenceSoundsView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final nfcSilenceSounds = ref.watch(androidNfcSilenceSoundsProvider);
return SwitchListTile(
title: Text(l10n.s_silence_nfc_sounds),
subtitle: Text(nfcSilenceSounds
? l10n.l_silence_nfc_sounds_on
: l10n.l_silence_nfc_sounds_off),
value: nfcSilenceSounds,
key: keys.nfcSilenceSoundsSettings,
onChanged: (value) {
ref
.read(androidNfcSilenceSoundsProvider.notifier)
.setNfcSilenceSounds(value);
});
}
}
class UsbOpenAppView extends ConsumerWidget {
const UsbOpenAppView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final usbOpenApp = ref.watch(androidUsbLaunchAppProvider);
return SwitchListTile(
title: Text(l10n.l_launch_app_on_usb),
subtitle: Text(usbOpenApp
? l10n.l_launch_app_on_usb_on
: l10n.l_launch_app_on_usb_off),
value: usbOpenApp,
key: keys.usbOpenApp,
onChanged: (value) {
ref.read(androidUsbLaunchAppProvider.notifier).setUsbLaunchApp(value);
});
}
}

View File

@ -22,14 +22,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:window_manager/window_manager.dart';
import '../about_page.dart';
import '../android/views/android_settings_page.dart';
import '../core/state.dart';
import '../desktop/state.dart';
import '../oath/keys.dart';
import '../settings_page.dart';
import 'message.dart';
import 'models.dart';
import 'state.dart';
import 'views/settings_page.dart';
class OpenIntent extends Intent {
const OpenIntent();
@ -122,9 +121,7 @@ Widget registerGlobalShortcuts(
if (!Navigator.of(context).canPop()) {
await showBlurDialog(
context: context,
builder: (context) => Platform.isAndroid
? const AndroidSettingsPage()
: const SettingsPage(),
builder: (context) => const SettingsPage(),
routeSettings: const RouteSettings(name: 'settings'),
);
}

View File

@ -26,3 +26,7 @@ const actionsIconButtonKey = Key('$_prefix.actions_icon_button');
// drawer items
const managementAppDrawer = Key('$_prefix.drawer.management');
// settings page
const themeModeSetting = Key('$_prefix.settings.theme_mode');
Key themeModeOption(ThemeMode mode) => Key('$_prefix.theme_mode.${mode.name}');

153
lib/app/views/settings_page.dart Executable file
View File

@ -0,0 +1,153 @@
/*
* Copyright (C) 2022-2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../android/views/settings_views.dart';
import '../../widgets/list_title.dart';
import '../../widgets/responsive_dialog.dart';
import '../state.dart';
import 'keys.dart' as keys;
extension on ThemeMode {
String getDisplayName(AppLocalizations l10n) {
switch (this) {
case ThemeMode.system:
return l10n.s_system_default;
case ThemeMode.light:
return l10n.s_light_mode;
case ThemeMode.dark:
return l10n.s_dark_mode;
}
}
}
class _ThemeModeView extends ConsumerWidget {
const _ThemeModeView();
Future<ThemeMode> _selectAppearance(BuildContext context,
List<ThemeMode> supportedThemes, ThemeMode themeMode) async =>
await showDialog<ThemeMode>(
context: context,
builder: (BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SimpleDialog(
title: Text(l10n.s_choose_app_theme),
children: supportedThemes
.map((e) => RadioListTile(
title: Text(e.getDisplayName(l10n)),
value: e,
key: keys.themeModeOption(e),
groupValue: themeMode,
toggleable: true,
onChanged: (mode) {
Navigator.pop(context, e);
},
))
.toList(),
);
}) ??
themeMode;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final themeMode = ref.watch(themeModeProvider);
return ListTile(
title: Text(l10n.s_app_theme),
subtitle: Text(themeMode.getDisplayName(l10n)),
key: keys.themeModeSetting,
onTap: () async {
final newMode = await _selectAppearance(
context, ref.read(supportedThemesProvider), themeMode);
ref.read(themeModeProvider.notifier).setThemeMode(newMode);
},
);
}
}
class _CommunityTranslationsView extends ConsumerWidget {
const _CommunityTranslationsView();
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final enableTranslations = ref.watch(communityTranslationsProvider);
return SwitchListTile(
title: Text(l10n.l_enable_community_translations),
subtitle: Text(l10n.p_community_translations_desc),
isThreeLine: true,
value: enableTranslations,
onChanged: (value) {
ref
.read(communityTranslationsProvider.notifier)
.setEnableCommunityTranslations(value);
});
}
}
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final enableTranslations = ref.watch(communityTranslationsProvider);
return ResponsiveDialog(
title: Text(l10n.s_settings),
child: Theme(
// Make the headers use the primary color to pop a bit.
// Once M3 is implemented this will probably not be needed.
data: theme.copyWith(
textTheme: theme.textTheme.copyWith(
labelLarge: theme.textTheme.labelLarge
?.copyWith(color: theme.colorScheme.primary)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (defaultTargetPlatform == TargetPlatform.android) ...[
ListTitle(l10n.s_nfc_options),
const NfcTapActionView(),
const NfcKbdLayoutView(),
const NfcBypassTouchView(),
const NfcSilenceSoundsView(),
ListTitle(l10n.s_usb_options),
const UsbOpenAppView(),
],
ListTitle(l10n.s_appearance),
const _ThemeModeView(),
if (enableTranslations ||
basicLocaleListResolution(window.locales, officialLocales) !=
basicLocaleListResolution(
window.locales, AppLocalizations.supportedLocales)) ...[
ListTitle(l10n.s_language),
const _CommunityTranslationsView(),
],
],
),
),
);
}
}

View File

@ -1,104 +0,0 @@
/*
* Copyright (C) 2022 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'app/logging.dart';
import 'app/state.dart';
import 'widgets/list_title.dart';
import 'widgets/responsive_dialog.dart';
final _log = Logger('settings');
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final themeMode = ref.watch(themeModeProvider);
final theme = Theme.of(context);
final enableTranslations = ref.watch(communityTranslationsProvider);
return ResponsiveDialog(
title: Text(l10n.s_settings),
child: Theme(
// Make the headers use the primary color to pop a bit.
// Once M3 is implemented this will probably not be needed.
data: theme.copyWith(
textTheme: theme.textTheme.copyWith(
labelLarge: theme.textTheme.labelLarge
?.copyWith(color: theme.colorScheme.primary)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTitle(l10n.s_appearance),
RadioListTile<ThemeMode>(
title: Text(l10n.s_system_default),
value: ThemeMode.system,
groupValue: themeMode,
onChanged: (mode) {
ref.read(themeModeProvider.notifier).setThemeMode(mode!);
_log.debug('Set theme mode to $mode');
},
),
RadioListTile<ThemeMode>(
title: Text(l10n.s_light_mode),
value: ThemeMode.light,
groupValue: themeMode,
onChanged: (mode) {
ref.read(themeModeProvider.notifier).setThemeMode(mode!);
_log.debug('Set theme mode to $mode');
},
),
RadioListTile<ThemeMode>(
title: Text(l10n.s_dark_mode),
value: ThemeMode.dark,
groupValue: themeMode,
onChanged: (mode) {
ref.read(themeModeProvider.notifier).setThemeMode(mode!);
_log.debug('Set theme mode to $mode');
},
),
if (enableTranslations ||
basicLocaleListResolution(window.locales, officialLocales) !=
basicLocaleListResolution(
window.locales, AppLocalizations.supportedLocales)) ...[
ListTitle(l10n.s_language),
SwitchListTile(
title: Text(l10n.l_enable_community_translations),
subtitle: Text(l10n.p_community_translations_desc),
isThreeLine: true,
value: enableTranslations,
onChanged: (value) {
ref
.read(communityTranslationsProvider.notifier)
.setEnableCommunityTranslations(value);
}),
],
],
),
),
);
}
}

View File

@ -15,16 +15,18 @@
*/
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:yubico_authenticator/android/keys.dart' as keys;
import 'package:yubico_authenticator/android/preferences.dart';
import 'package:yubico_authenticator/android/models.dart';
import 'package:yubico_authenticator/app/views/keys.dart' as app_keys;
import 'package:yubico_authenticator/android/keys.dart' as android_keys;
import 'package:yubico_authenticator/android/state.dart';
import 'package:yubico_authenticator/android/views/android_settings_page.dart';
import 'package:yubico_authenticator/app/state.dart';
import 'package:yubico_authenticator/app/views/settings_page.dart';
import 'package:yubico_authenticator/core/state.dart';
Widget createMaterialApp({required Widget child}) {
@ -44,7 +46,7 @@ Widget createMaterialApp({required Widget child}) {
extension _WidgetTesterHelper on WidgetTester {
Future<void> openNfcTapOptionSelection() async {
var widget = find.byKey(keys.nfcTapSetting).hitTestable();
var widget = find.byKey(android_keys.nfcTapSetting).hitTestable();
expect(widget, findsOneWidget);
await tap(widget);
await pumpAndSettle();
@ -52,28 +54,29 @@ extension _WidgetTesterHelper on WidgetTester {
Future<void> selectLaunchOption() async {
await openNfcTapOptionSelection();
await tap(find.byKey(keys.launchTapAction));
await tap(find.byKey(android_keys.nfcTapOption(NfcTapAction.launch)));
await pumpAndSettle();
}
Future<void> selectCopyOption() async {
await openNfcTapOptionSelection();
await tap(find.byKey(keys.copyTapAction));
await tap(find.byKey(android_keys.nfcTapOption(NfcTapAction.copy)));
await pumpAndSettle();
}
Future<void> selectBothOption() async {
await openNfcTapOptionSelection();
await tap(find.byKey(keys.bothTapAction));
await tap(find.byKey(android_keys.nfcTapOption(NfcTapAction.both)));
await pumpAndSettle();
}
ListTile keyboardLayoutListTile() =>
find.byKey(keys.nfcKeyboardLayoutSetting).evaluate().single.widget
find.byKey(android_keys.nfcKeyboardLayoutSetting).evaluate().single.widget
as ListTile;
Future<void> openKeyboardLayoutOptionSelection() async {
var widget = find.byKey(keys.nfcKeyboardLayoutSetting).hitTestable();
var widget =
find.byKey(android_keys.nfcKeyboardLayoutSetting).hitTestable();
expect(widget, findsOneWidget);
await tap(widget);
await pumpAndSettle();
@ -81,44 +84,45 @@ extension _WidgetTesterHelper on WidgetTester {
Future<void> selectKeyboardLayoutUSOption() async {
await openKeyboardLayoutOptionSelection();
await tap(find.byKey(keys.keyboardLayoutOption('US')));
await tap(find.byKey(android_keys.keyboardLayoutOption('US')));
await pumpAndSettle();
}
Future<void> selectKeyboardLayoutDEOption() async {
await openKeyboardLayoutOptionSelection();
await tap(find.byKey(keys.keyboardLayoutOption('DE')));
await tap(find.byKey(android_keys.keyboardLayoutOption('DE')));
await pumpAndSettle();
}
Future<void> selectKeyboardLayoutDECHOption() async {
await openKeyboardLayoutOptionSelection();
await tap(find.byKey(keys.keyboardLayoutOption('DE-CH')));
await tap(find.byKey(android_keys.keyboardLayoutOption('DE-CH')));
await pumpAndSettle();
}
Future<void> tapBypassTouch() async {
await tap(find.byKey(keys.nfcBypassTouchSetting));
await tap(find.byKey(android_keys.nfcBypassTouchSetting));
await pumpAndSettle();
}
Future<void> tapOpenAppOnUsb() async {
await ensureVisible(find.byKey(keys.usbOpenApp));
await tap(find.byKey(keys.usbOpenApp));
await ensureVisible(find.byKey(android_keys.usbOpenApp));
await tap(find.byKey(android_keys.usbOpenApp));
await pumpAndSettle();
}
Future<void> tapSilenceNfcSounds() async {
await tap(find.byKey(keys.nfcSilenceSoundsSettings));
await tap(find.byKey(android_keys.nfcSilenceSoundsSettings));
await pumpAndSettle();
}
ListTile themeModeListTile() =>
find.byKey(keys.themeModeSetting).evaluate().single.widget as ListTile;
find.byKey(app_keys.themeModeSetting).evaluate().single.widget
as ListTile;
Future<void> openAppThemeOptionSelection() async {
await ensureVisible(find.byKey(keys.themeModeSetting));
var widget = find.byKey(keys.themeModeSetting).hitTestable();
await ensureVisible(find.byKey(app_keys.themeModeSetting));
var widget = find.byKey(app_keys.themeModeSetting).hitTestable();
expect(widget, findsOneWidget);
await tap(widget);
await pumpAndSettle();
@ -126,19 +130,19 @@ extension _WidgetTesterHelper on WidgetTester {
Future<void> selectSystemTheme() async {
await openAppThemeOptionSelection();
await tap(find.byKey(keys.themeModeSystem));
await tap(find.byKey(app_keys.themeModeOption(ThemeMode.system)));
await pumpAndSettle();
}
Future<void> selectLightTheme() async {
await openAppThemeOptionSelection();
await tap(find.byKey(keys.themeModeLight));
await tap(find.byKey(app_keys.themeModeOption(ThemeMode.light)));
await pumpAndSettle();
}
Future<void> selectDarkTheme() async {
await openAppThemeOptionSelection();
await tap(find.byKey(keys.themeModeDark));
await tap(find.byKey(app_keys.themeModeOption(ThemeMode.dark)));
await pumpAndSettle();
}
}
@ -156,9 +160,12 @@ Widget androidWidget({
], child: child);
void main() {
var widget = createMaterialApp(child: const AndroidSettingsPage());
debugDefaultTargetPlatformOverride = TargetPlatform.android;
var widget = createMaterialApp(child: const SettingsPage());
testWidgets('NFC Tap options', (WidgetTester tester) async {
const prefNfcOpenApp = 'prefNfcOpenApp';
const prefNfcCopyOtp = 'prefNfcCopyOtp';
SharedPreferences.setMockInitialValues(
{prefNfcOpenApp: true, prefNfcCopyOtp: false});
@ -191,6 +198,9 @@ void main() {
});
testWidgets('Static password keyboard layout', (WidgetTester tester) async {
const prefNfcOpenApp = 'prefNfcOpenApp';
const prefNfcCopyOtp = 'prefNfcCopyOtp';
const prefClipKbdLayout = 'prefClipKbdLayout';
SharedPreferences.setMockInitialValues(
{prefNfcOpenApp: true, prefNfcCopyOtp: false, prefClipKbdLayout: 'US'});
@ -229,6 +239,7 @@ void main() {
});
testWidgets('Bypass touch req', (WidgetTester tester) async {
const prefNfcBypassTouch = 'prefNfcBypassTouch';
SharedPreferences.setMockInitialValues({prefNfcBypassTouch: false});
SharedPreferences sharedPrefs = await SharedPreferences.getInstance();
@ -285,6 +296,7 @@ void main() {
// no value for theme
SharedPreferences.setMockInitialValues({});
SharedPreferences sharedPrefs = await SharedPreferences.getInstance();
const prefTheme = 'APP_STATE_THEME';
await tester.pumpWidget(androidWidget(
sharedPrefs: sharedPrefs,
@ -303,6 +315,7 @@ void main() {
});
testWidgets('Open app on USB', (WidgetTester tester) async {
const prefUsbOpenApp = 'prefUsbOpenApp';
SharedPreferences.setMockInitialValues({prefUsbOpenApp: false});
SharedPreferences sharedPrefs = await SharedPreferences.getInstance();
@ -321,6 +334,7 @@ void main() {
});
testWidgets('Silence NFC sound', (WidgetTester tester) async {
const prefNfcSilenceSounds = 'prefNfcSilenceSounds';
SharedPreferences.setMockInitialValues({prefNfcSilenceSounds: false});
SharedPreferences sharedPrefs = await SharedPreferences.getInstance();
@ -337,4 +351,6 @@ void main() {
await tester.tapSilenceNfcSounds();
expect(sharedPrefs.getBool(prefNfcSilenceSounds), equals(false));
});
debugDefaultTargetPlatformOverride = null;
}