mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-12-23 10:11:52 +03:00
Merge branch 'main' into adamve/android_fido_bio
This commit is contained in:
commit
f5d1b21f19
@ -271,22 +271,25 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
|
||||
private fun processYubiKey(device: YubiKeyDevice) {
|
||||
lifecycleScope.launch {
|
||||
// verify that current context supports connection provided by the YubiKey
|
||||
// if not, switch to a context which supports the connection
|
||||
val supportedApps = DeviceManager.getSupportedContexts(device)
|
||||
logger.debug("Connected key supports: {}", supportedApps)
|
||||
if (!supportedApps.contains(viewModel.appContext.value)) {
|
||||
val preferredContext = DeviceManager.getPreferredContext(supportedApps)
|
||||
logger.debug(
|
||||
"Current context ({}) is not supported by the key. Using preferred context {}",
|
||||
viewModel.appContext.value,
|
||||
preferredContext
|
||||
)
|
||||
switchContext(preferredContext)
|
||||
}
|
||||
|
||||
if (contextManager == null) {
|
||||
switchContext(DeviceManager.getPreferredContext(supportedApps))
|
||||
if (device is NfcYubiKeyDevice) {
|
||||
// verify that current context supports connection provided by the YubiKey
|
||||
// if not, switch to a context which supports the connection
|
||||
val supportedApps = DeviceManager.getSupportedContexts(device)
|
||||
logger.debug("Connected key supports: {}", supportedApps)
|
||||
if (!supportedApps.contains(viewModel.appContext.value)) {
|
||||
val preferredContext = DeviceManager.getPreferredContext(supportedApps)
|
||||
logger.debug(
|
||||
"Current context ({}) is not supported by the key. Using preferred context {}",
|
||||
viewModel.appContext.value,
|
||||
preferredContext
|
||||
)
|
||||
switchContext(preferredContext)
|
||||
}
|
||||
|
||||
if (contextManager == null) {
|
||||
switchContext(DeviceManager.getPreferredContext(supportedApps))
|
||||
}
|
||||
}
|
||||
|
||||
contextManager?.let {
|
||||
|
@ -295,7 +295,7 @@ class FidoManager(
|
||||
|
||||
fidoViewModel.setSessionState(
|
||||
Session(
|
||||
fidoSession.cachedInfo,
|
||||
fidoSession.info,
|
||||
pinStore.hasPin()
|
||||
)
|
||||
)
|
||||
|
@ -42,6 +42,7 @@ from yubikit.logging import LOG_LEVEL
|
||||
from ykman.pcsc import list_devices, YK_READER_NAME
|
||||
from smartcard.Exceptions import SmartcardException, NoCardException
|
||||
from smartcard.pcsc.PCSCExceptions import EstablishContextException
|
||||
from smartcard.CardMonitoring import CardObserver, CardMonitor
|
||||
from hashlib import sha256
|
||||
from dataclasses import asdict
|
||||
from typing import Mapping, Tuple
|
||||
@ -263,9 +264,6 @@ class AbstractDeviceNode(RpcNode):
|
||||
|
||||
|
||||
class UsbDeviceNode(AbstractDeviceNode):
|
||||
def __init__(self, device, info):
|
||||
super().__init__(device, info)
|
||||
|
||||
def _supports_connection(self, conn_type):
|
||||
return self._device.pid.supports_connection(conn_type)
|
||||
|
||||
@ -308,15 +306,53 @@ class UsbDeviceNode(AbstractDeviceNode):
|
||||
raise ConnectionException("fido", e)
|
||||
|
||||
|
||||
class _ReaderObserver(CardObserver):
|
||||
def __init__(self, device):
|
||||
self.device = device
|
||||
self.card = None
|
||||
self.data = None
|
||||
|
||||
def update(self, observable, actions):
|
||||
added, removed = actions
|
||||
for card in added:
|
||||
if card.reader == self.device.reader.name:
|
||||
if card != self.card:
|
||||
self.card = card
|
||||
break
|
||||
else:
|
||||
self.card = None
|
||||
self.data = None
|
||||
logger.debug(f"NFC card: {self.card}")
|
||||
|
||||
|
||||
class ReaderDeviceNode(AbstractDeviceNode):
|
||||
def __init__(self, device, info):
|
||||
super().__init__(device, info)
|
||||
self._observer = _ReaderObserver(device)
|
||||
self._monitor = CardMonitor()
|
||||
self._monitor.addObserver(self._observer)
|
||||
|
||||
def close(self):
|
||||
self._monitor.deleteObserver(self._observer)
|
||||
super().close()
|
||||
|
||||
def get_data(self):
|
||||
try:
|
||||
with self._device.open_connection(SmartCardConnection) as conn:
|
||||
return dict(self._read_data(conn), present=True)
|
||||
except NoCardException:
|
||||
return dict(present=False, status="no-card")
|
||||
except ValueError:
|
||||
return dict(present=False, status="unknown-device")
|
||||
if self._observer.data is None:
|
||||
card = self._observer.card
|
||||
if card is None:
|
||||
return dict(present=False, status="no-card")
|
||||
try:
|
||||
with self._device.open_connection(SmartCardConnection) as conn:
|
||||
self._observer.data = dict(self._read_data(conn), present=True)
|
||||
except NoCardException:
|
||||
return dict(present=False, status="no-card")
|
||||
except ValueError:
|
||||
self._observer.data = dict(present=False, status="unknown-device")
|
||||
return self._observer.data
|
||||
|
||||
@action(closes_child=False)
|
||||
def get(self, params, event, signal):
|
||||
return super().get(params, event, signal)
|
||||
|
||||
@child
|
||||
def ccid(self):
|
||||
|
@ -399,12 +399,41 @@ class SlotNode(RpcNode):
|
||||
else None,
|
||||
)
|
||||
|
||||
@action(condition=lambda self: self.certificate)
|
||||
@action(condition=lambda self: self.certificate or self.metadata)
|
||||
def delete(self, params, event, signal):
|
||||
self.session.delete_certificate(self.slot)
|
||||
self.session.put_object(OBJECT_ID.CHUID, generate_chuid())
|
||||
delete_cert = params.pop("delete_cert", False)
|
||||
delete_key = params.pop("delete_key", False)
|
||||
|
||||
if not delete_cert and not delete_key:
|
||||
raise ValueError("Missing delete option")
|
||||
|
||||
if delete_cert:
|
||||
self.session.delete_certificate(self.slot)
|
||||
self.session.put_object(OBJECT_ID.CHUID, generate_chuid())
|
||||
self.certificate = None
|
||||
if delete_key:
|
||||
self.session.delete_key(self.slot)
|
||||
self._refresh()
|
||||
return dict()
|
||||
|
||||
@action(condition=lambda self: self.metadata)
|
||||
def move_key(self, params, event, signal):
|
||||
destination = params.pop("destination")
|
||||
overwrite_key = params.pop("overwrite_key")
|
||||
include_certificate = params.pop("include_certificate")
|
||||
|
||||
if include_certificate:
|
||||
source_object = self.session.get_object(OBJECT_ID.from_slot(self.slot))
|
||||
destination = SLOT(int(destination, base=16))
|
||||
if overwrite_key:
|
||||
self.session.delete_key(destination)
|
||||
self.session.move_key(self.slot, destination)
|
||||
if include_certificate:
|
||||
self.session.put_object(OBJECT_ID.from_slot(destination), source_object)
|
||||
self.session.delete_certificate(self.slot)
|
||||
self.session.put_object(OBJECT_ID.CHUID, generate_chuid())
|
||||
self.certificate = None
|
||||
self._refresh()
|
||||
self.certificate = None
|
||||
return dict()
|
||||
|
||||
@action
|
||||
|
@ -24,7 +24,6 @@ import 'package:window_manager/window_manager.dart';
|
||||
import '../about_page.dart';
|
||||
import '../core/state.dart';
|
||||
import '../desktop/state.dart';
|
||||
import '../oath/keys.dart';
|
||||
import 'message.dart';
|
||||
import 'models.dart';
|
||||
import 'state.dart';
|
||||
@ -130,8 +129,8 @@ class GlobalShortcuts extends ConsumerWidget {
|
||||
return null;
|
||||
}),
|
||||
SearchIntent: CallbackAction<SearchIntent>(onInvoke: (intent) {
|
||||
// If the OATH view doesn't have focus, but is shown, find and select the search bar.
|
||||
final searchContext = searchAccountsField.currentContext;
|
||||
// If the view doesn't have focus, but is shown, find and select the search bar.
|
||||
final searchContext = searchField.currentContext;
|
||||
if (searchContext != null) {
|
||||
if (!Navigator.of(searchContext).canPop()) {
|
||||
return Actions.maybeInvoke(searchContext, intent);
|
||||
|
@ -53,7 +53,7 @@ class ActionListItem extends StatelessWidget {
|
||||
// };
|
||||
|
||||
return ListTile(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(48)),
|
||||
title: Text(title),
|
||||
subtitle: subtitle != null ? Text(subtitle!) : null,
|
||||
leading: Opacity(
|
||||
|
@ -77,7 +77,7 @@ class _AppListItemState<T> extends ConsumerState<AppListItem> {
|
||||
item: widget.item,
|
||||
child: InkWell(
|
||||
focusNode: _focusNode,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
borderRadius: BorderRadius.circular(48),
|
||||
onSecondaryTapDown: buildPopupActions == null
|
||||
? null
|
||||
: (details) {
|
||||
|
@ -14,11 +14,15 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
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 'package:sliver_tools/sliver_tools.dart';
|
||||
|
||||
import '../../core/state.dart';
|
||||
import '../../management/models.dart';
|
||||
@ -30,12 +34,30 @@ import 'fs_dialog.dart';
|
||||
import 'keys.dart';
|
||||
import 'navigation.dart';
|
||||
|
||||
// We use global keys here to maintain the NavigatorContent between AppPages.
|
||||
final _navigationProvider = StateNotifierProvider<_NavigationProvider, bool>(
|
||||
(ref) => _NavigationProvider());
|
||||
|
||||
class _NavigationProvider extends StateNotifier<bool> {
|
||||
_NavigationProvider() : super(true);
|
||||
|
||||
void toggleExpanded() {
|
||||
state = !state;
|
||||
}
|
||||
}
|
||||
|
||||
// We use global keys here to maintain the content between AppPages,
|
||||
// and keep track of what has been scrolled under AppBar
|
||||
final _navKey = GlobalKey();
|
||||
final _navExpandedKey = GlobalKey();
|
||||
final _sliverTitleGlobalKey = GlobalKey();
|
||||
final _sliverTitleWrapperGlobalKey = GlobalKey();
|
||||
final _headerSliverGlobalKey = GlobalKey();
|
||||
final _detailsViewGlobalKey = GlobalKey();
|
||||
final _mainContentGlobalKey = GlobalKey();
|
||||
|
||||
class AppPage extends StatelessWidget {
|
||||
class AppPage extends ConsumerStatefulWidget {
|
||||
final String? title;
|
||||
final String? alternativeTitle;
|
||||
final String? footnote;
|
||||
final Widget Function(BuildContext context, bool expanded) builder;
|
||||
final Widget Function(BuildContext context)? detailViewBuilder;
|
||||
@ -49,72 +71,82 @@ class AppPage extends StatelessWidget {
|
||||
final Widget? fileDropOverlay;
|
||||
final Function(File file)? onFileDropped;
|
||||
final List<Capability>? capabilities;
|
||||
const AppPage({
|
||||
super.key,
|
||||
this.title,
|
||||
this.footnote,
|
||||
required this.builder,
|
||||
this.centered = false,
|
||||
this.keyActionsBuilder,
|
||||
this.detailViewBuilder,
|
||||
this.actionButtonBuilder,
|
||||
this.actionsBuilder,
|
||||
this.fileDropOverlay,
|
||||
this.capabilities,
|
||||
this.onFileDropped,
|
||||
this.delayedContent = false,
|
||||
this.keyActionsBadge = false,
|
||||
}) : assert(!(onFileDropped != null && fileDropOverlay == null),
|
||||
final Widget? headerSliver;
|
||||
const AppPage(
|
||||
{super.key,
|
||||
this.title,
|
||||
this.alternativeTitle,
|
||||
this.footnote,
|
||||
required this.builder,
|
||||
this.centered = false,
|
||||
this.keyActionsBuilder,
|
||||
this.detailViewBuilder,
|
||||
this.actionButtonBuilder,
|
||||
this.actionsBuilder,
|
||||
this.fileDropOverlay,
|
||||
this.capabilities,
|
||||
this.onFileDropped,
|
||||
this.delayedContent = false,
|
||||
this.keyActionsBadge = false,
|
||||
this.headerSliver})
|
||||
: assert(!(onFileDropped != null && fileDropOverlay == null),
|
||||
'Declaring onFileDropped requires declaring a fileDropOverlay');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _AppPageState();
|
||||
}
|
||||
|
||||
if (width < 400 ||
|
||||
(isAndroid && width < 600 && width < constraints.maxHeight)) {
|
||||
return _buildScaffold(context, true, false, false);
|
||||
class _AppPageState extends ConsumerState<AppPage> {
|
||||
final _VisibilityController _sliverTitleController = _VisibilityController();
|
||||
final _VisibilityController _headerSliverController = _VisibilityController();
|
||||
final _VisibilityController _navController = _VisibilityController();
|
||||
final _VisibilityController _detailsController = _VisibilityController();
|
||||
late _VisibilitiesController _scrolledUnderController;
|
||||
|
||||
final ScrollController _sliverTitleScrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrolledUnderController = _VisibilitiesController(
|
||||
[_sliverTitleController, _navController, _detailsController]);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sliverTitleController.dispose();
|
||||
_headerSliverController.dispose();
|
||||
_navController.dispose();
|
||||
_detailsController.dispose();
|
||||
_scrolledUnderController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
if (width < 400 ||
|
||||
(isAndroid && width < 600 && width < constraints.maxHeight)) {
|
||||
return _buildScaffold(context, true, false, false);
|
||||
}
|
||||
if (width < 800) {
|
||||
return _buildScaffold(context, true, true, false);
|
||||
}
|
||||
if (width < 1000) {
|
||||
return _buildScaffold(context, true, true, true);
|
||||
} else {
|
||||
// Fully expanded layout, close existing drawer if open
|
||||
final scaffoldState = scaffoldGlobalKey.currentState;
|
||||
if (scaffoldState?.isDrawerOpen == true) {
|
||||
scaffoldState?.openEndDrawer();
|
||||
}
|
||||
if (width < 800) {
|
||||
return _buildScaffold(context, true, true, false);
|
||||
}
|
||||
if (width < 1000) {
|
||||
return _buildScaffold(context, true, true, true);
|
||||
} else {
|
||||
// Fully expanded layout, close existing drawer if open
|
||||
final scaffoldState = scaffoldGlobalKey.currentState;
|
||||
if (scaffoldState?.isDrawerOpen == true) {
|
||||
scaffoldState?.openEndDrawer();
|
||||
}
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 280,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildLogo(context),
|
||||
NavigationContent(
|
||||
key: _navExpandedKey,
|
||||
shouldPop: false,
|
||||
extended: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildScaffold(context, false, false, true),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
return _buildScaffold(context, false, true, true);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLogo(BuildContext context) {
|
||||
final color =
|
||||
@ -160,40 +192,159 @@ class AppPage extends StatelessWidget {
|
||||
));
|
||||
}
|
||||
|
||||
void _scrollElement(
|
||||
BuildContext context,
|
||||
ScrollController scrollController,
|
||||
_ScrollDirection direction,
|
||||
_VisibilityController controller,
|
||||
GlobalKey targetKey,
|
||||
GlobalKey? anchorKey) {
|
||||
if (direction != _ScrollDirection.idle) {
|
||||
final currentContext = targetKey.currentContext;
|
||||
if (currentContext == null) return;
|
||||
|
||||
final RenderBox renderBox =
|
||||
currentContext.findRenderObject() as RenderBox;
|
||||
final RenderBox? anchorRenderBox = anchorKey != null
|
||||
? anchorKey.currentContext?.findRenderObject() as RenderBox?
|
||||
: null;
|
||||
|
||||
final anchorHeight = anchorRenderBox != null
|
||||
? anchorRenderBox.size.height
|
||||
: Scaffold.of(context).appBarMaxHeight!;
|
||||
|
||||
final targetHeight = renderBox.size.height;
|
||||
final positionOffset = anchorRenderBox != null
|
||||
? Offset(0, -anchorRenderBox.localToGlobal(Offset.zero).dy)
|
||||
: Offset.zero;
|
||||
|
||||
final position = renderBox.localToGlobal(positionOffset);
|
||||
|
||||
if (direction == _ScrollDirection.up) {
|
||||
var offset = scrollController.position.pixels +
|
||||
(targetHeight - (anchorHeight - position.dy));
|
||||
if (offset > scrollController.position.maxScrollExtent) {
|
||||
offset = scrollController.position.maxScrollExtent;
|
||||
}
|
||||
Timer.run(() {
|
||||
scrollController.animateTo(offset,
|
||||
duration: const Duration(milliseconds: 100), curve: Curves.ease);
|
||||
});
|
||||
} else {
|
||||
var offset =
|
||||
scrollController.position.pixels - (anchorHeight - position.dy);
|
||||
|
||||
if (offset < scrollController.position.minScrollExtent) {
|
||||
offset = scrollController.position.minScrollExtent;
|
||||
}
|
||||
if (controller.visibility != _Visibility.visible) {
|
||||
Timer.run(() {
|
||||
scrollController.animateTo(offset,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
curve: Curves.ease);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildTitle(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
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))],
|
||||
)
|
||||
],
|
||||
return ListenableBuilder(
|
||||
listenable: _sliverTitleController,
|
||||
builder: (context, child) {
|
||||
_scrollElement(
|
||||
context,
|
||||
_sliverTitleScrollController,
|
||||
_sliverTitleController.scrollDirection,
|
||||
_sliverTitleController,
|
||||
_sliverTitleWrapperGlobalKey,
|
||||
null);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
key: _sliverTitleGlobalKey,
|
||||
widget.alternativeTitle ?? widget.title!,
|
||||
style: Theme.of(context).textTheme.displaySmall!.copyWith(
|
||||
color: widget.alternativeTitle != null
|
||||
? Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant
|
||||
.withOpacity(0.4)
|
||||
: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withOpacity(0.9),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (widget.capabilities != null && widget.alternativeTitle == null)
|
||||
Wrap(
|
||||
spacing: 4.0,
|
||||
runSpacing: 8.0,
|
||||
children: [
|
||||
...widget.capabilities!.map((c) => CapabilityBadge(c))
|
||||
],
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _buildAppBarTitle(
|
||||
BuildContext context, bool hasRail, bool hasManage, bool fullyExpanded) {
|
||||
final showNavigation = ref.watch(_navigationProvider);
|
||||
EdgeInsets padding;
|
||||
if (fullyExpanded) {
|
||||
padding = EdgeInsets.only(left: showNavigation ? 280 : 72, right: 320);
|
||||
} else if (!hasRail && hasManage) {
|
||||
padding = const EdgeInsets.only(right: 320);
|
||||
} else if (hasRail && hasManage) {
|
||||
padding = const EdgeInsets.only(left: 72, right: 320);
|
||||
} else if (hasRail && !hasManage) {
|
||||
padding = const EdgeInsets.only(left: 72);
|
||||
} else {
|
||||
padding = const EdgeInsets.all(0);
|
||||
}
|
||||
|
||||
if (widget.title != null) {
|
||||
return ListenableBuilder(
|
||||
listenable: _sliverTitleController,
|
||||
builder: (context, child) {
|
||||
final visible =
|
||||
_sliverTitleController.visibility == _Visibility.scrolledUnder;
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
opacity: visible ? 1 : 0,
|
||||
child: Text(widget.alternativeTitle ?? widget.title!),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Widget _buildMainContent(BuildContext context, bool expanded) {
|
||||
final actions = actionsBuilder?.call(context, expanded) ?? [];
|
||||
final actions = widget.actionsBuilder?.call(context, expanded) ?? [];
|
||||
final content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment:
|
||||
centered ? CrossAxisAlignment.center : CrossAxisAlignment.start,
|
||||
crossAxisAlignment: widget.centered
|
||||
? CrossAxisAlignment.center
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (title != null && !centered)
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 24.0),
|
||||
child: _buildTitle(context),
|
||||
),
|
||||
builder(context, expanded),
|
||||
widget.builder(context, expanded),
|
||||
if (actions.isNotEmpty)
|
||||
Align(
|
||||
alignment: centered ? Alignment.center : Alignment.centerLeft,
|
||||
alignment:
|
||||
widget.centered ? Alignment.center : Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 16, bottom: 0, left: 16, right: 16),
|
||||
@ -204,14 +355,14 @@ class AppPage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (footnote != null)
|
||||
if (widget.footnote != null)
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(bottom: 16, top: 33, left: 16, right: 16),
|
||||
child: Opacity(
|
||||
opacity: 0.6,
|
||||
child: Text(
|
||||
footnote!,
|
||||
widget.footnote!,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
@ -220,7 +371,7 @@ class AppPage extends StatelessWidget {
|
||||
);
|
||||
|
||||
final safeArea = SafeArea(
|
||||
child: delayedContent
|
||||
child: widget.delayedContent
|
||||
? DelayedVisibility(
|
||||
key: GlobalKey(), // Ensure we reset the delay on rebuild
|
||||
delay: const Duration(milliseconds: 400),
|
||||
@ -229,39 +380,99 @@ class AppPage extends StatelessWidget {
|
||||
: 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.centered) {
|
||||
return Stack(children: [
|
||||
if (widget.title != null)
|
||||
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,
|
||||
),
|
||||
alignment: Alignment.topLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16.0, right: 16.0, bottom: 24.0),
|
||||
child: _buildTitle(context),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
Positioned.fill(
|
||||
top: widget.title != null ? 68.0 : 0,
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: ScrollConfiguration(
|
||||
behavior:
|
||||
ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
||||
child: SingleChildScrollView(
|
||||
physics: isAndroid
|
||||
? const ClampingScrollPhysics(
|
||||
parent: AlwaysScrollableScrollPhysics())
|
||||
: null,
|
||||
child: safeArea,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
]);
|
||||
}
|
||||
if (widget.title != null) {
|
||||
return _VisibilityListener(
|
||||
targetKey: _sliverTitleGlobalKey,
|
||||
controller: _sliverTitleController,
|
||||
subTargetKey:
|
||||
widget.headerSliver != null ? _headerSliverGlobalKey : null,
|
||||
subController:
|
||||
widget.headerSliver != null ? _headerSliverController : null,
|
||||
subAnchorKey:
|
||||
widget.headerSliver != null ? _sliverTitleWrapperGlobalKey : null,
|
||||
child: CustomScrollView(
|
||||
physics: isAndroid
|
||||
? const ClampingScrollPhysics(
|
||||
parent: AlwaysScrollableScrollPhysics())
|
||||
: null,
|
||||
controller: _sliverTitleScrollController,
|
||||
key: _mainContentGlobalKey,
|
||||
slivers: [
|
||||
SliverMainAxisGroup(
|
||||
slivers: [
|
||||
SliverPinnedHeader(
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context).colorScheme.background,
|
||||
child: Padding(
|
||||
key: _sliverTitleWrapperGlobalKey,
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16.0, right: 16.0, bottom: 12.0, top: 4.0),
|
||||
child: _buildTitle(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.headerSliver != null)
|
||||
SliverToBoxAdapter(
|
||||
child: ListenableBuilder(
|
||||
listenable: _headerSliverController,
|
||||
builder: (context, child) {
|
||||
_scrollElement(
|
||||
context,
|
||||
_sliverTitleScrollController,
|
||||
_headerSliverController.scrollDirection,
|
||||
_headerSliverController,
|
||||
_headerSliverGlobalKey,
|
||||
_sliverTitleWrapperGlobalKey);
|
||||
|
||||
return Container(
|
||||
key: _headerSliverGlobalKey,
|
||||
child: widget.headerSliver);
|
||||
},
|
||||
))
|
||||
],
|
||||
),
|
||||
SliverToBoxAdapter(child: safeArea)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
physics: isAndroid
|
||||
? const ClampingScrollPhysics(parent: AlwaysScrollableScrollPhysics())
|
||||
: null,
|
||||
primary: false,
|
||||
child: safeArea,
|
||||
);
|
||||
@ -269,12 +480,14 @@ class AppPage extends StatelessWidget {
|
||||
|
||||
Scaffold _buildScaffold(
|
||||
BuildContext context, bool hasDrawer, bool hasRail, bool hasManage) {
|
||||
final fullyExpanded = !hasDrawer && hasRail && hasManage;
|
||||
final showNavigation = ref.watch(_navigationProvider);
|
||||
var body = _buildMainContent(context, hasManage);
|
||||
|
||||
if (onFileDropped != null) {
|
||||
if (widget.onFileDropped != null) {
|
||||
body = FileDropTarget(
|
||||
onFileDropped: onFileDropped!,
|
||||
overlay: fileDropOverlay!,
|
||||
onFileDropped: widget.onFileDropped!,
|
||||
overlay: widget.fileDropOverlay!,
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
@ -282,17 +495,38 @@ class AppPage extends StatelessWidget {
|
||||
body = Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (hasRail)
|
||||
if (hasRail && (!fullyExpanded || !showNavigation))
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: SingleChildScrollView(
|
||||
child: NavigationContent(
|
||||
key: _navKey,
|
||||
shouldPop: false,
|
||||
extended: false,
|
||||
child: _VisibilityListener(
|
||||
targetKey: _navKey,
|
||||
controller: _navController,
|
||||
child: SingleChildScrollView(
|
||||
child: NavigationContent(
|
||||
key: _navKey,
|
||||
shouldPop: false,
|
||||
extended: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (fullyExpanded && showNavigation)
|
||||
SizedBox(
|
||||
width: 280,
|
||||
child: _VisibilityListener(
|
||||
controller: _navController,
|
||||
targetKey: _navExpandedKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: NavigationContent(
|
||||
key: _navExpandedKey,
|
||||
shouldPop: false,
|
||||
extended: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
@ -308,19 +542,25 @@ class AppPage extends StatelessWidget {
|
||||
]),
|
||||
)),
|
||||
if (hasManage &&
|
||||
(detailViewBuilder != null || keyActionsBuilder != null))
|
||||
SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: SizedBox(
|
||||
width: 320,
|
||||
child: Column(
|
||||
children: [
|
||||
if (detailViewBuilder != null)
|
||||
detailViewBuilder!(context),
|
||||
if (keyActionsBuilder != null)
|
||||
keyActionsBuilder!(context),
|
||||
],
|
||||
(widget.detailViewBuilder != null ||
|
||||
widget.keyActionsBuilder != null))
|
||||
_VisibilityListener(
|
||||
controller: _detailsController,
|
||||
targetKey: _detailsViewGlobalKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: SizedBox(
|
||||
width: 320,
|
||||
child: Column(
|
||||
key: _detailsViewGlobalKey,
|
||||
children: [
|
||||
if (widget.detailViewBuilder != null)
|
||||
widget.detailViewBuilder!(context),
|
||||
if (widget.keyActionsBuilder != null)
|
||||
widget.keyActionsBuilder!(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -331,24 +571,56 @@ class AppPage extends StatelessWidget {
|
||||
return Scaffold(
|
||||
key: scaffoldGlobalKey,
|
||||
appBar: AppBar(
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(1.0),
|
||||
child: ListenableBuilder(
|
||||
listenable: _scrolledUnderController,
|
||||
builder: (context, child) {
|
||||
final visible = _scrolledUnderController.someIsScrolledUnder;
|
||||
return AnimatedOpacity(
|
||||
opacity: visible ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Container(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
height: 1.0,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
scrolledUnderElevation: 0.0,
|
||||
leadingWidth: hasRail ? 84 : null,
|
||||
backgroundColor: Theme.of(context).colorScheme.background,
|
||||
title: _buildAppBarTitle(
|
||||
context,
|
||||
hasRail,
|
||||
hasManage,
|
||||
fullyExpanded,
|
||||
),
|
||||
leading: hasRail
|
||||
? const Row(
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: DrawerButton(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: DrawerButton(
|
||||
onPressed: fullyExpanded
|
||||
? () {
|
||||
ref
|
||||
.read(_navigationProvider.notifier)
|
||||
.toggleExpanded();
|
||||
}
|
||||
: null,
|
||||
),
|
||||
)),
|
||||
SizedBox(width: 12),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
actions: [
|
||||
if (actionButtonBuilder == null &&
|
||||
(keyActionsBuilder != null && !hasManage))
|
||||
if (widget.actionButtonBuilder == null &&
|
||||
(widget.keyActionsBuilder != null && !hasManage))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: IconButton(
|
||||
@ -360,12 +632,12 @@ class AppPage extends StatelessWidget {
|
||||
builder: (context) => FsDialog(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 32),
|
||||
child: keyActionsBuilder!(context),
|
||||
child: widget.keyActionsBuilder!(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: keyActionsBadge
|
||||
icon: widget.keyActionsBadge
|
||||
? const Badge(
|
||||
child: Icon(Symbols.more_vert),
|
||||
)
|
||||
@ -375,10 +647,10 @@ class AppPage extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
),
|
||||
if (actionButtonBuilder != null)
|
||||
if (widget.actionButtonBuilder != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: actionButtonBuilder!.call(context),
|
||||
child: widget.actionButtonBuilder!.call(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -408,3 +680,203 @@ class CapabilityBadge extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _Visibility { visible, topScrolledUnder, halfScrolledUnder, scrolledUnder }
|
||||
|
||||
enum _ScrollDirection { idle, up, down }
|
||||
|
||||
class _VisibilityController with ChangeNotifier {
|
||||
_Visibility _visibility = _Visibility.visible;
|
||||
_ScrollDirection _scrollDirection = _ScrollDirection.idle;
|
||||
|
||||
void setVisibility(_Visibility visibility) {
|
||||
if (visibility != _visibility) {
|
||||
_visibility = visibility;
|
||||
if (_visibility != _Visibility.visible) {
|
||||
_scrollDirection = _ScrollDirection.idle;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void notifyScroll(_ScrollDirection scrollDirection) {
|
||||
if (visibility != _Visibility.scrolledUnder) {
|
||||
_scrollDirection = scrollDirection;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
_ScrollDirection get scrollDirection => _scrollDirection;
|
||||
_Visibility get visibility => _visibility;
|
||||
}
|
||||
|
||||
class _VisibilitiesController with ChangeNotifier {
|
||||
final List<_VisibilityController> controllers;
|
||||
bool someIsScrolledUnder = false;
|
||||
_VisibilitiesController(this.controllers) {
|
||||
for (var element in controllers) {
|
||||
element.addListener(() {
|
||||
_setScrolledUnder();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _setScrolledUnder() {
|
||||
final val =
|
||||
controllers.any((element) => element.visibility != _Visibility.visible);
|
||||
if (val != someIsScrolledUnder) {
|
||||
someIsScrolledUnder = val;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _VisibilityListener extends StatefulWidget {
|
||||
final _VisibilityController controller;
|
||||
final Widget child;
|
||||
final GlobalKey targetKey;
|
||||
final _VisibilityController? subController;
|
||||
final GlobalKey? subTargetKey;
|
||||
final GlobalKey? subAnchorKey;
|
||||
const _VisibilityListener(
|
||||
{required this.controller,
|
||||
required this.child,
|
||||
required this.targetKey,
|
||||
this.subController,
|
||||
this.subTargetKey,
|
||||
this.subAnchorKey})
|
||||
: assert(
|
||||
(subController == null &&
|
||||
subTargetKey == null &&
|
||||
subAnchorKey == null) ||
|
||||
(subController != null &&
|
||||
subTargetKey != null &&
|
||||
subAnchorKey != null),
|
||||
'Declaring requires subTargetKey and subAnchorKey, and vice versa',
|
||||
);
|
||||
|
||||
@override
|
||||
State<_VisibilityListener> createState() => _VisibilityListenerState();
|
||||
}
|
||||
|
||||
class _VisibilityListenerState extends State<_VisibilityListener> {
|
||||
bool disableScroll = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Listener(
|
||||
onPointerDown: (event) {
|
||||
setState(() {
|
||||
disableScroll = true;
|
||||
});
|
||||
},
|
||||
onPointerUp: (event) {
|
||||
setState(() {
|
||||
disableScroll = false;
|
||||
});
|
||||
},
|
||||
onPointerSignal: (event) {
|
||||
if (event is PointerScrollEvent) {
|
||||
if (!disableScroll) {
|
||||
setState(() {
|
||||
disableScroll = true;
|
||||
});
|
||||
Timer(const Duration(seconds: 1), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
disableScroll = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
child: NotificationListener(
|
||||
onNotification: (notification) {
|
||||
if (notification is ScrollMetricsNotification ||
|
||||
notification is ScrollUpdateNotification) {
|
||||
_handleScrollUpdate(context);
|
||||
}
|
||||
|
||||
if (notification is ScrollEndNotification &&
|
||||
widget.child is CustomScrollView) {
|
||||
// Disable auto scrolling for mouse wheel and scrollbar
|
||||
_handleScrollEnd(context);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
|
||||
void _handleScrollUpdate(
|
||||
BuildContext context,
|
||||
) {
|
||||
widget.controller
|
||||
.setVisibility(_scrolledUnderState(context, widget.targetKey, null));
|
||||
|
||||
if (widget.subController != null) {
|
||||
widget.subController!.setVisibility(_scrolledUnderState(
|
||||
context, widget.subTargetKey!, widget.subAnchorKey));
|
||||
}
|
||||
}
|
||||
|
||||
void _handleScrollEnd(
|
||||
BuildContext context,
|
||||
) {
|
||||
if (!disableScroll) {
|
||||
widget.controller.notifyScroll(_getSrollDirection(
|
||||
_scrolledUnderState(context, widget.targetKey, null)));
|
||||
|
||||
if (widget.subController != null) {
|
||||
widget.subController!.notifyScroll(_getSrollDirection(
|
||||
_scrolledUnderState(
|
||||
context, widget.subTargetKey!, widget.subAnchorKey)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ScrollDirection _getSrollDirection(_Visibility visibility) {
|
||||
if (visibility == _Visibility.halfScrolledUnder) {
|
||||
return _ScrollDirection.up;
|
||||
} else if (visibility == _Visibility.topScrolledUnder) {
|
||||
return _ScrollDirection.down;
|
||||
} else {
|
||||
return _ScrollDirection.idle;
|
||||
}
|
||||
}
|
||||
|
||||
_Visibility _scrolledUnderState(
|
||||
BuildContext context,
|
||||
GlobalKey targetKey,
|
||||
GlobalKey? anchorKey,
|
||||
) {
|
||||
final currentContext = targetKey.currentContext;
|
||||
if (currentContext == null) return _Visibility.visible;
|
||||
|
||||
final RenderBox renderBox = currentContext.findRenderObject() as RenderBox;
|
||||
final RenderBox? anchorRenderBox = anchorKey != null
|
||||
? anchorKey.currentContext?.findRenderObject() as RenderBox?
|
||||
: null;
|
||||
|
||||
final anchorHeight = anchorRenderBox != null
|
||||
? anchorRenderBox.size.height
|
||||
: Scaffold.of(context).appBarMaxHeight!;
|
||||
|
||||
final targetHeight = renderBox.size.height;
|
||||
final positionOffset = anchorRenderBox != null
|
||||
? Offset(0, -anchorRenderBox.localToGlobal(Offset.zero).dy)
|
||||
: Offset.zero;
|
||||
|
||||
final position = renderBox.localToGlobal(positionOffset);
|
||||
|
||||
if (anchorHeight - position.dy > targetHeight - 10) {
|
||||
return _Visibility.scrolledUnder;
|
||||
} else if (anchorHeight - position.dy > targetHeight / 2) {
|
||||
return _Visibility.halfScrolledUnder;
|
||||
} else if (anchorHeight - position.dy > 0) {
|
||||
return _Visibility.topScrolledUnder;
|
||||
} else {
|
||||
return _Visibility.visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -131,7 +131,16 @@ class DevicePickerContent extends ConsumerWidget {
|
||||
),
|
||||
];
|
||||
|
||||
return Column(children: children);
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: !extended && children.length > 1
|
||||
? 13
|
||||
: !extended
|
||||
? 6.5
|
||||
: 0,
|
||||
),
|
||||
child: Column(children: children),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -311,7 +320,7 @@ class _DeviceRowState extends ConsumerState<_DeviceRow> {
|
||||
isDesktop && menuItems.isNotEmpty ? showMenuFn : null,
|
||||
onLongPressStart: isAndroid ? showMenuFn : null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6.5),
|
||||
padding: const EdgeInsets.only(bottom: 6.5),
|
||||
child: widget.selected
|
||||
? IconButton.filled(
|
||||
tooltip: isDesktop ? tooltip : null,
|
||||
|
@ -18,6 +18,8 @@ import 'package:flutter/material.dart';
|
||||
|
||||
// global keys
|
||||
final scaffoldGlobalKey = GlobalKey<ScaffoldState>();
|
||||
// This is global so we can access it from the global Ctrl+F shortcut.
|
||||
final searchField = GlobalKey();
|
||||
|
||||
const _prefix = 'app.keys';
|
||||
const deviceInfoListTile = Key('$_prefix.device_info_list_tile');
|
||||
|
@ -128,7 +128,8 @@ class NavigationContent extends ConsumerWidget {
|
||||
final currentSection = ref.watch(currentSectionProvider);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
padding:
|
||||
const EdgeInsets.only(left: 8.0, right: 8.0, bottom: 8.0, top: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
AnimatedSize(
|
||||
|
@ -31,9 +31,8 @@ import 'state.dart';
|
||||
|
||||
const _usbPollDelay = Duration(milliseconds: 500);
|
||||
|
||||
const _nfcPollDelay = Duration(milliseconds: 2500);
|
||||
const _nfcAttachPollDelay = Duration(seconds: 1);
|
||||
const _nfcDetachPollDelay = Duration(seconds: 5);
|
||||
const _nfcPollReadersDelay = Duration(milliseconds: 2500);
|
||||
const _nfcPollCardDelay = Duration(seconds: 1);
|
||||
|
||||
final _log = Logger('desktop.devices');
|
||||
|
||||
@ -197,7 +196,7 @@ class NfcDeviceNotifier extends StateNotifier<List<NfcReaderNode>> {
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
_pollTimer = Timer(_nfcPollDelay, _pollReaders);
|
||||
_pollTimer = Timer(_nfcPollReadersDelay, _pollReaders);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -260,7 +259,7 @@ class CurrentDeviceDataNotifier extends StateNotifier<AsyncValue<YubiKeyData>> {
|
||||
|
||||
void _notifyWindowState(WindowState windowState) {
|
||||
if (windowState.active) {
|
||||
_pollReader();
|
||||
_pollCard();
|
||||
} else {
|
||||
_pollTimer?.cancel();
|
||||
// TODO: Should we clear the key here?
|
||||
@ -276,16 +275,23 @@ class CurrentDeviceDataNotifier extends StateNotifier<AsyncValue<YubiKeyData>> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _pollReader() async {
|
||||
void _pollCard() async {
|
||||
_pollTimer?.cancel();
|
||||
final node = _deviceNode!;
|
||||
try {
|
||||
_log.debug('Polling for USB device changes...');
|
||||
_log.debug('Polling for NFC device changes...');
|
||||
var result = await _rpc?.command('get', node.path.segments);
|
||||
if (mounted && result != null) {
|
||||
if (result['data']['present']) {
|
||||
state = AsyncValue.data(YubiKeyData(node, result['data']['name'],
|
||||
DeviceInfo.fromJson(result['data']['info'])));
|
||||
final oldState = state.valueOrNull;
|
||||
final newState = YubiKeyData(node, result['data']['name'],
|
||||
DeviceInfo.fromJson(result['data']['info']));
|
||||
if (oldState != null && oldState != newState) {
|
||||
// Ensure state is cleared
|
||||
state = const AsyncValue.loading();
|
||||
} else {
|
||||
state = AsyncValue.data(newState);
|
||||
}
|
||||
} else {
|
||||
final status = result['data']['status'];
|
||||
// Only update if status is not changed
|
||||
@ -298,9 +304,7 @@ class CurrentDeviceDataNotifier extends StateNotifier<AsyncValue<YubiKeyData>> {
|
||||
_log.error('Error polling NFC', jsonEncode(e));
|
||||
}
|
||||
if (mounted) {
|
||||
_pollTimer = Timer(
|
||||
state is AsyncData ? _nfcDetachPollDelay : _nfcAttachPollDelay,
|
||||
_pollReader);
|
||||
_pollTimer = Timer(_nfcPollCardDelay, _pollCard);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -295,7 +295,7 @@ class DesktopCredentialListNotifier extends OathCredentialListNotifier {
|
||||
code = OathCode.fromJson(result);
|
||||
}
|
||||
_log.debug('Calculate', jsonEncode(code));
|
||||
if (update && mounted) {
|
||||
if (update && mounted && state != null) {
|
||||
final creds = state!.toList();
|
||||
final i = creds.indexWhere((e) => e.credential.id == credential.id);
|
||||
state = creds..[i] = creds[i].copyWith(code: code);
|
||||
|
@ -72,24 +72,42 @@ class _DesktopPivStateNotifier extends PivStateNotifier {
|
||||
ref.invalidate(_sessionProvider(devicePath));
|
||||
})
|
||||
..setErrorHandler('auth-required', (e) async {
|
||||
final String? mgmtKey;
|
||||
if (state.valueOrNull?.metadata?.managementKeyMetadata.defaultValue ==
|
||||
true) {
|
||||
mgmtKey = defaultManagementKey;
|
||||
} else {
|
||||
mgmtKey = ref.read(_managementKeyProvider(devicePath));
|
||||
}
|
||||
if (mgmtKey != null) {
|
||||
if (await authenticate(mgmtKey)) {
|
||||
ref.invalidateSelf();
|
||||
try {
|
||||
if (state.valueOrNull?.protectedKey == true) {
|
||||
final String? pin;
|
||||
if (state.valueOrNull?.metadata?.pinMetadata.defaultValue == true) {
|
||||
pin = defaultPin;
|
||||
} else {
|
||||
pin = ref.read(_pinProvider(devicePath));
|
||||
}
|
||||
if (pin != null) {
|
||||
if (await verifyPin(pin) is PinSuccess) {
|
||||
return;
|
||||
} else {
|
||||
ref.read(_pinProvider(devicePath).notifier).state = null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ref.read(_managementKeyProvider(devicePath).notifier).state = null;
|
||||
ref.invalidateSelf();
|
||||
throw e;
|
||||
final String? mgmtKey;
|
||||
if (state.valueOrNull?.metadata?.managementKeyMetadata
|
||||
.defaultValue ==
|
||||
true) {
|
||||
mgmtKey = defaultManagementKey;
|
||||
} else {
|
||||
mgmtKey = ref.read(_managementKeyProvider(devicePath));
|
||||
}
|
||||
if (mgmtKey != null) {
|
||||
if (await authenticate(mgmtKey)) {
|
||||
return;
|
||||
} else {
|
||||
ref.read(_managementKeyProvider(devicePath).notifier).state =
|
||||
null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ref.invalidateSelf();
|
||||
throw e;
|
||||
} finally {
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
});
|
||||
ref.onDispose(() {
|
||||
@ -299,8 +317,24 @@ class _DesktopPivSlotsNotifier extends PivSlotsNotifier {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(SlotId slot) async {
|
||||
await _session.command('delete', target: ['slots', slot.hexId]);
|
||||
Future<void> delete(SlotId slot, bool deleteCert, bool deleteKey) async {
|
||||
await _session.command('delete',
|
||||
target: ['slots', slot.hexId],
|
||||
params: {'delete_cert': deleteCert, 'delete_key': deleteKey});
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> moveKey(SlotId source, SlotId destination, bool overwriteKey,
|
||||
bool includeCertificate) async {
|
||||
await _session.command('move_key', target: [
|
||||
'slots',
|
||||
source.hexId
|
||||
], params: {
|
||||
'destination': destination.hexId,
|
||||
'overwrite_key': overwriteKey,
|
||||
'include_certificate': includeCertificate
|
||||
});
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
|
@ -40,6 +40,8 @@ class FidoState with _$FidoState {
|
||||
info['options']['credMgmt'] == true ||
|
||||
info['options']['credentialMgmtPreview'] == true;
|
||||
|
||||
int? get remainingCreds => info['remaining_disc_creds'];
|
||||
|
||||
bool? get bioEnroll => info['options']['bioEnroll'];
|
||||
|
||||
bool get alwaysUv => info['options']['alwaysUv'] == true;
|
||||
|
@ -20,6 +20,18 @@ import '../app/models.dart';
|
||||
import '../core/state.dart';
|
||||
import 'models.dart';
|
||||
|
||||
final passkeysSearchProvider =
|
||||
StateNotifierProvider<PasskeysSearchNotifier, String>(
|
||||
(ref) => PasskeysSearchNotifier());
|
||||
|
||||
class PasskeysSearchNotifier extends StateNotifier<String> {
|
||||
PasskeysSearchNotifier() : super('');
|
||||
|
||||
void setFilter(String value) {
|
||||
state = value;
|
||||
}
|
||||
}
|
||||
|
||||
final fidoStateProvider = AsyncNotifierProvider.autoDispose
|
||||
.family<FidoStateNotifier, FidoState, DevicePath>(
|
||||
() => throw UnimplementedError(),
|
||||
@ -52,3 +64,23 @@ abstract class FidoCredentialsNotifier
|
||||
extends AutoDisposeFamilyAsyncNotifier<List<FidoCredential>, DevicePath> {
|
||||
Future<void> deleteCredential(FidoCredential credential);
|
||||
}
|
||||
|
||||
final filteredFidoCredentialsProvider = StateNotifierProvider.autoDispose
|
||||
.family<FilteredFidoCredentialsNotifier, List<FidoCredential>,
|
||||
List<FidoCredential>>(
|
||||
(ref, full) {
|
||||
return FilteredFidoCredentialsNotifier(
|
||||
full, ref.watch(passkeysSearchProvider));
|
||||
},
|
||||
);
|
||||
|
||||
class FilteredFidoCredentialsNotifier
|
||||
extends StateNotifier<List<FidoCredential>> {
|
||||
final String query;
|
||||
FilteredFidoCredentialsNotifier(List<FidoCredential> full, this.query)
|
||||
: super(full
|
||||
.where((credential) =>
|
||||
credential.rpId.toLowerCase().contains(query.toLowerCase()) ||
|
||||
credential.userName.toLowerCase().contains(query.toLowerCase()))
|
||||
.toList());
|
||||
}
|
||||
|
@ -75,7 +75,7 @@ class CredentialDialog extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Icon(Symbols.person, size: 72),
|
||||
const Icon(Symbols.passkey, size: 72),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -17,6 +17,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@ -29,11 +30,14 @@ import '../../app/views/action_list.dart';
|
||||
import '../../app/views/app_failure_page.dart';
|
||||
import '../../app/views/app_list_item.dart';
|
||||
import '../../app/views/app_page.dart';
|
||||
import '../../app/views/keys.dart';
|
||||
import '../../app/views/message_page.dart';
|
||||
import '../../app/views/message_page_not_initialized.dart';
|
||||
import '../../core/state.dart';
|
||||
import '../../exception/no_data_exception.dart';
|
||||
import '../../management/models.dart';
|
||||
import '../../widgets/app_input_decoration.dart';
|
||||
import '../../widgets/app_text_form_field.dart';
|
||||
import '../../widgets/list_title.dart';
|
||||
import '../features.dart' as features;
|
||||
import '../models.dart';
|
||||
@ -137,7 +141,7 @@ class _FidoLockedPage extends ConsumerWidget {
|
||||
: alwaysUv
|
||||
? l10n.l_pin_change_required_desc
|
||||
: l10n.l_register_sk_on_websites,
|
||||
footnote: isBio ? null : l10n.l_non_passkeys_note,
|
||||
footnote: isBio ? null : l10n.p_non_passkeys_note,
|
||||
keyActionsBuilder: hasActions ? _buildActions : null,
|
||||
keyActionsBadge: passkeysShowActionsNotifier(state),
|
||||
);
|
||||
@ -149,7 +153,7 @@ class _FidoLockedPage extends ConsumerWidget {
|
||||
capabilities: const [Capability.fido2],
|
||||
header: l10n.l_ready_to_use,
|
||||
message: l10n.l_register_sk_on_websites,
|
||||
footnote: l10n.l_non_passkeys_note,
|
||||
footnote: l10n.p_non_passkeys_note,
|
||||
keyActionsBuilder: hasActions ? _buildActions : null,
|
||||
keyActionsBadge: passkeysShowActionsNotifier(state),
|
||||
);
|
||||
@ -206,8 +210,30 @@ class _FidoUnlockedPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
|
||||
late FocusNode searchFocus;
|
||||
late TextEditingController searchController;
|
||||
FidoCredential? _selected;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
searchFocus = FocusNode();
|
||||
searchController =
|
||||
TextEditingController(text: ref.read(passkeysSearchProvider));
|
||||
searchFocus.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
searchFocus.dispose();
|
||||
searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
@ -222,7 +248,7 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
|
||||
capabilities: const [Capability.fido2],
|
||||
header: l10n.l_no_discoverable_accounts,
|
||||
message: l10n.l_register_sk_on_websites,
|
||||
footnote: l10n.l_non_passkeys_note,
|
||||
footnote: l10n.p_non_passkeys_note,
|
||||
keyActionsBuilder: hasActions
|
||||
? (context) =>
|
||||
passkeysBuildActions(context, widget.node, widget.state)
|
||||
@ -236,6 +262,12 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
|
||||
return _buildLoadingPage(context);
|
||||
}
|
||||
final credentials = data.value;
|
||||
final filteredCredentials =
|
||||
ref.watch(filteredFidoCredentialsProvider(credentials.toList()));
|
||||
|
||||
final remainingCreds = widget.state.remainingCreds;
|
||||
final maxCreds =
|
||||
remainingCreds != null ? remainingCreds + credentials.length : 25;
|
||||
|
||||
if (credentials.isEmpty) {
|
||||
return MessagePage(
|
||||
@ -265,14 +297,22 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
|
||||
passkeysBuildActions(context, widget.node, widget.state)
|
||||
: null,
|
||||
keyActionsBadge: passkeysShowActionsNotifier(widget.state),
|
||||
footnote: l10n.l_non_passkeys_note,
|
||||
footnote: l10n.p_non_passkeys_note,
|
||||
);
|
||||
}
|
||||
|
||||
final credential = _selected;
|
||||
final searchText = searchController.text;
|
||||
return FidoActions(
|
||||
devicePath: widget.node.path,
|
||||
actions: (context) => {
|
||||
SearchIntent: CallbackAction<SearchIntent>(onInvoke: (_) {
|
||||
searchController.selection = TextSelection(
|
||||
baseOffset: 0, extentOffset: searchController.text.length);
|
||||
searchFocus.unfocus();
|
||||
Timer.run(() => searchFocus.requestFocus());
|
||||
return null;
|
||||
}),
|
||||
EscapeIntent: CallbackAction<EscapeIntent>(onInvoke: (intent) {
|
||||
if (_selected != null) {
|
||||
setState(() {
|
||||
@ -307,8 +347,84 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
|
||||
},
|
||||
builder: (context) => AppPage(
|
||||
title: l10n.s_passkeys,
|
||||
alternativeTitle:
|
||||
searchText != '' ? l10n.l_results_for(searchText) : null,
|
||||
capabilities: const [Capability.fido2],
|
||||
footnote: l10n.l_non_passkeys_note,
|
||||
footnote:
|
||||
'${l10n.p_passkeys_used(credentials.length, maxCreds)} ${l10n.p_non_passkeys_note}',
|
||||
headerSliver: Focus(
|
||||
canRequestFocus: false,
|
||||
onKeyEvent: (node, event) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
node.focusInDirection(TraversalDirection.down);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
searchController.clear();
|
||||
ref.read(passkeysSearchProvider.notifier).setFilter('');
|
||||
node.unfocus();
|
||||
setState(() {});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: Builder(builder: (context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: AppTextFormField(
|
||||
key: searchField,
|
||||
controller: searchController,
|
||||
focusNode: searchFocus,
|
||||
// Use the default style, but with a smaller font size:
|
||||
style: textTheme.titleMedium
|
||||
?.copyWith(fontSize: textTheme.titleSmall?.fontSize),
|
||||
decoration: AppInputDecoration(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(48),
|
||||
borderSide: BorderSide(
|
||||
width: 0,
|
||||
style: searchFocus.hasFocus
|
||||
? BorderStyle.solid
|
||||
: BorderStyle.none,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
fillColor: Theme.of(context).hoverColor,
|
||||
filled: true,
|
||||
hintText: l10n.s_search_passkeys,
|
||||
isDense: true,
|
||||
prefixIcon: const Padding(
|
||||
padding: EdgeInsetsDirectional.only(start: 8.0),
|
||||
child: Icon(Icons.search_outlined),
|
||||
),
|
||||
suffixIcon: searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
iconSize: 16,
|
||||
onPressed: () {
|
||||
searchController.clear();
|
||||
ref
|
||||
.read(passkeysSearchProvider.notifier)
|
||||
.setFilter('');
|
||||
setState(() {});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: (value) {
|
||||
ref.read(passkeysSearchProvider.notifier).setFilter(value);
|
||||
setState(() {});
|
||||
},
|
||||
textInputAction: TextInputAction.next,
|
||||
onFieldSubmitted: (value) {
|
||||
Focus.of(context).focusInDirection(TraversalDirection.down);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
detailViewBuilder: credential != null
|
||||
? (context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@ -347,7 +463,7 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Icon(Symbols.person, size: 72),
|
||||
const Icon(Symbols.passkey, size: 72),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -390,15 +506,19 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: credentials
|
||||
.map(
|
||||
(cred) => _CredentialListItem(
|
||||
cred,
|
||||
expanded: expanded,
|
||||
selected: _selected == cred,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
children: [
|
||||
if (filteredCredentials.isEmpty)
|
||||
Center(
|
||||
child: Text(l10n.s_no_passkeys),
|
||||
),
|
||||
...filteredCredentials.map(
|
||||
(cred) => _CredentialListItem(
|
||||
cred,
|
||||
expanded: expanded,
|
||||
selected: _selected == cred,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
@ -423,13 +543,14 @@ class _CredentialListItem extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return AppListItem(
|
||||
credential,
|
||||
selected: selected,
|
||||
leading: CircleAvatar(
|
||||
foregroundColor: Theme.of(context).colorScheme.onPrimary,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
child: const Icon(Symbols.person),
|
||||
foregroundColor: colorScheme.onSecondary,
|
||||
backgroundColor: colorScheme.secondary,
|
||||
child: const Icon(Symbols.passkey),
|
||||
),
|
||||
title: credential.userName,
|
||||
subtitle: credential.rpId,
|
||||
|
@ -14,21 +14,39 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../../app/views/message_page.dart';
|
||||
import '../../management/models.dart';
|
||||
|
||||
class WebAuthnScreen extends StatelessWidget {
|
||||
class WebAuthnScreen extends StatefulWidget {
|
||||
const WebAuthnScreen({super.key});
|
||||
|
||||
@override
|
||||
State<WebAuthnScreen> createState() => _WebAuthnScreenState();
|
||||
}
|
||||
|
||||
class _WebAuthnScreenState extends State<WebAuthnScreen> {
|
||||
bool hide = true;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
// We need this to avoid unwanted app switch animation
|
||||
if (hide) {
|
||||
Timer.run(() {
|
||||
setState(() {
|
||||
hide = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
return MessagePage(
|
||||
title: l10n.s_security_key,
|
||||
title: hide ? null : l10n.s_security_key,
|
||||
capabilities: const [Capability.u2f],
|
||||
delayedContent: hide,
|
||||
header: l10n.l_ready_to_use,
|
||||
message: l10n.l_register_sk_on_websites,
|
||||
);
|
||||
|
@ -14,6 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@ -32,32 +34,49 @@ import '../../widgets/product_image.dart';
|
||||
import 'key_actions.dart';
|
||||
import 'manage_label_dialog.dart';
|
||||
|
||||
class HomeScreen extends ConsumerWidget {
|
||||
class HomeScreen extends ConsumerStatefulWidget {
|
||||
final YubiKeyData deviceData;
|
||||
const HomeScreen(this.deviceData, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
bool hide = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
final serial = deviceData.info.serial;
|
||||
final serial = widget.deviceData.info.serial;
|
||||
final keyCustomization = ref.watch(keyCustomizationManagerProvider)[serial];
|
||||
final enabledCapabilities =
|
||||
deviceData.info.config.enabledCapabilities[deviceData.node.transport] ??
|
||||
0;
|
||||
final enabledCapabilities = widget.deviceData.info.config
|
||||
.enabledCapabilities[widget.deviceData.node.transport] ??
|
||||
0;
|
||||
final primaryColor = ref.watch(defaultColorProvider);
|
||||
|
||||
// We need this to avoid unwanted app switch animation
|
||||
if (hide) {
|
||||
Timer.run(() {
|
||||
setState(() {
|
||||
hide = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return AppPage(
|
||||
title: l10n.s_home,
|
||||
title: hide ? null : l10n.s_home,
|
||||
delayedContent: hide,
|
||||
keyActionsBuilder: (context) =>
|
||||
homeBuildActions(context, deviceData, ref),
|
||||
homeBuildActions(context, widget.deviceData, ref),
|
||||
builder: (context, expanded) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_DeviceContent(deviceData, keyCustomization),
|
||||
_DeviceContent(widget.deviceData, keyCustomization),
|
||||
const SizedBox(height: 16.0),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -79,7 +98,7 @@ class HomeScreen extends ConsumerWidget {
|
||||
if (serial != null) ...[
|
||||
const SizedBox(height: 32.0),
|
||||
_DeviceColor(
|
||||
deviceData: deviceData,
|
||||
deviceData: widget.deviceData,
|
||||
initialCustomization: keyCustomization ??
|
||||
KeyCustomization(serial: serial))
|
||||
]
|
||||
@ -93,9 +112,9 @@ class HomeScreen extends ConsumerWidget {
|
||||
child: _HeroAvatar(
|
||||
color: keyCustomization?.color ?? primaryColor,
|
||||
child: ProductImage(
|
||||
name: deviceData.name,
|
||||
formFactor: deviceData.info.formFactor,
|
||||
isNfc: deviceData.info.supportedCapabilities
|
||||
name: widget.deviceData.name,
|
||||
formFactor: widget.deviceData.info.formFactor,
|
||||
isNfc: widget.deviceData.info.supportedCapabilities
|
||||
.containsKey(Transport.nfc),
|
||||
),
|
||||
),
|
||||
|
@ -28,6 +28,7 @@
|
||||
"s_cancel": "Abbrechen",
|
||||
"s_close": "Schließen",
|
||||
"s_delete": "Löschen",
|
||||
"s_move": null,
|
||||
"s_quit": "Beenden",
|
||||
"s_status": null,
|
||||
"s_unlock": "Entsperren",
|
||||
@ -352,6 +353,12 @@
|
||||
},
|
||||
"s_accounts": "Konten",
|
||||
"s_no_accounts": "Keine Konten",
|
||||
"l_results_for": null,
|
||||
"@l_results_for": {
|
||||
"placeholders": {
|
||||
"query": {}
|
||||
}
|
||||
},
|
||||
"l_authenticator_get_started": null,
|
||||
"l_no_accounts_desc": null,
|
||||
"s_add_account": "Konto hinzufügen",
|
||||
@ -419,15 +426,23 @@
|
||||
}
|
||||
},
|
||||
"s_passkeys": null,
|
||||
"s_no_passkeys": null,
|
||||
"l_ready_to_use": "Bereit zur Verwendung",
|
||||
"l_register_sk_on_websites": "Als Sicherheitsschlüssel auf Webseiten registrieren",
|
||||
"l_no_discoverable_accounts": "Keine erkennbaren Konten",
|
||||
"l_non_passkeys_note": null,
|
||||
"p_non_passkeys_note": null,
|
||||
"s_delete_passkey": null,
|
||||
"l_delete_passkey_desc": null,
|
||||
"s_passkey_deleted": null,
|
||||
"p_warning_delete_passkey": null,
|
||||
|
||||
"s_search_passkeys": null,
|
||||
"p_passkeys_used": null,
|
||||
"@p_passkeys_used": {
|
||||
"placeholders": {
|
||||
"used": {},
|
||||
"max": {}
|
||||
}
|
||||
},
|
||||
"@_fingerprints": {},
|
||||
"l_fingerprint": "Fingerabdruck: {label}",
|
||||
"@l_fingerprint": {
|
||||
@ -505,6 +520,12 @@
|
||||
"l_unsupported_key_type": null,
|
||||
"l_delete_certificate": null,
|
||||
"l_delete_certificate_desc": null,
|
||||
"l_delete_key": null,
|
||||
"l_delete_key_desc": null,
|
||||
"l_delete_certificate_or_key": null,
|
||||
"l_delete_certificate_or_key_desc": null,
|
||||
"l_move_key": null,
|
||||
"l_move_key_desc": null,
|
||||
"s_issuer": null,
|
||||
"s_serial": null,
|
||||
"s_certificate_fingerprint": null,
|
||||
@ -522,14 +543,53 @@
|
||||
},
|
||||
"l_generating_private_key": null,
|
||||
"s_private_key_generated": null,
|
||||
"p_select_what_to_delete": null,
|
||||
"p_warning_delete_certificate": null,
|
||||
"p_warning_delete_key": null,
|
||||
"p_warning_delete_certificate_and_key": null,
|
||||
"q_delete_certificate_confirm": null,
|
||||
"@q_delete_certificate_confirm": {
|
||||
"placeholders": {
|
||||
"slot": {}
|
||||
}
|
||||
},
|
||||
"q_delete_key_confirm": null,
|
||||
"@q_delete_key_confirm": {
|
||||
"placeholders": {
|
||||
"slot": {}
|
||||
}
|
||||
},
|
||||
"q_delete_certificate_and_key_confirm": null,
|
||||
"@q_delete_certificate_and_key_confirm": {
|
||||
"placeholders": {
|
||||
"slot": {}
|
||||
}
|
||||
},
|
||||
"l_certificate_deleted": null,
|
||||
"l_key_deleted": null,
|
||||
"l_certificate_and_key_deleted": null,
|
||||
"l_include_certificate": null,
|
||||
"l_select_destination_slot": null,
|
||||
"q_move_key_confirm": null,
|
||||
"@q_move_key_confirm": {
|
||||
"placeholders": {
|
||||
"from_slot": {}
|
||||
}
|
||||
},
|
||||
"q_move_key_to_slot_confirm": null,
|
||||
"@q_move_key_to_slot_confirm": {
|
||||
"placeholders": {
|
||||
"from_slot": {},
|
||||
"to_slot": {}
|
||||
}
|
||||
},
|
||||
"q_move_key_and_certificate_to_slot_confirm": null,
|
||||
"@q_move_key_and_certificate_to_slot_confirm": {
|
||||
"placeholders": {
|
||||
"from_slot": {},
|
||||
"to_slot": {}
|
||||
}
|
||||
},
|
||||
"p_password_protected_file": null,
|
||||
"p_import_items_desc": null,
|
||||
"@p_import_items_desc": {
|
||||
@ -537,6 +597,8 @@
|
||||
"slot": {}
|
||||
}
|
||||
},
|
||||
"l_key_moved": null,
|
||||
"l_key_and_certificate_moved": null,
|
||||
"p_subject_desc": null,
|
||||
"l_rfc4514_invalid": null,
|
||||
"rfc4514_examples": null,
|
||||
@ -560,6 +622,12 @@
|
||||
"hexid": {}
|
||||
}
|
||||
},
|
||||
"s_retired_slot_display_name": null,
|
||||
"@s_retired_slot_display_name": {
|
||||
"placeholders": {
|
||||
"hexid": {}
|
||||
}
|
||||
},
|
||||
"s_slot_9a": null,
|
||||
"s_slot_9c": null,
|
||||
"s_slot_9d": null,
|
||||
|
@ -28,6 +28,7 @@
|
||||
"s_cancel": "Cancel",
|
||||
"s_close": "Close",
|
||||
"s_delete": "Delete",
|
||||
"s_move": "Move",
|
||||
"s_quit": "Quit",
|
||||
"s_status": "Status",
|
||||
"s_unlock": "Unlock",
|
||||
@ -352,6 +353,12 @@
|
||||
},
|
||||
"s_accounts": "Accounts",
|
||||
"s_no_accounts": "No accounts",
|
||||
"l_results_for": "Results for \"{query}\"",
|
||||
"@l_results_for": {
|
||||
"placeholders": {
|
||||
"query": {}
|
||||
}
|
||||
},
|
||||
"l_authenticator_get_started": "Get started with OTP accounts",
|
||||
"l_no_accounts_desc": "Add accounts to your YubiKey from any service provider supporting OATH TOTP/HOTP",
|
||||
"s_add_account": "Add account",
|
||||
@ -419,15 +426,23 @@
|
||||
}
|
||||
},
|
||||
"s_passkeys": "Passkeys",
|
||||
"s_no_passkeys": "No passkeys",
|
||||
"l_ready_to_use": "Ready to use",
|
||||
"l_register_sk_on_websites": "Register as a Security Key on websites",
|
||||
"l_no_discoverable_accounts": "No passkeys stored",
|
||||
"l_non_passkeys_note": "Non-passkey credentials may exist, but can not be listed",
|
||||
"p_non_passkeys_note": "Non-passkey credentials may exist, but can not be listed.",
|
||||
"s_delete_passkey": "Delete passkey",
|
||||
"l_delete_passkey_desc": "Remove the passkey from the YubiKey",
|
||||
"s_passkey_deleted": "Passkey deleted",
|
||||
"p_warning_delete_passkey": "This will delete the passkey from your YubiKey.",
|
||||
|
||||
"s_search_passkeys": "Search passkeys",
|
||||
"p_passkeys_used": "{used} of {max} passkeys used.",
|
||||
"@p_passkeys_used": {
|
||||
"placeholders": {
|
||||
"used": {},
|
||||
"max": {}
|
||||
}
|
||||
},
|
||||
"@_fingerprints": {},
|
||||
"l_fingerprint": "Fingerprint: {label}",
|
||||
"@l_fingerprint": {
|
||||
@ -505,6 +520,12 @@
|
||||
"l_unsupported_key_type": "Unsupported key type",
|
||||
"l_delete_certificate": "Delete certificate",
|
||||
"l_delete_certificate_desc": "Remove the certificate from your YubiKey",
|
||||
"l_delete_key": "Delete key",
|
||||
"l_delete_key_desc": "Remove the key from your YubiKey",
|
||||
"l_delete_certificate_or_key": "Delete certificate/key",
|
||||
"l_delete_certificate_or_key_desc": "Remove the certificate or key from your YubiKey",
|
||||
"l_move_key": "Move key",
|
||||
"l_move_key_desc": "Move a key from one PIV slot into another",
|
||||
"s_issuer": "Issuer",
|
||||
"s_serial": "Serial",
|
||||
"s_certificate_fingerprint": "Fingerprint",
|
||||
@ -522,14 +543,53 @@
|
||||
},
|
||||
"l_generating_private_key": "Generating private key\u2026",
|
||||
"s_private_key_generated": "Private key generated",
|
||||
"p_select_what_to_delete": "Select what to delete from the slot.",
|
||||
"p_warning_delete_certificate": "Warning! This action will delete the certificate from your YubiKey.",
|
||||
"p_warning_delete_key": "Warning! This action will delete the private key from your YubiKey.",
|
||||
"p_warning_delete_certificate_and_key": "Warning! This action will delete the certificate and private key from your YubiKey.",
|
||||
"q_delete_certificate_confirm": "Delete the certificate in PIV slot {slot}?",
|
||||
"@q_delete_certificate_confirm": {
|
||||
"placeholders": {
|
||||
"slot": {}
|
||||
}
|
||||
},
|
||||
"q_delete_key_confirm": "Delete the private key in PIV slot {slot}?",
|
||||
"@q_delete_key_confirm": {
|
||||
"placeholders": {
|
||||
"slot": {}
|
||||
}
|
||||
},
|
||||
"q_delete_certificate_and_key_confirm": "Delete the certificate and private key in PIV slot {slot}?",
|
||||
"@q_delete_certificate_and_key_confirm": {
|
||||
"placeholders": {
|
||||
"slot": {}
|
||||
}
|
||||
},
|
||||
"l_certificate_deleted": "Certificate deleted",
|
||||
"l_key_deleted": "Key deleted",
|
||||
"l_certificate_and_key_deleted": "Certificate and key deleted",
|
||||
"l_include_certificate": "Include certificate",
|
||||
"l_select_destination_slot": "Select destination slot",
|
||||
"q_move_key_confirm": "Move the private key in PIV slot {from_slot}?",
|
||||
"@q_move_key_confirm": {
|
||||
"placeholders": {
|
||||
"from_slot": {}
|
||||
}
|
||||
},
|
||||
"q_move_key_to_slot_confirm": "Move the private key in PIV slot {from_slot} to slot {to_slot}?",
|
||||
"@q_move_key_to_slot_confirm": {
|
||||
"placeholders": {
|
||||
"from_slot": {},
|
||||
"to_slot": {}
|
||||
}
|
||||
},
|
||||
"q_move_key_and_certificate_to_slot_confirm": "Move the private key and certificate in PIV slot {from_slot} to slot {to_slot}?",
|
||||
"@q_move_key_and_certificate_to_slot_confirm": {
|
||||
"placeholders": {
|
||||
"from_slot": {},
|
||||
"to_slot": {}
|
||||
}
|
||||
},
|
||||
"p_password_protected_file": "The selected file is password protected. Enter the password to proceed.",
|
||||
"p_import_items_desc": "The following item(s) will be imported into PIV slot {slot}.",
|
||||
"@p_import_items_desc": {
|
||||
@ -537,6 +597,8 @@
|
||||
"slot": {}
|
||||
}
|
||||
},
|
||||
"l_key_moved": "Key moved",
|
||||
"l_key_and_certificate_moved": "Key and certificate moved",
|
||||
"p_subject_desc": "A distinguished name (DN) formatted in accordance to the RFC 4514 specification.",
|
||||
"l_rfc4514_invalid": "Invalid RFC 4514 format",
|
||||
"rfc4514_examples": "Examples:\nCN=Example Name\nCN=jsmith,DC=example,DC=net",
|
||||
@ -560,6 +622,12 @@
|
||||
"hexid": {}
|
||||
}
|
||||
},
|
||||
"s_retired_slot_display_name": "Retired Key Management ({hexid})",
|
||||
"@s_retired_slot_display_name": {
|
||||
"placeholders": {
|
||||
"hexid": {}
|
||||
}
|
||||
},
|
||||
"s_slot_9a": "Authentication",
|
||||
"s_slot_9c": "Digital Signature",
|
||||
"s_slot_9d": "Key Management",
|
||||
|
@ -28,6 +28,7 @@
|
||||
"s_cancel": "Annuler",
|
||||
"s_close": "Fermer",
|
||||
"s_delete": "Supprimer",
|
||||
"s_move": null,
|
||||
"s_quit": "Quitter",
|
||||
"s_status": null,
|
||||
"s_unlock": "Déverrouiller",
|
||||
@ -352,6 +353,12 @@
|
||||
},
|
||||
"s_accounts": "Comptes",
|
||||
"s_no_accounts": "Aucun compte",
|
||||
"l_results_for": null,
|
||||
"@l_results_for": {
|
||||
"placeholders": {
|
||||
"query": {}
|
||||
}
|
||||
},
|
||||
"l_authenticator_get_started": null,
|
||||
"l_no_accounts_desc": null,
|
||||
"s_add_account": "Ajouter un compte",
|
||||
@ -419,15 +426,23 @@
|
||||
}
|
||||
},
|
||||
"s_passkeys": "Passkeys",
|
||||
"s_no_passkeys": null,
|
||||
"l_ready_to_use": "Prêt à l'emploi",
|
||||
"l_register_sk_on_websites": "Enregistrer comme clé de sécurité sur les sites internet",
|
||||
"l_no_discoverable_accounts": "Aucune Passkey détectée",
|
||||
"l_non_passkeys_note": null,
|
||||
"p_non_passkeys_note": null,
|
||||
"s_delete_passkey": "Supprimer une Passkey",
|
||||
"l_delete_passkey_desc": "Supprimer la Passkey de votre YubiKey",
|
||||
"s_passkey_deleted": "Passkey supprimée",
|
||||
"p_warning_delete_passkey": "Cette action supprimera cette Passkey de votre YubiKey.",
|
||||
|
||||
"s_search_passkeys": null,
|
||||
"p_passkeys_used": null,
|
||||
"@p_passkeys_used": {
|
||||
"placeholders": {
|
||||
"used": {},
|
||||
"max": {}
|
||||
}
|
||||
},
|
||||
"@_fingerprints": {},
|
||||
"l_fingerprint": "Empreinte: {label}",
|
||||
"@l_fingerprint": {
|
||||
@ -505,6 +520,12 @@
|
||||
"l_unsupported_key_type": null,
|
||||
"l_delete_certificate": "Supprimer un certificat",
|
||||
"l_delete_certificate_desc": "Supprimer un certificat de votre YubiKey",
|
||||
"l_delete_key": null,
|
||||
"l_delete_key_desc": null,
|
||||
"l_delete_certificate_or_key": null,
|
||||
"l_delete_certificate_or_key_desc": null,
|
||||
"l_move_key": null,
|
||||
"l_move_key_desc": null,
|
||||
"s_issuer": "Émetteur",
|
||||
"s_serial": "Série",
|
||||
"s_certificate_fingerprint": "Empreinte digitale",
|
||||
@ -522,14 +543,53 @@
|
||||
},
|
||||
"l_generating_private_key": "Génération d'une clé privée\u2026",
|
||||
"s_private_key_generated": "Clé privée générée",
|
||||
"p_select_what_to_delete": null,
|
||||
"p_warning_delete_certificate": "Attention! Cette action supprimera le certificat de votre YubiKey.",
|
||||
"p_warning_delete_key": null,
|
||||
"p_warning_delete_certificate_and_key": null,
|
||||
"q_delete_certificate_confirm": "Supprimer le certficat du slot PIV {slot}?",
|
||||
"@q_delete_certificate_confirm": {
|
||||
"placeholders": {
|
||||
"slot": {}
|
||||
}
|
||||
},
|
||||
"q_delete_key_confirm": null,
|
||||
"@q_delete_key_confirm": {
|
||||
"placeholders": {
|
||||
"slot": {}
|
||||
}
|
||||
},
|
||||
"q_delete_certificate_and_key_confirm": null,
|
||||
"@q_delete_certificate_and_key_confirm": {
|
||||
"placeholders": {
|
||||
"slot": {}
|
||||
}
|
||||
},
|
||||
"l_certificate_deleted": "Certificat supprimé",
|
||||
"l_key_deleted": null,
|
||||
"l_certificate_and_key_deleted": null,
|
||||
"l_include_certificate": null,
|
||||
"l_select_destination_slot": null,
|
||||
"q_move_key_confirm": null,
|
||||
"@q_move_key_confirm": {
|
||||
"placeholders": {
|
||||
"from_slot": {}
|
||||
}
|
||||
},
|
||||
"q_move_key_to_slot_confirm": null,
|
||||
"@q_move_key_to_slot_confirm": {
|
||||
"placeholders": {
|
||||
"from_slot": {},
|
||||
"to_slot": {}
|
||||
}
|
||||
},
|
||||
"q_move_key_and_certificate_to_slot_confirm": null,
|
||||
"@q_move_key_and_certificate_to_slot_confirm": {
|
||||
"placeholders": {
|
||||
"from_slot": {},
|
||||
"to_slot": {}
|
||||
}
|
||||
},
|
||||
"p_password_protected_file": "Le fichier sélectionné est protégé par un mot de passe. Enterez le mot de passe pour continuer.",
|
||||
"p_import_items_desc": "Les éléments suivants seront importés dans le slot PIV {slot}.",
|
||||
"@p_import_items_desc": {
|
||||
@ -537,6 +597,8 @@
|
||||
"slot": {}
|
||||
}
|
||||
},
|
||||
"l_key_moved": null,
|
||||
"l_key_and_certificate_moved": null,
|
||||
"p_subject_desc": "Un nom distinctif (DN) formaté conformément à la spécification RFC 4514.",
|
||||
"l_rfc4514_invalid": "Format RFC 4514 invalide",
|
||||
"rfc4514_examples": "Exemples:\nCN=Example Name\nCN=jsmith,DC=example,DC=net",
|
||||
@ -560,6 +622,12 @@
|
||||
"hexid": {}
|
||||
}
|
||||
},
|
||||
"s_retired_slot_display_name": null,
|
||||
"@s_retired_slot_display_name": {
|
||||
"placeholders": {
|
||||
"hexid": {}
|
||||
}
|
||||
},
|
||||
"s_slot_9a": "Authentification",
|
||||
"s_slot_9c": "Signature digitale",
|
||||
"s_slot_9d": "Gestion des clés",
|
||||
|
@ -28,6 +28,7 @@
|
||||
"s_cancel": "キャンセル",
|
||||
"s_close": "閉じる",
|
||||
"s_delete": "消去",
|
||||
"s_move": null,
|
||||
"s_quit": "終了",
|
||||
"s_status": null,
|
||||
"s_unlock": "ロック解除",
|
||||
@ -352,6 +353,12 @@
|
||||
},
|
||||
"s_accounts": "アカウント",
|
||||
"s_no_accounts": "アカウントがありません",
|
||||
"l_results_for": null,
|
||||
"@l_results_for": {
|
||||
"placeholders": {
|
||||
"query": {}
|
||||
}
|
||||
},
|
||||
"l_authenticator_get_started": null,
|
||||
"l_no_accounts_desc": null,
|
||||
"s_add_account": "アカウントの追加",
|
||||
@ -419,15 +426,23 @@
|
||||
}
|
||||
},
|
||||
"s_passkeys": "パスキー",
|
||||
"s_no_passkeys": null,
|
||||
"l_ready_to_use": "すぐに使用可能",
|
||||
"l_register_sk_on_websites": "Webサイトにセキュリティキーとして登録する",
|
||||
"l_no_discoverable_accounts": "パスキーは保存されていません",
|
||||
"l_non_passkeys_note": null,
|
||||
"p_non_passkeys_note": null,
|
||||
"s_delete_passkey": "パスキーを削除",
|
||||
"l_delete_passkey_desc": "YubiKeyからパスキーの削除",
|
||||
"s_passkey_deleted": "パスキーが削除されました",
|
||||
"p_warning_delete_passkey": "これにより、YubiKeyからパスキーが削除されます",
|
||||
|
||||
"s_search_passkeys": null,
|
||||
"p_passkeys_used": null,
|
||||
"@p_passkeys_used": {
|
||||
"placeholders": {
|
||||
"used": {},
|
||||
"max": {}
|
||||
}
|
||||
},
|
||||
"@_fingerprints": {},
|
||||
"l_fingerprint": "指紋:{label}",
|
||||
"@l_fingerprint": {
|
||||
@ -505,6 +520,12 @@
|
||||
"l_unsupported_key_type": null,
|
||||
"l_delete_certificate": "証明書を削除",
|
||||
"l_delete_certificate_desc": "YubiKeyか証明書の削除",
|
||||
"l_delete_key": null,
|
||||
"l_delete_key_desc": null,
|
||||
"l_delete_certificate_or_key": null,
|
||||
"l_delete_certificate_or_key_desc": null,
|
||||
"l_move_key": null,
|
||||
"l_move_key_desc": null,
|
||||
"s_issuer": "発行者",
|
||||
"s_serial": "シリアル番号",
|
||||
"s_certificate_fingerprint": "指紋",
|
||||
@ -522,14 +543,53 @@
|
||||
},
|
||||
"l_generating_private_key": "秘密鍵を生成しています\u2026",
|
||||
"s_private_key_generated": "秘密鍵を生成しました",
|
||||
"p_select_what_to_delete": null,
|
||||
"p_warning_delete_certificate": "警告!この操作によってYubiKeyから証明書が削除されます",
|
||||
"p_warning_delete_key": null,
|
||||
"p_warning_delete_certificate_and_key": null,
|
||||
"q_delete_certificate_confirm": "PIVスロット{slot}の証明書を削除しますか?",
|
||||
"@q_delete_certificate_confirm": {
|
||||
"placeholders": {
|
||||
"slot": {}
|
||||
}
|
||||
},
|
||||
"q_delete_key_confirm": null,
|
||||
"@q_delete_key_confirm": {
|
||||
"placeholders": {
|
||||
"slot": {}
|
||||
}
|
||||
},
|
||||
"q_delete_certificate_and_key_confirm": null,
|
||||
"@q_delete_certificate_and_key_confirm": {
|
||||
"placeholders": {
|
||||
"slot": {}
|
||||
}
|
||||
},
|
||||
"l_certificate_deleted": "証明書が削除されました",
|
||||
"l_key_deleted": null,
|
||||
"l_certificate_and_key_deleted": null,
|
||||
"l_include_certificate": null,
|
||||
"l_select_destination_slot": null,
|
||||
"q_move_key_confirm": null,
|
||||
"@q_move_key_confirm": {
|
||||
"placeholders": {
|
||||
"from_slot": {}
|
||||
}
|
||||
},
|
||||
"q_move_key_to_slot_confirm": null,
|
||||
"@q_move_key_to_slot_confirm": {
|
||||
"placeholders": {
|
||||
"from_slot": {},
|
||||
"to_slot": {}
|
||||
}
|
||||
},
|
||||
"q_move_key_and_certificate_to_slot_confirm": null,
|
||||
"@q_move_key_and_certificate_to_slot_confirm": {
|
||||
"placeholders": {
|
||||
"from_slot": {},
|
||||
"to_slot": {}
|
||||
}
|
||||
},
|
||||
"p_password_protected_file": "選択したファイルはパスワードで保護されています。パスワードを入力して続行します",
|
||||
"p_import_items_desc": "次のアイテムはPIVスロット{slot}にインポートされます",
|
||||
"@p_import_items_desc": {
|
||||
@ -537,6 +597,8 @@
|
||||
"slot": {}
|
||||
}
|
||||
},
|
||||
"l_key_moved": null,
|
||||
"l_key_and_certificate_moved": null,
|
||||
"p_subject_desc": "RFC 4514フォーマットの識別名識別名 (DN)",
|
||||
"l_rfc4514_invalid": "無効な RFC 4514 形式です",
|
||||
"rfc4514_examples": "例:\nCN=Example Name\nCN=jsmith,DC=example,DC=net",
|
||||
@ -560,6 +622,12 @@
|
||||
"hexid": {}
|
||||
}
|
||||
},
|
||||
"s_retired_slot_display_name": null,
|
||||
"@s_retired_slot_display_name": {
|
||||
"placeholders": {
|
||||
"hexid": {}
|
||||
}
|
||||
},
|
||||
"s_slot_9a": "認証",
|
||||
"s_slot_9c": "デジタル署名",
|
||||
"s_slot_9d": "鍵の管理",
|
||||
|
@ -28,6 +28,7 @@
|
||||
"s_cancel": "Anuluj",
|
||||
"s_close": "Zamknij",
|
||||
"s_delete": "Usuń",
|
||||
"s_move": null,
|
||||
"s_quit": "Wyjdź",
|
||||
"s_status": "Status",
|
||||
"s_unlock": "Odblokuj",
|
||||
@ -352,6 +353,12 @@
|
||||
},
|
||||
"s_accounts": "Konta",
|
||||
"s_no_accounts": "Brak kont",
|
||||
"l_results_for": null,
|
||||
"@l_results_for": {
|
||||
"placeholders": {
|
||||
"query": {}
|
||||
}
|
||||
},
|
||||
"l_authenticator_get_started": "Rozpocznij korzystanie z kont OTP",
|
||||
"l_no_accounts_desc": "Dodaj konta do swojego klucza YubiKey od dowolnego dostawcy usług obsługującego OATH TOTP/HOTP",
|
||||
"s_add_account": "Dodaj konto",
|
||||
@ -419,15 +426,23 @@
|
||||
}
|
||||
},
|
||||
"s_passkeys": "Klucze dostępu",
|
||||
"s_no_passkeys": null,
|
||||
"l_ready_to_use": "Gotowe do użycia",
|
||||
"l_register_sk_on_websites": "Zarejestruj jako klucz bezpieczeństwa na stronach internetowych",
|
||||
"l_no_discoverable_accounts": "Nie wykryto kont",
|
||||
"l_non_passkeys_note": "Mogą istnieć inne dane uwierzytelniające, ale nie mogą być wyświetlane",
|
||||
"p_non_passkeys_note": null,
|
||||
"s_delete_passkey": "Usuń klucz dostępu",
|
||||
"l_delete_passkey_desc": "Usuń klucz dostępu z klucza YubiKey",
|
||||
"s_passkey_deleted": "Usunięto klucz dostępu",
|
||||
"p_warning_delete_passkey": "Spowoduje to usunięcie klucza dostępu z klucza YubiKey.",
|
||||
|
||||
"s_search_passkeys": null,
|
||||
"p_passkeys_used": null,
|
||||
"@p_passkeys_used": {
|
||||
"placeholders": {
|
||||
"used": {},
|
||||
"max": {}
|
||||
}
|
||||
},
|
||||
"@_fingerprints": {},
|
||||
"l_fingerprint": "Odcisk palca: {label}",
|
||||
"@l_fingerprint": {
|
||||
@ -505,6 +520,12 @@
|
||||
"l_unsupported_key_type": null,
|
||||
"l_delete_certificate": "Usuń certyfikat",
|
||||
"l_delete_certificate_desc": "Usuń certyfikat z klucza YubiKey",
|
||||
"l_delete_key": null,
|
||||
"l_delete_key_desc": null,
|
||||
"l_delete_certificate_or_key": null,
|
||||
"l_delete_certificate_or_key_desc": null,
|
||||
"l_move_key": null,
|
||||
"l_move_key_desc": null,
|
||||
"s_issuer": "Wydawca",
|
||||
"s_serial": "Nr. seryjny",
|
||||
"s_certificate_fingerprint": "Odcisk palca",
|
||||
@ -522,14 +543,53 @@
|
||||
},
|
||||
"l_generating_private_key": "Generowanie prywatnego klucza\u2026",
|
||||
"s_private_key_generated": "Wygenerowano klucz prywatny",
|
||||
"p_select_what_to_delete": null,
|
||||
"p_warning_delete_certificate": "Uwaga! Ta czynność spowoduje usunięcie certyfikatu z klucza YubiKey.",
|
||||
"p_warning_delete_key": null,
|
||||
"p_warning_delete_certificate_and_key": null,
|
||||
"q_delete_certificate_confirm": "Usunąć certyfikat ze slotu PIV {slot}?",
|
||||
"@q_delete_certificate_confirm": {
|
||||
"placeholders": {
|
||||
"slot": {}
|
||||
}
|
||||
},
|
||||
"q_delete_key_confirm": null,
|
||||
"@q_delete_key_confirm": {
|
||||
"placeholders": {
|
||||
"slot": {}
|
||||
}
|
||||
},
|
||||
"q_delete_certificate_and_key_confirm": null,
|
||||
"@q_delete_certificate_and_key_confirm": {
|
||||
"placeholders": {
|
||||
"slot": {}
|
||||
}
|
||||
},
|
||||
"l_certificate_deleted": "Certyfikat został usunięty",
|
||||
"l_key_deleted": null,
|
||||
"l_certificate_and_key_deleted": null,
|
||||
"l_include_certificate": null,
|
||||
"l_select_destination_slot": null,
|
||||
"q_move_key_confirm": null,
|
||||
"@q_move_key_confirm": {
|
||||
"placeholders": {
|
||||
"from_slot": {}
|
||||
}
|
||||
},
|
||||
"q_move_key_to_slot_confirm": null,
|
||||
"@q_move_key_to_slot_confirm": {
|
||||
"placeholders": {
|
||||
"from_slot": {},
|
||||
"to_slot": {}
|
||||
}
|
||||
},
|
||||
"q_move_key_and_certificate_to_slot_confirm": null,
|
||||
"@q_move_key_and_certificate_to_slot_confirm": {
|
||||
"placeholders": {
|
||||
"from_slot": {},
|
||||
"to_slot": {}
|
||||
}
|
||||
},
|
||||
"p_password_protected_file": "Wybrany plik jest chroniony hasłem. Wprowadź je, aby kontynuować.",
|
||||
"p_import_items_desc": "Następujące elementy zostaną zaimportowane do slotu PIV {slot}.",
|
||||
"@p_import_items_desc": {
|
||||
@ -537,6 +597,8 @@
|
||||
"slot": {}
|
||||
}
|
||||
},
|
||||
"l_key_moved": null,
|
||||
"l_key_and_certificate_moved": null,
|
||||
"p_subject_desc": "Nazwa wyróżniająca (DN) sformatowana zgodnie ze specyfikacją RFC 4514.",
|
||||
"l_rfc4514_invalid": "Nieprawidłowy format RFC 4514",
|
||||
"rfc4514_examples": "Przykłady:\nCN=Przykładowa Nazwa\nCN=jkowalski,DC=przyklad,DC=pl",
|
||||
@ -560,6 +622,12 @@
|
||||
"hexid": {}
|
||||
}
|
||||
},
|
||||
"s_retired_slot_display_name": null,
|
||||
"@s_retired_slot_display_name": {
|
||||
"placeholders": {
|
||||
"hexid": {}
|
||||
}
|
||||
},
|
||||
"s_slot_9a": "Uwierzytelnienie",
|
||||
"s_slot_9c": "Cyfrowy podpis",
|
||||
"s_slot_9d": "Menedżer kluczy",
|
||||
|
@ -20,9 +20,6 @@ const _prefix = 'oath.keys';
|
||||
const _keyAction = '$_prefix.actions';
|
||||
const _accountAction = '$_prefix.account.actions';
|
||||
|
||||
// This is global so we can access it from the global Ctrl+F shortcut.
|
||||
final searchAccountsField = GlobalKey();
|
||||
|
||||
// Key actions
|
||||
const setOrManagePasswordAction =
|
||||
Key('$_keyAction.action.set_or_manage_password');
|
||||
|
@ -26,11 +26,12 @@ import '../app/state.dart';
|
||||
import '../core/state.dart';
|
||||
import 'models.dart';
|
||||
|
||||
final searchProvider =
|
||||
StateNotifierProvider<SearchNotifier, String>((ref) => SearchNotifier());
|
||||
final accountsSearchProvider =
|
||||
StateNotifierProvider<AccountsSearchNotifier, String>(
|
||||
(ref) => AccountsSearchNotifier());
|
||||
|
||||
class SearchNotifier extends StateNotifier<String> {
|
||||
SearchNotifier() : super('');
|
||||
class AccountsSearchNotifier extends StateNotifier<String> {
|
||||
AccountsSearchNotifier() : super('');
|
||||
|
||||
void setFilter(String value) {
|
||||
state = value;
|
||||
@ -184,7 +185,7 @@ class FavoritesNotifier extends StateNotifier<List<String>> {
|
||||
final filteredCredentialsProvider = StateNotifierProvider.autoDispose
|
||||
.family<FilteredCredentialsNotifier, List<OathPair>, List<OathPair>>(
|
||||
(ref, full) {
|
||||
return FilteredCredentialsNotifier(full, ref.watch(searchProvider));
|
||||
return FilteredCredentialsNotifier(full, ref.watch(accountsSearchProvider));
|
||||
});
|
||||
|
||||
class FilteredCredentialsNotifier extends StateNotifier<List<OathPair>> {
|
||||
|
@ -57,9 +57,11 @@ class AccountList extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
if (pinnedCreds.isNotEmpty && creds.isNotEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Divider(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
),
|
||||
),
|
||||
...creds.map(
|
||||
(entry) => AccountView(
|
||||
|
@ -30,6 +30,7 @@ import '../../app/state.dart';
|
||||
import '../../app/views/action_list.dart';
|
||||
import '../../app/views/app_failure_page.dart';
|
||||
import '../../app/views/app_page.dart';
|
||||
import '../../app/views/keys.dart';
|
||||
import '../../app/views/message_page.dart';
|
||||
import '../../app/views/message_page_not_initialized.dart';
|
||||
import '../../core/state.dart';
|
||||
@ -121,7 +122,8 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
searchFocus = FocusNode();
|
||||
searchController = TextEditingController(text: ref.read(searchProvider));
|
||||
searchController =
|
||||
TextEditingController(text: ref.read(accountsSearchProvider));
|
||||
searchFocus.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
@ -144,6 +146,7 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
|
||||
.select((value) => value?.length));
|
||||
final hasFeature = ref.watch(featureProvider);
|
||||
final hasActions = hasFeature(features.actions);
|
||||
final searchText = searchController.text;
|
||||
|
||||
Future<void> onFileDropped(File file) async {
|
||||
final qrScanner = ref.read(qrScannerProvider);
|
||||
@ -210,7 +213,8 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
|
||||
SearchIntent: CallbackAction<SearchIntent>(onInvoke: (_) {
|
||||
searchController.selection = TextSelection(
|
||||
baseOffset: 0, extentOffset: searchController.text.length);
|
||||
searchFocus.requestFocus();
|
||||
searchFocus.unfocus();
|
||||
Timer.run(() => searchFocus.requestFocus());
|
||||
return null;
|
||||
}),
|
||||
EscapeIntent: CallbackAction<EscapeIntent>(onInvoke: (intent) {
|
||||
@ -261,6 +265,8 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
|
||||
},
|
||||
builder: (context) => AppPage(
|
||||
title: l10n.s_accounts,
|
||||
alternativeTitle:
|
||||
searchText != '' ? l10n.l_results_for(searchText) : null,
|
||||
capabilities: const [Capability.oath],
|
||||
keyActionsBuilder: hasActions
|
||||
? (context) => oathBuildActions(
|
||||
@ -350,6 +356,79 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
|
||||
);
|
||||
}
|
||||
: null,
|
||||
headerSliver: Focus(
|
||||
canRequestFocus: false,
|
||||
onKeyEvent: (node, event) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
node.focusInDirection(TraversalDirection.down);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
searchController.clear();
|
||||
ref.read(accountsSearchProvider.notifier).setFilter('');
|
||||
node.unfocus();
|
||||
setState(() {});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: Builder(builder: (context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: AppTextFormField(
|
||||
key: searchField,
|
||||
controller: searchController,
|
||||
focusNode: searchFocus,
|
||||
// Use the default style, but with a smaller font size:
|
||||
style: textTheme.titleMedium
|
||||
?.copyWith(fontSize: textTheme.titleSmall?.fontSize),
|
||||
decoration: AppInputDecoration(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(48),
|
||||
borderSide: BorderSide(
|
||||
width: 0,
|
||||
style: searchFocus.hasFocus
|
||||
? BorderStyle.solid
|
||||
: BorderStyle.none,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
fillColor: Theme.of(context).hoverColor,
|
||||
filled: true,
|
||||
hintText: l10n.s_search_accounts,
|
||||
isDense: true,
|
||||
prefixIcon: const Padding(
|
||||
padding: EdgeInsetsDirectional.only(start: 8.0),
|
||||
child: Icon(Icons.search_outlined),
|
||||
),
|
||||
suffixIcon: searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
iconSize: 16,
|
||||
onPressed: () {
|
||||
searchController.clear();
|
||||
ref
|
||||
.read(accountsSearchProvider.notifier)
|
||||
.setFilter('');
|
||||
setState(() {});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: (value) {
|
||||
ref.read(accountsSearchProvider.notifier).setFilter(value);
|
||||
setState(() {});
|
||||
},
|
||||
textInputAction: TextInputAction.next,
|
||||
onFieldSubmitted: (value) {
|
||||
Focus.of(context).focusInDirection(TraversalDirection.down);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
builder: (context, expanded) {
|
||||
// De-select if window is resized to be non-expanded.
|
||||
if (!expanded && _selected != null) {
|
||||
@ -373,80 +452,6 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
Focus(
|
||||
canRequestFocus: false,
|
||||
onKeyEvent: (node, event) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
node.focusInDirection(TraversalDirection.down);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
searchController.clear();
|
||||
ref.read(searchProvider.notifier).setFilter('');
|
||||
node.unfocus();
|
||||
setState(() {});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: Builder(builder: (context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0, vertical: 8.0),
|
||||
child: AppTextFormField(
|
||||
key: keys.searchAccountsField,
|
||||
controller: searchController,
|
||||
focusNode: searchFocus,
|
||||
// Use the default style, but with a smaller font size:
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
fontSize: textTheme.titleSmall?.fontSize),
|
||||
decoration: AppInputDecoration(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
borderSide: BorderSide(
|
||||
width: 0,
|
||||
style: searchFocus.hasFocus
|
||||
? BorderStyle.solid
|
||||
: BorderStyle.none,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
fillColor: Theme.of(context).hoverColor,
|
||||
filled: true,
|
||||
hintText: l10n.s_search_accounts,
|
||||
isDense: true,
|
||||
prefixIcon: const Padding(
|
||||
padding: EdgeInsetsDirectional.only(start: 8.0),
|
||||
child: Icon(Symbols.search),
|
||||
),
|
||||
suffixIcon: searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Symbols.clear),
|
||||
iconSize: 16,
|
||||
onPressed: () {
|
||||
searchController.clear();
|
||||
ref
|
||||
.read(searchProvider.notifier)
|
||||
.setFilter('');
|
||||
setState(() {});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: (value) {
|
||||
ref.read(searchProvider.notifier).setFilter(value);
|
||||
setState(() {});
|
||||
},
|
||||
textInputAction: TextInputAction.next,
|
||||
onFieldSubmitted: (value) {
|
||||
Focus.of(context)
|
||||
.focusInDirection(TraversalDirection.down);
|
||||
},
|
||||
).init(),
|
||||
);
|
||||
}),
|
||||
),
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
return AccountList(
|
||||
|
@ -29,3 +29,4 @@ final slotsGenerate = slots.feature('generate');
|
||||
final slotsImport = slots.feature('import');
|
||||
final slotsExport = slots.feature('export');
|
||||
final slotsDelete = slots.feature('delete');
|
||||
final slotsMove = slots.feature('move');
|
||||
|
@ -32,6 +32,7 @@ const generateAction = Key('$_slotAction.generate');
|
||||
const importAction = Key('$_slotAction.import');
|
||||
const exportAction = Key('$_slotAction.export');
|
||||
const deleteAction = Key('$_slotAction.delete');
|
||||
const moveAction = Key('$_slotAction.move');
|
||||
|
||||
const saveButton = Key('$_prefix.save');
|
||||
const deleteButton = Key('$_prefix.delete');
|
||||
@ -50,11 +51,51 @@ const meatballButton9a = Key('$_prefix.9a.meatball.button');
|
||||
const meatballButton9c = Key('$_prefix.9c.meatball.button');
|
||||
const meatballButton9d = Key('$_prefix.9d.meatball.button');
|
||||
const meatballButton9e = Key('$_prefix.9e.meatball.button');
|
||||
const meatballButton82 = Key('$_prefix.82.meatball.button');
|
||||
const meatballButton83 = Key('$_prefix.83.meatball.button');
|
||||
const meatballButton84 = Key('$_prefix.84.meatball.button');
|
||||
const meatballButton85 = Key('$_prefix.85.meatball.button');
|
||||
const meatballButton86 = Key('$_prefix.86.meatball.button');
|
||||
const meatballButton87 = Key('$_prefix.87.meatball.button');
|
||||
const meatballButton88 = Key('$_prefix.88.meatball.button');
|
||||
const meatballButton89 = Key('$_prefix.89.meatball.button');
|
||||
const meatballButton8a = Key('$_prefix.8a.meatball.button');
|
||||
const meatballButton8b = Key('$_prefix.8b.meatball.button');
|
||||
const meatballButton8c = Key('$_prefix.8c.meatball.button');
|
||||
const meatballButton8d = Key('$_prefix.8d.meatball.button');
|
||||
const meatballButton8e = Key('$_prefix.8e.meatball.button');
|
||||
const meatballButton8f = Key('$_prefix.8f.meatball.button');
|
||||
const meatballButton90 = Key('$_prefix.90.meatball.button');
|
||||
const meatballButton91 = Key('$_prefix.91.meatball.button');
|
||||
const meatballButton92 = Key('$_prefix.92.meatball.button');
|
||||
const meatballButton93 = Key('$_prefix.93.meatball.button');
|
||||
const meatballButton94 = Key('$_prefix.94.meatball.button');
|
||||
const meatballButton95 = Key('$_prefix.95.meatball.button');
|
||||
|
||||
const appListItem9a = Key('$_prefix.9a.applistitem');
|
||||
const appListItem9c = Key('$_prefix.9c.applistitem');
|
||||
const appListItem9d = Key('$_prefix.9d.applistitem');
|
||||
const appListItem9e = Key('$_prefix.9e.applistitem');
|
||||
const appListItem82 = Key('$_prefix.82.applistitem');
|
||||
const appListItem83 = Key('$_prefix.83.applistitem');
|
||||
const appListItem84 = Key('$_prefix.84.applistitem');
|
||||
const appListItem85 = Key('$_prefix.85.applistitem');
|
||||
const appListItem86 = Key('$_prefix.86.applistitem');
|
||||
const appListItem87 = Key('$_prefix.87.applistitem');
|
||||
const appListItem88 = Key('$_prefix.88.applistitem');
|
||||
const appListItem89 = Key('$_prefix.89.applistitem');
|
||||
const appListItem8a = Key('$_prefix.8a.applistitem');
|
||||
const appListItem8b = Key('$_prefix.8b.applistitem');
|
||||
const appListItem8c = Key('$_prefix.8c.applistitem');
|
||||
const appListItem8d = Key('$_prefix.8d.applistitem');
|
||||
const appListItem8e = Key('$_prefix.8e.applistitem');
|
||||
const appListItem8f = Key('$_prefix.8f.applistitem');
|
||||
const appListItem90 = Key('$_prefix.90.applistitem');
|
||||
const appListItem91 = Key('$_prefix.91.applistitem');
|
||||
const appListItem92 = Key('$_prefix.92.applistitem');
|
||||
const appListItem93 = Key('$_prefix.93.applistitem');
|
||||
const appListItem94 = Key('$_prefix.94.applistitem');
|
||||
const appListItem95 = Key('$_prefix.95.applistitem');
|
||||
|
||||
// SlotMetadata body keys
|
||||
const slotMetadataKeyType = Key('$_prefix.slotMetadata.keyType');
|
||||
|
@ -47,10 +47,31 @@ enum SlotId {
|
||||
authentication(0x9a),
|
||||
signature(0x9c),
|
||||
keyManagement(0x9d),
|
||||
cardAuth(0x9e);
|
||||
cardAuth(0x9e),
|
||||
retired1(0x82, true),
|
||||
retired2(0x83, true),
|
||||
retired3(0x84, true),
|
||||
retired4(0x85, true),
|
||||
retired5(0x86, true),
|
||||
retired6(0x87, true),
|
||||
retired7(0x88, true),
|
||||
retired8(0x89, true),
|
||||
retired9(0x8a, true),
|
||||
retired10(0x8b, true),
|
||||
retired11(0x8c, true),
|
||||
retired12(0x8d, true),
|
||||
retired13(0x8e, true),
|
||||
retired14(0x8f, true),
|
||||
retired15(0x90, true),
|
||||
retired16(0x91, true),
|
||||
retired17(0x92, true),
|
||||
retired18(0x93, true),
|
||||
retired19(0x94, true),
|
||||
retired20(0x95, true);
|
||||
|
||||
final int id;
|
||||
const SlotId(this.id);
|
||||
final bool isRetired;
|
||||
const SlotId(this.id, [this.isRetired = false]);
|
||||
|
||||
String get hexId => id.toRadixString(16).padLeft(2, '0');
|
||||
|
||||
@ -61,6 +82,7 @@ enum SlotId {
|
||||
SlotId.signature => nameFor(l10n.s_slot_9c),
|
||||
SlotId.keyManagement => nameFor(l10n.s_slot_9d),
|
||||
SlotId.cardAuth => nameFor(l10n.s_slot_9e),
|
||||
_ => l10n.s_retired_slot_display_name(hexId)
|
||||
};
|
||||
}
|
||||
|
||||
@ -186,8 +208,8 @@ class PinMetadata with _$PinMetadata {
|
||||
|
||||
@freezed
|
||||
class PinVerificationStatus with _$PinVerificationStatus {
|
||||
const factory PinVerificationStatus.success() = _PinSuccess;
|
||||
factory PinVerificationStatus.failure(int attemptsRemaining) = _PinFailure;
|
||||
const factory PinVerificationStatus.success() = PinSuccess;
|
||||
factory PinVerificationStatus.failure(int attemptsRemaining) = PinFailure;
|
||||
}
|
||||
|
||||
@freezed
|
||||
|
@ -211,20 +211,20 @@ mixin _$PinVerificationStatus {
|
||||
throw _privateConstructorUsedError;
|
||||
@optionalTypeArgs
|
||||
TResult map<TResult extends Object?>({
|
||||
required TResult Function(_PinSuccess value) success,
|
||||
required TResult Function(_PinFailure value) failure,
|
||||
required TResult Function(PinSuccess value) success,
|
||||
required TResult Function(PinFailure value) failure,
|
||||
}) =>
|
||||
throw _privateConstructorUsedError;
|
||||
@optionalTypeArgs
|
||||
TResult? mapOrNull<TResult extends Object?>({
|
||||
TResult? Function(_PinSuccess value)? success,
|
||||
TResult? Function(_PinFailure value)? failure,
|
||||
TResult? Function(PinSuccess value)? success,
|
||||
TResult? Function(PinFailure value)? failure,
|
||||
}) =>
|
||||
throw _privateConstructorUsedError;
|
||||
@optionalTypeArgs
|
||||
TResult maybeMap<TResult extends Object?>({
|
||||
TResult Function(_PinSuccess value)? success,
|
||||
TResult Function(_PinFailure value)? failure,
|
||||
TResult Function(PinSuccess value)? success,
|
||||
TResult Function(PinFailure value)? failure,
|
||||
required TResult orElse(),
|
||||
}) =>
|
||||
throw _privateConstructorUsedError;
|
||||
@ -267,7 +267,7 @@ class __$$PinSuccessImplCopyWithImpl<$Res>
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$PinSuccessImpl implements _PinSuccess {
|
||||
class _$PinSuccessImpl implements PinSuccess {
|
||||
const _$PinSuccessImpl();
|
||||
|
||||
@override
|
||||
@ -318,8 +318,8 @@ class _$PinSuccessImpl implements _PinSuccess {
|
||||
@override
|
||||
@optionalTypeArgs
|
||||
TResult map<TResult extends Object?>({
|
||||
required TResult Function(_PinSuccess value) success,
|
||||
required TResult Function(_PinFailure value) failure,
|
||||
required TResult Function(PinSuccess value) success,
|
||||
required TResult Function(PinFailure value) failure,
|
||||
}) {
|
||||
return success(this);
|
||||
}
|
||||
@ -327,8 +327,8 @@ class _$PinSuccessImpl implements _PinSuccess {
|
||||
@override
|
||||
@optionalTypeArgs
|
||||
TResult? mapOrNull<TResult extends Object?>({
|
||||
TResult? Function(_PinSuccess value)? success,
|
||||
TResult? Function(_PinFailure value)? failure,
|
||||
TResult? Function(PinSuccess value)? success,
|
||||
TResult? Function(PinFailure value)? failure,
|
||||
}) {
|
||||
return success?.call(this);
|
||||
}
|
||||
@ -336,8 +336,8 @@ class _$PinSuccessImpl implements _PinSuccess {
|
||||
@override
|
||||
@optionalTypeArgs
|
||||
TResult maybeMap<TResult extends Object?>({
|
||||
TResult Function(_PinSuccess value)? success,
|
||||
TResult Function(_PinFailure value)? failure,
|
||||
TResult Function(PinSuccess value)? success,
|
||||
TResult Function(PinFailure value)? failure,
|
||||
required TResult orElse(),
|
||||
}) {
|
||||
if (success != null) {
|
||||
@ -347,8 +347,8 @@ class _$PinSuccessImpl implements _PinSuccess {
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _PinSuccess implements PinVerificationStatus {
|
||||
const factory _PinSuccess() = _$PinSuccessImpl;
|
||||
abstract class PinSuccess implements PinVerificationStatus {
|
||||
const factory PinSuccess() = _$PinSuccessImpl;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@ -384,7 +384,7 @@ class __$$PinFailureImplCopyWithImpl<$Res>
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$PinFailureImpl implements _PinFailure {
|
||||
class _$PinFailureImpl implements PinFailure {
|
||||
_$PinFailureImpl(this.attemptsRemaining);
|
||||
|
||||
@override
|
||||
@ -447,8 +447,8 @@ class _$PinFailureImpl implements _PinFailure {
|
||||
@override
|
||||
@optionalTypeArgs
|
||||
TResult map<TResult extends Object?>({
|
||||
required TResult Function(_PinSuccess value) success,
|
||||
required TResult Function(_PinFailure value) failure,
|
||||
required TResult Function(PinSuccess value) success,
|
||||
required TResult Function(PinFailure value) failure,
|
||||
}) {
|
||||
return failure(this);
|
||||
}
|
||||
@ -456,8 +456,8 @@ class _$PinFailureImpl implements _PinFailure {
|
||||
@override
|
||||
@optionalTypeArgs
|
||||
TResult? mapOrNull<TResult extends Object?>({
|
||||
TResult? Function(_PinSuccess value)? success,
|
||||
TResult? Function(_PinFailure value)? failure,
|
||||
TResult? Function(PinSuccess value)? success,
|
||||
TResult? Function(PinFailure value)? failure,
|
||||
}) {
|
||||
return failure?.call(this);
|
||||
}
|
||||
@ -465,8 +465,8 @@ class _$PinFailureImpl implements _PinFailure {
|
||||
@override
|
||||
@optionalTypeArgs
|
||||
TResult maybeMap<TResult extends Object?>({
|
||||
TResult Function(_PinSuccess value)? success,
|
||||
TResult Function(_PinFailure value)? failure,
|
||||
TResult Function(PinSuccess value)? success,
|
||||
TResult Function(PinFailure value)? failure,
|
||||
required TResult orElse(),
|
||||
}) {
|
||||
if (failure != null) {
|
||||
@ -476,8 +476,8 @@ class _$PinFailureImpl implements _PinFailure {
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _PinFailure implements PinVerificationStatus {
|
||||
factory _PinFailure(final int attemptsRemaining) = _$PinFailureImpl;
|
||||
abstract class PinFailure implements PinVerificationStatus {
|
||||
factory PinFailure(final int attemptsRemaining) = _$PinFailureImpl;
|
||||
|
||||
int get attemptsRemaining;
|
||||
@JsonKey(ignore: true)
|
||||
|
@ -20,6 +20,18 @@ import '../app/models.dart';
|
||||
import '../core/state.dart';
|
||||
import 'models.dart';
|
||||
|
||||
final passkeysSearchProvider =
|
||||
StateNotifierProvider<PasskeysSearchNotifier, String>(
|
||||
(ref) => PasskeysSearchNotifier());
|
||||
|
||||
class PasskeysSearchNotifier extends StateNotifier<String> {
|
||||
PasskeysSearchNotifier() : super('');
|
||||
|
||||
void setFilter(String value) {
|
||||
state = value;
|
||||
}
|
||||
}
|
||||
|
||||
final pivStateProvider = AsyncNotifierProvider.autoDispose
|
||||
.family<PivStateNotifier, PivState, DevicePath>(
|
||||
() => throw UnimplementedError(),
|
||||
@ -66,5 +78,7 @@ abstract class PivSlotsNotifier
|
||||
PinPolicy pinPolicy = PinPolicy.dfault,
|
||||
TouchPolicy touchPolicy = TouchPolicy.dfault,
|
||||
});
|
||||
Future<void> delete(SlotId slot);
|
||||
Future<void> delete(SlotId slot, bool deleteCert, bool deleteKey);
|
||||
Future<void> moveKey(SlotId source, SlotId destination, bool overwriteKey,
|
||||
bool includeCertificate);
|
||||
}
|
||||
|
@ -35,6 +35,7 @@ import 'authentication_dialog.dart';
|
||||
import 'delete_certificate_dialog.dart';
|
||||
import 'generate_key_dialog.dart';
|
||||
import 'import_file_dialog.dart';
|
||||
import 'move_key_dialog.dart';
|
||||
import 'pin_dialog.dart';
|
||||
|
||||
class GenerateIntent extends Intent {
|
||||
@ -52,15 +53,19 @@ class ExportIntent extends Intent {
|
||||
const ExportIntent(this.slot);
|
||||
}
|
||||
|
||||
class MoveIntent extends Intent {
|
||||
final PivSlot slot;
|
||||
const MoveIntent(this.slot);
|
||||
}
|
||||
|
||||
Future<bool> _authIfNeeded(BuildContext context, WidgetRef ref,
|
||||
DevicePath devicePath, PivState pivState) async {
|
||||
if (pivState.needsAuth) {
|
||||
if (pivState.protectedKey &&
|
||||
pivState.metadata?.pinMetadata.defaultValue == true) {
|
||||
final status = await ref
|
||||
return await ref
|
||||
.read(pivStateProvider(devicePath).notifier)
|
||||
.verifyPin(defaultPin);
|
||||
return status.when(success: () => true, failure: (_) => false);
|
||||
.verifyPin(defaultPin) is PinSuccess;
|
||||
}
|
||||
return await showBlurDialog(
|
||||
context: context,
|
||||
@ -108,11 +113,9 @@ class PivActions extends ConsumerWidget {
|
||||
if (!pivState.protectedKey) {
|
||||
bool verified;
|
||||
if (pivState.metadata?.pinMetadata.defaultValue == true) {
|
||||
final status = await ref
|
||||
verified = await ref
|
||||
.read(pivStateProvider(devicePath).notifier)
|
||||
.verifyPin(defaultPin);
|
||||
verified =
|
||||
status.when(success: () => true, failure: (_) => false);
|
||||
.verifyPin(defaultPin) is PinSuccess;
|
||||
} else {
|
||||
verified = await withContext((context) async =>
|
||||
await showBlurDialog(
|
||||
@ -266,12 +269,32 @@ class PivActions extends ConsumerWidget {
|
||||
context: context,
|
||||
builder: (context) => DeleteCertificateDialog(
|
||||
devicePath,
|
||||
pivState,
|
||||
intent.target,
|
||||
),
|
||||
) ??
|
||||
false);
|
||||
return deleted;
|
||||
}),
|
||||
if (hasFeature(features.slotsMove))
|
||||
MoveIntent: CallbackAction<MoveIntent>(onInvoke: (intent) async {
|
||||
if (!await withContext((context) =>
|
||||
_authIfNeeded(context, ref, devicePath, pivState))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final bool? moved = await withContext((context) async =>
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => MoveKeyDialog(
|
||||
devicePath,
|
||||
pivState,
|
||||
intent.slot,
|
||||
),
|
||||
) ??
|
||||
false);
|
||||
return moved;
|
||||
}),
|
||||
},
|
||||
child: Builder(
|
||||
// Builder to ensure new scope for actions, they can invoke parent actions
|
||||
@ -286,27 +309,31 @@ class PivActions extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
List<ActionItem> buildSlotActions(PivSlot slot, AppLocalizations l10n) {
|
||||
List<ActionItem> buildSlotActions(
|
||||
PivState pivState, PivSlot slot, AppLocalizations l10n) {
|
||||
final hasCert = slot.certInfo != null;
|
||||
final hasKey = slot.metadata != null;
|
||||
final canDeleteOrMoveKey = hasKey && pivState.version.isAtLeast(5, 7);
|
||||
return [
|
||||
ActionItem(
|
||||
key: keys.generateAction,
|
||||
feature: features.slotsGenerate,
|
||||
icon: const Icon(Symbols.add),
|
||||
actionStyle: ActionStyle.primary,
|
||||
title: l10n.s_generate_key,
|
||||
subtitle: l10n.l_generate_desc,
|
||||
intent: GenerateIntent(slot),
|
||||
),
|
||||
ActionItem(
|
||||
key: keys.importAction,
|
||||
feature: features.slotsImport,
|
||||
icon: const Icon(Symbols.file_download),
|
||||
title: l10n.l_import_file,
|
||||
subtitle: l10n.l_import_desc,
|
||||
intent: ImportIntent(slot),
|
||||
),
|
||||
if (!slot.slot.isRetired) ...[
|
||||
ActionItem(
|
||||
key: keys.generateAction,
|
||||
feature: features.slotsGenerate,
|
||||
icon: const Icon(Symbols.add),
|
||||
actionStyle: ActionStyle.primary,
|
||||
title: l10n.s_generate_key,
|
||||
subtitle: l10n.l_generate_desc,
|
||||
intent: GenerateIntent(slot),
|
||||
),
|
||||
ActionItem(
|
||||
key: keys.importAction,
|
||||
feature: features.slotsImport,
|
||||
icon: const Icon(Symbols.file_download),
|
||||
title: l10n.l_import_file,
|
||||
subtitle: l10n.l_import_desc,
|
||||
intent: ImportIntent(slot),
|
||||
),
|
||||
],
|
||||
if (hasCert) ...[
|
||||
ActionItem(
|
||||
key: keys.exportAction,
|
||||
@ -316,15 +343,6 @@ List<ActionItem> buildSlotActions(PivSlot slot, AppLocalizations l10n) {
|
||||
subtitle: l10n.l_export_certificate_desc,
|
||||
intent: ExportIntent(slot),
|
||||
),
|
||||
ActionItem(
|
||||
key: keys.deleteAction,
|
||||
feature: features.slotsDelete,
|
||||
actionStyle: ActionStyle.error,
|
||||
icon: const Icon(Symbols.delete),
|
||||
title: l10n.l_delete_certificate,
|
||||
subtitle: l10n.l_delete_certificate_desc,
|
||||
intent: DeleteIntent(slot),
|
||||
),
|
||||
] else if (hasKey) ...[
|
||||
ActionItem(
|
||||
key: keys.exportAction,
|
||||
@ -335,5 +353,33 @@ List<ActionItem> buildSlotActions(PivSlot slot, AppLocalizations l10n) {
|
||||
intent: ExportIntent(slot),
|
||||
),
|
||||
],
|
||||
if (canDeleteOrMoveKey)
|
||||
ActionItem(
|
||||
key: keys.moveAction,
|
||||
feature: features.slotsMove,
|
||||
actionStyle: ActionStyle.error,
|
||||
icon: const Icon(Symbols.move_item),
|
||||
title: l10n.l_move_key,
|
||||
subtitle: l10n.l_move_key_desc,
|
||||
intent: MoveIntent(slot),
|
||||
),
|
||||
if (hasCert || canDeleteOrMoveKey)
|
||||
ActionItem(
|
||||
key: keys.deleteAction,
|
||||
feature: features.slotsDelete,
|
||||
actionStyle: ActionStyle.error,
|
||||
icon: const Icon(Symbols.delete),
|
||||
title: hasCert && canDeleteOrMoveKey
|
||||
? l10n.l_delete_certificate_or_key
|
||||
: hasCert
|
||||
? l10n.l_delete_certificate
|
||||
: l10n.l_delete_key,
|
||||
subtitle: hasCert && canDeleteOrMoveKey
|
||||
? l10n.l_delete_certificate_or_key_desc
|
||||
: hasCert
|
||||
? l10n.l_delete_certificate_desc
|
||||
: l10n.l_delete_key_desc,
|
||||
intent: DeleteIntent(slot),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
@ -27,34 +27,75 @@ import '../keys.dart' as keys;
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
|
||||
class DeleteCertificateDialog extends ConsumerWidget {
|
||||
class DeleteCertificateDialog extends ConsumerStatefulWidget {
|
||||
final DevicePath devicePath;
|
||||
final PivState pivState;
|
||||
final PivSlot pivSlot;
|
||||
const DeleteCertificateDialog(this.devicePath, this.pivSlot, {super.key});
|
||||
const DeleteCertificateDialog(this.devicePath, this.pivState, this.pivSlot,
|
||||
{super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<ConsumerStatefulWidget> createState() =>
|
||||
_DeleteCertificateDialogState();
|
||||
}
|
||||
|
||||
class _DeleteCertificateDialogState
|
||||
extends ConsumerState<DeleteCertificateDialog> {
|
||||
late bool _deleteCertificate;
|
||||
late bool _deleteKey;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_deleteCertificate = widget.pivSlot.certInfo != null;
|
||||
_deleteKey = widget.pivSlot.metadata != null &&
|
||||
widget.pivState.version.isAtLeast(5, 7);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final canDeleteCertificate = widget.pivSlot.certInfo != null;
|
||||
final canDeleteKey = widget.pivSlot.metadata != null &&
|
||||
widget.pivState.version.isAtLeast(5, 7);
|
||||
|
||||
return ResponsiveDialog(
|
||||
title: Text(l10n.l_delete_certificate),
|
||||
title: Text(canDeleteKey && canDeleteCertificate
|
||||
? l10n.l_delete_certificate_or_key
|
||||
: canDeleteCertificate
|
||||
? l10n.l_delete_certificate
|
||||
: l10n.l_delete_key),
|
||||
actions: [
|
||||
TextButton(
|
||||
key: keys.deleteButton,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await ref
|
||||
.read(pivSlotsProvider(devicePath).notifier)
|
||||
.delete(pivSlot.slot);
|
||||
await ref.read(withContextProvider)(
|
||||
(context) async {
|
||||
Navigator.of(context).pop(true);
|
||||
showMessage(context, l10n.l_certificate_deleted);
|
||||
},
|
||||
);
|
||||
} on CancellationException catch (_) {
|
||||
// ignored
|
||||
}
|
||||
},
|
||||
onPressed: _deleteKey || _deleteCertificate
|
||||
? () async {
|
||||
try {
|
||||
await ref
|
||||
.read(pivSlotsProvider(widget.devicePath).notifier)
|
||||
.delete(widget.pivSlot.slot, _deleteCertificate,
|
||||
_deleteKey);
|
||||
|
||||
await ref.read(withContextProvider)(
|
||||
(context) async {
|
||||
String message;
|
||||
if (_deleteCertificate && _deleteKey) {
|
||||
message = l10n.l_certificate_and_key_deleted;
|
||||
} else if (_deleteCertificate) {
|
||||
message = l10n.l_certificate_deleted;
|
||||
} else {
|
||||
message = l10n.l_key_deleted;
|
||||
}
|
||||
|
||||
Navigator.of(context).pop(true);
|
||||
showMessage(context, message);
|
||||
},
|
||||
);
|
||||
} on CancellationException catch (_) {
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: Text(l10n.s_delete),
|
||||
),
|
||||
],
|
||||
@ -63,9 +104,55 @@ class DeleteCertificateDialog extends ConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.p_warning_delete_certificate),
|
||||
Text(l10n.q_delete_certificate_confirm(
|
||||
pivSlot.slot.getDisplayName(l10n))),
|
||||
if (_deleteCertificate || _deleteKey) ...[
|
||||
Text(
|
||||
_deleteCertificate && _deleteKey
|
||||
? l10n.p_warning_delete_certificate_and_key
|
||||
: _deleteCertificate
|
||||
? l10n.p_warning_delete_certificate
|
||||
: l10n.p_warning_delete_key,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
Text(_deleteCertificate && _deleteKey
|
||||
? l10n.q_delete_certificate_and_key_confirm(
|
||||
widget.pivSlot.slot.getDisplayName(l10n))
|
||||
: _deleteCertificate
|
||||
? l10n.q_delete_certificate_confirm(
|
||||
widget.pivSlot.slot.getDisplayName(l10n))
|
||||
: l10n.q_delete_key_confirm(
|
||||
widget.pivSlot.slot.getDisplayName(l10n)))
|
||||
],
|
||||
if (!_deleteCertificate && !_deleteKey)
|
||||
Text(l10n.p_select_what_to_delete),
|
||||
if (canDeleteKey && canDeleteCertificate)
|
||||
Wrap(
|
||||
spacing: 4.0,
|
||||
runSpacing: 8.0,
|
||||
children: [
|
||||
if (canDeleteCertificate)
|
||||
FilterChip(
|
||||
label: Text(l10n.s_certificate),
|
||||
selected: _deleteCertificate,
|
||||
onSelected: (value) {
|
||||
setState(() {
|
||||
_deleteCertificate = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (canDeleteKey)
|
||||
FilterChip(
|
||||
label: Text(l10n.s_private_key),
|
||||
selected: _deleteKey,
|
||||
onSelected: (value) {
|
||||
setState(() {
|
||||
_deleteKey = value;
|
||||
});
|
||||
})
|
||||
],
|
||||
),
|
||||
]
|
||||
.map((e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
|
162
lib/piv/views/move_key_dialog.dart
Normal file
162
lib/piv/views/move_key_dialog.dart
Normal file
@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/message.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../app/state.dart';
|
||||
import '../../exception/cancellation_exception.dart';
|
||||
import '../../widgets/choice_filter_chip.dart';
|
||||
import '../../widgets/responsive_dialog.dart';
|
||||
import '../keys.dart' as keys;
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
import 'overwrite_confirm_dialog.dart';
|
||||
|
||||
class MoveKeyDialog extends ConsumerStatefulWidget {
|
||||
final DevicePath devicePath;
|
||||
final PivState pivState;
|
||||
final PivSlot pivSlot;
|
||||
const MoveKeyDialog(this.devicePath, this.pivState, this.pivSlot,
|
||||
{super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _MoveKeyDialogState();
|
||||
}
|
||||
|
||||
class _MoveKeyDialogState extends ConsumerState<MoveKeyDialog> {
|
||||
SlotId? _destination;
|
||||
late bool _includeCertificate;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_includeCertificate = widget.pivSlot.certInfo != null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return ResponsiveDialog(
|
||||
title: Text(l10n.l_move_key),
|
||||
actions: [
|
||||
TextButton(
|
||||
key: keys.deleteButton,
|
||||
onPressed: _destination != null
|
||||
? () async {
|
||||
try {
|
||||
final pivSlots =
|
||||
ref.read(pivSlotsProvider(widget.devicePath)).asData;
|
||||
if (pivSlots != null) {
|
||||
final destination = pivSlots.value.firstWhere(
|
||||
(element) => element.slot == _destination);
|
||||
|
||||
if (!await confirmOverwrite(context, destination,
|
||||
writeKey: true, writeCert: _includeCertificate)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(pivSlotsProvider(widget.devicePath).notifier)
|
||||
.moveKey(
|
||||
widget.pivSlot.slot,
|
||||
destination.slot,
|
||||
destination.metadata != null,
|
||||
_includeCertificate);
|
||||
|
||||
await ref.read(withContextProvider)(
|
||||
(context) async {
|
||||
String message;
|
||||
if (_includeCertificate) {
|
||||
message = l10n.l_key_and_certificate_moved;
|
||||
} else {
|
||||
message = l10n.l_key_moved;
|
||||
}
|
||||
|
||||
Navigator.of(context).pop(true);
|
||||
showMessage(context, message);
|
||||
},
|
||||
);
|
||||
}
|
||||
} on CancellationException catch (_) {
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: Text(l10n.s_move),
|
||||
),
|
||||
],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(_destination == null
|
||||
? l10n.q_move_key_confirm(
|
||||
widget.pivSlot.slot.getDisplayName(l10n))
|
||||
: widget.pivSlot.certInfo != null && _includeCertificate
|
||||
? l10n.q_move_key_and_certificate_to_slot_confirm(
|
||||
widget.pivSlot.slot.getDisplayName(l10n),
|
||||
_destination!.getDisplayName(l10n))
|
||||
: l10n.q_move_key_to_slot_confirm(
|
||||
widget.pivSlot.slot.getDisplayName(l10n),
|
||||
_destination!.getDisplayName(l10n))),
|
||||
Wrap(
|
||||
spacing: 4.0,
|
||||
runSpacing: 8.0,
|
||||
children: [
|
||||
ChoiceFilterChip<SlotId?>(
|
||||
menuConstraints: const BoxConstraints(maxHeight: 200),
|
||||
value: _destination,
|
||||
items: SlotId.values
|
||||
.where((element) => element != widget.pivSlot.slot)
|
||||
.toList(),
|
||||
labelBuilder: (value) => Text(_destination == null
|
||||
? l10n.l_select_destination_slot
|
||||
: _destination!.getDisplayName(l10n)),
|
||||
itemBuilder: (value) => Text(value!.getDisplayName(l10n)),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_destination = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (widget.pivSlot.certInfo != null)
|
||||
FilterChip(
|
||||
label: Text(l10n.l_include_certificate),
|
||||
selected: _includeCertificate,
|
||||
onSelected: (value) {
|
||||
setState(() {
|
||||
_includeCertificate = value;
|
||||
});
|
||||
})
|
||||
],
|
||||
),
|
||||
]
|
||||
.map((e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: e,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -72,6 +72,16 @@ class _PivScreenState extends ConsumerState<PivScreen> {
|
||||
final selected = _selected != null
|
||||
? pivSlots?.value.firstWhere((e) => e.slot == _selected)
|
||||
: null;
|
||||
final normalSlots = pivSlots?.value
|
||||
.where((element) => !element.slot.isRetired)
|
||||
.toList() ??
|
||||
[];
|
||||
final shownRetiredSlots = pivSlots?.value
|
||||
.where((element) =>
|
||||
element.slot.isRetired &&
|
||||
(element.certInfo != null && element.metadata != null))
|
||||
.toList() ??
|
||||
[];
|
||||
final theme = Theme.of(context);
|
||||
final textTheme = theme.textTheme;
|
||||
// This is what ListTile uses for subtitle
|
||||
@ -150,7 +160,8 @@ class _PivScreenState extends ConsumerState<PivScreen> {
|
||||
ActionListSection.fromMenuActions(
|
||||
context,
|
||||
l10n.s_actions,
|
||||
actions: buildSlotActions(selected, l10n),
|
||||
actions:
|
||||
buildSlotActions(pivState, selected, l10n),
|
||||
),
|
||||
],
|
||||
)
|
||||
@ -183,14 +194,22 @@ class _PivScreenState extends ConsumerState<PivScreen> {
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
if (pivSlots?.hasValue == true)
|
||||
...pivSlots!.value.map(
|
||||
(e) => _CertificateListItem(
|
||||
e,
|
||||
expanded: expanded,
|
||||
selected: e == selected,
|
||||
),
|
||||
...normalSlots.map(
|
||||
(e) => _CertificateListItem(
|
||||
pivState,
|
||||
e,
|
||||
expanded: expanded,
|
||||
selected: e == selected,
|
||||
),
|
||||
),
|
||||
...shownRetiredSlots.map(
|
||||
(e) => _CertificateListItem(
|
||||
pivState,
|
||||
e,
|
||||
expanded: expanded,
|
||||
selected: e == selected,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
@ -204,11 +223,12 @@ class _PivScreenState extends ConsumerState<PivScreen> {
|
||||
}
|
||||
|
||||
class _CertificateListItem extends ConsumerWidget {
|
||||
final PivState pivState;
|
||||
final PivSlot pivSlot;
|
||||
final bool expanded;
|
||||
final bool selected;
|
||||
|
||||
const _CertificateListItem(this.pivSlot,
|
||||
const _CertificateListItem(this.pivState, this.pivSlot,
|
||||
{required this.expanded, required this.selected});
|
||||
|
||||
@override
|
||||
@ -226,7 +246,7 @@ class _CertificateListItem extends ConsumerWidget {
|
||||
leading: CircleAvatar(
|
||||
foregroundColor: colorScheme.onSecondary,
|
||||
backgroundColor: colorScheme.secondary,
|
||||
child: const Icon(Symbols.badge),
|
||||
child: Icon(slot.isRetired ? Symbols.manage_history : Symbols.badge),
|
||||
),
|
||||
title: slot.getDisplayName(l10n),
|
||||
subtitle: certInfo != null
|
||||
@ -245,7 +265,7 @@ class _CertificateListItem extends ConsumerWidget {
|
||||
tapIntent: isDesktop && !expanded ? null : OpenIntent(pivSlot),
|
||||
doubleTapIntent: isDesktop && !expanded ? OpenIntent(pivSlot) : null,
|
||||
buildPopupActions: hasFeature(features.slots)
|
||||
? (context) => buildSlotActions(pivSlot, l10n)
|
||||
? (context) => buildSlotActions(pivState, pivSlot, l10n)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
@ -255,12 +275,52 @@ class _CertificateListItem extends ConsumerWidget {
|
||||
SlotId.signature => meatballButton9c,
|
||||
SlotId.keyManagement => meatballButton9d,
|
||||
SlotId.cardAuth => meatballButton9e,
|
||||
SlotId.retired1 => meatballButton82,
|
||||
SlotId.retired2 => meatballButton83,
|
||||
SlotId.retired3 => meatballButton84,
|
||||
SlotId.retired4 => meatballButton85,
|
||||
SlotId.retired5 => meatballButton86,
|
||||
SlotId.retired6 => meatballButton87,
|
||||
SlotId.retired7 => meatballButton88,
|
||||
SlotId.retired8 => meatballButton89,
|
||||
SlotId.retired9 => meatballButton8a,
|
||||
SlotId.retired10 => meatballButton8b,
|
||||
SlotId.retired11 => meatballButton8c,
|
||||
SlotId.retired12 => meatballButton8d,
|
||||
SlotId.retired13 => meatballButton8e,
|
||||
SlotId.retired14 => meatballButton8f,
|
||||
SlotId.retired15 => meatballButton90,
|
||||
SlotId.retired16 => meatballButton91,
|
||||
SlotId.retired17 => meatballButton92,
|
||||
SlotId.retired18 => meatballButton93,
|
||||
SlotId.retired19 => meatballButton94,
|
||||
SlotId.retired20 => meatballButton95
|
||||
};
|
||||
|
||||
Key _getAppListItemKey(SlotId slotId) => switch (slotId) {
|
||||
SlotId.authentication => appListItem9a,
|
||||
SlotId.signature => appListItem9c,
|
||||
SlotId.keyManagement => appListItem9d,
|
||||
SlotId.cardAuth => appListItem9e
|
||||
SlotId.cardAuth => appListItem9e,
|
||||
SlotId.retired1 => appListItem82,
|
||||
SlotId.retired2 => appListItem83,
|
||||
SlotId.retired3 => appListItem84,
|
||||
SlotId.retired4 => appListItem85,
|
||||
SlotId.retired5 => appListItem86,
|
||||
SlotId.retired6 => appListItem87,
|
||||
SlotId.retired7 => appListItem88,
|
||||
SlotId.retired8 => appListItem89,
|
||||
SlotId.retired9 => appListItem8a,
|
||||
SlotId.retired10 => appListItem8b,
|
||||
SlotId.retired11 => appListItem8c,
|
||||
SlotId.retired12 => appListItem8d,
|
||||
SlotId.retired13 => appListItem8e,
|
||||
SlotId.retired14 => appListItem8f,
|
||||
SlotId.retired15 => appListItem90,
|
||||
SlotId.retired16 => appListItem91,
|
||||
SlotId.retired17 => appListItem92,
|
||||
SlotId.retired18 => appListItem93,
|
||||
SlotId.retired19 => appListItem94,
|
||||
SlotId.retired20 => appListItem95
|
||||
};
|
||||
}
|
||||
|
@ -111,7 +111,7 @@ class SlotDialog extends ConsumerWidget {
|
||||
ActionListSection.fromMenuActions(
|
||||
context,
|
||||
l10n.s_actions,
|
||||
actions: buildSlotActions(slotData, l10n),
|
||||
actions: buildSlotActions(pivState, slotData, l10n),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -29,6 +29,7 @@ class ChoiceFilterChip<T> extends StatefulWidget {
|
||||
final Widget? avatar;
|
||||
final bool selected;
|
||||
final bool? disableHover;
|
||||
final BoxConstraints? menuConstraints;
|
||||
const ChoiceFilterChip({
|
||||
super.key,
|
||||
required this.value,
|
||||
@ -40,6 +41,7 @@ class ChoiceFilterChip<T> extends StatefulWidget {
|
||||
this.selected = false,
|
||||
this.disableHover,
|
||||
this.labelBuilder,
|
||||
this.menuConstraints,
|
||||
});
|
||||
|
||||
@override
|
||||
@ -63,6 +65,7 @@ class _ChoiceFilterChipState<T> extends State<ChoiceFilterChip<T>> {
|
||||
Offset.zero & overlay.size,
|
||||
);
|
||||
return await showMenu(
|
||||
constraints: widget.menuConstraints,
|
||||
context: context,
|
||||
position: position,
|
||||
shape: const RoundedRectangleBorder(
|
||||
|
@ -815,6 +815,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.99"
|
||||
sliver_tools:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sliver_tools
|
||||
sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.12"
|
||||
source_gen:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -49,7 +49,7 @@ dependencies:
|
||||
flutter_riverpod: ^2.4.10
|
||||
json_annotation: ^4.8.1
|
||||
freezed_annotation: ^2.4.1
|
||||
window_manager:
|
||||
window_manager:
|
||||
git:
|
||||
url: https://github.com/fdennis/window_manager.git
|
||||
ref: 2272d45bcf46d7e2b452a038906fbc85df3ce83d
|
||||
@ -71,6 +71,7 @@ dependencies:
|
||||
base32: ^2.1.3
|
||||
convert: ^3.1.1
|
||||
material_symbols_icons: ^4.2719.1
|
||||
sliver_tools: ^0.2.12
|
||||
|
||||
dev_dependencies:
|
||||
integration_test:
|
||||
|
Loading…
Reference in New Issue
Block a user