diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt b/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt index 09083bd4..ee8cef32 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt @@ -23,15 +23,16 @@ import com.yubico.authenticator.device.Info import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice enum class OperationContext(val value: Int) { - Oath(0), - FidoU2f(1), - FidoFingerprints(2), - FidoPasskeys(3), - YubiOtp(4), - Piv(5), - OpenPgp(6), - HsmAuth(7), - Management(8), + Home(0), + Oath(1), + FidoU2f(2), + FidoFingerprints(3), + FidoPasskeys(4), + YubiOtp(5), + Piv(6), + OpenPgp(7), + HsmAuth(8), + Management(9), Invalid(-1); companion object { diff --git a/lib/android/init.dart b/lib/android/init.dart index 87e0559d..3e79adef 100644 --- a/lib/android/init.dart +++ b/lib/android/init.dart @@ -73,8 +73,11 @@ Future initialize() async { credentialListProvider .overrideWithProvider(androidCredentialListProvider.call), currentAppProvider.overrideWith((ref) { - final notifier = - AndroidSubPageNotifier(ref, ref.watch(supportedAppsProvider)); + final notifier = AndroidSubPageNotifier( + ref, + ref.watch(supportedAppsProvider), + ref.watch(prefProvider), + ); ref.listen>(currentDeviceDataProvider, (_, data) { notifier.notifyDeviceChanged(data.whenOrNull(data: ((data) => data))); @@ -114,6 +117,7 @@ Future initialize() async { // Disable unimplemented feature ..setFeature(features.piv, false) ..setFeature(features.otp, false) + ..setFeature(features.home, false) ..setFeature(features.management, false); }); diff --git a/lib/android/state.dart b/lib/android/state.dart index 178d3b95..128627b1 100644 --- a/lib/android/state.dart +++ b/lib/android/state.dart @@ -102,7 +102,7 @@ final androidAppContextHandler = class AndroidSubPageNotifier extends CurrentAppNotifier { final StateNotifierProviderRef _ref; - AndroidSubPageNotifier(this._ref, super.supportedApps) { + AndroidSubPageNotifier(this._ref, super.supportedApps, super.prefs) { _ref.read(androidAppContextHandler).switchAppContext(state); } diff --git a/lib/app/features.dart b/lib/app/features.dart index 9bf87976..95e437f7 100644 --- a/lib/app/features.dart +++ b/lib/app/features.dart @@ -21,5 +21,6 @@ final fido = root.feature('fido'); final piv = root.feature('piv'); final otp = root.feature('otp'); final management = root.feature('management'); +final home = root.feature('home'); final fingerprints = fido.feature('fingerprints'); diff --git a/lib/app/key_customization/models.dart b/lib/app/key_customization/models.dart deleted file mode 100644 index 91410ba5..00000000 --- a/lib/app/key_customization/models.dart +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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:ui'; - -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'models.freezed.dart'; - -part 'models.g.dart'; - -@freezed -class KeyCustomization with _$KeyCustomization { - factory KeyCustomization({ - required int serial, - @JsonKey(includeIfNull: false) String? name, - @JsonKey(includeIfNull: false) @_ColorConverter() Color? color, - }) = _KeyCustomization; - - factory KeyCustomization.fromJson(Map json) => - _$KeyCustomizationFromJson(json); -} - -class _ColorConverter implements JsonConverter { - const _ColorConverter(); - - @override - Color? fromJson(int? json) => json != null ? Color(json) : null; - - @override - int? toJson(Color? object) => object?.value; -} diff --git a/lib/app/key_customization/models.freezed.dart b/lib/app/key_customization/models.freezed.dart deleted file mode 100644 index ab173b08..00000000 --- a/lib/app/key_customization/models.freezed.dart +++ /dev/null @@ -1,207 +0,0 @@ -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'models.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -T _$identity(T value) => value; - -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -KeyCustomization _$KeyCustomizationFromJson(Map json) { - return _KeyCustomization.fromJson(json); -} - -/// @nodoc -mixin _$KeyCustomization { - int get serial => throw _privateConstructorUsedError; - @JsonKey(includeIfNull: false) - String? get name => throw _privateConstructorUsedError; - @JsonKey(includeIfNull: false) - @_ColorConverter() - Color? get color => throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $KeyCustomizationCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $KeyCustomizationCopyWith<$Res> { - factory $KeyCustomizationCopyWith( - KeyCustomization value, $Res Function(KeyCustomization) then) = - _$KeyCustomizationCopyWithImpl<$Res, KeyCustomization>; - @useResult - $Res call( - {int serial, - @JsonKey(includeIfNull: false) String? name, - @JsonKey(includeIfNull: false) @_ColorConverter() Color? color}); -} - -/// @nodoc -class _$KeyCustomizationCopyWithImpl<$Res, $Val extends KeyCustomization> - implements $KeyCustomizationCopyWith<$Res> { - _$KeyCustomizationCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? serial = null, - Object? name = freezed, - Object? color = freezed, - }) { - return _then(_value.copyWith( - serial: null == serial - ? _value.serial - : serial // ignore: cast_nullable_to_non_nullable - as int, - name: freezed == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String?, - color: freezed == color - ? _value.color - : color // ignore: cast_nullable_to_non_nullable - as Color?, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$KeyCustomizationImplCopyWith<$Res> - implements $KeyCustomizationCopyWith<$Res> { - factory _$$KeyCustomizationImplCopyWith(_$KeyCustomizationImpl value, - $Res Function(_$KeyCustomizationImpl) then) = - __$$KeyCustomizationImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {int serial, - @JsonKey(includeIfNull: false) String? name, - @JsonKey(includeIfNull: false) @_ColorConverter() Color? color}); -} - -/// @nodoc -class __$$KeyCustomizationImplCopyWithImpl<$Res> - extends _$KeyCustomizationCopyWithImpl<$Res, _$KeyCustomizationImpl> - implements _$$KeyCustomizationImplCopyWith<$Res> { - __$$KeyCustomizationImplCopyWithImpl(_$KeyCustomizationImpl _value, - $Res Function(_$KeyCustomizationImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? serial = null, - Object? name = freezed, - Object? color = freezed, - }) { - return _then(_$KeyCustomizationImpl( - serial: null == serial - ? _value.serial - : serial // ignore: cast_nullable_to_non_nullable - as int, - name: freezed == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String?, - color: freezed == color - ? _value.color - : color // ignore: cast_nullable_to_non_nullable - as Color?, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$KeyCustomizationImpl implements _KeyCustomization { - _$KeyCustomizationImpl( - {required this.serial, - @JsonKey(includeIfNull: false) this.name, - @JsonKey(includeIfNull: false) @_ColorConverter() this.color}); - - factory _$KeyCustomizationImpl.fromJson(Map json) => - _$$KeyCustomizationImplFromJson(json); - - @override - final int serial; - @override - @JsonKey(includeIfNull: false) - final String? name; - @override - @JsonKey(includeIfNull: false) - @_ColorConverter() - final Color? color; - - @override - String toString() { - return 'KeyCustomization(serial: $serial, name: $name, color: $color)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$KeyCustomizationImpl && - (identical(other.serial, serial) || other.serial == serial) && - (identical(other.name, name) || other.name == name) && - (identical(other.color, color) || other.color == color)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash(runtimeType, serial, name, color); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$KeyCustomizationImplCopyWith<_$KeyCustomizationImpl> get copyWith => - __$$KeyCustomizationImplCopyWithImpl<_$KeyCustomizationImpl>( - this, _$identity); - - @override - Map toJson() { - return _$$KeyCustomizationImplToJson( - this, - ); - } -} - -abstract class _KeyCustomization implements KeyCustomization { - factory _KeyCustomization( - {required final int serial, - @JsonKey(includeIfNull: false) final String? name, - @JsonKey(includeIfNull: false) - @_ColorConverter() - final Color? color}) = _$KeyCustomizationImpl; - - factory _KeyCustomization.fromJson(Map json) = - _$KeyCustomizationImpl.fromJson; - - @override - int get serial; - @override - @JsonKey(includeIfNull: false) - String? get name; - @override - @JsonKey(includeIfNull: false) - @_ColorConverter() - Color? get color; - @override - @JsonKey(ignore: true) - _$$KeyCustomizationImplCopyWith<_$KeyCustomizationImpl> get copyWith => - throw _privateConstructorUsedError; -} diff --git a/lib/app/key_customization/state.dart b/lib/app/key_customization/state.dart deleted file mode 100644 index 972639a7..00000000 --- a/lib/app/key_customization/state.dart +++ /dev/null @@ -1,80 +0,0 @@ -/* - * 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:ui'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:logging/logging.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import '../../core/state.dart'; -import '../logging.dart'; -import 'models.dart'; - -final keyCustomizationManagerProvider = - StateNotifierProvider>( - (ref) => KeyCustomizationNotifier(ref.watch(prefProvider))); - -final _log = Logger('key_customization_manager'); - -class KeyCustomizationNotifier - extends StateNotifier> { - static const _prefKeyCustomizations = 'KEY_CUSTOMIZATIONS'; - final SharedPreferences _prefs; - - KeyCustomizationNotifier(this._prefs) - : super(_readCustomizations(_prefs.getString(_prefKeyCustomizations))); - - static Map _readCustomizations(String? pref) { - if (pref == null) { - return {}; - } - - try { - final retval = {}; - for (var element in json.decode(pref)) { - final keyCustomization = KeyCustomization.fromJson(element); - retval[keyCustomization.serial] = keyCustomization; - } - return retval; - } catch (e) { - _log.error('Failure reading customizations: $e'); - return {}; - } - } - - KeyCustomization? get(int serial) { - _log.debug('Getting key customization for $serial'); - return state[serial]; - } - - Future set({required int serial, String? name, Color? color}) async { - _log.debug('Setting key customization for $serial: $name, $color'); - if (name == null && color == null) { - // remove this customization - state = {...state..remove(serial)}; - } else { - state = { - ...state - ..[serial] = - KeyCustomization(serial: serial, name: name, color: color) - }; - } - await _prefs.setString( - _prefKeyCustomizations, json.encode(state.values.toList())); - } -} diff --git a/lib/app/key_customization/views/key_customization_dialog.dart b/lib/app/key_customization/views/key_customization_dialog.dart deleted file mode 100644 index 37a7dcb0..00000000 --- a/lib/app/key_customization/views/key_customization_dialog.dart +++ /dev/null @@ -1,335 +0,0 @@ -/* - * 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:material_symbols_icons/symbols.dart'; - -import '../../../android/state.dart'; -import '../../../core/state.dart'; -import '../../../management/models.dart'; -import '../../../theme.dart'; -import '../../../widgets/app_input_decoration.dart'; -import '../../../widgets/app_text_form_field.dart'; -import '../../../widgets/focus_utils.dart'; -import '../../../widgets/responsive_dialog.dart'; -import '../../models.dart'; -import '../../state.dart'; -import '../../views/device_avatar.dart'; -import '../../views/keys.dart'; -import '../models.dart'; -import '../state.dart'; - -class KeyCustomizationDialog extends ConsumerStatefulWidget { - final KeyCustomization initialCustomization; - final DeviceNode? node; - - const KeyCustomizationDialog( - {super.key, required this.node, required this.initialCustomization}); - - @override - ConsumerState createState() => - _KeyCustomizationDialogState(); -} - -class _KeyCustomizationDialogState - extends ConsumerState { - String? _customName; - Color? _customColor; - - @override - void initState() { - super.initState(); - _customName = widget.initialCustomization.name; - _customColor = widget.initialCustomization.color; - } - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final currentNode = widget.node; - final theme = Theme.of(context); - final primaryColor = ref.watch(defaultColorProvider); - - final Widget hero; - if (currentNode != null) { - hero = _CurrentDeviceAvatar(currentNode, _customColor ?? primaryColor); - } else { - hero = Column( - children: [ - _HeroAvatar( - color: _customColor ?? primaryColor, - child: DeviceAvatar( - radius: 64, - child: Icon(isAndroid ? Symbols.contactless_off : Symbols.usb), - ), - ), - ListTile( - title: Center(child: Text(l10n.l_no_yk_present)), - subtitle: Center( - child: Text(isAndroid ? l10n.l_insert_or_tap_yk : l10n.s_usb)), - ), - ], - ); - } - - final didChange = widget.initialCustomization.name != _customName || - widget.initialCustomization.color != _customColor; - - return Theme( - data: AppTheme.getTheme(theme.brightness, _customColor ?? primaryColor), - child: ResponsiveDialog( - actions: [ - TextButton( - onPressed: didChange ? _submit : null, - child: Text(l10n.s_save), - ), - ], - child: Column( - children: [ - hero, - Padding( - padding: const EdgeInsets.fromLTRB(18, 18, 18, 0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - constraints: const BoxConstraints(maxWidth: 360), - child: AppTextFormField( - initialValue: _customName, - maxLength: 20, - decoration: AppInputDecoration( - border: const OutlineInputBorder(), - labelText: l10n.s_label, - helperText: - '', // Prevents dialog resizing when disabled - prefixIcon: const Icon(Symbols.key), - ), - textInputAction: TextInputAction.done, - onChanged: (value) { - setState(() { - final trimmed = value.trim(); - _customName = trimmed.isEmpty ? null : trimmed; - }); - }, - onFieldSubmitted: (_) { - _submit(); - }, - ), - ), - Text(l10n.s_theme_color), - const SizedBox(height: 16), - Container( - constraints: const BoxConstraints(maxWidth: 360), - child: Wrap( - alignment: WrapAlignment.center, - runSpacing: 8, - spacing: 16, - children: [ - ...[ - Colors.teal, - Colors.cyan, - Colors.blueAccent, - Colors.deepPurple, - Colors.red, - Colors.orange, - Colors.yellow, - // add nice color to devices with dynamic color - if (isAndroid && - ref.read(androidSdkVersionProvider) >= 31) - Colors.lightGreen - ].map((e) => _ColorButton( - color: e, - isSelected: _customColor == e, - onPressed: () { - _updateColor(e); - }, - )), - - // remove color button - RawMaterialButton( - onPressed: () => _updateColor(null), - constraints: const BoxConstraints( - minWidth: 32.0, minHeight: 32.0), - fillColor: (isAndroid && - ref.read(androidSdkVersionProvider) >= 31) - ? theme.colorScheme.onSurface - : primaryColor, - shape: const CircleBorder(), - child: Icon( - Symbols.cancel, - size: 16, - color: _customColor == null - ? theme.colorScheme.onSurface - : theme.colorScheme.surface.withOpacity(0.2), - ), - ), - ], - ), - ) - ], - ), - ), - ], - ), - ), - ); - } - - void _submit() async { - final manager = ref.read(keyCustomizationManagerProvider.notifier); - await manager.set( - serial: widget.initialCustomization.serial, - name: _customName, - color: _customColor); - - await ref.read(withContextProvider)((context) async { - FocusUtils.unfocus(context); - final nav = Navigator.of(context); - nav.pop(); - }); - } - - void _updateColor(Color? color) { - setState(() { - _customColor = color; - }); - } -} - -String _getDeviceInfoString(BuildContext context, DeviceInfo info) { - final l10n = AppLocalizations.of(context)!; - final serial = info.serial; - return [ - if (serial != null) l10n.s_sn_serial(serial), - if (info.version.isAtLeast(1)) - l10n.s_fw_version(info.version) - else - l10n.s_unknown_type, - ].join(' '); -} - -List _getDeviceStrings( - BuildContext context, WidgetRef ref, DeviceNode node) { - final data = ref.watch(currentDeviceDataProvider); - - final messages = node is UsbYubiKeyNode - ? node.info != null - ? [node.name, _getDeviceInfoString(context, node.info!)] - : [] - : data.hasValue - ? data.value?.node.path == node.path - ? [ - data.value!.name, - _getDeviceInfoString(context, data.value!.info) - ] - : [] - : []; - return messages; -} - -class _HeroAvatar extends StatelessWidget { - final Widget child; - final Color color; - - const _HeroAvatar({required this.color, required this.child}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - colors: [ - color.withOpacity(0.6), - color.withOpacity(0.25), - (DialogTheme.of(context).backgroundColor ?? - theme.dialogBackgroundColor) - .withOpacity(0), - ], - ), - ), - padding: const EdgeInsets.all(12), - child: Theme( - // Give the avatar a transparent background - data: theme.copyWith( - colorScheme: - theme.colorScheme.copyWith(surfaceVariant: Colors.transparent)), - child: child, - ), - ); - } -} - -class _CurrentDeviceAvatar extends ConsumerWidget { - final DeviceNode node; - final Color color; - - const _CurrentDeviceAvatar(this.node, this.color); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final hero = DeviceAvatar.deviceNode(node, radius: 64); - final messages = _getDeviceStrings(context, ref, node); - - return Column( - children: [ - _HeroAvatar(color: color, child: hero), - ListTile( - key: deviceInfoListTile, - title: Text(messages.removeAt(0), textAlign: TextAlign.center), - isThreeLine: messages.length > 1, - subtitle: Text(messages.join('\n'), textAlign: TextAlign.center), - ) - ], - ); - } -} - -class _ColorButton extends StatefulWidget { - final Color? color; - final bool isSelected; - final Function()? onPressed; - - const _ColorButton({ - required this.color, - required this.isSelected, - required this.onPressed, - }); - - @override - State<_ColorButton> createState() => _ColorButtonState(); -} - -class _ColorButtonState extends State<_ColorButton> { - @override - Widget build(BuildContext context) { - return RawMaterialButton( - onPressed: widget.onPressed, - constraints: const BoxConstraints(minWidth: 32.0, minHeight: 32.0), - fillColor: widget.color, - shape: const CircleBorder(), - child: Icon( - Symbols.circle, - size: 16, - color: widget.isSelected ? Colors.white : Colors.transparent, - ), - ); - } -} diff --git a/lib/app/models.dart b/lib/app/models.dart index ab5b89b0..041e3212 100755 --- a/lib/app/models.dart +++ b/lib/app/models.dart @@ -25,11 +25,14 @@ import '../core/state.dart'; part 'models.freezed.dart'; +part 'models.g.dart'; + const _listEquality = ListEquality(); enum Availability { enabled, disabled, unsupported } enum Application { + home(), accounts([Capability.oath]), webauthn([Capability.u2f]), fingerprints([Capability.fido2]), @@ -43,6 +46,7 @@ enum Application { const Application([this.capabilities = const []]); String getDisplayName(AppLocalizations l10n) => switch (this) { + Application.home => l10n.s_home, Application.accounts => l10n.s_accounts, Application.webauthn => l10n.s_webauthn, Application.fingerprints => l10n.s_fingerprints, @@ -155,3 +159,25 @@ class WindowState with _$WindowState { @Default(false) bool hidden, }) = _WindowState; } + +@freezed +class KeyCustomization with _$KeyCustomization { + factory KeyCustomization({ + required int serial, + @JsonKey(includeIfNull: false) String? name, + @JsonKey(includeIfNull: false) @_ColorConverter() Color? color, + }) = _KeyCustomization; + + factory KeyCustomization.fromJson(Map json) => + _$KeyCustomizationFromJson(json); +} + +class _ColorConverter implements JsonConverter { + const _ColorConverter(); + + @override + Color? fromJson(int? json) => json != null ? Color(json) : null; + + @override + int? toJson(Color? object) => object?.value; +} diff --git a/lib/app/models.freezed.dart b/lib/app/models.freezed.dart index 4697880a..7b15fb31 100644 --- a/lib/app/models.freezed.dart +++ b/lib/app/models.freezed.dart @@ -1084,3 +1084,195 @@ abstract class _WindowState implements WindowState { _$$WindowStateImplCopyWith<_$WindowStateImpl> get copyWith => throw _privateConstructorUsedError; } + +KeyCustomization _$KeyCustomizationFromJson(Map json) { + return _KeyCustomization.fromJson(json); +} + +/// @nodoc +mixin _$KeyCustomization { + int get serial => throw _privateConstructorUsedError; + @JsonKey(includeIfNull: false) + String? get name => throw _privateConstructorUsedError; + @JsonKey(includeIfNull: false) + @_ColorConverter() + Color? get color => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $KeyCustomizationCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $KeyCustomizationCopyWith<$Res> { + factory $KeyCustomizationCopyWith( + KeyCustomization value, $Res Function(KeyCustomization) then) = + _$KeyCustomizationCopyWithImpl<$Res, KeyCustomization>; + @useResult + $Res call( + {int serial, + @JsonKey(includeIfNull: false) String? name, + @JsonKey(includeIfNull: false) @_ColorConverter() Color? color}); +} + +/// @nodoc +class _$KeyCustomizationCopyWithImpl<$Res, $Val extends KeyCustomization> + implements $KeyCustomizationCopyWith<$Res> { + _$KeyCustomizationCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? serial = null, + Object? name = freezed, + Object? color = freezed, + }) { + return _then(_value.copyWith( + serial: null == serial + ? _value.serial + : serial // ignore: cast_nullable_to_non_nullable + as int, + name: freezed == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String?, + color: freezed == color + ? _value.color + : color // ignore: cast_nullable_to_non_nullable + as Color?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$KeyCustomizationImplCopyWith<$Res> + implements $KeyCustomizationCopyWith<$Res> { + factory _$$KeyCustomizationImplCopyWith(_$KeyCustomizationImpl value, + $Res Function(_$KeyCustomizationImpl) then) = + __$$KeyCustomizationImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {int serial, + @JsonKey(includeIfNull: false) String? name, + @JsonKey(includeIfNull: false) @_ColorConverter() Color? color}); +} + +/// @nodoc +class __$$KeyCustomizationImplCopyWithImpl<$Res> + extends _$KeyCustomizationCopyWithImpl<$Res, _$KeyCustomizationImpl> + implements _$$KeyCustomizationImplCopyWith<$Res> { + __$$KeyCustomizationImplCopyWithImpl(_$KeyCustomizationImpl _value, + $Res Function(_$KeyCustomizationImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? serial = null, + Object? name = freezed, + Object? color = freezed, + }) { + return _then(_$KeyCustomizationImpl( + serial: null == serial + ? _value.serial + : serial // ignore: cast_nullable_to_non_nullable + as int, + name: freezed == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String?, + color: freezed == color + ? _value.color + : color // ignore: cast_nullable_to_non_nullable + as Color?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$KeyCustomizationImpl implements _KeyCustomization { + _$KeyCustomizationImpl( + {required this.serial, + @JsonKey(includeIfNull: false) this.name, + @JsonKey(includeIfNull: false) @_ColorConverter() this.color}); + + factory _$KeyCustomizationImpl.fromJson(Map json) => + _$$KeyCustomizationImplFromJson(json); + + @override + final int serial; + @override + @JsonKey(includeIfNull: false) + final String? name; + @override + @JsonKey(includeIfNull: false) + @_ColorConverter() + final Color? color; + + @override + String toString() { + return 'KeyCustomization(serial: $serial, name: $name, color: $color)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$KeyCustomizationImpl && + (identical(other.serial, serial) || other.serial == serial) && + (identical(other.name, name) || other.name == name) && + (identical(other.color, color) || other.color == color)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, serial, name, color); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$KeyCustomizationImplCopyWith<_$KeyCustomizationImpl> get copyWith => + __$$KeyCustomizationImplCopyWithImpl<_$KeyCustomizationImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$KeyCustomizationImplToJson( + this, + ); + } +} + +abstract class _KeyCustomization implements KeyCustomization { + factory _KeyCustomization( + {required final int serial, + @JsonKey(includeIfNull: false) final String? name, + @JsonKey(includeIfNull: false) + @_ColorConverter() + final Color? color}) = _$KeyCustomizationImpl; + + factory _KeyCustomization.fromJson(Map json) = + _$KeyCustomizationImpl.fromJson; + + @override + int get serial; + @override + @JsonKey(includeIfNull: false) + String? get name; + @override + @JsonKey(includeIfNull: false) + @_ColorConverter() + Color? get color; + @override + @JsonKey(ignore: true) + _$$KeyCustomizationImplCopyWith<_$KeyCustomizationImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/app/key_customization/models.g.dart b/lib/app/models.g.dart similarity index 100% rename from lib/app/key_customization/models.g.dart rename to lib/app/models.g.dart diff --git a/lib/app/state.dart b/lib/app/state.dart index 7eb306f9..c2ea62fc 100755 --- a/lib/app/state.dart +++ b/lib/app/state.dart @@ -15,6 +15,7 @@ */ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'dart:ui'; @@ -27,7 +28,6 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../core/state.dart'; import '../theme.dart'; import 'features.dart' as features; -import 'key_customization/state.dart'; import 'logging.dart'; import 'models.dart'; @@ -40,6 +40,7 @@ const officialLocales = [ extension on Application { Feature get _feature => switch (this) { + Application.home => features.home, Application.accounts => features.oath, Application.webauthn => features.fido, Application.passkeys => features.fido, @@ -202,7 +203,8 @@ abstract class CurrentDeviceNotifier extends Notifier { final currentAppProvider = StateNotifierProvider((ref) { - final notifier = CurrentAppNotifier(ref.watch(supportedAppsProvider)); + final notifier = CurrentAppNotifier( + ref.watch(supportedAppsProvider), ref.watch(prefProvider)); ref.listen>(currentDeviceDataProvider, (_, data) { notifier.notifyDeviceChanged(data.whenOrNull(data: ((data) => data))); }, fireImmediately: true); @@ -211,16 +213,37 @@ final currentAppProvider = class CurrentAppNotifier extends StateNotifier { final List _supportedApps; + static const String _key = 'APP_STATE_LAST_APP'; + final SharedPreferences _prefs; - CurrentAppNotifier(this._supportedApps) : super(_supportedApps.first); + CurrentAppNotifier(this._supportedApps, this._prefs) + : super(_fromName(_prefs.getString(_key), _supportedApps)); void setCurrentApp(Application app) { state = app; + _prefs.setString(_key, app.name); } void notifyDeviceChanged(YubiKeyData? data) { - if (data == null || - state.getAvailability(data) != Availability.unsupported) { + if (data == null) { + state = _supportedApps.first; + return; + } + + String? lastAppName = _prefs.getString(_key); + if (lastAppName != null && lastAppName != state.name) { + // Try switching to saved app + state = Application.values.firstWhere((app) => app.name == lastAppName); + } + if (state == Application.passkeys && + state.getAvailability(data) != Availability.enabled) { + state = Application.webauthn; + } + if (state == Application.webauthn && + state.getAvailability(data) != Availability.enabled) { + state = Application.passkeys; + } + if (state.getAvailability(data) != Availability.unsupported) { // Keep current app return; } @@ -230,6 +253,10 @@ class CurrentAppNotifier extends StateNotifier { orElse: () => _supportedApps.first, ); } + + static Application _fromName(String? name, List supportedApps) => + supportedApps.firstWhere((element) => element.name == name, + orElse: () => supportedApps.first); } abstract class QrScanner { @@ -285,3 +312,55 @@ typedef WithContext = Future Function( final withContextProvider = Provider( (ref) => ref.watch(contextConsumer.notifier).withContext); + +final keyCustomizationManagerProvider = + StateNotifierProvider>( + (ref) => KeyCustomizationNotifier(ref.watch(prefProvider))); + +class KeyCustomizationNotifier + extends StateNotifier> { + static const _prefKeyCustomizations = 'KEY_CUSTOMIZATIONS'; + final SharedPreferences _prefs; + + KeyCustomizationNotifier(this._prefs) + : super(_readCustomizations(_prefs.getString(_prefKeyCustomizations))); + + static Map _readCustomizations(String? pref) { + if (pref == null) { + return {}; + } + + try { + final retval = {}; + for (var element in json.decode(pref)) { + final keyCustomization = KeyCustomization.fromJson(element); + retval[keyCustomization.serial] = keyCustomization; + } + return retval; + } catch (e) { + _log.error('Failure reading customizations: $e'); + return {}; + } + } + + KeyCustomization? get(int serial) { + _log.debug('Getting key customization for $serial'); + return state[serial]; + } + + Future set({required int serial, String? name, Color? color}) async { + _log.debug('Setting key customization for $serial: $name, $color'); + if (name == null && color == null) { + // remove this customization + state = {...state..remove(serial)}; + } else { + state = { + ...state + ..[serial] = + KeyCustomization(serial: serial, name: name, color: color) + }; + } + await _prefs.setString( + _prefKeyCustomizations, json.encode(state.values.toList())); + } +} diff --git a/lib/app/views/app_page.dart b/lib/app/views/app_page.dart index bc0596e6..605d6b02 100755 --- a/lib/app/views/app_page.dart +++ b/lib/app/views/app_page.dart @@ -71,6 +71,7 @@ class AppPage extends StatelessWidget { Widget build(BuildContext context) => LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; + if (width < 400 || (isAndroid && width < 600 && width < constraints.maxHeight)) { return _buildScaffold(context, true, false, false); @@ -160,28 +161,18 @@ class AppPage extends StatelessWidget { } Widget _buildTitle(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Wrap( - alignment: WrapAlignment.spaceBetween, - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 2.0, + Text(title!, + style: Theme.of(context).textTheme.displaySmall!.copyWith( + color: Theme.of(context).colorScheme.primary.withOpacity(0.9))), + if (capabilities != null) + Wrap( + spacing: 4.0, runSpacing: 8.0, - children: [ - Text(title!, - style: Theme.of(context).textTheme.displaySmall!.copyWith( - color: Theme.of(context) - .colorScheme - .primary - .withOpacity(0.9))), - if (capabilities != null) - Wrap( - spacing: 4.0, - runSpacing: 8.0, - children: [...capabilities!.map((c) => _CapabilityBadge(c))], - ) - ]) + children: [...capabilities!.map((c) => CapabilityBadge(c))], + ) ], ); } @@ -189,10 +180,11 @@ class AppPage extends StatelessWidget { Widget _buildMainContent(BuildContext context, bool expanded) { final actions = actionsBuilder?.call(context, expanded) ?? []; final content = Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: centered ? CrossAxisAlignment.center : CrossAxisAlignment.start, children: [ - if (title != null) + if (title != null && !centered) Padding( padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 24.0), @@ -226,26 +218,59 @@ class AppPage extends StatelessWidget { ), ], ); + + final safeArea = SafeArea( + child: delayedContent + ? DelayedVisibility( + key: GlobalKey(), // Ensure we reset the delay on rebuild + delay: const Duration(milliseconds: 400), + child: content, + ) + : content, + ); + + if (centered) { + return Stack( + children: [ + if (title != null) + Positioned.fill( + child: Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only( + left: 16.0, right: 16.0, bottom: 24.0), + child: _buildTitle(context), + ), + ), + ), + Positioned.fill( + top: title != null ? 68.0 : 0, + child: Align( + alignment: Alignment.center, + child: ScrollConfiguration( + behavior: + ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: safeArea, + ), + ), + ), + ) + ], + ); + } + return SingleChildScrollView( primary: false, - child: SafeArea( - child: delayedContent - ? DelayedVisibility( - key: GlobalKey(), // Ensure we reset the delay on rebuild - delay: const Duration(milliseconds: 400), - child: content, - ) - : content, - ), + child: safeArea, ); } Scaffold _buildScaffold( BuildContext context, bool hasDrawer, bool hasRail, bool hasManage) { var body = _buildMainContent(context, hasManage); - if (centered) { - body = Center(child: body); - } + if (onFileDropped != null) { body = FileDropTarget( onFileDropped: onFileDropped!, @@ -363,10 +388,10 @@ class AppPage extends StatelessWidget { } } -class _CapabilityBadge extends StatelessWidget { +class CapabilityBadge extends StatelessWidget { final Capability capability; - const _CapabilityBadge(this.capability); + const CapabilityBadge(this.capability, {super.key}); @override Widget build(BuildContext context) { diff --git a/lib/app/views/device_error_screen.dart b/lib/app/views/device_error_screen.dart index 221e081c..2b793927 100755 --- a/lib/app/views/device_error_screen.dart +++ b/lib/app/views/device_error_screen.dart @@ -24,10 +24,10 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../core/models.dart'; import '../../core/state.dart'; import '../../desktop/state.dart'; +import '../../home/views/home_message_page.dart'; import '../models.dart'; import '../state.dart'; import 'elevate_fido_buttons.dart'; -import 'message_page.dart'; class DeviceErrorScreen extends ConsumerWidget { final DeviceNode node; @@ -40,8 +40,7 @@ class DeviceErrorScreen extends ConsumerWidget { if (Platform.isWindows && !ref.watch(rpcStateProvider.select((state) => state.isAdmin))) { final currentApp = ref.read(currentAppProvider); - return MessagePage( - title: currentApp.getDisplayName(l10n), + return HomeMessagePage( capabilities: currentApp.capabilities, header: l10n.l_admin_privileges_required, message: l10n.p_elevated_permissions_required, @@ -52,7 +51,7 @@ class DeviceErrorScreen extends ConsumerWidget { ); } } - return MessagePage( + return HomeMessagePage( centered: true, graphic: Image.asset( 'assets/product-images/generic.png', @@ -70,7 +69,7 @@ class DeviceErrorScreen extends ConsumerWidget { return node.map( usbYubiKey: (node) => _buildUsbPid(context, ref, node.pid), nfcReader: (node) => switch (error) { - 'unknown-device' => MessagePage( + 'unknown-device' => HomeMessagePage( centered: true, graphic: Icon( Symbols.help, @@ -79,7 +78,7 @@ class DeviceErrorScreen extends ConsumerWidget { ), header: l10n.s_unknown_device, ), - _ => MessagePage( + _ => HomeMessagePage( centered: true, graphic: Image.asset( 'assets/graphics/no-key.png', diff --git a/lib/app/views/device_picker.dart b/lib/app/views/device_picker.dart index 34f16f52..0ab99ab3 100644 --- a/lib/app/views/device_picker.dart +++ b/lib/app/views/device_picker.dart @@ -24,18 +24,11 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../android/state.dart'; import '../../core/state.dart'; import '../../management/models.dart'; -import '../../management/views/management_screen.dart'; -import '../features.dart' as features; -import '../key_customization/models.dart'; -import '../key_customization/state.dart'; -import '../key_customization/views/key_customization_dialog.dart'; -import '../message.dart'; import '../models.dart'; import '../state.dart'; import 'device_avatar.dart'; import 'keys.dart' as keys; import 'keys.dart'; -import 'reset_dialog.dart'; final _hiddenDevicesProvider = StateNotifierProvider<_HiddenDevicesNotifier, List>( @@ -276,14 +269,16 @@ class _DeviceRowState extends ConsumerState<_DeviceRow> { const EdgeInsets.symmetric(horizontal: 8, vertical: 0), horizontalTitleGap: 8, leading: widget.leading, - trailing: _DeviceMenuButton( - menuItems: menuItems, - opacity: widget.selected - ? 1.0 - : _showContextMenu - ? 0.3 - : 0.0, - ), + trailing: menuItems.isNotEmpty + ? _DeviceMenuButton( + menuItems: menuItems, + opacity: widget.selected + ? 1.0 + : _showContextMenu + ? 0.3 + : 0.0, + ) + : null, title: Text( widget.title, overflow: TextOverflow.fade, @@ -339,44 +334,9 @@ class _DeviceRowState extends ConsumerState<_DeviceRow> { List _getMenuItems( BuildContext context, WidgetRef ref, DeviceNode? node) { final l10n = AppLocalizations.of(context)!; - final keyCustomizations = ref.watch(keyCustomizationManagerProvider); - final hasFeature = ref.watch(featureProvider); final hidden = ref.watch(_hiddenDevicesProvider); - final data = ref.watch(currentDeviceDataProvider).valueOrNull; - final managementAvailability = - data == null || !hasFeature(features.management) - ? Availability.unsupported - : Application.management.getAvailability(data); - - final serial = node is UsbYubiKeyNode - ? node.info?.serial - : data != null - ? data.node.path == node?.path && node != null - ? data.info.serial - : null - : null; - return [ - if (serial != null) - PopupMenuItem( - enabled: true, - onTap: () async { - await ref.read(withContextProvider)((context) async { - await _showKeyCustomizationDialog( - keyCustomizations[serial] ?? KeyCustomization(serial: serial), - context, - node); - }); - }, - child: ListTile( - title: Text(l10n.s_customize_key_action), - leading: const Icon(Symbols.palette), - key: yubikeyLabelColorMenuButton, - dense: true, - contentPadding: EdgeInsets.zero, - enabled: true), - ), if (isDesktop && hidden.isNotEmpty) PopupMenuItem( enabled: hidden.isNotEmpty, @@ -403,59 +363,8 @@ class _DeviceRowState extends ConsumerState<_DeviceRow> { contentPadding: EdgeInsets.zero, ), ), - if (node == data?.node && managementAvailability == Availability.enabled) - PopupMenuItem( - onTap: () { - showBlurDialog( - context: context, - builder: (context) => ManagementScreen(data), - ); - }, - child: ListTile( - title: Text(data!.info.version.major > 4 - ? l10n.s_toggle_applications - : l10n.s_toggle_interfaces), - leading: const Icon(Symbols.construction), - key: yubikeyApplicationToggleMenuButton, - dense: true, - contentPadding: EdgeInsets.zero, - ), - ), - if (data != null && - node == data.node && - getResetCapabilities(hasFeature).any((c) => - c.value & - (data.info.supportedCapabilities[node!.transport] ?? 0) != - 0)) - PopupMenuItem( - onTap: () { - showBlurDialog( - context: context, - builder: (context) => ResetDialog(data), - ); - }, - child: ListTile( - title: Text(l10n.s_factory_reset), - leading: const Icon(Symbols.delete_forever), - key: yubikeyFactoryResetMenuButton, - dense: true, - contentPadding: EdgeInsets.zero, - ), - ), ]; } - - Future _showKeyCustomizationDialog(KeyCustomization keyCustomization, - BuildContext context, DeviceNode? node) async { - await showBlurDialog( - context: context, - builder: (context) => KeyCustomizationDialog( - node: node, - initialCustomization: keyCustomization, - ), - routeSettings: const RouteSettings(name: 'customize'), - ); - } } _DeviceRow _buildDeviceRow( diff --git a/lib/app/views/keys.dart b/lib/app/views/keys.dart index c5a2d2c4..bb0e5079 100644 --- a/lib/app/views/keys.dart +++ b/lib/app/views/keys.dart @@ -25,6 +25,7 @@ const noDeviceAvatar = Key('$_prefix.no_device_avatar'); const actionsIconButtonKey = Key('$_prefix.actions_icon_button'); // drawer items +const homeDrawer = Key('$_prefix.drawer.home'); const managementAppDrawer = Key('$_prefix.drawer.management'); const oathAppDrawer = Key('$_prefix.drawer.oath'); const u2fAppDrawer = Key('$_prefix.drawer.fido.webauthn'); diff --git a/lib/app/views/main_page.dart b/lib/app/views/main_page.dart index c4443dbc..c351c18b 100755 --- a/lib/app/views/main_page.dart +++ b/lib/app/views/main_page.dart @@ -25,6 +25,8 @@ import '../../core/state.dart'; import '../../fido/views/fingerprints_screen.dart'; import '../../fido/views/passkeys_screen.dart'; import '../../fido/views/webauthn_page.dart'; +import '../../home/views/home_message_page.dart'; +import '../../home/views/home_screen.dart'; import '../../management/views/management_screen.dart'; import '../../oath/views/oath_screen.dart'; import '../../oath/views/utils.dart'; @@ -82,7 +84,7 @@ class MainPage extends ConsumerWidget { if (isAndroid) { var hasNfcSupport = ref.watch(androidNfcSupportProvider); var isNfcEnabled = ref.watch(androidNfcStateProvider); - return MessagePage( + return HomeMessagePage( centered: true, graphic: noKeyImage, header: hasNfcSupport && isNfcEnabled @@ -106,7 +108,7 @@ class MainPage extends ConsumerWidget { ), ); } else { - return MessagePage( + return HomeMessagePage( centered: true, delayedContent: false, graphic: noKeyImage, @@ -120,7 +122,7 @@ class MainPage extends ConsumerWidget { final capabilities = app.capabilities; if (data.info.supportedCapabilities.isEmpty && data.name == 'Unrecognized device') { - return MessagePage( + return HomeMessagePage( centered: true, graphic: Icon( Symbols.help, @@ -165,6 +167,7 @@ class MainPage extends ConsumerWidget { } return switch (app) { + Application.home => HomeScreen(data), Application.accounts => OathScreen(data.node.path), Application.webauthn => const WebAuthnScreen(), Application.passkeys => PasskeysScreen(data), diff --git a/lib/app/views/navigation.dart b/lib/app/views/navigation.dart index c28f0484..02278c9f 100644 --- a/lib/app/views/navigation.dart +++ b/lib/app/views/navigation.dart @@ -19,8 +19,8 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../core/state.dart'; import '../models.dart'; -import '../shortcuts.dart'; import '../state.dart'; import 'device_picker.dart'; import 'keys.dart'; @@ -95,6 +95,7 @@ extension on Application { Application.slots => Symbols.touch_app, Application.certificates => Symbols.approval, Application.management => Symbols.construction, + Application.home => Symbols.home }; Key get _key => switch (this) { @@ -105,6 +106,7 @@ extension on Application { Application.slots => otpAppDrawer, Application.certificates => pivAppDrawer, Application.management => managementAppDrawer, + Application.home => homeDrawer, }; } @@ -125,7 +127,9 @@ class NavigationContent extends ConsumerWidget { .where( (app) => app.getAvailability(data) != Availability.unsupported) .toList() - : []; + : !isAndroid // TODO: Remove check when Home is implemented on Android + ? [Application.home] + : []; availableApps.remove(Application.management); final currentApp = ref.watch(currentAppProvider); @@ -137,64 +141,36 @@ class NavigationContent extends ConsumerWidget { duration: const Duration(milliseconds: 150), child: DevicePickerContent(extended: extended), ), - const SizedBox(height: 32), - AnimatedSize( duration: const Duration(milliseconds: 150), child: Column( children: [ - if (data != null) ...[ - // Normal YubiKey Applications - ...availableApps.map((app) => NavigationItem( - key: app._key, - title: app.getDisplayName(l10n), - leading: Icon(app._icon, - fill: app == currentApp ? 1.0 : 0.0), - collapsed: !extended, - selected: app == currentApp, - onTap: app.getAvailability(data) == Availability.enabled - ? () { - ref - .read(currentAppProvider.notifier) - .setCurrentApp(app); - if (shouldPop) { - Navigator.of(context).pop(); - } + // Normal YubiKey Applications + ...availableApps.map((app) => NavigationItem( + key: app._key, + title: app.getDisplayName(l10n), + leading: + Icon(app._icon, fill: app == currentApp ? 1.0 : 0.0), + collapsed: !extended, + selected: app == currentApp, + onTap: data == null && currentApp == Application.home || + data != null && + app.getAvailability(data) == + Availability.enabled + ? () { + ref + .read(currentAppProvider.notifier) + .setCurrentApp(app); + if (shouldPop) { + Navigator.of(context).pop(); } - : null, - )), - const SizedBox(height: 32), - ], + } + : null, + )), ], ), ), - - // Non-YubiKey pages - NavigationItem( - leading: const Icon(Symbols.settings), - key: settingDrawerIcon, - title: l10n.s_settings, - collapsed: !extended, - onTap: () { - if (shouldPop) { - Navigator.of(context).pop(); - } - Actions.maybeInvoke(context, const SettingsIntent()); - }, - ), - NavigationItem( - leading: const Icon(Symbols.help), - key: helpDrawerIcon, - title: l10n.s_help_and_about, - collapsed: !extended, - onTap: () { - if (shouldPop) { - Navigator.of(context).pop(); - } - Actions.maybeInvoke(context, const AboutIntent()); - }, - ), ], ), ); diff --git a/lib/home/views/home_message_page.dart b/lib/home/views/home_message_page.dart new file mode 100644 index 00000000..c6b664bc --- /dev/null +++ b/lib/home/views/home_message_page.dart @@ -0,0 +1,82 @@ +/* + * 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:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/views/message_page.dart'; +import '../../core/state.dart'; +import '../../management/models.dart'; +import 'key_actions.dart'; + +class HomeMessagePage extends ConsumerWidget { + final Widget? graphic; + final String? header; + final String? message; + final String? footnote; + final bool delayedContent; + final Widget Function(BuildContext context)? actionButtonBuilder; + final List Function(BuildContext context, bool expanded)? + actionsBuilder; + final Widget? fileDropOverlay; + final Function(File file)? onFileDropped; + final List? capabilities; + final bool keyActionsBadge; + final bool centered; + + const HomeMessagePage({ + super.key, + this.graphic, + this.header, + this.message, + this.footnote, + this.actionButtonBuilder, + this.actionsBuilder, + this.fileDropOverlay, + this.onFileDropped, + this.delayedContent = false, + this.keyActionsBadge = false, + this.capabilities, + this.centered = false, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + + // TODO: Remove Android check when Home is implemented on Android + return MessagePage( + title: !isAndroid ? l10n.s_home : null, + graphic: graphic, + header: header, + message: message, + footnote: footnote, + keyActionsBuilder: + !isAndroid ? (context) => homeBuildActions(context, null, ref) : null, + actionButtonBuilder: actionButtonBuilder, + actionsBuilder: actionsBuilder, + fileDropOverlay: fileDropOverlay, + onFileDropped: onFileDropped, + delayedContent: delayedContent, + keyActionsBadge: keyActionsBadge, + capabilities: capabilities, + centered: centered, + ); + } +} diff --git a/lib/home/views/home_screen.dart b/lib/home/views/home_screen.dart new file mode 100644 index 00000000..6ef74528 --- /dev/null +++ b/lib/home/views/home_screen.dart @@ -0,0 +1,351 @@ +/* + * 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:material_symbols_icons/symbols.dart'; + +import '../../android/state.dart'; +import '../../app/message.dart'; +import '../../app/models.dart'; +import '../../app/state.dart'; +import '../../app/views/app_page.dart'; +import '../../core/models.dart'; +import '../../core/state.dart'; +import '../../management/models.dart'; +import '../../widgets/choice_filter_chip.dart'; +import '../../widgets/product_image.dart'; +import 'key_actions.dart'; +import 'manage_label_dialog.dart'; + +class HomeScreen extends ConsumerWidget { + final YubiKeyData deviceData; + const HomeScreen(this.deviceData, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + + final serial = deviceData.info.serial; + final keyCustomization = ref.watch(keyCustomizationManagerProvider)[serial]; + final enabledCapabilities = + deviceData.info.config.enabledCapabilities[deviceData.node.transport] ?? + 0; + final primaryColor = ref.watch(defaultColorProvider); + + return AppPage( + title: l10n.s_home, + keyActionsBuilder: (context) => + homeBuildActions(context, deviceData, ref), + builder: (context, expanded) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _DeviceContent(deviceData, keyCustomization), + const SizedBox(height: 16.0), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + flex: 8, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 4, + runSpacing: 8, + children: Capability.values + .where((c) => enabledCapabilities & c.value != 0) + .map((c) => CapabilityBadge(c)) + .toList(), + ), + if (serial != null) ...[ + const SizedBox(height: 32.0), + _DeviceColor( + deviceData: deviceData, + initialCustomization: keyCustomization ?? + KeyCustomization(serial: serial)) + ] + ], + ), + ), + Flexible( + flex: 6, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200), + child: _HeroAvatar( + color: keyCustomization?.color ?? primaryColor, + child: ProductImage( + name: deviceData.name, + formFactor: deviceData.info.formFactor, + isNfc: deviceData.info.supportedCapabilities + .containsKey(Transport.nfc), + ), + ), + ), + ) + ], + ) + ], + ), + ); + }, + ); + } +} + +class _DeviceContent extends ConsumerWidget { + final YubiKeyData deviceData; + final KeyCustomization? initialCustomization; + const _DeviceContent(this.deviceData, this.initialCustomization); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + + final name = deviceData.name; + final serial = deviceData.info.serial; + final version = deviceData.info.version; + + final label = initialCustomization?.name; + String displayName = label != null ? '$label ($name)' : name; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayName, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox( + height: 12, + ), + if (serial != null) + Text( + l10n.l_serial_number(serial), + style: Theme.of(context).textTheme.titleSmall?.apply( + color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + Text( + l10n.l_firmware_version(version), + style: Theme.of(context).textTheme.titleSmall?.apply( + color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], + ), + ), + if (serial != null) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: IconButton( + icon: const Icon(Symbols.edit), + onPressed: () async { + await ref.read(withContextProvider)((context) async { + await _showManageLabelDialog( + initialCustomization ?? KeyCustomization(serial: serial), + context, + ); + }); + }, + ), + ) + ], + ); + } + + Future _showManageLabelDialog( + KeyCustomization keyCustomization, BuildContext context) async { + await showBlurDialog( + context: context, + builder: (context) => ManageLabelDialog( + initialCustomization: keyCustomization, + ), + ); + } +} + +class _DeviceColor extends ConsumerWidget { + final YubiKeyData deviceData; + final KeyCustomization initialCustomization; + const _DeviceColor( + {required this.deviceData, required this.initialCustomization}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + final primaryColor = ref.watch(defaultColorProvider); + final defaultColor = + (isAndroid && ref.read(androidSdkVersionProvider) >= 31) + ? theme.colorScheme.onSurface + : primaryColor; + final customColor = initialCustomization.color; + + return ChoiceFilterChip( + disableHover: true, + value: customColor, + items: const [null], + selected: customColor != null && customColor != defaultColor, + itemBuilder: (e) => Wrap( + alignment: WrapAlignment.center, + runSpacing: 8, + spacing: 16, + children: [ + ...[ + Colors.teal, + Colors.cyan, + Colors.blueAccent, + Colors.deepPurple, + Colors.red, + Colors.orange, + Colors.yellow, + // add nice color to devices with dynamic color + if (isAndroid && ref.read(androidSdkVersionProvider) >= 31) + Colors.lightGreen + ].map((e) => _ColorButton( + color: e, + isSelected: customColor == e, + onPressed: () { + _updateColor(e, ref); + Navigator.of(context).pop(); + }, + )), + + // remove color button + RawMaterialButton( + onPressed: () { + _updateColor(null, ref); + Navigator.of(context).pop(); + }, + constraints: const BoxConstraints(minWidth: 26.0, minHeight: 26.0), + fillColor: (isAndroid && ref.read(androidSdkVersionProvider) >= 31) + ? theme.colorScheme.onSurface + : primaryColor, + hoverColor: Colors.black12, + shape: const CircleBorder(), + child: Icon( + Symbols.cancel, + size: 16, + color: customColor == null + ? theme.colorScheme.onSurface + : theme.colorScheme.surface.withOpacity(0.2), + ), + ), + ], + ), + labelBuilder: (e) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + constraints: const BoxConstraints(minWidth: 22.0, minHeight: 22.0), + decoration: BoxDecoration( + color: customColor ?? defaultColor, shape: BoxShape.circle), + ), + const SizedBox( + width: 12, + ), + Flexible(child: Text(l10n.s_color)) + ], + ), + onChanged: (e) {}, + ); + } + + void _updateColor(Color? color, WidgetRef ref) async { + final manager = ref.read(keyCustomizationManagerProvider.notifier); + await manager.set( + serial: initialCustomization.serial, + name: initialCustomization.name, + color: color, + ); + } +} + +class _ColorButton extends StatefulWidget { + final Color? color; + final bool isSelected; + final Function()? onPressed; + + const _ColorButton({ + required this.color, + required this.isSelected, + required this.onPressed, + }); + + @override + State<_ColorButton> createState() => _ColorButtonState(); +} + +class _ColorButtonState extends State<_ColorButton> { + @override + Widget build(BuildContext context) { + return RawMaterialButton( + onPressed: widget.onPressed, + constraints: const BoxConstraints(minWidth: 26.0, minHeight: 26.0), + fillColor: widget.color, + hoverColor: Colors.black12, + shape: const CircleBorder(), + child: Icon( + Symbols.circle, + fill: 1, + size: 16, + color: widget.isSelected ? Colors.white : Colors.transparent, + ), + ); + } +} + +class _HeroAvatar extends StatelessWidget { + final Widget child; + final Color color; + + const _HeroAvatar({required this.color, required this.child}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + color.withOpacity(0.6), + color.withOpacity(0.25), + (DialogTheme.of(context).backgroundColor ?? + theme.dialogBackgroundColor) + .withOpacity(0), + ], + ), + ), + padding: const EdgeInsets.all(12), + child: Theme( + // Give the avatar a transparent background + data: theme.copyWith( + colorScheme: + theme.colorScheme.copyWith(surfaceVariant: Colors.transparent)), + child: child, + ), + ); + } +} diff --git a/lib/home/views/key_actions.dart b/lib/home/views/key_actions.dart new file mode 100644 index 00000000..dbb51857 --- /dev/null +++ b/lib/home/views/key_actions.dart @@ -0,0 +1,113 @@ +/* + * 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:material_symbols_icons/material_symbols_icons.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../app/features.dart' as features; +import '../../app/message.dart'; +import '../../app/models.dart'; +import '../../app/shortcuts.dart'; +import '../../app/state.dart'; +import '../../app/views/action_list.dart'; +import '../../app/views/reset_dialog.dart'; +import '../../core/state.dart'; +import '../../management/views/management_screen.dart'; + +Widget homeBuildActions( + BuildContext context, YubiKeyData? deviceData, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + final hasFeature = ref.watch(featureProvider); + + final managementAvailability = + deviceData == null || !hasFeature(features.management) + ? Availability.unsupported + : Application.management.getAvailability(deviceData); + + return Column( + children: [ + if (deviceData != null) + ActionListSection( + l10n.s_device, + children: [ + if (managementAvailability == Availability.enabled) + ActionListItem( + feature: features.management, + icon: const Icon(Symbols.construction), + actionStyle: ActionStyle.primary, + title: deviceData.info.version.major > 4 + ? l10n.s_toggle_applications + : l10n.s_toggle_interfaces, + subtitle: deviceData.info.version.major > 4 + ? l10n.l_toggle_applications_desc + : l10n.l_toggle_interfaces_desc, + onTap: (context) async { + await ref.read(withContextProvider)( + (context) => showBlurDialog( + context: context, + builder: (context) => ManagementScreen(deviceData), + ), + ); + }, + ), + if (getResetCapabilities(hasFeature).any((c) => + c.value & + (deviceData.info + .supportedCapabilities[deviceData.node.transport] ?? + 0) != + 0)) + ActionListItem( + icon: const Icon(Symbols.delete_forever), + title: l10n.s_factory_reset, + subtitle: l10n.l_factory_reset_desc, + actionStyle: ActionStyle.primary, + onTap: (context) async { + await ref.read(withContextProvider)( + (context) => showBlurDialog( + context: context, + builder: (context) => ResetDialog(deviceData), + ), + ); + }, + ) + ], + ), + ActionListSection(l10n.s_application, children: [ + ActionListItem( + icon: const Icon(Symbols.settings), + title: l10n.s_settings, + subtitle: l10n.l_settings_desc, + actionStyle: ActionStyle.primary, + onTap: (_) { + Actions.maybeInvoke(context, const SettingsIntent()); + }, + ), + ActionListItem( + icon: const Icon(Symbols.help), + title: l10n.s_help_and_about, + subtitle: l10n.l_help_and_about_desc, + actionStyle: ActionStyle.primary, + onTap: (_) { + Actions.maybeInvoke(context, const AboutIntent()); + }, + ) + ]) + ], + ); +} diff --git a/lib/home/views/manage_label_dialog.dart b/lib/home/views/manage_label_dialog.dart new file mode 100644 index 00000000..aa861fcb --- /dev/null +++ b/lib/home/views/manage_label_dialog.dart @@ -0,0 +1,114 @@ +/* + * 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:material_symbols_icons/symbols.dart'; + +import '../../app/models.dart'; +import '../../app/state.dart'; +import '../../widgets/app_input_decoration.dart'; +import '../../widgets/app_text_form_field.dart'; +import '../../widgets/focus_utils.dart'; +import '../../widgets/responsive_dialog.dart'; + +class ManageLabelDialog extends ConsumerStatefulWidget { + final KeyCustomization initialCustomization; + + const ManageLabelDialog({super.key, required this.initialCustomization}); + + @override + ConsumerState createState() => _ManageLabelDialogState(); +} + +class _ManageLabelDialogState extends ConsumerState { + String? _label; + + @override + void initState() { + super.initState(); + _label = widget.initialCustomization.name; + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final initialLabel = widget.initialCustomization.name; + final didChange = initialLabel != _label; + return ResponsiveDialog( + title: + Text(initialLabel != null ? l10n.s_change_label : l10n.s_set_label), + actions: [ + TextButton( + onPressed: didChange ? _submit : null, + child: Text(l10n.s_save), + ) + ], + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (initialLabel != null) Text(l10n.q_rename_target(initialLabel)), + Text(initialLabel == null + ? l10n.p_set_will_add_custom_name + : l10n.p_rename_will_change_custom_name), + AppTextFormField( + autofocus: true, + initialValue: _label, + maxLength: 20, + decoration: AppInputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.s_label, + helperText: '', + prefixIcon: const Icon(Symbols.key), + ), + textInputAction: TextInputAction.done, + onChanged: (value) { + setState(() { + final trimmed = value.trim(); + _label = trimmed.isEmpty ? null : trimmed; + }); + }, + onFieldSubmitted: (_) { + _submit(); + }, + ) + ] + .map((e) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: e, + )) + .toList(), + ), + ), + ); + } + + void _submit() async { + final manager = ref.read(keyCustomizationManagerProvider.notifier); + await manager.set( + serial: widget.initialCustomization.serial, + name: _label, + color: widget.initialCustomization.color); + + await ref.read(withContextProvider)((context) async { + FocusUtils.unfocus(context); + Navigator.of(context).pop(); + }); + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index d95ef957..9279b38f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -61,12 +61,17 @@ "s_actions": null, "s_manage": "Verwalten", "s_setup": "Einrichten", + "s_device": null, + "s_application": null, "s_settings": "Einstellungen", + "l_settings_desc": null, "s_certificates": null, "s_webauthn": "WebAuthn", "s_slots": null, "s_help_and_about": "Hilfe und Über", + "l_help_and_about_desc": null, "s_help_and_feedback": "Hilfe und Feedback", + "s_home": null, "s_send_feedback": "Senden Sie uns Feedback", "s_i_need_help": "Ich brauche Hilfe", "s_troubleshooting": "Problembehebung", @@ -128,6 +133,18 @@ "version": {} } }, + "@l_serial_number": { + "placeholders": { + "serial": {} + } + }, + "l_serial_number": null, + "@l_firmware_version": { + "placeholders": { + "version": {} + } + }, + "l_firmware_version": null, "@_yubikey_interactions": {}, "l_insert_yk": "YubiKey anschließen", @@ -154,6 +171,8 @@ "@_app_configuration": {}, "s_toggle_applications": "Anwendungen umschalten", "s_toggle_interfaces": null, + "l_toggle_applications_desc": null, + "l_toggle_interfaces_desc": null, "s_reconfiguring_yk": "YubiKey wird neu konfiguriert\u2026", "s_config_updated": "Konfiguration aktualisiert", "l_config_updated_reinsert": "Konfiguration aktualisiert, entfernen Sie Ihren YubiKey und schließen ihn wieder an", @@ -645,6 +664,7 @@ "@_factory_reset": {}, "s_reset": "Zurücksetzen", "s_factory_reset": "Werkseinstellungen", + "l_factory_reset_desc": null, "l_oath_application_reset": "OATH Anwendung zurücksetzen", "l_fido_app_reset": "FIDO Anwendung zurückgesetzt", "l_reset_failed": "Fehler beim Zurücksetzen: {message}", @@ -750,7 +770,12 @@ "@_key_customization": {}, "s_customize_key_action": null, + "s_set_label": null, + "s_change_label": null, "s_theme_color": null, + "s_color": null, + "p_set_will_add_custom_name": null, + "p_rename_will_change_custom_name": null, "@_eof": {} } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 753828a6..6213d754 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -61,12 +61,17 @@ "s_actions": "Actions", "s_manage": "Manage", "s_setup": "Setup", + "s_device": "Device", + "s_application": "Application", "s_settings": "Settings", + "l_settings_desc": "Change application preferences", "s_certificates": "Certificates", "s_webauthn": "WebAuthn", "s_slots": "Slots", "s_help_and_about": "Help and about", + "l_help_and_about_desc": "Troubleshoot and support", "s_help_and_feedback": "Help and feedback", + "s_home": "Home", "s_send_feedback": "Send us feedback", "s_i_need_help": "I need help", "s_troubleshooting": "Troubleshooting", @@ -128,6 +133,18 @@ "version": {} } }, + "@l_serial_number": { + "placeholders": { + "serial": {} + } + }, + "l_serial_number": "Serial number: {serial}", + "@l_firmware_version": { + "placeholders": { + "version": {} + } + }, + "l_firmware_version": "Firmware version: {version}", "@_yubikey_interactions": {}, "l_insert_yk": "Insert your YubiKey", @@ -154,6 +171,8 @@ "@_app_configuration": {}, "s_toggle_applications": "Toggle applications", "s_toggle_interfaces": "Toggle interfaces", + "l_toggle_applications_desc": "Enable/disable applications", + "l_toggle_interfaces_desc": "Enable/disable interfaces", "s_reconfiguring_yk": "Reconfiguring YubiKey\u2026", "s_config_updated": "Configuration updated", "l_config_updated_reinsert": "Configuration updated, remove and reinsert your YubiKey", @@ -645,6 +664,7 @@ "@_factory_reset": {}, "s_reset": "Reset", "s_factory_reset": "Factory reset", + "l_factory_reset_desc": "Restore YubiKey defaults", "l_oath_application_reset": "OATH application reset", "l_fido_app_reset": "FIDO application reset", "l_reset_failed": "Error performing reset: {message}", @@ -750,7 +770,12 @@ "@_key_customization": {}, "s_customize_key_action": "Set label/color", + "s_set_label": "Set label", + "s_change_label": "Change label", "s_theme_color": "Theme color", + "s_color": "Color", + "p_set_will_add_custom_name": "This will give your YubiKey a custom name.", + "p_rename_will_change_custom_name": "This will change the label of your YubiKey.", "@_eof": {} } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 7950795d..0b80f394 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -61,12 +61,17 @@ "s_actions": "Actions", "s_manage": "Gérer", "s_setup": "Configuration", + "s_device": null, + "s_application": null, "s_settings": "Paramètres", + "l_settings_desc": null, "s_certificates": "Certificats", "s_webauthn": "WebAuthn", "s_slots": null, "s_help_and_about": "Aide et à propos", + "l_help_and_about_desc": null, "s_help_and_feedback": "Aide et retours", + "s_home": null, "s_send_feedback": "Envoyer nous un retour", "s_i_need_help": "J'ai besoin d'aide", "s_troubleshooting": "Dépannage", @@ -128,6 +133,18 @@ "version": {} } }, + "@l_serial_number": { + "placeholders": { + "serial": {} + } + }, + "l_serial_number": null, + "@l_firmware_version": { + "placeholders": { + "version": {} + } + }, + "l_firmware_version": null, "@_yubikey_interactions": {}, "l_insert_yk": "Insérez votre YubiKey", @@ -154,6 +171,8 @@ "@_app_configuration": {}, "s_toggle_applications": "Changer les applications", "s_toggle_interfaces": null, + "l_toggle_applications_desc": null, + "l_toggle_interfaces_desc": null, "s_reconfiguring_yk": "Reconfiguration de la YubiKey\u2026", "s_config_updated": "Configuration mise à jour", "l_config_updated_reinsert": "Configuration mise à jour; retirez et réinsérez votre YubiKey", @@ -645,6 +664,7 @@ "@_factory_reset": {}, "s_reset": "Réinitialiser", "s_factory_reset": "Réinitialisation", + "l_factory_reset_desc": null, "l_oath_application_reset": "L'application OATH à été réinitialisée", "l_fido_app_reset": "L'application FIDO à été réinitialisée", "l_reset_failed": "Erreur pendant la réinitialisation: {message}", @@ -750,7 +770,12 @@ "@_key_customization": {}, "s_customize_key_action": null, + "s_set_label": null, + "s_change_label": null, "s_theme_color": null, + "s_color": null, + "p_set_will_add_custom_name": null, + "p_rename_will_change_custom_name": null, "@_eof": {} } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index a933f073..a54dc6d1 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -61,12 +61,17 @@ "s_actions": "アクション", "s_manage": "管理", "s_setup": "セットアップ", + "s_device": null, + "s_application": null, "s_settings": "設定", + "l_settings_desc": null, "s_certificates": "証明書", "s_webauthn": "WebAuthn", "s_slots": null, "s_help_and_about": "ヘルプと概要", + "l_help_and_about_desc": null, "s_help_and_feedback": "ヘルプとフィードバック", + "s_home": null, "s_send_feedback": "フィードバックの送信", "s_i_need_help": "ヘルプが必要", "s_troubleshooting": "トラブルシューティング", @@ -128,6 +133,18 @@ "version": {} } }, + "@l_serial_number": { + "placeholders": { + "serial": {} + } + }, + "l_serial_number": null, + "@l_firmware_version": { + "placeholders": { + "version": {} + } + }, + "l_firmware_version": null, "@_yubikey_interactions": {}, "l_insert_yk": "YubiKeyを挿入する", @@ -154,6 +171,8 @@ "@_app_configuration": {}, "s_toggle_applications": "アプリケーションの切替え", "s_toggle_interfaces": null, + "l_toggle_applications_desc": null, + "l_toggle_interfaces_desc": null, "s_reconfiguring_yk": "YubiKeyを再構成しています\u2026", "s_config_updated": "構成が更新されました", "l_config_updated_reinsert": "設定が更新されました。YubiKeyを取り外して再挿入してください", @@ -645,6 +664,7 @@ "@_factory_reset": {}, "s_reset": "リセット", "s_factory_reset": "工場出荷リセット", + "l_factory_reset_desc": null, "l_oath_application_reset": "OATHアプリケーションのリセット", "l_fido_app_reset": "FIDOアプリケーションのリセット", "l_reset_failed": "リセット実行中のエラー:{message}", @@ -750,7 +770,12 @@ "@_key_customization": {}, "s_customize_key_action": null, + "s_set_label": null, + "s_change_label": null, "s_theme_color": null, + "s_color": null, + "p_set_will_add_custom_name": null, + "p_rename_will_change_custom_name": null, "@_eof": {} } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 73c33ea3..6e503ddd 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -61,12 +61,17 @@ "s_actions": "Działania", "s_manage": "Zarządzaj", "s_setup": "Konfiguruj", + "s_device": null, + "s_application": null, "s_settings": "Ustawienia", + "l_settings_desc": null, "s_certificates": "Certyfikaty", "s_webauthn": "WebAuthn", "s_slots": "Sloty", "s_help_and_about": "Pomoc i informacje", + "l_help_and_about_desc": null, "s_help_and_feedback": "Pomoc i opinie", + "s_home": null, "s_send_feedback": "Prześlij opinię", "s_i_need_help": "Pomoc", "s_troubleshooting": "Rozwiązywanie problemów", @@ -128,6 +133,18 @@ "version": {} } }, + "@l_serial_number": { + "placeholders": { + "serial": {} + } + }, + "l_serial_number": null, + "@l_firmware_version": { + "placeholders": { + "version": {} + } + }, + "l_firmware_version": null, "@_yubikey_interactions": {}, "l_insert_yk": "Podłącz klucz YubiKey", @@ -154,6 +171,8 @@ "@_app_configuration": {}, "s_toggle_applications": "Przełączanie funkcji", "s_toggle_interfaces": "Przełącz interfejsy", + "l_toggle_applications_desc": null, + "l_toggle_interfaces_desc": null, "s_reconfiguring_yk": "Rekonfigurowanie YubiKey\u2026", "s_config_updated": "Zaktualizowano konfigurację", "l_config_updated_reinsert": "Zaktualizowano konfigurację, podłącz ponownie klucz YubiKey", @@ -645,6 +664,7 @@ "@_factory_reset": {}, "s_reset": "Zresetuj", "s_factory_reset": "Ustawienia fabryczne", + "l_factory_reset_desc": null, "l_oath_application_reset": "Reset funkcji OATH", "l_fido_app_reset": "Reset funkcji FIDO", "l_reset_failed": "Błąd podczas resetowania: {message}", @@ -750,7 +770,12 @@ "@_key_customization": {}, "s_customize_key_action": "Dostosuj klucz", + "s_set_label": null, + "s_change_label": null, "s_theme_color": "Kolor motywu", + "s_color": null, + "p_set_will_add_custom_name": null, + "p_rename_will_change_custom_name": null, "@_eof": {} } diff --git a/lib/widgets/choice_filter_chip.dart b/lib/widgets/choice_filter_chip.dart index 843eb8f0..93ef92d6 100755 --- a/lib/widgets/choice_filter_chip.dart +++ b/lib/widgets/choice_filter_chip.dart @@ -28,6 +28,7 @@ class ChoiceFilterChip extends StatefulWidget { final void Function(T value)? onChanged; final Widget? avatar; final bool selected; + final bool? disableHover; const ChoiceFilterChip({ super.key, required this.value, @@ -37,6 +38,7 @@ class ChoiceFilterChip extends StatefulWidget { this.tooltip, this.avatar, this.selected = false, + this.disableHover, this.labelBuilder, }); @@ -69,6 +71,8 @@ class _ChoiceFilterChipState extends State> { color: Theme.of(context).colorScheme.background, items: widget.items .map((e) => PopupMenuItem( + enabled: + widget.disableHover != null ? !widget.disableHover! : true, value: e, height: chipBox.size.height, textStyle: ChipTheme.of(context).labelStyle,