This commit is contained in:
Elias Bonnici 2024-03-08 17:03:02 +01:00
commit 8e2b6a1f31
No known key found for this signature in database
GPG Key ID: 5EAC28EA3F980CCF
28 changed files with 1206 additions and 872 deletions

View File

@ -23,7 +23,7 @@ import com.yubico.authenticator.device.Info
import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice
enum class OperationContext(val value: Int) {
Oath(0), Yubikey(1), Invalid(-1);
Home(0), Oath(1), Yubikey(2), Invalid(-1);
companion object {
fun getByValue(value: Int) = values().firstOrNull { it.value == value } ?: Invalid

View File

@ -64,8 +64,8 @@ Future<Widget> initialize() async {
oathStateProvider.overrideWithProvider(androidOathStateProvider.call),
credentialListProvider
.overrideWithProvider(androidCredentialListProvider.call),
currentAppProvider.overrideWith(
(ref) => AndroidSubPageNotifier(ref.watch(supportedAppsProvider))),
currentAppProvider.overrideWith((ref) => AndroidSubPageNotifier(
ref.watch(supportedAppsProvider), ref.watch(prefProvider))),
managementStateProvider.overrideWithProvider(androidManagementState.call),
currentDeviceProvider.overrideWith(
() => AndroidCurrentDeviceNotifier(),
@ -95,6 +95,7 @@ Future<Widget> initialize() async {
..setFeature(features.fido, false)
..setFeature(features.piv, false)
..setFeature(features.otp, false)
..setFeature(features.home, false)
..setFeature(features.management, false);
});

View File

@ -91,7 +91,7 @@ final androidSupportedThemesProvider = StateProvider<List<ThemeMode>>((ref) {
});
class AndroidSubPageNotifier extends CurrentAppNotifier {
AndroidSubPageNotifier(super.supportedApps) {
AndroidSubPageNotifier(super.supportedApps, super.prefs) {
_handleSubPage(state);
}

View File

@ -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');

View File

@ -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<String, dynamic> json) =>
_$KeyCustomizationFromJson(json);
}
class _ColorConverter implements JsonConverter<Color?, int?> {
const _ColorConverter();
@override
Color? fromJson(int? json) => json != null ? Color(json) : null;
@override
int? toJson(Color? object) => object?.value;
}

View File

@ -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>(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<String, dynamic> 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<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$KeyCustomizationCopyWith<KeyCustomization> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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;
}

View File

@ -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<KeyCustomizationNotifier, Map<int, KeyCustomization>>(
(ref) => KeyCustomizationNotifier(ref.watch(prefProvider)));
final _log = Logger('key_customization_manager');
class KeyCustomizationNotifier
extends StateNotifier<Map<int, KeyCustomization>> {
static const _prefKeyCustomizations = 'KEY_CUSTOMIZATIONS';
final SharedPreferences _prefs;
KeyCustomizationNotifier(this._prefs)
: super(_readCustomizations(_prefs.getString(_prefKeyCustomizations)));
static Map<int, KeyCustomization> _readCustomizations(String? pref) {
if (pref == null) {
return {};
}
try {
final retval = <int, KeyCustomization>{};
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<void> 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()));
}
}

View File

@ -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<KeyCustomizationDialog> createState() =>
_KeyCustomizationDialogState();
}
class _KeyCustomizationDialogState
extends ConsumerState<KeyCustomizationDialog> {
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<String> _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!)]
: <String>[]
: data.hasValue
? data.value?.node.path == node.path
? [
data.value!.name,
_getDeviceInfoString(context, data.value!.info)
]
: <String>[]
: <String>[];
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,
),
);
}
}

View File

@ -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<String, dynamic> json) =>
_$KeyCustomizationFromJson(json);
}
class _ColorConverter implements JsonConverter<Color?, int?> {
const _ColorConverter();
@override
Color? fromJson(int? json) => json != null ? Color(json) : null;
@override
int? toJson(Color? object) => object?.value;
}

View File

