customize color and display name

This commit is contained in:
Adam Velebil 2023-10-10 08:54:25 +02:00
parent 9793af6405
commit c7f2b651fd
No known key found for this signature in database
GPG Key ID: C9B1E4A3CBBD2E10
13 changed files with 595 additions and 33 deletions

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2022-2023 Yubico. * Copyright (C) 2022-2024 Yubico.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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.content.ContextCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.color.DynamicColors
import com.yubico.authenticator.logging.FlutterLog import com.yubico.authenticator.logging.FlutterLog
import com.yubico.authenticator.oath.AppLinkMethodChannel import com.yubico.authenticator.oath.AppLinkMethodChannel
import com.yubico.authenticator.oath.OathManager import com.yubico.authenticator.oath.OathManager
@ -383,6 +384,11 @@ class MainActivity : FlutterFragmentActivity() {
methodCall.arguments as Boolean, methodCall.arguments as Boolean,
) )
) )
"getPrimaryColor" -> result.success(
getPrimaryColor(this@MainActivity)
)
"getAndroidSdkVersion" -> result.success( "getAndroidSdkVersion" -> result.success(
Build.VERSION.SDK_INT Build.VERSION.SDK_INT
) )
@ -456,6 +462,30 @@ class MainActivity : FlutterFragmentActivity() {
return FLAG_SECURE != (window.attributes.flags and FLAG_SECURE) 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") @SuppressLint("SourceLockedOrientationActivity")
private fun forcePortraitOrientation() { private fun forcePortraitOrientation() {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
@ -465,5 +495,5 @@ class MainActivity : FlutterFragmentActivity() {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
} }
private fun isPortraitOnly() = resources.getBoolean(R.bool.portrait_only); private fun isPortraitOnly() = resources.getBoolean(R.bool.portrait_only)
} }

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2022-2023 Yubico. * Copyright (C) 2022-2024 Yubico.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -52,6 +52,10 @@ Future<int> getAndroidSdkVersion() async {
return await appMethodsChannel.invokeMethod('getAndroidSdkVersion'); return await appMethodsChannel.invokeMethod('getAndroidSdkVersion');
} }
Future<int?> getPrimaryColor() async {
return await appMethodsChannel.invokeMethod('getPrimaryColor');
}
Future<void> setPrimaryClip(String toClipboard, bool isSensitive) async { Future<void> setPrimaryClip(String toClipboard, bool isSensitive) async {
await appMethodsChannel.invokeMethod('setPrimaryClip', await appMethodsChannel.invokeMethod('setPrimaryClip',
{'toClipboard': toClipboard, 'isSensitive': isSensitive}); {'toClipboard': toClipboard, 'isSensitive': isSensitive});

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2022-2023 Yubico. * Copyright (C) 2022-2024 Yubico.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -51,6 +51,9 @@ Future<Widget> initialize() async {
_initLicenses(); _initLicenses();
final primaryColor = await getPrimaryColor();
final primaryColorInt = primaryColor != null ? Color(primaryColor) : null;
return ProviderScope( return ProviderScope(
overrides: [ overrides: [
supportedAppsProvider.overrideWith(implementedApps([ supportedAppsProvider.overrideWith(implementedApps([
@ -84,7 +87,8 @@ Future<Widget> initialize() async {
androidNfcSupportProvider.overrideWithValue(await getHasNfc()), androidNfcSupportProvider.overrideWithValue(await getHasNfc()),
supportedThemesProvider.overrideWith( supportedThemesProvider.overrideWith(
(ref) => ref.watch(androidSupportedThemesProvider), (ref) => ref.watch(androidSupportedThemesProvider),
) ),
primaryColorProvider.overrideWithValue(primaryColorInt),
], ],
child: DismissKeyboard( child: DismissKeyboard(
child: YubicoAuthenticatorApp(page: Consumer( child: YubicoAuthenticatorApp(page: Consumer(

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2022 Yubico. * Copyright (C) 2022,2024 Yubico.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../theme.dart';
import 'logging.dart'; import 'logging.dart';
import 'shortcuts.dart'; import 'shortcuts.dart';
import 'state.dart'; import 'state.dart';
class YubicoAuthenticatorApp extends StatelessWidget { class YubicoAuthenticatorApp extends StatelessWidget {
final Widget page; final Widget page;
const YubicoAuthenticatorApp({required this.page, super.key}); const YubicoAuthenticatorApp({required this.page, super.key});
@override @override
@ -34,8 +34,8 @@ class YubicoAuthenticatorApp extends StatelessWidget {
child: Consumer( child: Consumer(
builder: (context, ref, _) => MaterialApp( builder: (context, ref, _) => MaterialApp(
title: ref.watch(l10nProvider).app_name, title: ref.watch(l10nProvider).app_name,
theme: AppTheme.lightTheme, theme: ref.watch(lightThemeProvider),
darkTheme: AppTheme.darkTheme, darkTheme: ref.watch(darkThemeProvider),
themeMode: ref.watch(themeModeProvider), themeMode: ref.watch(themeModeProvider),
home: page, home: page,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,

View File

@ -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<KeyCustomizationManager>((ref) {
final retval = KeyCustomizationManager();
retval.read();
return retval;
});
class KeyCustomizationManager {
var _customizations = <String, dynamic>{};
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<void> 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<Directory> get _dataDir async {
final supportDirectory = await getApplicationSupportDirectory();
return Directory(join(supportDirectory.path, _dataSubDir));
}
Future<File> 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();
}

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2022 Yubico. * Copyright (C) 2022,2024 Yubico.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,6 +14,8 @@
* limitations under the License. * limitations under the License.
*/ */
import 'dart:convert';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
@ -144,3 +146,15 @@ class WindowState with _$WindowState {
@Default(false) bool hidden, @Default(false) bool hidden,
}) = _WindowState; }) = _WindowState;
} }
class KeyCustomization {
final String serialNumber;
final Map<String, dynamic> 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);
}
}

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2022 Yubico. * Copyright (C) 2022,2024 Yubico.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -51,6 +51,10 @@ class NextDeviceIntent extends Intent {
const NextDeviceIntent(); const NextDeviceIntent();
} }
class KeyCustomizationIntent extends Intent {
const KeyCustomizationIntent();
}
class SettingsIntent extends Intent { class SettingsIntent extends Intent {
const SettingsIntent(); const SettingsIntent();
} }
@ -61,26 +65,31 @@ class AboutIntent extends Intent {
class OpenIntent<T> extends Intent { class OpenIntent<T> extends Intent {
final T target; final T target;
const OpenIntent(this.target); const OpenIntent(this.target);
} }
class CopyIntent<T> extends Intent { class CopyIntent<T> extends Intent {
final T target; final T target;
const CopyIntent(this.target); const CopyIntent(this.target);
} }
class EditIntent<T> extends Intent { class EditIntent<T> extends Intent {
final T target; final T target;
const EditIntent(this.target); const EditIntent(this.target);
} }
class DeleteIntent<T> extends Intent { class DeleteIntent<T> extends Intent {
final T target; final T target;
const DeleteIntent(this.target); const DeleteIntent(this.target);
} }
class RefreshIntent<T> extends Intent { class RefreshIntent<T> extends Intent {
final T target; final T target;
const RefreshIntent(this.target); const RefreshIntent(this.target);
} }
@ -92,6 +101,7 @@ SingleActivator ctrlOrCmd(LogicalKeyboardKey key) =>
class ItemShortcuts<T> extends StatelessWidget { class ItemShortcuts<T> extends StatelessWidget {
final T item; final T item;
final Widget child; final Widget child;
const ItemShortcuts({super.key, required this.item, required this.child}); const ItemShortcuts({super.key, required this.item, required this.child});
@override @override
@ -112,6 +122,7 @@ class ItemShortcuts<T> extends StatelessWidget {
/// Global keyboard shortcuts /// Global keyboard shortcuts
class GlobalShortcuts extends ConsumerWidget { class GlobalShortcuts extends ConsumerWidget {
final Widget child; final Widget child;
const GlobalShortcuts({super.key, required this.child}); const GlobalShortcuts({super.key, required this.child});
@override @override

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2022 Yubico. * Copyright (C) 2022,2024 Yubico.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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:shared_preferences/shared_preferences.dart';
import '../core/state.dart'; import '../core/state.dart';
import '../theme.dart';
import 'features.dart' as features; import 'features.dart' as features;
import 'logging.dart'; import 'logging.dart';
import 'models.dart'; import 'models.dart';
@ -139,6 +140,42 @@ class ThemeModeNotifier extends StateNotifier<ThemeMode> {
orElse: () => supportedThemes.first); orElse: () => supportedThemes.first);
} }
final primaryColorProvider = Provider<Color?>((ref) => null);
final darkThemeProvider = StateNotifierProvider<ThemeNotifier, ThemeData>(
(ref) => ThemeNotifier(ref.watch(primaryColorProvider), ThemeMode.dark),
);
final lightThemeProvider = StateNotifierProvider<ThemeNotifier, ThemeData>(
(ref) => ThemeNotifier(ref.watch(primaryColorProvider), ThemeMode.light),
);
class ThemeNotifier extends StateNotifier<ThemeData> {
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 // Override with platform implementation
final attachedDevicesProvider = final attachedDevicesProvider =
NotifierProvider<AttachedDevicesNotifier, List<DeviceNode>>( NotifierProvider<AttachedDevicesNotifier, List<DeviceNode>>(

252
lib/app/views/customize_page.dart Executable file
View File

@ -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<ConsumerStatefulWidget> createState() => _CustomizePageState();
}
class _CustomizePageState extends ConsumerState<CustomizePage> {
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, <String, dynamic>{
'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<ColorButton> createState() => _ColorButtonState();
}
class _ColorButtonState extends State<ColorButton> {
@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,
),
);
}
}

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2022 Yubico. * Copyright (C) 2022,2024 Yubico.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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 '../state.dart';
import 'keys.dart'; import 'keys.dart';
class DeviceAvatar extends StatelessWidget { class DeviceAvatar extends ConsumerWidget {
final Widget child; final Widget child;
final Widget? decoration;
final Color? backgroundColor;
final Widget? badge; final Widget? badge;
final double? radius; final double? radius;
const DeviceAvatar({super.key, required this.child, this.badge, this.radius});
factory DeviceAvatar.yubiKeyData(YubiKeyData data, {double? radius}) => const DeviceAvatar(
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, badge: isDesktop && data.node is NfcReaderNode ? nfcIcon : null,
radius: radius, radius: radius,
child: Padding(
padding: const EdgeInsets.all(2.0),
child: ProductImage( child: ProductImage(
name: data.name, name: data.name,
formFactor: data.info.formFactor, formFactor: data.info.formFactor,
isNfc: data.info.supportedCapabilities.containsKey(Transport.nfc)), 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( node.map(
usbYubiKey: (node) { usbYubiKey: (node) {
final info = node.info; final info = node.info;
if (info != null) { if (info != null) {
return DeviceAvatar.yubiKeyData( return DeviceAvatar.yubiKeyData(
YubiKeyData(node, node.name, info), YubiKeyData(node, node.name, info),
ref,
radius: radius, radius: radius,
); );
} }
@ -73,10 +90,12 @@ class DeviceAvatar extends StatelessWidget {
return ref.watch(currentDeviceDataProvider).maybeWhen( return ref.watch(currentDeviceDataProvider).maybeWhen(
data: (data) => DeviceAvatar.yubiKeyData( data: (data) => DeviceAvatar.yubiKeyData(
data, data,
ref,
radius: radius, radius: radius,
), ),
orElse: () => DeviceAvatar.deviceNode( orElse: () => DeviceAvatar.deviceNode(
deviceNode, deviceNode,
ref,
radius: radius, radius: radius,
), ),
); );
@ -90,14 +109,15 @@ class DeviceAvatar extends StatelessWidget {
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final radius = this.radius ?? 20; final radius = this.radius ?? 20;
return Stack( return Stack(
alignment: AlignmentDirectional.bottomEnd, alignment: AlignmentDirectional.bottomEnd,
children: [ children: [
CircleAvatar( CircleAvatar(
radius: radius, radius: radius,
backgroundColor: Colors.transparent, backgroundColor:
backgroundColor ?? Theme.of(context).colorScheme.surfaceVariant,
child: IconTheme( child: IconTheme(
data: IconTheme.of(context).copyWith( data: IconTheme.of(context).copyWith(
size: radius, size: radius,
@ -105,6 +125,7 @@ class DeviceAvatar extends StatelessWidget {
child: child, child: child,
), ),
), ),
if (decoration != null) decoration!,
if (badge != null) if (badge != null)
CircleAvatar( CircleAvatar(
radius: radius / 3, radius: radius / 3,

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2022-2023 Yubico. * Copyright (C) 2022-2024 Yubico.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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 '../../android/state.dart';
import '../../core/state.dart'; import '../../core/state.dart';
import '../../management/models.dart'; import '../../management/models.dart';
import '../key_customization.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import 'device_avatar.dart'; import 'device_avatar.dart';
@ -34,6 +35,7 @@ final _hiddenDevicesProvider =
class _HiddenDevicesNotifier extends StateNotifier<List<String>> { class _HiddenDevicesNotifier extends StateNotifier<List<String>> {
static const String _key = 'DEVICE_PICKER_HIDDEN'; static const String _key = 'DEVICE_PICKER_HIDDEN';
final SharedPreferences _prefs; final SharedPreferences _prefs;
_HiddenDevicesNotifier(this._prefs) : super(_prefs.getStringList(_key) ?? []); _HiddenDevicesNotifier(this._prefs) : super(_prefs.getStringList(_key) ?? []);
void showAll() { void showAll() {
@ -49,7 +51,8 @@ class _HiddenDevicesNotifier extends StateNotifier<List<String>> {
class DevicePickerContent extends ConsumerWidget { class DevicePickerContent extends ConsumerWidget {
final bool extended; final bool extended;
const DevicePickerContent({super.key, this.extended = true});
const DevicePickerContent({required this.extended, super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -188,6 +191,7 @@ class _DeviceRow extends StatelessWidget {
final String subtitle; final String subtitle;
final bool extended; final bool extended;
final bool selected; final bool selected;
final Color? background;
final void Function() onTap; final void Function() onTap;
const _DeviceRow({ const _DeviceRow({
@ -197,6 +201,7 @@ class _DeviceRow extends StatelessWidget {
required this.subtitle, required this.subtitle,
required this.extended, required this.extended,
required this.selected, required this.selected,
this.background,
required this.onTap, required this.onTap,
}); });
@ -218,7 +223,8 @@ class _DeviceRow extends StatelessWidget {
subtitle: subtitle:
Text(subtitle, overflow: TextOverflow.fade, softWrap: false), Text(subtitle, overflow: TextOverflow.fade, softWrap: false),
dense: true, dense: true,
tileColor: selected ? colorScheme.primary : null, tileColor:
selected ? colorScheme.primary : background?.withOpacity(0.3),
textColor: selected ? colorScheme.onPrimary : null, textColor: selected ? colorScheme.onPrimary : null,
iconColor: selected ? colorScheme.onPrimary : null, iconColor: selected ? colorScheme.onPrimary : null,
onTap: onTap, onTap: onTap,
@ -260,12 +266,35 @@ _DeviceRow _buildDeviceRow(
: _getDeviceInfoString(context, info), : _getDeviceInfoString(context, info),
nfcReader: (_, __) => l10n.s_select_to_scan, 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( return _DeviceRow(
key: ValueKey(node.path.key), key: ValueKey(node.path.key),
leading: DeviceAvatar.deviceNode(node), leading: IconTheme(
title: node.name, // Force the standard icon theme
data: IconTheme.of(context),
child: DeviceAvatar.deviceNode(node, ref),
),
title: displayName,
subtitle: subtitle, subtitle: subtitle,
extended: extended, extended: extended,
background: displayColor,
selected: false, selected: false,
onTap: () { onTap: () {
ref.read(currentDeviceProvider.notifier).setCurrentDevice(node); ref.read(currentDeviceProvider.notifier).setCurrentDevice(node);
@ -288,16 +317,37 @@ _DeviceRow _buildCurrentDeviceRow(
final title = messages.removeAt(0); final title = messages.removeAt(0);
final subtitle = messages.join('\n'); 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( return _DeviceRow(
key: keys.deviceInfoListTile, key: keys.deviceInfoListTile,
leading: data.maybeWhen( leading: data.maybeWhen(
data: (data) => data: (data) =>
DeviceAvatar.yubiKeyData(data, radius: extended ? null : 16), DeviceAvatar.yubiKeyData(data, ref, radius: extended ? null : 16),
orElse: () => DeviceAvatar.deviceNode(node, radius: extended ? null : 16), orElse: () =>
DeviceAvatar.deviceNode(node, ref, radius: extended ? null : 16),
), ),
title: title, title: displayName,
subtitle: subtitle, subtitle: subtitle,
extended: extended, extended: extended,
background: displayColor,
selected: true, selected: true,
onTap: () {}, onTap: () {},
); );

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2023 Yubico. * Copyright (C) 2023-2024 Yubico.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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 { class NavigationContent extends ConsumerWidget {
final bool shouldPop; final bool shouldPop;
final bool extended; final bool extended;
const NavigationContent( const NavigationContent(
{super.key, this.shouldPop = true, this.extended = false}); {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), const SizedBox(height: 32),
], ],
], ],

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2022 Yubico. * Copyright (C) 2022,2024 Yubico.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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:shared_preferences/shared_preferences.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import '../app/key_customization.dart';
import '../app/logging.dart'; import '../app/logging.dart';
import '../app/models.dart'; import '../app/models.dart';
import '../app/state.dart'; import '../app/state.dart';
@ -215,5 +216,18 @@ class DesktopCurrentDeviceNotifier extends CurrentDeviceNotifier {
setCurrentDevice(DeviceNode? device) { setCurrentDevice(DeviceNode? device) {
state = device; state = device;
ref.read(prefProvider).setString(_lastDevice, device?.path.key ?? ''); 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);
}
} }
} }