From c7f2b651fdcdf5c1860437d203edfface5382f04 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Tue, 10 Oct 2023 08:54:25 +0200 Subject: [PATCH] customize color and display name --- .../com/yubico/authenticator/MainActivity.kt | 34 ++- lib/android/app_methods.dart | 6 +- lib/android/init.dart | 8 +- lib/app/app.dart | 8 +- lib/app/key_customization.dart | 110 ++++++++ lib/app/models.dart | 16 +- lib/app/shortcuts.dart | 13 +- lib/app/state.dart | 39 ++- lib/app/views/customize_page.dart | 252 ++++++++++++++++++ lib/app/views/device_avatar.dart | 43 ++- lib/app/views/device_picker.dart | 66 ++++- lib/app/views/navigation.dart | 17 +- lib/desktop/state.dart | 16 +- 13 files changed, 595 insertions(+), 33 deletions(-) create mode 100644 lib/app/key_customization.dart create mode 100755 lib/app/views/customize_page.dart diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt index ed28131a..d034d77a 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022-2023 Yubico. + * Copyright (C) 2022-2024 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -38,6 +38,7 @@ import androidx.activity.viewModels import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import androidx.lifecycle.lifecycleScope +import com.google.android.material.color.DynamicColors import com.yubico.authenticator.logging.FlutterLog import com.yubico.authenticator.oath.AppLinkMethodChannel import com.yubico.authenticator.oath.OathManager @@ -383,6 +384,11 @@ class MainActivity : FlutterFragmentActivity() { methodCall.arguments as Boolean, ) ) + + "getPrimaryColor" -> result.success( + getPrimaryColor(this@MainActivity) + ) + "getAndroidSdkVersion" -> result.success( Build.VERSION.SDK_INT ) @@ -456,6 +462,30 @@ class MainActivity : FlutterFragmentActivity() { return FLAG_SECURE != (window.attributes.flags and FLAG_SECURE) } + private fun getPrimaryColor(context: Context): Int? { + if (DynamicColors.isDynamicColorAvailable()) { + val dynamicColorContext = DynamicColors.wrapContextIfAvailable( + context, + com.google.android.material.R.style.ThemeOverlay_Material3_DynamicColors_DayNight + ) + + val typedArray = dynamicColorContext.obtainStyledAttributes( + intArrayOf( + android.R.attr.colorPrimary, + ) + ) + try { + return if (typedArray.hasValue(0)) + typedArray.getColor(0, 0) + else + null + } finally { + typedArray.recycle() + } + } + return null + } + @SuppressLint("SourceLockedOrientationActivity") private fun forcePortraitOrientation() { requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT @@ -465,5 +495,5 @@ class MainActivity : FlutterFragmentActivity() { requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } - private fun isPortraitOnly() = resources.getBoolean(R.bool.portrait_only); + private fun isPortraitOnly() = resources.getBoolean(R.bool.portrait_only) } diff --git a/lib/android/app_methods.dart b/lib/android/app_methods.dart index 7cda083c..3bb6aae9 100644 --- a/lib/android/app_methods.dart +++ b/lib/android/app_methods.dart @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022-2023 Yubico. + * Copyright (C) 2022-2024 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -52,6 +52,10 @@ Future getAndroidSdkVersion() async { return await appMethodsChannel.invokeMethod('getAndroidSdkVersion'); } +Future getPrimaryColor() async { + return await appMethodsChannel.invokeMethod('getPrimaryColor'); +} + Future setPrimaryClip(String toClipboard, bool isSensitive) async { await appMethodsChannel.invokeMethod('setPrimaryClip', {'toClipboard': toClipboard, 'isSensitive': isSensitive}); diff --git a/lib/android/init.dart b/lib/android/init.dart index a4b9020b..6e029ea6 100644 --- a/lib/android/init.dart +++ b/lib/android/init.dart @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022-2023 Yubico. + * Copyright (C) 2022-2024 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -51,6 +51,9 @@ Future initialize() async { _initLicenses(); + final primaryColor = await getPrimaryColor(); + final primaryColorInt = primaryColor != null ? Color(primaryColor) : null; + return ProviderScope( overrides: [ supportedAppsProvider.overrideWith(implementedApps([ @@ -84,7 +87,8 @@ Future initialize() async { androidNfcSupportProvider.overrideWithValue(await getHasNfc()), supportedThemesProvider.overrideWith( (ref) => ref.watch(androidSupportedThemesProvider), - ) + ), + primaryColorProvider.overrideWithValue(primaryColorInt), ], child: DismissKeyboard( child: YubicoAuthenticatorApp(page: Consumer( diff --git a/lib/app/app.dart b/lib/app/app.dart index a9cee013..56dd59f9 100755 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Yubico. + * Copyright (C) 2022,2024 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,13 +19,13 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../theme.dart'; import 'logging.dart'; import 'shortcuts.dart'; import 'state.dart'; class YubicoAuthenticatorApp extends StatelessWidget { final Widget page; + const YubicoAuthenticatorApp({required this.page, super.key}); @override @@ -34,8 +34,8 @@ class YubicoAuthenticatorApp extends StatelessWidget { child: Consumer( builder: (context, ref, _) => MaterialApp( title: ref.watch(l10nProvider).app_name, - theme: AppTheme.lightTheme, - darkTheme: AppTheme.darkTheme, + theme: ref.watch(lightThemeProvider), + darkTheme: ref.watch(darkThemeProvider), themeMode: ref.watch(themeModeProvider), home: page, debugShowCheckedModeBanner: false, diff --git a/lib/app/key_customization.dart b/lib/app/key_customization.dart new file mode 100644 index 00000000..080ef9ba --- /dev/null +++ b/lib/app/key_customization.dart @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2024 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:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; + +import 'logging.dart'; +import 'models.dart'; + +final _log = Logger('key_customization'); + +final keyCustomizationManagerProvider = + Provider((ref) { + final retval = KeyCustomizationManager(); + retval.read(); + return retval; +}); + +class KeyCustomizationManager { + var _customizations = {}; + + void read() async { + final customizationFile = await _customizationFile; + // get content + if (!await customizationFile.exists()) { + return; + } + + try { + var customizationContent = await customizationFile.readAsString(); + _customizations = + json.decode(String.fromCharCodes(base64Decode(customizationContent))); + } catch (e) { + return; + } + } + + KeyCustomization? get(String? serialNumber) { + _log.debug('Getting customization for: $serialNumber'); + + if (serialNumber == null || serialNumber.isEmpty) { + return null; + } + + final sha = getSerialSha(serialNumber); + + if (_customizations.containsKey(sha)) { + return KeyCustomization(serialNumber, _customizations[sha]); + } + + return null; + } + + void set(KeyCustomization customization) { + _log.debug( + 'Added: ${customization.serialNumber}: ${customization.properties}'); + final sha = getSerialSha(customization.serialNumber); + _customizations[sha] = customization.properties; + } + + Future write() async { + final customizationFile = await _customizationFile; + + try { + await customizationFile.writeAsString( + base64UrlEncode(utf8.encode(json.encode(_customizations))), + flush: true); + } catch (e) { + _log.error('Error writing customization file: $e'); + return; + } + } + + final _dataSubDir = 'customizations'; + + Future get _dataDir async { + final supportDirectory = await getApplicationSupportDirectory(); + return Directory(join(supportDirectory.path, _dataSubDir)); + } + + Future get _customizationFile async { + final dataDir = await _dataDir; + if (!await dataDir.exists()) { + await dataDir.create(); + } + return File(join(dataDir.path, 'key_customizations.dat')); + } + + String getSerialSha(String serialNumber) => + sha256.convert(utf8.encode(serialNumber)).toString(); +} diff --git a/lib/app/models.dart b/lib/app/models.dart index 9398dd57..c08abbb4 100755 --- a/lib/app/models.dart +++ b/lib/app/models.dart @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Yubico. + * Copyright (C) 2022,2024 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,8 @@ * limitations under the License. */ +import 'dart:convert'; + import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -144,3 +146,15 @@ class WindowState with _$WindowState { @Default(false) bool hidden, }) = _WindowState; } + +class KeyCustomization { + final String serialNumber; + final Map properties; + + const KeyCustomization(this.serialNumber, this.properties); + + factory KeyCustomization.fromString(String serialNumber, String encodedJson) { + final data = json.decode(String.fromCharCodes(base64Decode(encodedJson))); + return KeyCustomization(serialNumber, data); + } +} diff --git a/lib/app/shortcuts.dart b/lib/app/shortcuts.dart index 43502a1e..bd4575a1 100755 --- a/lib/app/shortcuts.dart +++ b/lib/app/shortcuts.dart @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Yubico. + * Copyright (C) 2022,2024 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -51,6 +51,10 @@ class NextDeviceIntent extends Intent { const NextDeviceIntent(); } +class KeyCustomizationIntent extends Intent { + const KeyCustomizationIntent(); +} + class SettingsIntent extends Intent { const SettingsIntent(); } @@ -61,26 +65,31 @@ class AboutIntent extends Intent { class OpenIntent extends Intent { final T target; + const OpenIntent(this.target); } class CopyIntent extends Intent { final T target; + const CopyIntent(this.target); } class EditIntent extends Intent { final T target; + const EditIntent(this.target); } class DeleteIntent extends Intent { final T target; + const DeleteIntent(this.target); } class RefreshIntent extends Intent { final T target; + const RefreshIntent(this.target); } @@ -92,6 +101,7 @@ SingleActivator ctrlOrCmd(LogicalKeyboardKey key) => class ItemShortcuts extends StatelessWidget { final T item; final Widget child; + const ItemShortcuts({super.key, required this.item, required this.child}); @override @@ -112,6 +122,7 @@ class ItemShortcuts extends StatelessWidget { /// Global keyboard shortcuts class GlobalShortcuts extends ConsumerWidget { final Widget child; + const GlobalShortcuts({super.key, required this.child}); @override diff --git a/lib/app/state.dart b/lib/app/state.dart index 97bf3f05..945986d7 100755 --- a/lib/app/state.dart +++ b/lib/app/state.dart @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Yubico. + * Copyright (C) 2022,2024 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,6 +25,7 @@ import 'package:logging/logging.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../core/state.dart'; +import '../theme.dart'; import 'features.dart' as features; import 'logging.dart'; import 'models.dart'; @@ -139,6 +140,42 @@ class ThemeModeNotifier extends StateNotifier { orElse: () => supportedThemes.first); } +final primaryColorProvider = Provider((ref) => null); + +final darkThemeProvider = StateNotifierProvider( + (ref) => ThemeNotifier(ref.watch(primaryColorProvider), ThemeMode.dark), +); + +final lightThemeProvider = StateNotifierProvider( + (ref) => ThemeNotifier(ref.watch(primaryColorProvider), ThemeMode.light), +); + +class ThemeNotifier extends StateNotifier { + final ThemeMode _themeMode; + + ThemeNotifier(Color? systemPrimaryColor, this._themeMode) + : super(_get(systemPrimaryColor, _themeMode)); + + static ThemeData _getDefault(ThemeMode themeMode) => + themeMode == ThemeMode.light ? AppTheme.lightTheme : AppTheme.darkTheme; + + static ThemeData _get(Color? primaryColor, ThemeMode themeMode) => + (primaryColor != null) + ? _getDefault(themeMode).copyWith( + colorScheme: ColorScheme.fromSeed( + brightness: themeMode == ThemeMode.dark + ? Brightness.dark + : Brightness.light, + seedColor: primaryColor) + .copyWith(primary: primaryColor)) + : _getDefault(themeMode); + + void setPrimaryColor(Color? primaryColor) { + _log.debug('Set primary color to $primaryColor'); + state = _get(primaryColor, _themeMode); + } +} + // Override with platform implementation final attachedDevicesProvider = NotifierProvider>( diff --git a/lib/app/views/customize_page.dart b/lib/app/views/customize_page.dart new file mode 100755 index 00000000..fdd08c5f --- /dev/null +++ b/lib/app/views/customize_page.dart @@ -0,0 +1,252 @@ +/* + * Copyright (C) 2024 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 'package:logging/logging.dart'; + +import '../../widgets/focus_utils.dart'; +import '../../widgets/responsive_dialog.dart'; +import '../key_customization.dart'; +import '../logging.dart'; +import '../models.dart'; +import '../state.dart'; + +final _log = Logger('CustomizePage'); + +class CustomizePage extends ConsumerStatefulWidget { + final KeyCustomization? initialCustomization; + + const CustomizePage({super.key, required this.initialCustomization}); + + @override + ConsumerState createState() => _CustomizePageState(); +} + +class _CustomizePageState extends ConsumerState { + String? _displayName; + String? _displayColor; + + @override + void initState() { + super.initState(); + + _displayColor = widget.initialCustomization != null + ? widget.initialCustomization?.properties['display_color'] + : null; + _displayName = widget.initialCustomization != null + ? widget.initialCustomization?.properties['display_name'] + : null; + } + + void updateColor(String? colorString) { + setState(() { + _displayColor = colorString; + Color? color = + colorString != null ? Color(int.parse(colorString, radix: 16)) : null; + ref.watch(darkThemeProvider.notifier).setPrimaryColor(color); + ref.watch(lightThemeProvider.notifier).setPrimaryColor(color); + }); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + + return ResponsiveDialog( + title: const Text('Customize key appearance'), + actions: [ + TextButton( + onPressed: () async { + KeyCustomization newValue = KeyCustomization( + widget.initialCustomization!.serialNumber, { + 'display_color': _displayColor, + 'display_name': _displayName + }); + + _log.debug('Saving customization for ' + '${widget.initialCustomization!.serialNumber}: ' + '$_displayName/$_displayColor'); + + final manager = ref.read(keyCustomizationManagerProvider); + manager.set(newValue); + await manager.write(); + + await ref.read(withContextProvider)((context) async { + FocusUtils.unfocus(context); + final nav = Navigator.of(context); + nav.pop(); + }); + }, + child: Text(l10n.s_save), + ), + ], + 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: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + //controller: displayNameController, + initialValue: _displayName, + maxLength: 20, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Nick name', + helperText: '', // Prevents dialog resizing when disabled + prefixIcon: Icon(Icons.key), + ), + textInputAction: TextInputAction.done, + onChanged: (value) { + setState(() { + _displayName = value.trim(); + }); + }, + onFieldSubmitted: (_) {}, + ), + Row( + children: [ + const Text('Color: '), + const SizedBox(width: 16), + ColorButton( + color: Colors.yellow, + isSelected: _displayColor == 'FFFFEB3B', + onPressed: () { + updateColor('FFFFEB3B'); + }, + ), + ColorButton( + color: Colors.orange, + isSelected: _displayColor == 'FFFF9800', + onPressed: () { + updateColor('FFFF9800'); + }, + ), + ColorButton( + color: Colors.red, + isSelected: _displayColor == 'FFF44336', + onPressed: () { + updateColor('FFF44336'); + }, + ), + ColorButton( + color: Colors.deepPurple, + isSelected: _displayColor == 'FF673AB7', + onPressed: () { + updateColor('FF673AB7'); + }, + ), + ColorButton( + color: Colors.green, + isSelected: _displayColor == 'FF4CAF50', + onPressed: () { + updateColor('FF4CAF50'); + }, + ), + ColorButton( + color: Colors.teal, + isSelected: _displayColor == 'FF009688', + onPressed: () { + updateColor('FF009688'); + }, + ), + ColorButton( + color: Colors.cyan, + isSelected: _displayColor == 'FF00BCD4', + onPressed: () { + updateColor('FF00BCD4'); + }, + ), + IconButton( + icon: const Icon(Icons.cancel_rounded), + color: _displayColor == null + ? theme.colorScheme.surface + : theme.colorScheme.onSurface, + style: OutlinedButton.styleFrom( + side: const BorderSide( + style: BorderStyle.solid, + width: 0.4, + ), + backgroundColor: _displayColor == null + ? theme.colorScheme.onSurface + : theme.colorScheme.surface, + ), + onPressed: () { + updateColor(null); + }, + ), + ], + ) + ], + ), + ), + ), + ); + } +} + +class ColorButton extends StatefulWidget { + final MaterialColor color; + final bool isSelected; + final Function()? onPressed; + + const ColorButton( + {super.key, + required this.color, + required this.isSelected, + required this.onPressed}); + + @override + State createState() => _ColorButtonState(); +} + +class _ColorButtonState extends State { + @override + Widget build(BuildContext context) { + var style = OutlinedButton.styleFrom( + side: BorderSide( + color: widget.color, + style: BorderStyle.solid, + width: 0.4, + ), + ); + if (widget.isSelected) { + style = style.copyWith( + backgroundColor: MaterialStatePropertyAll(widget.color.shade900)); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: IconButton( + icon: const Icon(Icons.circle), + color: widget.color, + style: style, + onPressed: widget.onPressed, + ), + ); + } +} diff --git a/lib/app/views/device_avatar.dart b/lib/app/views/device_avatar.dart index f402df26..40f8dedd 100755 --- a/lib/app/views/device_avatar.dart +++ b/lib/app/views/device_avatar.dart @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Yubico. + * Copyright (C) 2022,2024 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,29 +26,46 @@ import '../models.dart'; import '../state.dart'; import 'keys.dart'; -class DeviceAvatar extends StatelessWidget { +class DeviceAvatar extends ConsumerWidget { final Widget child; + final Widget? decoration; + final Color? backgroundColor; final Widget? badge; final double? radius; - const DeviceAvatar({super.key, required this.child, this.badge, this.radius}); - factory DeviceAvatar.yubiKeyData(YubiKeyData data, {double? radius}) => - DeviceAvatar( - badge: isDesktop && data.node is NfcReaderNode ? nfcIcon : null, - radius: radius, + const DeviceAvatar( + {super.key, + required this.child, + this.decoration, + this.backgroundColor, + this.badge, + this.radius}); + + factory DeviceAvatar.yubiKeyData(YubiKeyData data, WidgetRef ref, + {double? radius}) { + return DeviceAvatar( + backgroundColor: Colors.transparent, + badge: isDesktop && data.node is NfcReaderNode ? nfcIcon : null, + radius: radius, + child: Padding( + padding: const EdgeInsets.all(2.0), child: ProductImage( name: data.name, formFactor: data.info.formFactor, isNfc: data.info.supportedCapabilities.containsKey(Transport.nfc)), - ); + ), + ); + } - factory DeviceAvatar.deviceNode(DeviceNode node, {double? radius}) => + factory DeviceAvatar.deviceNode(DeviceNode node, WidgetRef ref, + {double? radius}) => node.map( usbYubiKey: (node) { final info = node.info; if (info != null) { return DeviceAvatar.yubiKeyData( YubiKeyData(node, node.name, info), + ref, radius: radius, ); } @@ -73,10 +90,12 @@ class DeviceAvatar extends StatelessWidget { return ref.watch(currentDeviceDataProvider).maybeWhen( data: (data) => DeviceAvatar.yubiKeyData( data, + ref, radius: radius, ), orElse: () => DeviceAvatar.deviceNode( deviceNode, + ref, radius: radius, ), ); @@ -90,14 +109,15 @@ class DeviceAvatar extends StatelessWidget { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final radius = this.radius ?? 20; return Stack( alignment: AlignmentDirectional.bottomEnd, children: [ CircleAvatar( radius: radius, - backgroundColor: Colors.transparent, + backgroundColor: + backgroundColor ?? Theme.of(context).colorScheme.surfaceVariant, child: IconTheme( data: IconTheme.of(context).copyWith( size: radius, @@ -105,6 +125,7 @@ class DeviceAvatar extends StatelessWidget { child: child, ), ), + if (decoration != null) decoration!, if (badge != null) CircleAvatar( radius: radius / 3, diff --git a/lib/app/views/device_picker.dart b/lib/app/views/device_picker.dart index a974324f..c42370ca 100644 --- a/lib/app/views/device_picker.dart +++ b/lib/app/views/device_picker.dart @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022-2023 Yubico. + * Copyright (C) 2022-2024 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,6 +22,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../android/state.dart'; import '../../core/state.dart'; import '../../management/models.dart'; +import '../key_customization.dart'; import '../models.dart'; import '../state.dart'; import 'device_avatar.dart'; @@ -34,6 +35,7 @@ final _hiddenDevicesProvider = class _HiddenDevicesNotifier extends StateNotifier> { static const String _key = 'DEVICE_PICKER_HIDDEN'; final SharedPreferences _prefs; + _HiddenDevicesNotifier(this._prefs) : super(_prefs.getStringList(_key) ?? []); void showAll() { @@ -49,7 +51,8 @@ class _HiddenDevicesNotifier extends StateNotifier> { class DevicePickerContent extends ConsumerWidget { final bool extended; - const DevicePickerContent({super.key, this.extended = true}); + + const DevicePickerContent({required this.extended, super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -188,6 +191,7 @@ class _DeviceRow extends StatelessWidget { final String subtitle; final bool extended; final bool selected; + final Color? background; final void Function() onTap; const _DeviceRow({ @@ -197,6 +201,7 @@ class _DeviceRow extends StatelessWidget { required this.subtitle, required this.extended, required this.selected, + this.background, required this.onTap, }); @@ -218,7 +223,8 @@ class _DeviceRow extends StatelessWidget { subtitle: Text(subtitle, overflow: TextOverflow.fade, softWrap: false), dense: true, - tileColor: selected ? colorScheme.primary : null, + tileColor: + selected ? colorScheme.primary : background?.withOpacity(0.3), textColor: selected ? colorScheme.onPrimary : null, iconColor: selected ? colorScheme.onPrimary : null, onTap: onTap, @@ -260,12 +266,35 @@ _DeviceRow _buildDeviceRow( : _getDeviceInfoString(context, info), nfcReader: (_, __) => l10n.s_select_to_scan, ); + + String displayName = node.name; + Color? displayColor; + if (info?.serial != null) { + final properties = ref + .read(keyCustomizationManagerProvider) + .get(info?.serial?.toString()) + ?.properties; + var customizedName = properties?['display_name']; + if (customizedName != null && customizedName != '') { + displayName = customizedName + ' (${node.name})'; + } + var displayColorCustomization = properties?['display_color']; + if (displayColorCustomization != null) { + displayColor = Color(int.parse(displayColorCustomization, radix: 16)); + } + } + return _DeviceRow( key: ValueKey(node.path.key), - leading: DeviceAvatar.deviceNode(node), - title: node.name, + leading: IconTheme( + // Force the standard icon theme + data: IconTheme.of(context), + child: DeviceAvatar.deviceNode(node, ref), + ), + title: displayName, subtitle: subtitle, extended: extended, + background: displayColor, selected: false, onTap: () { ref.read(currentDeviceProvider.notifier).setCurrentDevice(node); @@ -288,16 +317,37 @@ _DeviceRow _buildCurrentDeviceRow( final title = messages.removeAt(0); final subtitle = messages.join('\n'); + String displayName = title; + Color? displayColor; + if (node is UsbYubiKeyNode) { + if (node.info?.serial != null) { + final properties = ref + .read(keyCustomizationManagerProvider) + .get(node.info?.serial.toString()) + ?.properties; + var customizedName = properties?['display_name']; + if (customizedName != null && customizedName != '') { + displayName = customizedName + ' (${node.name})'; + } + var displayColorCustomization = properties?['display_color']; + if (displayColorCustomization != null) { + displayColor = Color(int.parse(displayColorCustomization, radix: 16)); + } + } + } + return _DeviceRow( key: keys.deviceInfoListTile, leading: data.maybeWhen( data: (data) => - DeviceAvatar.yubiKeyData(data, radius: extended ? null : 16), - orElse: () => DeviceAvatar.deviceNode(node, radius: extended ? null : 16), + DeviceAvatar.yubiKeyData(data, ref, radius: extended ? null : 16), + orElse: () => + DeviceAvatar.deviceNode(node, ref, radius: extended ? null : 16), ), - title: title, + title: displayName, subtitle: subtitle, extended: extended, + background: displayColor, selected: true, onTap: () {}, ); diff --git a/lib/app/views/navigation.dart b/lib/app/views/navigation.dart index bc9e69fd..0dabc4f6 100644 --- a/lib/app/views/navigation.dart +++ b/lib/app/views/navigation.dart @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 Yubico. + * Copyright (C) 2023-2024 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -111,6 +111,7 @@ extension on Application { class NavigationContent extends ConsumerWidget { final bool shouldPop; final bool extended; + const NavigationContent( {super.key, this.shouldPop = true, this.extended = false}); @@ -181,6 +182,20 @@ class NavigationContent extends ConsumerWidget { }, ), ], + if (data.info.serial != null) ...[ + NavigationItem( + leading: const Icon(Icons.settings_applications_sharp), + title: 'Customize', + collapsed: !extended, + onTap: () { + if (shouldPop) { + Navigator.of(context).pop(); + } + Actions.maybeInvoke( + context, const KeyCustomizationIntent()); + }, + ) + ], const SizedBox(height: 32), ], ], diff --git a/lib/desktop/state.dart b/lib/desktop/state.dart index 4b332cc5..58c5c66e 100755 --- a/lib/desktop/state.dart +++ b/lib/desktop/state.dart @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Yubico. + * Copyright (C) 2022,2024 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,6 +25,7 @@ import 'package:logging/logging.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:window_manager/window_manager.dart'; +import '../app/key_customization.dart'; import '../app/logging.dart'; import '../app/models.dart'; import '../app/state.dart'; @@ -215,5 +216,18 @@ class DesktopCurrentDeviceNotifier extends CurrentDeviceNotifier { setCurrentDevice(DeviceNode? device) { state = device; ref.read(prefProvider).setString(_lastDevice, device?.path.key ?? ''); + if (device != null && + device is UsbYubiKeyNode && + device.info?.serial != null) { + final manager = ref.read(keyCustomizationManagerProvider); + final customization = manager.get(device.info?.serial!.toString()); + String? displayColorCustomization = + customization?.properties['display_color']; + Color? displayColor; + if (displayColorCustomization != null) { + displayColor = Color(int.parse(displayColorCustomization, radix: 16)); + } + ref.watch(darkThemeProvider.notifier).setPrimaryColor(displayColor); + } } }