@ -1084,3 +1084,195 @@ abstract class _WindowState implements WindowState {
_$$WindowStateImplCopyWith<_$WindowStateImpl> get copyWith =>
throw _privateConstructorUsedError;
}
KeyCustomization _$KeyCustomizationFromJson(Map<String, dynamic> 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<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$KeyCustomizationCopyWith<KeyCustomization> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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;
}

View File

@ -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<DeviceNode?> {
final currentAppProvider =
StateNotifierProvider<CurrentAppNotifier, Application>((ref) {
final notifier = CurrentAppNotifier(ref.watch(supportedAppsProvider));
final notifier = CurrentAppNotifier(
ref.watch(supportedAppsProvider), ref.watch(prefProvider));
ref.listen<AsyncValue<YubiKeyData>>(currentDeviceDataProvider, (_, data) {
notifier._notifyDeviceChanged(data.whenOrNull(data: ((data) => data)));
}, fireImmediately: true);
@ -211,16 +213,37 @@ final currentAppProvider =
class CurrentAppNotifier extends StateNotifier<Application> {
final List<Application> _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<Application> {
orElse: () => _supportedApps.first,
);
}
static Application _fromName(String? name, List<Application> supportedApps) =>
supportedApps.firstWhere((element) => element.name == name,
orElse: () => supportedApps.first);
}
abstract class QrScanner {
@ -285,3 +312,55 @@ typedef WithContext = Future<T> Function<T>(
final withContextProvider = Provider<WithContext>(
(ref) => ref.watch(contextConsumer.notifier).withContext);
final keyCustomizationManagerProvider =
StateNotifierProvider<KeyCustomizationNotifier, Map<int, KeyCustomization>>(
(ref) => KeyCustomizationNotifier(ref.watch(prefProvider)));
class KeyCustomizationNotifier
extends StateNotifier<Map<int, KeyCustomization>> {
static const _prefKeyCustomizations = 'KEY_CUSTOMIZATIONS';
final SharedPreferences _prefs;
KeyCustomizationNotifier(this._prefs)
: super(_readCustomizations(_prefs.getString(_prefKeyCustomizations)));
static Map<int, KeyCustomization> _readCustomizations(String? pref) {
if (pref == null) {
return {};
}
try {
final retval = <int, KeyCustomization>{};
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<void> 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()));
}
}

View File

@ -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) {

View File

@ -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',

View File

@ -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<String>>(
@ -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<PopupMenuItem> _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<void> _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(

View File

@ -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');

View File

@ -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),

View File

@ -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()
: <Application>[];
: !isAndroid // TODO: Remove check when Home is implemented on Android
? [Application.home]
: <Application>[];
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());
},
),
],
),
);

View File

@ -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<Widget> Function(BuildContext context, bool expanded)?
actionsBuilder;
final Widget? fileDropOverlay;
final Function(File file)? onFileDropped;
final List<Capability>? 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,
);
}
}

View File

@ -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<void> _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<Color?>(
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,
),
);
}
}

View File

@ -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());
},
)
])
],
);
}

View File

@ -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<ManageLabelDialog> createState() => _ManageLabelDialogState();
}
class _ManageLabelDialogState extends ConsumerState<ManageLabelDialog> {
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();
});
}
}

View File

@ -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}",
@ -744,7 +764,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": {}
}

View File

@ -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}",
@ -744,7 +764,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": {}
}

View File

@ -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}",
@ -744,7 +764,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": {}
}

View File

@ -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}",
@ -744,7 +764,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": {}
}

View File

@ -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}",
@ -744,7 +764,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": {}
}

View File

@ -28,6 +28,7 @@ class ChoiceFilterChip<T> 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<T> extends StatefulWidget {
this.tooltip,
this.avatar,
this.selected = false,
this.disableHover,
this.labelBuilder,
});
@ -69,6 +71,8 @@ class _ChoiceFilterChipState<T> extends State<ChoiceFilterChip<T>> {
color: Theme.of(context).colorScheme.background,
items: widget.items
.map((e) => PopupMenuItem<T>(
enabled:
widget.disableHover != null ? !widget.disableHover! : true,
value: e,
height: chipBox.size.height,
textStyle: ChipTheme.of(context).labelStyle,