From 07d4f2a0274a26f91b747327648259194ce14f33 Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Tue, 19 Mar 2024 11:43:49 +0100 Subject: [PATCH 01/34] Fix re-auth in PIV when using PIN-protected mgmt key --- lib/desktop/piv/state.dart | 48 +++++++++++++++++++++++++------------ lib/piv/models.dart | 4 ++-- lib/piv/models.freezed.dart | 48 ++++++++++++++++++------------------- lib/piv/views/actions.dart | 11 ++++----- 4 files changed, 63 insertions(+), 48 deletions(-) diff --git a/lib/desktop/piv/state.dart b/lib/desktop/piv/state.dart index f5d36ec3..75c2c51f 100644 --- a/lib/desktop/piv/state.dart +++ b/lib/desktop/piv/state.dart @@ -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(() { diff --git a/lib/piv/models.dart b/lib/piv/models.dart index 116115ed..3d790024 100644 --- a/lib/piv/models.dart +++ b/lib/piv/models.dart @@ -186,8 +186,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 diff --git a/lib/piv/models.freezed.dart b/lib/piv/models.freezed.dart index a1f4263c..f895b9f0 100644 --- a/lib/piv/models.freezed.dart +++ b/lib/piv/models.freezed.dart @@ -211,20 +211,20 @@ mixin _$PinVerificationStatus { throw _privateConstructorUsedError; @optionalTypeArgs TResult map({ - 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? 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 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({ - 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? 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 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({ - 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? 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 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) diff --git a/lib/piv/views/actions.dart b/lib/piv/views/actions.dart index 5790f774..78307345 100644 --- a/lib/piv/views/actions.dart +++ b/lib/piv/views/actions.dart @@ -57,10 +57,9 @@ Future _authIfNeeded(BuildContext context, WidgetRef ref, 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 +107,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( From 31f2712bf8b67413d4e6dc0f33bb41fdba7ec5d7 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Tue, 20 Feb 2024 08:57:24 +0100 Subject: [PATCH 02/34] Add sliver scrolling functionality This adds the title in a `SliverPinnedHeader`, which is pinned until `headerSliver` (optional) scrolls under it. When the `SliverPinnedHeader` is scrolled under `AppBar` the title is shown in the `AppBar`. --- lib/app/views/app_page.dart | 384 ++++++++++++++++++++++--------- lib/app/views/device_picker.dart | 13 +- lib/app/views/navigation.dart | 3 +- lib/oath/views/account_list.dart | 8 +- lib/oath/views/oath_screen.dart | 145 ++++++------ lib/theme.dart | 6 - pubspec.lock | 8 + pubspec.yaml | 3 +- 8 files changed, 373 insertions(+), 197 deletions(-) diff --git a/lib/app/views/app_page.dart b/lib/app/views/app_page.dart index 605d6b02..5d22267e 100755 --- a/lib/app/views/app_page.dart +++ b/lib/app/views/app_page.dart @@ -19,6 +19,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:sliver_tools/sliver_tools.dart'; import '../../core/state.dart'; import '../../management/models.dart'; @@ -30,11 +31,15 @@ import 'fs_dialog.dart'; import 'keys.dart'; import 'navigation.dart'; -// We use global keys here to maintain the NavigatorContent between AppPages. +// 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 _detailsViewGlobalKey = GlobalKey(); +final _mainContentGlobalKey = GlobalKey(); -class AppPage extends StatelessWidget { +class AppPage extends StatefulWidget { final String? title; final String? footnote; final Widget Function(BuildContext context, bool expanded) builder; @@ -49,29 +54,96 @@ class AppPage extends StatelessWidget { final Widget? fileDropOverlay; final Function(File file)? onFileDropped; final List? 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.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 + State createState() => _AppPageState(); +} + +class _AppPageState extends State { + final ScrollController _mainScrollController = ScrollController(); + final ScrollController _navScrollController = ScrollController(); + final ScrollController _detailsScrollController = ScrollController(); + + bool _showFullNavigation = true; + bool _isSliverTitleScrolledUnder = false; + bool _isNavigationScrolledUnder = false; + bool _isDetailsScrolledUnder = false; + + bool _scrolledUnderAppBar(GlobalKey key) { + final currentContext = key.currentContext; + if (currentContext != null) { + final RenderBox renderBox = + currentContext.findRenderObject() as RenderBox; + final appBarHeight = MediaQuery.of(context).padding.top + kToolbarHeight; + final position = renderBox.localToGlobal(Offset.zero); + + return appBarHeight - position.dy > 0; + } + return false; + } + + @override + void dispose() { + _mainScrollController.dispose(); + _navScrollController.dispose(); + _detailsScrollController.dispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + + _mainScrollController.addListener(() { + setState(() { + _isSliverTitleScrolledUnder = + _scrolledUnderAppBar(_sliverTitleGlobalKey); + }); + }); + _navScrollController.addListener(() { + setState(() { + _isNavigationScrolledUnder = _scrolledUnderAppBar(_navKey) || + _scrolledUnderAppBar(_navExpandedKey); + }); + }); + _detailsScrollController.addListener(() { + setState(() { + _isDetailsScrolledUnder = _scrolledUnderAppBar(_detailsViewGlobalKey); + }); + }); + } @override Widget build(BuildContext context) => LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; - + // Reset state on screen width change, to make sure + // navigation always expands in fully expanded layout + if (width < 1000) { + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + _showFullNavigation = true; + }); + }); + } if (width < 400 || (isAndroid && width < 600 && width < constraints.maxHeight)) { return _buildScaffold(context, true, false, false); @@ -87,31 +159,7 @@ class AppPage extends StatelessWidget { 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); } }, ); @@ -164,36 +212,82 @@ class AppPage extends StatelessWidget { 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))], - ) + AnimatedOpacity( + opacity: !_isSliverTitleScrolledUnder ? 1 : 0, + duration: const Duration(milliseconds: 300), + child: Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 2.0, + runSpacing: 8.0, + children: [ + Text( + key: _sliverTitleGlobalKey, + widget.title!, + style: Theme.of(context).textTheme.displaySmall!.copyWith( + color: Theme.of(context) + .colorScheme + .primary + .withOpacity(0.9), + ), + ), + if (widget.capabilities != 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) { + EdgeInsets padding; + if (fullyExpanded) { + padding = + EdgeInsets.only(left: _showFullNavigation ? 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 Padding( + padding: padding, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: _isSliverTitleScrolledUnder ? 1 : 0, + child: Text(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 +298,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 +314,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 +323,67 @@ 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: const NeverScrollableScrollPhysics(), + child: safeArea, + ), + ), + ), + ) + ]); + } + if (widget.title != null) { + return CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + key: _mainContentGlobalKey, + controller: _mainScrollController, + slivers: [ + SliverMainAxisGroup( + slivers: [ + SliverPinnedHeader( + child: ColoredBox( + color: Theme.of(context).colorScheme.background, + child: Padding( + 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: widget.headerSliver, + ) + ], + ), + SliverToBoxAdapter(child: safeArea) ], ); } return SingleChildScrollView( + key: _mainContentGlobalKey, + controller: _mainScrollController, primary: false, child: safeArea, ); @@ -269,12 +391,13 @@ class AppPage extends StatelessWidget { Scaffold _buildScaffold( BuildContext context, bool hasDrawer, bool hasRail, bool hasManage) { + final fullyExpanded = !hasDrawer && hasRail && hasManage; 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,10 +405,11 @@ class AppPage extends StatelessWidget { body = Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (hasRail) + if (hasRail && (!fullyExpanded || !_showFullNavigation)) SizedBox( width: 72, child: SingleChildScrollView( + controller: _navScrollController, child: NavigationContent( key: _navKey, shouldPop: false, @@ -293,6 +417,18 @@ class AppPage extends StatelessWidget { ), ), ), + if (fullyExpanded && _showFullNavigation) + SizedBox( + width: 280, + child: SingleChildScrollView( + controller: _navScrollController, + child: NavigationContent( + key: _navExpandedKey, + shouldPop: false, + extended: true, + ), + ), + ), const SizedBox(width: 8), Expanded( child: GestureDetector( @@ -308,18 +444,21 @@ class AppPage extends StatelessWidget { ]), )), if (hasManage && - (detailViewBuilder != null || keyActionsBuilder != null)) + (widget.detailViewBuilder != null || + widget.keyActionsBuilder != null)) SingleChildScrollView( + controller: _detailsScrollController, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: SizedBox( width: 320, child: Column( + key: _detailsViewGlobalKey, children: [ - if (detailViewBuilder != null) - detailViewBuilder!(context), - if (keyActionsBuilder != null) - keyActionsBuilder!(context), + if (widget.detailViewBuilder != null) + widget.detailViewBuilder!(context), + if (widget.keyActionsBuilder != null) + widget.keyActionsBuilder!(context), ], ), ), @@ -331,24 +470,49 @@ class AppPage extends StatelessWidget { return Scaffold( key: scaffoldGlobalKey, appBar: AppBar( + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1.0), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: _isSliverTitleScrolledUnder || + _isNavigationScrolledUnder || + _isDetailsScrolledUnder + ? 1 + : 0, + 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 + ? () { + setState(() { + _showFullNavigation = !_showFullNavigation; + }); + } + : 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 +524,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 +539,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), ), ], ), diff --git a/lib/app/views/device_picker.dart b/lib/app/views/device_picker.dart index 0ab99ab3..01e805a6 100644 --- a/lib/app/views/device_picker.dart +++ b/lib/app/views/device_picker.dart @@ -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, diff --git a/lib/app/views/navigation.dart b/lib/app/views/navigation.dart index 6d968327..4d7474de 100644 --- a/lib/app/views/navigation.dart +++ b/lib/app/views/navigation.dart @@ -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( diff --git a/lib/oath/views/account_list.dart b/lib/oath/views/account_list.dart index f1ada5f9..e0c88bb2 100755 --- a/lib/oath/views/account_list.dart +++ b/lib/oath/views/account_list.dart @@ -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( diff --git a/lib/oath/views/oath_screen.dart b/lib/oath/views/oath_screen.dart index 2c0fcf56..6d3d2f9a 100755 --- a/lib/oath/views/oath_screen.dart +++ b/lib/oath/views/oath_screen.dart @@ -350,6 +350,77 @@ 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(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(Icons.search_outlined), + ), + suffixIcon: searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.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); + }, + ), + ); + }), + ), builder: (context, expanded) { // De-select if window is resized to be non-expanded. if (!expanded && _selected != null) { @@ -373,80 +444,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( diff --git a/lib/theme.dart b/lib/theme.dart index 86f50d6e..6afbb1ea 100755 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -34,9 +34,6 @@ class AppTheme { onSurfaceVariant: const Color(0x99000000), ), fontFamily: 'Roboto', - appBarTheme: const AppBarTheme( - color: Colors.transparent, - ), listTileTheme: const ListTileThemeData( // For alignment under menu button contentPadding: EdgeInsets.symmetric(horizontal: 18.0), @@ -62,9 +59,6 @@ class AppTheme { onSurfaceVariant: const Color(0xaaffffff), ), fontFamily: 'Roboto', - appBarTheme: const AppBarTheme( - color: Colors.transparent, - ), listTileTheme: const ListTileThemeData( // For alignment under menu button contentPadding: EdgeInsets.symmetric(horizontal: 18.0), diff --git a/pubspec.lock b/pubspec.lock index 8012ec4e..63b269a1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 6909753c..6850e22d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: From ad54c182055875074815740c31ea654758f479b9 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Wed, 28 Feb 2024 14:47:22 +0100 Subject: [PATCH 03/34] Make sure OATH search field is always shown on `SearchIntent` --- lib/oath/views/oath_screen.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/oath/views/oath_screen.dart b/lib/oath/views/oath_screen.dart index 6d3d2f9a..484a578e 100755 --- a/lib/oath/views/oath_screen.dart +++ b/lib/oath/views/oath_screen.dart @@ -210,7 +210,8 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> { SearchIntent: CallbackAction(onInvoke: (_) { searchController.selection = TextSelection( baseOffset: 0, extentOffset: searchController.text.length); - searchFocus.requestFocus(); + searchFocus.unfocus(); + Timer.run(() => searchFocus.requestFocus()); return null; }), EscapeIntent: CallbackAction(onInvoke: (intent) { From c3c70029a9709c9c63840ee494b5aeaca9752ba8 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Wed, 28 Feb 2024 14:49:16 +0100 Subject: [PATCH 04/34] Add `alternativeTitle` to `AppPage` and change border radius --- lib/app/views/action_list.dart | 2 +- lib/app/views/app_list_item.dart | 2 +- lib/app/views/app_page.dart | 53 +++++++++++++++++++------------- lib/oath/views/oath_screen.dart | 4 ++- 4 files changed, 36 insertions(+), 25 deletions(-) diff --git a/lib/app/views/action_list.dart b/lib/app/views/action_list.dart index 7274ebe1..3c5f4034 100644 --- a/lib/app/views/action_list.dart +++ b/lib/app/views/action_list.dart @@ -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( diff --git a/lib/app/views/app_list_item.dart b/lib/app/views/app_list_item.dart index 0ef48e27..cb288662 100644 --- a/lib/app/views/app_list_item.dart +++ b/lib/app/views/app_list_item.dart @@ -77,7 +77,7 @@ class _AppListItemState extends ConsumerState { item: widget.item, child: InkWell( focusNode: _focusNode, - borderRadius: BorderRadius.circular(30), + borderRadius: BorderRadius.circular(48), onSecondaryTapDown: buildPopupActions == null ? null : (details) { diff --git a/lib/app/views/app_page.dart b/lib/app/views/app_page.dart index 5d22267e..75889379 100755 --- a/lib/app/views/app_page.dart +++ b/lib/app/views/app_page.dart @@ -41,6 +41,7 @@ final _mainContentGlobalKey = GlobalKey(); class AppPage extends StatefulWidget { final String? title; + final String? alternativeTitle; final String? footnote; final Widget Function(BuildContext context, bool expanded) builder; final Widget Function(BuildContext context)? detailViewBuilder; @@ -58,6 +59,7 @@ class AppPage extends StatefulWidget { const AppPage( {super.key, this.title, + this.alternativeTitle, this.footnote, required this.builder, this.centered = false, @@ -215,31 +217,38 @@ class _AppPageState extends State { AnimatedOpacity( opacity: !_isSliverTitleScrolledUnder ? 1 : 0, duration: const Duration(milliseconds: 300), - child: Wrap( - alignment: WrapAlignment.spaceBetween, - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 2.0, - runSpacing: 8.0, - children: [ - Text( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( key: _sliverTitleGlobalKey, - widget.title!, + widget.alternativeTitle ?? widget.title!, style: Theme.of(context).textTheme.displaySmall!.copyWith( - color: Theme.of(context) - .colorScheme - .primary - .withOpacity(0.9), + 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) - Wrap( - spacing: 4.0, - runSpacing: 8.0, - children: [ - ...widget.capabilities!.map((c) => CapabilityBadge(c)) - ], - ) - ]), + ), + if (widget.capabilities != null && + widget.alternativeTitle == null) + Wrap( + spacing: 4.0, + runSpacing: 8.0, + children: [ + ...widget.capabilities!.map((c) => CapabilityBadge(c)) + ], + ) + ], + ), ) ], ); @@ -267,7 +276,7 @@ class _AppPageState extends State { child: AnimatedOpacity( duration: const Duration(milliseconds: 300), opacity: _isSliverTitleScrolledUnder ? 1 : 0, - child: Text(widget.title!), + child: Text(widget.alternativeTitle ?? widget.title!), ), ); } diff --git a/lib/oath/views/oath_screen.dart b/lib/oath/views/oath_screen.dart index 484a578e..9f0afaba 100755 --- a/lib/oath/views/oath_screen.dart +++ b/lib/oath/views/oath_screen.dart @@ -144,6 +144,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 onFileDropped(File file) async { final qrScanner = ref.read(qrScannerProvider); @@ -262,6 +263,7 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> { }, builder: (context) => AppPage( title: l10n.s_accounts, + alternativeTitle: searchText != '' ? 'Results for "$searchText"' : null, capabilities: const [Capability.oath], keyActionsBuilder: hasActions ? (context) => oathBuildActions( @@ -381,7 +383,7 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> { ?.copyWith(fontSize: textTheme.titleSmall?.fontSize), decoration: AppInputDecoration( border: OutlineInputBorder( - borderRadius: BorderRadius.circular(32), + borderRadius: BorderRadius.circular(48), borderSide: BorderSide( width: 0, style: searchFocus.hasFocus From 1ceaaee52e808516efc20acdce1a469065b38d97 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Wed, 28 Feb 2024 16:12:29 +0100 Subject: [PATCH 05/34] Persist navigation expanded state --- lib/app/views/app_page.dart | 46 +++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/lib/app/views/app_page.dart b/lib/app/views/app_page.dart index 75889379..0deec66b 100755 --- a/lib/app/views/app_page.dart +++ b/lib/app/views/app_page.dart @@ -18,6 +18,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:sliver_tools/sliver_tools.dart'; @@ -31,6 +32,17 @@ import 'fs_dialog.dart'; import 'keys.dart'; import 'navigation.dart'; +final _navigationProvider = StateNotifierProvider<_NavigationProvider, bool>( + (ref) => _NavigationProvider()); + +class _NavigationProvider extends StateNotifier { + _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(); @@ -39,7 +51,7 @@ final _sliverTitleGlobalKey = GlobalKey(); final _detailsViewGlobalKey = GlobalKey(); final _mainContentGlobalKey = GlobalKey(); -class AppPage extends StatefulWidget { +class AppPage extends ConsumerStatefulWidget { final String? title; final String? alternativeTitle; final String? footnote; @@ -76,15 +88,14 @@ class AppPage extends StatefulWidget { : assert(!(onFileDropped != null && fileDropOverlay == null), 'Declaring onFileDropped requires declaring a fileDropOverlay'); @override - State createState() => _AppPageState(); + ConsumerState createState() => _AppPageState(); } -class _AppPageState extends State { +class _AppPageState extends ConsumerState { final ScrollController _mainScrollController = ScrollController(); final ScrollController _navScrollController = ScrollController(); final ScrollController _detailsScrollController = ScrollController(); - bool _showFullNavigation = true; bool _isSliverTitleScrolledUnder = false; bool _isNavigationScrolledUnder = false; bool _isDetailsScrolledUnder = false; @@ -137,15 +148,7 @@ class _AppPageState extends State { Widget build(BuildContext context) => LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; - // Reset state on screen width change, to make sure - // navigation always expands in fully expanded layout - if (width < 1000) { - WidgetsBinding.instance.addPostFrameCallback((_) { - setState(() { - _showFullNavigation = true; - }); - }); - } + if (width < 400 || (isAndroid && width < 600 && width < constraints.maxHeight)) { return _buildScaffold(context, true, false, false); @@ -256,10 +259,10 @@ class _AppPageState extends State { Widget? _buildAppBarTitle( BuildContext context, bool hasRail, bool hasManage, bool fullyExpanded) { + final showNavigation = ref.watch(_navigationProvider); EdgeInsets padding; if (fullyExpanded) { - padding = - EdgeInsets.only(left: _showFullNavigation ? 280 : 72, right: 320); + padding = EdgeInsets.only(left: showNavigation ? 280 : 72, right: 320); } else if (!hasRail && hasManage) { padding = const EdgeInsets.only(right: 320); } else if (hasRail && hasManage) { @@ -391,8 +394,6 @@ class _AppPageState extends State { } return SingleChildScrollView( - key: _mainContentGlobalKey, - controller: _mainScrollController, primary: false, child: safeArea, ); @@ -401,6 +402,7 @@ class _AppPageState extends State { 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 (widget.onFileDropped != null) { @@ -414,7 +416,7 @@ class _AppPageState extends State { body = Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (hasRail && (!fullyExpanded || !_showFullNavigation)) + if (hasRail && (!fullyExpanded || !showNavigation)) SizedBox( width: 72, child: SingleChildScrollView( @@ -426,7 +428,7 @@ class _AppPageState extends State { ), ), ), - if (fullyExpanded && _showFullNavigation) + if (fullyExpanded && showNavigation) SizedBox( width: 280, child: SingleChildScrollView( @@ -508,9 +510,9 @@ class _AppPageState extends State { child: DrawerButton( onPressed: fullyExpanded ? () { - setState(() { - _showFullNavigation = !_showFullNavigation; - }); + ref + .read(_navigationProvider.notifier) + .toggleExpanded(); } : null, ), From ec3265fc1d945d4332c8bd70893997943e70dae2 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Wed, 28 Feb 2024 17:24:39 +0100 Subject: [PATCH 06/34] Use `NotificationListener` instead of ScrollController --- lib/app/views/app_page.dart | 166 ++++++++++++++++++------------------ 1 file changed, 82 insertions(+), 84 deletions(-) diff --git a/lib/app/views/app_page.dart b/lib/app/views/app_page.dart index 0deec66b..6c57a5e8 100755 --- a/lib/app/views/app_page.dart +++ b/lib/app/views/app_page.dart @@ -14,6 +14,7 @@ * limitations under the License. */ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -92,10 +93,6 @@ class AppPage extends ConsumerStatefulWidget { } class _AppPageState extends ConsumerState { - final ScrollController _mainScrollController = ScrollController(); - final ScrollController _navScrollController = ScrollController(); - final ScrollController _detailsScrollController = ScrollController(); - bool _isSliverTitleScrolledUnder = false; bool _isNavigationScrolledUnder = false; bool _isDetailsScrolledUnder = false; @@ -113,37 +110,6 @@ class _AppPageState extends ConsumerState { return false; } - @override - void dispose() { - _mainScrollController.dispose(); - _navScrollController.dispose(); - _detailsScrollController.dispose(); - super.dispose(); - } - - @override - void initState() { - super.initState(); - - _mainScrollController.addListener(() { - setState(() { - _isSliverTitleScrolledUnder = - _scrolledUnderAppBar(_sliverTitleGlobalKey); - }); - }); - _navScrollController.addListener(() { - setState(() { - _isNavigationScrolledUnder = _scrolledUnderAppBar(_navKey) || - _scrolledUnderAppBar(_navExpandedKey); - }); - }); - _detailsScrollController.addListener(() { - setState(() { - _isDetailsScrolledUnder = _scrolledUnderAppBar(_detailsViewGlobalKey); - }); - }); - } - @override Widget build(BuildContext context) => LayoutBuilder( builder: (context, constraints) { @@ -365,31 +331,40 @@ class _AppPageState extends ConsumerState { ]); } if (widget.title != null) { - return CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - key: _mainContentGlobalKey, - controller: _mainScrollController, - slivers: [ - SliverMainAxisGroup( - slivers: [ - SliverPinnedHeader( - child: ColoredBox( - color: Theme.of(context).colorScheme.background, - child: Padding( - padding: const EdgeInsets.only( - left: 16.0, right: 16.0, bottom: 12.0, top: 4.0), - child: _buildTitle(context), + return NotificationListener( + onNotification: (_) { + Timer.run(() { + setState(() { + _isSliverTitleScrolledUnder = + _scrolledUnderAppBar(_sliverTitleGlobalKey); + }); + }); + return false; + }, + child: CustomScrollView( + key: _mainContentGlobalKey, + slivers: [ + SliverMainAxisGroup( + slivers: [ + SliverPinnedHeader( + child: ColoredBox( + color: Theme.of(context).colorScheme.background, + child: Padding( + 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: widget.headerSliver, - ) - ], - ), - SliverToBoxAdapter(child: safeArea) - ], + if (widget.headerSliver != null) + SliverToBoxAdapter( + child: widget.headerSliver, + ) + ], + ), + SliverToBoxAdapter(child: safeArea) + ], + ), ); } @@ -419,24 +394,39 @@ class _AppPageState extends ConsumerState { if (hasRail && (!fullyExpanded || !showNavigation)) SizedBox( width: 72, - child: SingleChildScrollView( - controller: _navScrollController, - child: NavigationContent( - key: _navKey, - shouldPop: false, - extended: false, + child: NotificationListener( + onNotification: (_) { + setState(() { + _isNavigationScrolledUnder = _scrolledUnderAppBar(_navKey); + }); + return false; + }, + child: SingleChildScrollView( + child: NavigationContent( + key: _navKey, + shouldPop: false, + extended: false, + ), ), ), ), if (fullyExpanded && showNavigation) SizedBox( width: 280, - child: SingleChildScrollView( - controller: _navScrollController, - child: NavigationContent( - key: _navExpandedKey, - shouldPop: false, - extended: true, + child: NotificationListener( + onNotification: (_) { + setState(() { + _isNavigationScrolledUnder = + _scrolledUnderAppBar(_navExpandedKey); + }); + return false; + }, + child: SingleChildScrollView( + child: NavigationContent( + key: _navExpandedKey, + shouldPop: false, + extended: true, + ), ), ), ), @@ -457,20 +447,28 @@ class _AppPageState extends ConsumerState { if (hasManage && (widget.detailViewBuilder != null || widget.keyActionsBuilder != null)) - SingleChildScrollView( - controller: _detailsScrollController, - 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), - ], + NotificationListener( + onNotification: (_) { + setState(() { + _isDetailsScrolledUnder = + _scrolledUnderAppBar(_detailsViewGlobalKey); + }); + return false; + }, + 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), + ], + ), ), ), ), From 1c7bcfd97a9a4dbe57892ceaf7385cc83943979e Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Fri, 1 Mar 2024 10:35:17 +0100 Subject: [PATCH 07/34] Handle zero scroll offset --- lib/app/views/app_page.dart | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/lib/app/views/app_page.dart b/lib/app/views/app_page.dart index 6c57a5e8..924e3f67 100755 --- a/lib/app/views/app_page.dart +++ b/lib/app/views/app_page.dart @@ -331,12 +331,14 @@ class _AppPageState extends ConsumerState { ]); } if (widget.title != null) { - return NotificationListener( - onNotification: (_) { + return NotificationListener( + onNotification: (scrollNotification) { + final scrollOffset = scrollNotification.metrics.pixels; Timer.run(() { setState(() { _isSliverTitleScrolledUnder = - _scrolledUnderAppBar(_sliverTitleGlobalKey); + _scrolledUnderAppBar(_sliverTitleGlobalKey) && + scrollOffset != 0; }); }); return false; @@ -394,10 +396,12 @@ class _AppPageState extends ConsumerState { if (hasRail && (!fullyExpanded || !showNavigation)) SizedBox( width: 72, - child: NotificationListener( - onNotification: (_) { + child: NotificationListener( + onNotification: (scrollNotification) { + final scrollOffset = scrollNotification.metrics.pixels; setState(() { - _isNavigationScrolledUnder = _scrolledUnderAppBar(_navKey); + _isNavigationScrolledUnder = + _scrolledUnderAppBar(_navKey) && scrollOffset != 0; }); return false; }, @@ -413,11 +417,13 @@ class _AppPageState extends ConsumerState { if (fullyExpanded && showNavigation) SizedBox( width: 280, - child: NotificationListener( - onNotification: (_) { + child: NotificationListener( + onNotification: (scrollNotification) { + final scrollOffset = scrollNotification.metrics.pixels; setState(() { _isNavigationScrolledUnder = - _scrolledUnderAppBar(_navExpandedKey); + _scrolledUnderAppBar(_navExpandedKey) && + scrollOffset != 0; }); return false; }, @@ -447,11 +453,13 @@ class _AppPageState extends ConsumerState { if (hasManage && (widget.detailViewBuilder != null || widget.keyActionsBuilder != null)) - NotificationListener( - onNotification: (_) { + NotificationListener( + onNotification: (scrollNotification) { + final scrollOffset = scrollNotification.metrics.pixels; setState(() { _isDetailsScrolledUnder = - _scrolledUnderAppBar(_detailsViewGlobalKey); + _scrolledUnderAppBar(_detailsViewGlobalKey) && + scrollOffset != 0; }); return false; }, From 550266e7488401c789503bcd16c79e4610b4194e Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Fri, 1 Mar 2024 12:53:49 +0100 Subject: [PATCH 08/34] Remove unnecessary `setState` --- lib/app/views/app_page.dart | 50 ++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/lib/app/views/app_page.dart b/lib/app/views/app_page.dart index 924e3f67..3c7d163a 100755 --- a/lib/app/views/app_page.dart +++ b/lib/app/views/app_page.dart @@ -334,13 +334,15 @@ class _AppPageState extends ConsumerState { return NotificationListener( onNotification: (scrollNotification) { final scrollOffset = scrollNotification.metrics.pixels; - Timer.run(() { - setState(() { - _isSliverTitleScrolledUnder = - _scrolledUnderAppBar(_sliverTitleGlobalKey) && - scrollOffset != 0; + final scrolledUnder = _scrolledUnderAppBar(_sliverTitleGlobalKey); + if (_isSliverTitleScrolledUnder != scrolledUnder) { + Timer.run(() { + setState(() { + _isSliverTitleScrolledUnder = + scrolledUnder && scrollOffset != 0; + }); }); - }); + } return false; }, child: CustomScrollView( @@ -399,10 +401,13 @@ class _AppPageState extends ConsumerState { child: NotificationListener( onNotification: (scrollNotification) { final scrollOffset = scrollNotification.metrics.pixels; - setState(() { - _isNavigationScrolledUnder = - _scrolledUnderAppBar(_navKey) && scrollOffset != 0; - }); + final scrolledUnder = _scrolledUnderAppBar(_navKey); + if (_isNavigationScrolledUnder != scrolledUnder) { + setState(() { + _isNavigationScrolledUnder = + scrolledUnder && scrollOffset != 0; + }); + } return false; }, child: SingleChildScrollView( @@ -420,11 +425,13 @@ class _AppPageState extends ConsumerState { child: NotificationListener( onNotification: (scrollNotification) { final scrollOffset = scrollNotification.metrics.pixels; - setState(() { - _isNavigationScrolledUnder = - _scrolledUnderAppBar(_navExpandedKey) && - scrollOffset != 0; - }); + final scrolledUnder = _scrolledUnderAppBar(_navExpandedKey); + if (_isNavigationScrolledUnder != scrolledUnder) { + setState(() { + _isNavigationScrolledUnder = + scrolledUnder && scrollOffset != 0; + }); + } return false; }, child: SingleChildScrollView( @@ -456,11 +463,14 @@ class _AppPageState extends ConsumerState { NotificationListener( onNotification: (scrollNotification) { final scrollOffset = scrollNotification.metrics.pixels; - setState(() { - _isDetailsScrolledUnder = - _scrolledUnderAppBar(_detailsViewGlobalKey) && - scrollOffset != 0; - }); + final scrolledUnder = + _scrolledUnderAppBar(_detailsViewGlobalKey); + if (_isDetailsScrolledUnder != scrolledUnder) { + setState(() { + _isDetailsScrolledUnder = + scrolledUnder && scrollOffset != 0; + }); + } return false; }, child: SingleChildScrollView( From 4a5e5bd43dd4ff1a173d7b58ecb53d8529c97724 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Fri, 1 Mar 2024 16:50:20 +0100 Subject: [PATCH 09/34] Optimize handling of items scrolling under `AppBar` --- lib/app/views/app_page.dart | 194 +++++++++++++++++------------------- 1 file changed, 94 insertions(+), 100 deletions(-) diff --git a/lib/app/views/app_page.dart b/lib/app/views/app_page.dart index 3c7d163a..687eef6c 100755 --- a/lib/app/views/app_page.dart +++ b/lib/app/views/app_page.dart @@ -14,10 +14,10 @@ * limitations under the License. */ -import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -93,6 +93,9 @@ class AppPage extends ConsumerStatefulWidget { } class _AppPageState extends ConsumerState { + final ScrollController _mainController = ScrollController(); + final ScrollController _navController = ScrollController(); + final ScrollController _detailsController = ScrollController(); bool _isSliverTitleScrolledUnder = false; bool _isNavigationScrolledUnder = false; bool _isDetailsScrolledUnder = false; @@ -110,6 +113,47 @@ class _AppPageState extends ConsumerState { return false; } + void _handleScroll( + ScrollController controller, bool isScrolledUnderState, GlobalKey key) { + final scrollDirection = controller.position.userScrollDirection; + final scrollOffset = controller.offset; + + if (isScrolledUnderState && scrollDirection == ScrollDirection.forward || + !isScrolledUnderState && scrollDirection == ScrollDirection.reverse || + scrollOffset == 0) { + final scrolledUnder = _scrolledUnderAppBar(key); + if (scrolledUnder != isScrolledUnderState || scrollOffset == 0) { + setState(() { + if (controller == _mainController) { + _isSliverTitleScrolledUnder = scrolledUnder && scrollOffset != 0; + } else if (controller == _navController) { + _isNavigationScrolledUnder = scrolledUnder && scrollOffset != 0; + } else { + _isDetailsScrolledUnder = scrolledUnder && scrollOffset != 0; + } + }); + } + } + } + + @override + void initState() { + super.initState(); + _mainController.addListener(() { + _handleScroll( + _mainController, _isSliverTitleScrolledUnder, _sliverTitleGlobalKey); + }); + _navController.addListener(() { + _handleScroll(_navController, _isNavigationScrolledUnder, _navKey); + _handleScroll( + _navController, _isNavigationScrolledUnder, _navExpandedKey); + }); + _detailsController.addListener(() { + _handleScroll( + _detailsController, _isDetailsScrolledUnder, _detailsViewGlobalKey); + }); + } + @override Widget build(BuildContext context) => LayoutBuilder( builder: (context, constraints) { @@ -331,44 +375,31 @@ class _AppPageState extends ConsumerState { ]); } if (widget.title != null) { - return NotificationListener( - onNotification: (scrollNotification) { - final scrollOffset = scrollNotification.metrics.pixels; - final scrolledUnder = _scrolledUnderAppBar(_sliverTitleGlobalKey); - if (_isSliverTitleScrolledUnder != scrolledUnder) { - Timer.run(() { - setState(() { - _isSliverTitleScrolledUnder = - scrolledUnder && scrollOffset != 0; - }); - }); - } - return false; - }, - child: CustomScrollView( - key: _mainContentGlobalKey, - slivers: [ - SliverMainAxisGroup( - slivers: [ - SliverPinnedHeader( - child: ColoredBox( - color: Theme.of(context).colorScheme.background, - child: Padding( - padding: const EdgeInsets.only( - left: 16.0, right: 16.0, bottom: 12.0, top: 4.0), - child: _buildTitle(context), - ), + return CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + controller: _mainController, + key: _mainContentGlobalKey, + slivers: [ + SliverMainAxisGroup( + slivers: [ + SliverPinnedHeader( + child: ColoredBox( + color: Theme.of(context).colorScheme.background, + child: Padding( + 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: widget.headerSliver, - ) - ], - ), - SliverToBoxAdapter(child: safeArea) - ], - ), + ), + if (widget.headerSliver != null) + SliverToBoxAdapter( + child: widget.headerSliver, + ) + ], + ), + SliverToBoxAdapter(child: safeArea) + ], ); } @@ -398,48 +429,24 @@ class _AppPageState extends ConsumerState { if (hasRail && (!fullyExpanded || !showNavigation)) SizedBox( width: 72, - child: NotificationListener( - onNotification: (scrollNotification) { - final scrollOffset = scrollNotification.metrics.pixels; - final scrolledUnder = _scrolledUnderAppBar(_navKey); - if (_isNavigationScrolledUnder != scrolledUnder) { - setState(() { - _isNavigationScrolledUnder = - scrolledUnder && scrollOffset != 0; - }); - } - return false; - }, - child: SingleChildScrollView( - child: NavigationContent( - key: _navKey, - shouldPop: false, - extended: false, - ), + child: SingleChildScrollView( + controller: _navController, + child: NavigationContent( + key: _navKey, + shouldPop: false, + extended: false, ), ), ), if (fullyExpanded && showNavigation) SizedBox( width: 280, - child: NotificationListener( - onNotification: (scrollNotification) { - final scrollOffset = scrollNotification.metrics.pixels; - final scrolledUnder = _scrolledUnderAppBar(_navExpandedKey); - if (_isNavigationScrolledUnder != scrolledUnder) { - setState(() { - _isNavigationScrolledUnder = - scrolledUnder && scrollOffset != 0; - }); - } - return false; - }, - child: SingleChildScrollView( - child: NavigationContent( - key: _navExpandedKey, - shouldPop: false, - extended: true, - ), + child: SingleChildScrollView( + controller: _navController, + child: NavigationContent( + key: _navExpandedKey, + shouldPop: false, + extended: true, ), ), ), @@ -460,33 +467,20 @@ class _AppPageState extends ConsumerState { if (hasManage && (widget.detailViewBuilder != null || widget.keyActionsBuilder != null)) - NotificationListener( - onNotification: (scrollNotification) { - final scrollOffset = scrollNotification.metrics.pixels; - final scrolledUnder = - _scrolledUnderAppBar(_detailsViewGlobalKey); - if (_isDetailsScrolledUnder != scrolledUnder) { - setState(() { - _isDetailsScrolledUnder = - scrolledUnder && scrollOffset != 0; - }); - } - return false; - }, - 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), - ], - ), + SingleChildScrollView( + controller: _detailsController, + 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), + ], ), ), ), From bb72c0a1ee4fd9f7c8026926ec1ab5c1e0a6a3bc Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Mon, 4 Mar 2024 11:43:47 +0100 Subject: [PATCH 10/34] Move scrolled under state to providers --- lib/app/views/app_page.dart | 250 +++++++++++++++++++----------------- lib/theme.dart | 6 + 2 files changed, 135 insertions(+), 121 deletions(-) diff --git a/lib/app/views/app_page.dart b/lib/app/views/app_page.dart index 687eef6c..46c9034a 100755 --- a/lib/app/views/app_page.dart +++ b/lib/app/views/app_page.dart @@ -17,7 +17,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -44,6 +43,16 @@ class _NavigationProvider extends StateNotifier { } } +class _ScrolledUnderProvider extends StateNotifier { + _ScrolledUnderProvider() : super(false); + + void toggleScrolledUnder(bool scrolledUnder) { + if (state != scrolledUnder) { + state = scrolledUnder; + } + } +} + // We use global keys here to maintain the content between AppPages, // and keep track of what has been scrolled under AppBar final _navKey = GlobalKey(); @@ -88,18 +97,20 @@ class AppPage extends ConsumerStatefulWidget { this.headerSliver}) : assert(!(onFileDropped != null && fileDropOverlay == null), 'Declaring onFileDropped requires declaring a fileDropOverlay'); + @override ConsumerState createState() => _AppPageState(); } class _AppPageState extends ConsumerState { - final ScrollController _mainController = ScrollController(); - final ScrollController _navController = ScrollController(); - final ScrollController _detailsController = ScrollController(); - bool _isSliverTitleScrolledUnder = false; - bool _isNavigationScrolledUnder = false; - bool _isDetailsScrolledUnder = false; - + final _sliverTitleProvider = + StateNotifierProvider<_ScrolledUnderProvider, bool>( + (ref) => _ScrolledUnderProvider()); + final _navViewProvider = StateNotifierProvider<_ScrolledUnderProvider, bool>( + (ref) => _ScrolledUnderProvider()); + final _detailsViewProvider = + StateNotifierProvider<_ScrolledUnderProvider, bool>( + (ref) => _ScrolledUnderProvider()); bool _scrolledUnderAppBar(GlobalKey key) { final currentContext = key.currentContext; if (currentContext != null) { @@ -113,52 +124,10 @@ class _AppPageState extends ConsumerState { return false; } - void _handleScroll( - ScrollController controller, bool isScrolledUnderState, GlobalKey key) { - final scrollDirection = controller.position.userScrollDirection; - final scrollOffset = controller.offset; - - if (isScrolledUnderState && scrollDirection == ScrollDirection.forward || - !isScrolledUnderState && scrollDirection == ScrollDirection.reverse || - scrollOffset == 0) { - final scrolledUnder = _scrolledUnderAppBar(key); - if (scrolledUnder != isScrolledUnderState || scrollOffset == 0) { - setState(() { - if (controller == _mainController) { - _isSliverTitleScrolledUnder = scrolledUnder && scrollOffset != 0; - } else if (controller == _navController) { - _isNavigationScrolledUnder = scrolledUnder && scrollOffset != 0; - } else { - _isDetailsScrolledUnder = scrolledUnder && scrollOffset != 0; - } - }); - } - } - } - - @override - void initState() { - super.initState(); - _mainController.addListener(() { - _handleScroll( - _mainController, _isSliverTitleScrolledUnder, _sliverTitleGlobalKey); - }); - _navController.addListener(() { - _handleScroll(_navController, _isNavigationScrolledUnder, _navKey); - _handleScroll( - _navController, _isNavigationScrolledUnder, _navExpandedKey); - }); - _detailsController.addListener(() { - _handleScroll( - _detailsController, _isDetailsScrolledUnder, _detailsViewGlobalKey); - }); - } - @override Widget build(BuildContext context) => LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; - if (width < 400 || (isAndroid && width < 600 && width < constraints.maxHeight)) { return _buildScaffold(context, true, false, false); @@ -223,12 +192,12 @@ class _AppPageState extends ConsumerState { )); } - Widget _buildTitle(BuildContext context) { + Widget _buildTitle(BuildContext context, bool? visible) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ AnimatedOpacity( - opacity: !_isSliverTitleScrolledUnder ? 1 : 0, + opacity: visible ?? true ? 1 : 0, duration: const Duration(milliseconds: 300), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -267,8 +236,8 @@ class _AppPageState extends ConsumerState { ); } - Widget? _buildAppBarTitle( - BuildContext context, bool hasRail, bool hasManage, bool fullyExpanded) { + Widget? _buildAppBarTitle(BuildContext context, bool hasRail, bool hasManage, + bool fullyExpanded, bool visible) { final showNavigation = ref.watch(_navigationProvider); EdgeInsets padding; if (fullyExpanded) { @@ -288,7 +257,7 @@ class _AppPageState extends ConsumerState { padding: padding, child: AnimatedOpacity( duration: const Duration(milliseconds: 300), - opacity: _isSliverTitleScrolledUnder ? 1 : 0, + opacity: visible ? 1 : 0, child: Text(widget.alternativeTitle ?? widget.title!), ), ); @@ -354,7 +323,7 @@ class _AppPageState extends ConsumerState { child: Padding( padding: const EdgeInsets.only( left: 16.0, right: 16.0, bottom: 24.0), - child: _buildTitle(context), + child: _buildTitle(context, null), ), ), ), @@ -375,31 +344,41 @@ class _AppPageState extends ConsumerState { ]); } if (widget.title != null) { - return CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - controller: _mainController, - key: _mainContentGlobalKey, - slivers: [ - SliverMainAxisGroup( - slivers: [ - SliverPinnedHeader( - child: ColoredBox( - color: Theme.of(context).colorScheme.background, - child: Padding( - padding: const EdgeInsets.only( - left: 16.0, right: 16.0, bottom: 12.0, top: 4.0), - child: _buildTitle(context), + return NotificationListener( + onNotification: (notification) { + ref + .read(_sliverTitleProvider.notifier) + .toggleScrolledUnder(_scrolledUnderAppBar(_sliverTitleGlobalKey)); + return false; + }, + child: CustomScrollView( + key: _mainContentGlobalKey, + slivers: [ + SliverMainAxisGroup( + slivers: [ + SliverPinnedHeader( + child: ColoredBox( + color: Theme.of(context).colorScheme.background, + child: Padding( + padding: const EdgeInsets.only( + left: 16.0, right: 16.0, bottom: 12.0, top: 4.0), + child: Consumer( + builder: (context, ref, child) { + final visible = !ref.watch(_sliverTitleProvider); + return _buildTitle(context, visible); + }, + )), ), ), - ), - if (widget.headerSliver != null) - SliverToBoxAdapter( - child: widget.headerSliver, - ) - ], - ), - SliverToBoxAdapter(child: safeArea) - ], + if (widget.headerSliver != null) + SliverToBoxAdapter( + child: widget.headerSliver, + ) + ], + ), + SliverToBoxAdapter(child: safeArea) + ], + ), ); } @@ -429,27 +408,39 @@ class _AppPageState extends ConsumerState { if (hasRail && (!fullyExpanded || !showNavigation)) SizedBox( width: 72, - child: SingleChildScrollView( - controller: _navController, - child: NavigationContent( - key: _navKey, - shouldPop: false, - extended: false, + child: NotificationListener( + onNotification: (_) { + ref + .read(_navViewProvider.notifier) + .toggleScrolledUnder(_scrolledUnderAppBar(_navKey)); + return false; + }, + child: SingleChildScrollView( + child: NavigationContent( + key: _navKey, + shouldPop: false, + extended: false, + ), ), ), ), if (fullyExpanded && showNavigation) SizedBox( - width: 280, - child: SingleChildScrollView( - controller: _navController, - child: NavigationContent( - key: _navExpandedKey, - shouldPop: false, - extended: true, - ), - ), - ), + width: 280, + child: NotificationListener( + onNotification: (_) { + ref.read(_navViewProvider.notifier).toggleScrolledUnder( + _scrolledUnderAppBar(_navExpandedKey)); + return false; + }, + child: SingleChildScrollView( + child: NavigationContent( + key: _navExpandedKey, + shouldPop: false, + extended: true, + ), + ), + )), const SizedBox(width: 8), Expanded( child: GestureDetector( @@ -467,20 +458,26 @@ class _AppPageState extends ConsumerState { if (hasManage && (widget.detailViewBuilder != null || widget.keyActionsBuilder != null)) - SingleChildScrollView( - controller: _detailsController, - 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), - ], + NotificationListener( + onNotification: (_) { + ref.read(_detailsViewProvider.notifier).toggleScrolledUnder( + _scrolledUnderAppBar(_detailsViewGlobalKey)); + return false; + }, + 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), + ], + ), ), ), ), @@ -493,23 +490,34 @@ class _AppPageState extends ConsumerState { appBar: AppBar( bottom: PreferredSize( preferredSize: const Size.fromHeight(1.0), - child: AnimatedOpacity( - duration: const Duration(milliseconds: 300), - opacity: _isSliverTitleScrolledUnder || - _isNavigationScrolledUnder || - _isDetailsScrolledUnder - ? 1 - : 0, - child: Container( - color: Theme.of(context).colorScheme.secondaryContainer, - height: 1.0, - ), + child: Consumer( + builder: (context, ref, child) { + final visible = ref.watch(_sliverTitleProvider) || + ref.watch(_navViewProvider) || + ref.watch(_detailsViewProvider); + return AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: visible ? 1 : 0, + 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), + title: widget.title != null + ? Consumer( + builder: (context, ref, child) { + final visible = ref.watch(_sliverTitleProvider); + return _buildAppBarTitle( + context, hasRail, hasManage, fullyExpanded, visible)!; + }, + ) + : null, leading: hasRail ? Row( mainAxisAlignment: MainAxisAlignment.spaceAround, diff --git a/lib/theme.dart b/lib/theme.dart index 6afbb1ea..86f50d6e 100755 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -34,6 +34,9 @@ class AppTheme { onSurfaceVariant: const Color(0x99000000), ), fontFamily: 'Roboto', + appBarTheme: const AppBarTheme( + color: Colors.transparent, + ), listTileTheme: const ListTileThemeData( // For alignment under menu button contentPadding: EdgeInsets.symmetric(horizontal: 18.0), @@ -59,6 +62,9 @@ class AppTheme { onSurfaceVariant: const Color(0xaaffffff), ), fontFamily: 'Roboto', + appBarTheme: const AppBarTheme( + color: Colors.transparent, + ), listTileTheme: const ListTileThemeData( // For alignment under menu button contentPadding: EdgeInsets.symmetric(horizontal: 18.0), From f123ba313874e6cc2d9a8b996c9980e8a5fd790f Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Tue, 5 Mar 2024 09:41:07 +0100 Subject: [PATCH 11/34] Add scroll animation to sliver title and header --- lib/app/views/app_page.dart | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/app/views/app_page.dart b/lib/app/views/app_page.dart index 46c9034a..1fc9d51a 100755 --- a/lib/app/views/app_page.dart +++ b/lib/app/views/app_page.dart @@ -111,6 +111,7 @@ class _AppPageState extends ConsumerState { final _detailsViewProvider = StateNotifierProvider<_ScrolledUnderProvider, bool>( (ref) => _ScrolledUnderProvider()); + bool _scrolledUnderAppBar(GlobalKey key) { final currentContext = key.currentContext; if (currentContext != null) { @@ -204,7 +205,6 @@ class _AppPageState extends ConsumerState { children: [ Flexible( child: Text( - key: _sliverTitleGlobalKey, widget.alternativeTitle ?? widget.title!, style: Theme.of(context).textTheme.displaySmall!.copyWith( color: widget.alternativeTitle != null @@ -346,9 +346,18 @@ class _AppPageState extends ConsumerState { if (widget.title != null) { return NotificationListener( onNotification: (notification) { + final isSliverScrolledUnder = ref.read(_sliverTitleProvider); + final scrolledUnder = _scrolledUnderAppBar(_sliverTitleGlobalKey); + if (isSliverScrolledUnder != scrolledUnder && + isSliverScrolledUnder && + !scrolledUnder) { + Scrollable.ensureVisible(_sliverTitleGlobalKey.currentContext!, + duration: const Duration(milliseconds: 300)); + } ref .read(_sliverTitleProvider.notifier) - .toggleScrolledUnder(_scrolledUnderAppBar(_sliverTitleGlobalKey)); + .toggleScrolledUnder(scrolledUnder); + return false; }, child: CustomScrollView( @@ -360,6 +369,7 @@ class _AppPageState extends ConsumerState { child: ColoredBox( color: Theme.of(context).colorScheme.background, child: Padding( + key: _sliverTitleGlobalKey, padding: const EdgeInsets.only( left: 16.0, right: 16.0, bottom: 12.0, top: 4.0), child: Consumer( From 078b572a5284d7ee5a09a4332a7fb48aa86f169c Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Thu, 14 Mar 2024 12:57:09 +0100 Subject: [PATCH 12/34] Use controllers to listen for visibility changes --- lib/app/views/app_page.dart | 437 +++++++++++++++++++----------- lib/fido/views/webauthn_page.dart | 22 +- 2 files changed, 297 insertions(+), 162 deletions(-) diff --git a/lib/app/views/app_page.dart b/lib/app/views/app_page.dart index 1fc9d51a..e48cf696 100755 --- a/lib/app/views/app_page.dart +++ b/lib/app/views/app_page.dart @@ -14,8 +14,10 @@ * 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'; @@ -43,16 +45,6 @@ class _NavigationProvider extends StateNotifier { } } -class _ScrolledUnderProvider extends StateNotifier { - _ScrolledUnderProvider() : super(false); - - void toggleScrolledUnder(bool scrolledUnder) { - if (state != scrolledUnder) { - state = scrolledUnder; - } - } -} - // We use global keys here to maintain the content between AppPages, // and keep track of what has been scrolled under AppBar final _navKey = GlobalKey(); @@ -103,51 +95,54 @@ class AppPage extends ConsumerStatefulWidget { } class _AppPageState extends ConsumerState { - final _sliverTitleProvider = - StateNotifierProvider<_ScrolledUnderProvider, bool>( - (ref) => _ScrolledUnderProvider()); - final _navViewProvider = StateNotifierProvider<_ScrolledUnderProvider, bool>( - (ref) => _ScrolledUnderProvider()); - final _detailsViewProvider = - StateNotifierProvider<_ScrolledUnderProvider, bool>( - (ref) => _ScrolledUnderProvider()); + final _VisibilityController _sliverTitleController = _VisibilityController(); + final _VisibilityController _navController = _VisibilityController(); + final _VisibilityController _detailsController = _VisibilityController(); + late _VisibilitiesController _scrolledUnderController; - bool _scrolledUnderAppBar(GlobalKey key) { - final currentContext = key.currentContext; - if (currentContext != null) { - final RenderBox renderBox = - currentContext.findRenderObject() as RenderBox; - final appBarHeight = MediaQuery.of(context).padding.top + kToolbarHeight; - final position = renderBox.localToGlobal(Offset.zero); + final ScrollController _sliverTitleScrollController = ScrollController(); - return appBarHeight - position.dy > 0; - } - return false; + @override + void initState() { + super.initState(); + _scrolledUnderController = _VisibilitiesController( + [_sliverTitleController, _navController, _detailsController]); } @override - Widget build(BuildContext context) => LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth; - if (width < 400 || - (isAndroid && width < 600 && width < constraints.maxHeight)) { - return _buildScaffold(context, true, false, false); + void dispose() { + _sliverTitleController.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 _buildScaffold(context, false, true, true); - } - }, - ); + return _buildScaffold(context, false, true, true); + } + }, + ); + } Widget _buildLogo(BuildContext context) { final color = @@ -193,51 +188,73 @@ class _AppPageState extends ConsumerState { )); } - Widget _buildTitle(BuildContext context, bool? visible) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - AnimatedOpacity( - opacity: visible ?? true ? 1 : 0, - duration: const Duration(milliseconds: 300), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: Text( - 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, - ), + void _scrollTitle(BuildContext context, _ScrollDirection direction) { + if (direction != _ScrollDirection.idle) { + if (direction == _ScrollDirection.up) { + final currentContext = _sliverTitleGlobalKey.currentContext; + if (currentContext != null) { + final RenderBox renderBox = + currentContext.findRenderObject() as RenderBox; + final appBarHeight = Scaffold.of(context).appBarMaxHeight!; + final targetHeight = renderBox.size.height; + final position = renderBox.localToGlobal(Offset.zero); + _sliverTitleScrollController.animateTo( + _sliverTitleScrollController.position.pixels + + (targetHeight - (appBarHeight - position.dy)), + duration: const Duration(milliseconds: 300), + curve: Curves.ease); + } + } else { + Timer.run(() { + Scrollable.ensureVisible(_sliverTitleGlobalKey.currentContext!, + duration: const Duration(milliseconds: 300)); + }); + } + } + } + + Widget _buildTitle(BuildContext context) { + return ListenableBuilder( + listenable: _sliverTitleController, + builder: (context, child) { + _scrollTitle(context, _sliverTitleController.scrollDirection); + 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)) - ], - ) - ], - ), - ) - ], + ), + 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, bool visible) { + Widget? _buildAppBarTitle( + BuildContext context, bool hasRail, bool hasManage, bool fullyExpanded) { final showNavigation = ref.watch(_navigationProvider); EdgeInsets padding; if (fullyExpanded) { @@ -253,13 +270,19 @@ class _AppPageState extends ConsumerState { } if (widget.title != null) { - return Padding( - padding: padding, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 300), - opacity: visible ? 1 : 0, - child: Text(widget.alternativeTitle ?? widget.title!), - ), + return ListenableBuilder( + listenable: _sliverTitleController, + builder: (context, child) { + final visible = !_sliverTitleController.isVisible; + return Padding( + padding: padding, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: visible ? 1 : 0, + child: Text(widget.alternativeTitle ?? widget.title!), + ), + ); + }, ); } @@ -323,7 +346,7 @@ class _AppPageState extends ConsumerState { child: Padding( padding: const EdgeInsets.only( left: 16.0, right: 16.0, bottom: 24.0), - child: _buildTitle(context, null), + child: _buildTitle(context), ), ), ), @@ -344,23 +367,12 @@ class _AppPageState extends ConsumerState { ]); } if (widget.title != null) { - return NotificationListener( - onNotification: (notification) { - final isSliverScrolledUnder = ref.read(_sliverTitleProvider); - final scrolledUnder = _scrolledUnderAppBar(_sliverTitleGlobalKey); - if (isSliverScrolledUnder != scrolledUnder && - isSliverScrolledUnder && - !scrolledUnder) { - Scrollable.ensureVisible(_sliverTitleGlobalKey.currentContext!, - duration: const Duration(milliseconds: 300)); - } - ref - .read(_sliverTitleProvider.notifier) - .toggleScrolledUnder(scrolledUnder); - - return false; - }, + return _VisibilityListener( + targetKey: _sliverTitleGlobalKey, + controller: _sliverTitleController, child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + controller: _sliverTitleScrollController, key: _mainContentGlobalKey, slivers: [ SliverMainAxisGroup( @@ -369,21 +381,14 @@ class _AppPageState extends ConsumerState { child: ColoredBox( color: Theme.of(context).colorScheme.background, child: Padding( - key: _sliverTitleGlobalKey, - padding: const EdgeInsets.only( - left: 16.0, right: 16.0, bottom: 12.0, top: 4.0), - child: Consumer( - builder: (context, ref, child) { - final visible = !ref.watch(_sliverTitleProvider); - return _buildTitle(context, visible); - }, - )), + 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: widget.headerSliver, - ) + SliverToBoxAdapter(child: widget.headerSliver) ], ), SliverToBoxAdapter(child: safeArea) @@ -418,13 +423,9 @@ class _AppPageState extends ConsumerState { if (hasRail && (!fullyExpanded || !showNavigation)) SizedBox( width: 72, - child: NotificationListener( - onNotification: (_) { - ref - .read(_navViewProvider.notifier) - .toggleScrolledUnder(_scrolledUnderAppBar(_navKey)); - return false; - }, + child: _VisibilityListener( + targetKey: _navKey, + controller: _navController, child: SingleChildScrollView( child: NavigationContent( key: _navKey, @@ -437,12 +438,9 @@ class _AppPageState extends ConsumerState { if (fullyExpanded && showNavigation) SizedBox( width: 280, - child: NotificationListener( - onNotification: (_) { - ref.read(_navViewProvider.notifier).toggleScrolledUnder( - _scrolledUnderAppBar(_navExpandedKey)); - return false; - }, + child: _VisibilityListener( + controller: _navController, + targetKey: _navExpandedKey, child: SingleChildScrollView( child: NavigationContent( key: _navExpandedKey, @@ -468,12 +466,9 @@ class _AppPageState extends ConsumerState { if (hasManage && (widget.detailViewBuilder != null || widget.keyActionsBuilder != null)) - NotificationListener( - onNotification: (_) { - ref.read(_detailsViewProvider.notifier).toggleScrolledUnder( - _scrolledUnderAppBar(_detailsViewGlobalKey)); - return false; - }, + _VisibilityListener( + controller: _detailsController, + targetKey: _detailsViewGlobalKey, child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -500,14 +495,13 @@ class _AppPageState extends ConsumerState { appBar: AppBar( bottom: PreferredSize( preferredSize: const Size.fromHeight(1.0), - child: Consumer( - builder: (context, ref, child) { - final visible = ref.watch(_sliverTitleProvider) || - ref.watch(_navViewProvider) || - ref.watch(_detailsViewProvider); + child: ListenableBuilder( + listenable: _scrolledUnderController, + builder: (context, child) { + final visible = _scrolledUnderController.someIsScrolledUnder; return AnimatedOpacity( - duration: const Duration(milliseconds: 300), opacity: visible ? 1 : 0, + duration: const Duration(milliseconds: 300), child: Container( color: Theme.of(context).colorScheme.secondaryContainer, height: 1.0, @@ -519,15 +513,12 @@ class _AppPageState extends ConsumerState { scrolledUnderElevation: 0.0, leadingWidth: hasRail ? 84 : null, backgroundColor: Theme.of(context).colorScheme.background, - title: widget.title != null - ? Consumer( - builder: (context, ref, child) { - final visible = ref.watch(_sliverTitleProvider); - return _buildAppBarTitle( - context, hasRail, hasManage, fullyExpanded, visible)!; - }, - ) - : null, + title: _buildAppBarTitle( + context, + hasRail, + hasManage, + fullyExpanded, + ), leading: hasRail ? Row( mainAxisAlignment: MainAxisAlignment.spaceAround, @@ -611,3 +602,129 @@ class CapabilityBadge extends StatelessWidget { ); } } + +class _VisibilityController with ChangeNotifier { + bool isVisible = true; + _ScrollDirection scrollDirection = _ScrollDirection.idle; + + void setVisibility(bool visibility) { + if (visibility != isVisible) { + isVisible = visibility; + if (!visibility) { + scrollDirection = _ScrollDirection.idle; + } + notifyListeners(); + } + } + + void notifyScroll(_ScrollDirection direction) { + if (isVisible) { + scrollDirection = direction; + notifyListeners(); + } + } +} + +enum _ScrollDirection { idle, up, down } + +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.isVisible); + if (val != someIsScrolledUnder) { + someIsScrolledUnder = val; + notifyListeners(); + } + } +} + +class _VisibilityListener extends StatefulWidget { + final _VisibilityController controller; + final Widget child; + final GlobalKey targetKey; + const _VisibilityListener({ + required this.controller, + required this.child, + required this.targetKey, + }); + + @override + State<_VisibilityListener> createState() => _VisibilityListenerState(); +} + +class _VisibilityListenerState extends State<_VisibilityListener> { + bool isMouseWheel = false; + + @override + Widget build(BuildContext context) => Listener( + onPointerSignal: (event) { + if (event is PointerScrollEvent) { + if (!isMouseWheel) { + setState(() { + isMouseWheel = true; + }); + Timer(const Duration(seconds: 1), () { + setState(() { + isMouseWheel = false; + }); + }); + } + } + }, + child: NotificationListener( + onNotification: (notification) { + if (notification is ScrollMetricsNotification || + notification is ScrollUpdateNotification) { + widget.controller.setVisibility( + !_scrolledUnderAppBar(context, widget.targetKey, false)); + } + + if (notification is ScrollEndNotification && + widget.child is CustomScrollView) { + // Disable auto scrolling for mouse wheel + if (!isMouseWheel) { + final shouldScrollUp = + _scrolledUnderAppBar(context, widget.targetKey, true); + + widget.controller.notifyScroll(shouldScrollUp + ? _ScrollDirection.up + : _ScrollDirection.down); + } + } + return false; + }, + child: widget.child, + ), + ); + + bool _scrolledUnderAppBar( + BuildContext context, GlobalKey key, bool checkHalfVisible) { + final currentContext = key.currentContext; + if (currentContext != null) { + final RenderBox renderBox = + currentContext.findRenderObject() as RenderBox; + final appBarHeight = Scaffold.of(context).appBarMaxHeight!; + final targetHeight = renderBox.size.height; + final position = renderBox.localToGlobal(Offset.zero); + + if (widget.child is SingleChildScrollView) { + return appBarHeight - position.dy > 0; + } else if (checkHalfVisible) { + // Check if more than half of the target is scrolled under + return appBarHeight - position.dy > targetHeight / 2; + } else { + return appBarHeight - position.dy > targetHeight - 10; + } + } + return false; + } +} diff --git a/lib/fido/views/webauthn_page.dart b/lib/fido/views/webauthn_page.dart index fcaef06d..ff4bb6e8 100644 --- a/lib/fido/views/webauthn_page.dart +++ b/lib/fido/views/webauthn_page.dart @@ -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 createState() => _WebAuthnScreenState(); +} + +class _WebAuthnScreenState extends State { + 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, ); From fe4160baa5a254e4eb93d8cd7f371accbd223a9e Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Fri, 15 Mar 2024 08:54:36 +0100 Subject: [PATCH 13/34] Show border when scrollables touch the `AppBar` --- lib/app/views/app_page.dart | 89 ++++++++++++++++++++++++------------- 1 file changed, 57 insertions(+), 32 deletions(-) diff --git a/lib/app/views/app_page.dart b/lib/app/views/app_page.dart index e48cf696..e5b1640a 100755 --- a/lib/app/views/app_page.dart +++ b/lib/app/views/app_page.dart @@ -50,6 +50,7 @@ class _NavigationProvider extends StateNotifier { final _navKey = GlobalKey(); final _navExpandedKey = GlobalKey(); final _sliverTitleGlobalKey = GlobalKey(); +final _headerSliverGlobalKey = GlobalKey(); final _detailsViewGlobalKey = GlobalKey(); final _mainContentGlobalKey = GlobalKey(); @@ -206,7 +207,10 @@ class _AppPageState extends ConsumerState { } } else { Timer.run(() { - Scrollable.ensureVisible(_sliverTitleGlobalKey.currentContext!, + Scrollable.ensureVisible( + widget.headerSliver != null + ? _headerSliverGlobalKey.currentContext! + : _sliverTitleGlobalKey.currentContext!, duration: const Duration(milliseconds: 300)); }); } @@ -273,7 +277,8 @@ class _AppPageState extends ConsumerState { return ListenableBuilder( listenable: _sliverTitleController, builder: (context, child) { - final visible = !_sliverTitleController.isVisible; + final visible = + _sliverTitleController.visibility == _Visibility.scrolledUnder; return Padding( padding: padding, child: AnimatedOpacity( @@ -371,7 +376,6 @@ class _AppPageState extends ConsumerState { targetKey: _sliverTitleGlobalKey, controller: _sliverTitleController, child: CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), controller: _sliverTitleScrollController, key: _mainContentGlobalKey, slivers: [ @@ -388,7 +392,10 @@ class _AppPageState extends ConsumerState { ), ), if (widget.headerSliver != null) - SliverToBoxAdapter(child: widget.headerSliver) + SliverToBoxAdapter( + child: Container( + key: _headerSliverGlobalKey, + child: widget.headerSliver)) ], ), SliverToBoxAdapter(child: safeArea) @@ -603,29 +610,34 @@ class CapabilityBadge extends StatelessWidget { } } -class _VisibilityController with ChangeNotifier { - bool isVisible = true; - _ScrollDirection scrollDirection = _ScrollDirection.idle; +enum _Visibility { visible, topScrolledUnder, scrolledUnder } - void setVisibility(bool visibility) { - if (visibility != isVisible) { - isVisible = visibility; - if (!visibility) { - scrollDirection = _ScrollDirection.idle; +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 direction) { - if (isVisible) { - scrollDirection = direction; + void notifyScroll(_ScrollDirection scrollDirection) { + if (visibility != _Visibility.scrolledUnder) { + _scrollDirection = scrollDirection; notifyListeners(); } } -} -enum _ScrollDirection { idle, up, down } + _ScrollDirection get scrollDirection => _scrollDirection; + _Visibility get visibility => _visibility; +} class _VisibilitiesController with ChangeNotifier { final List<_VisibilityController> controllers; @@ -639,7 +651,8 @@ class _VisibilitiesController with ChangeNotifier { } void _setScrolledUnder() { - final val = controllers.any((element) => !element.isVisible); + final val = + controllers.any((element) => element.visibility != _Visibility.visible); if (val != someIsScrolledUnder) { someIsScrolledUnder = val; notifyListeners(); @@ -685,17 +698,17 @@ class _VisibilityListenerState extends State<_VisibilityListener> { if (notification is ScrollMetricsNotification || notification is ScrollUpdateNotification) { widget.controller.setVisibility( - !_scrolledUnderAppBar(context, widget.targetKey, false)); + _scrolledUnderState(context, widget.targetKey)); } if (notification is ScrollEndNotification && widget.child is CustomScrollView) { // Disable auto scrolling for mouse wheel if (!isMouseWheel) { - final shouldScrollUp = - _scrolledUnderAppBar(context, widget.targetKey, true); + final halfScrolledUnder = + _halfScrolledUnder(context, widget.targetKey); - widget.controller.notifyScroll(shouldScrollUp + widget.controller.notifyScroll(halfScrolledUnder ? _ScrollDirection.up : _ScrollDirection.down); } @@ -706,8 +719,7 @@ class _VisibilityListenerState extends State<_VisibilityListener> { ), ); - bool _scrolledUnderAppBar( - BuildContext context, GlobalKey key, bool checkHalfVisible) { + bool _halfScrolledUnder(BuildContext context, GlobalKey key) { final currentContext = key.currentContext; if (currentContext != null) { final RenderBox renderBox = @@ -716,15 +728,28 @@ class _VisibilityListenerState extends State<_VisibilityListener> { final targetHeight = renderBox.size.height; final position = renderBox.localToGlobal(Offset.zero); - if (widget.child is SingleChildScrollView) { - return appBarHeight - position.dy > 0; - } else if (checkHalfVisible) { - // Check if more than half of the target is scrolled under - return appBarHeight - position.dy > targetHeight / 2; - } else { - return appBarHeight - position.dy > targetHeight - 10; - } + return appBarHeight - position.dy > targetHeight / 2; } return false; } + + _Visibility _scrolledUnderState(BuildContext context, GlobalKey key) { + final currentContext = key.currentContext; + if (currentContext != null) { + final RenderBox renderBox = + currentContext.findRenderObject() as RenderBox; + final appBarHeight = Scaffold.of(context).appBarMaxHeight!; + final targetHeight = renderBox.size.height; + final position = renderBox.localToGlobal(Offset.zero); + + if (appBarHeight - position.dy > targetHeight - 10) { + return _Visibility.scrolledUnder; + } else if (appBarHeight - position.dy > 0) { + return _Visibility.topScrolledUnder; + } else { + return _Visibility.visible; + } + } + return _Visibility.visible; + } } From 55d95e92bee05651c05790e0734d2d3b685830f3 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Fri, 15 Mar 2024 09:38:00 +0100 Subject: [PATCH 14/34] Remove lagging issue in expanded `NavigationContent` --- lib/app/views/app_page.dart | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/app/views/app_page.dart b/lib/app/views/app_page.dart index e5b1640a..b3781189 100755 --- a/lib/app/views/app_page.dart +++ b/lib/app/views/app_page.dart @@ -449,10 +449,13 @@ class _AppPageState extends ConsumerState { controller: _navController, targetKey: _navExpandedKey, child: SingleChildScrollView( - child: NavigationContent( - key: _navExpandedKey, - shouldPop: false, - extended: true, + child: Material( + type: MaterialType.transparency, + child: NavigationContent( + key: _navExpandedKey, + shouldPop: false, + extended: true, + ), ), ), )), From 0a4278b9a18008a512bf2ca0ae8a3abed763e529 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Fri, 15 Mar 2024 14:08:33 +0100 Subject: [PATCH 15/34] Listen to `headerSliver` visibility --- lib/app/views/app_page.dart | 216 ++++++++++++++++++++++++++---------- 1 file changed, 157 insertions(+), 59 deletions(-) diff --git a/lib/app/views/app_page.dart b/lib/app/views/app_page.dart index b3781189..c22d8c07 100755 --- a/lib/app/views/app_page.dart +++ b/lib/app/views/app_page.dart @@ -50,6 +50,7 @@ class _NavigationProvider extends StateNotifier { final _navKey = GlobalKey(); final _navExpandedKey = GlobalKey(); final _sliverTitleGlobalKey = GlobalKey(); +final _sliverTitleWrapperGlobalKey = GlobalKey(); final _headerSliverGlobalKey = GlobalKey(); final _detailsViewGlobalKey = GlobalKey(); final _mainContentGlobalKey = GlobalKey(); @@ -97,6 +98,7 @@ class AppPage extends ConsumerStatefulWidget { class _AppPageState extends ConsumerState { final _VisibilityController _sliverTitleController = _VisibilityController(); + final _VisibilityController _headerSliverController = _VisibilityController(); final _VisibilityController _navController = _VisibilityController(); final _VisibilityController _detailsController = _VisibilityController(); late _VisibilitiesController _scrolledUnderController; @@ -189,30 +191,58 @@ class _AppPageState extends ConsumerState { )); } - void _scrollTitle(BuildContext context, _ScrollDirection direction) { + 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) { - final currentContext = _sliverTitleGlobalKey.currentContext; - if (currentContext != null) { - final RenderBox renderBox = - currentContext.findRenderObject() as RenderBox; - final appBarHeight = Scaffold.of(context).appBarMaxHeight!; - final targetHeight = renderBox.size.height; - final position = renderBox.localToGlobal(Offset.zero); - _sliverTitleScrollController.animateTo( - _sliverTitleScrollController.position.pixels + - (targetHeight - (appBarHeight - position.dy)), - duration: const Duration(milliseconds: 300), - curve: Curves.ease); + var offset = scrollController.position.pixels + + (targetHeight - (anchorHeight - position.dy)); + if (offset > scrollController.position.maxScrollExtent) { + offset = scrollController.position.maxScrollExtent; } - } else { Timer.run(() { - Scrollable.ensureVisible( - widget.headerSliver != null - ? _headerSliverGlobalKey.currentContext! - : _sliverTitleGlobalKey.currentContext!, - duration: const Duration(milliseconds: 300)); + 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); + }); + } } } } @@ -221,7 +251,14 @@ class _AppPageState extends ConsumerState { return ListenableBuilder( listenable: _sliverTitleController, builder: (context, child) { - _scrollTitle(context, _sliverTitleController.scrollDirection); + _scrollElement( + context, + _sliverTitleScrollController, + _sliverTitleController.scrollDirection, + _sliverTitleController, + _sliverTitleWrapperGlobalKey, + null); + return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -375,7 +412,12 @@ class _AppPageState extends ConsumerState { return _VisibilityListener( targetKey: _sliverTitleGlobalKey, controller: _sliverTitleController, + subTargetKey: + widget.headerSliver != null ? _headerSliverGlobalKey : null, + subController: + widget.headerSliver != null ? _headerSliverController : null, child: CustomScrollView( + physics: const ClampingScrollPhysics(), controller: _sliverTitleScrollController, key: _mainContentGlobalKey, slivers: [ @@ -385,6 +427,7 @@ class _AppPageState extends ConsumerState { 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), @@ -393,9 +436,22 @@ class _AppPageState extends ConsumerState { ), if (widget.headerSliver != null) SliverToBoxAdapter( - child: Container( + child: ListenableBuilder( + listenable: _headerSliverController, + builder: (context, child) { + _scrollElement( + context, + _sliverTitleScrollController, + _headerSliverController.scrollDirection, + _headerSliverController, + _headerSliverGlobalKey, + _sliverTitleWrapperGlobalKey); + + return Container( key: _headerSliverGlobalKey, - child: widget.headerSliver)) + child: widget.headerSliver); + }, + )) ], ), SliverToBoxAdapter(child: safeArea) @@ -613,7 +669,7 @@ class CapabilityBadge extends StatelessWidget { } } -enum _Visibility { visible, topScrolledUnder, scrolledUnder } +enum _Visibility { visible, topScrolledUnder, halfScrolledUnder, scrolledUnder } enum _ScrollDirection { idle, up, down } @@ -667,11 +723,18 @@ class _VisibilityListener extends StatefulWidget { final _VisibilityController controller; final Widget child; final GlobalKey targetKey; + final _VisibilityController? subController; + final GlobalKey? subTargetKey; const _VisibilityListener({ required this.controller, required this.child, required this.targetKey, - }); + this.subController, + this.subTargetKey, + }) : assert( + !((subController != null && subTargetKey == null) || + (subController == null && subTargetKey != null)), + 'Decalring subController requires subTargetKey and vice versa'); @override State<_VisibilityListener> createState() => _VisibilityListenerState(); @@ -700,21 +763,14 @@ class _VisibilityListenerState extends State<_VisibilityListener> { onNotification: (notification) { if (notification is ScrollMetricsNotification || notification is ScrollUpdateNotification) { - widget.controller.setVisibility( - _scrolledUnderState(context, widget.targetKey)); + _handleScrollUpdate( + context, widget.targetKey, widget.subTargetKey); } if (notification is ScrollEndNotification && widget.child is CustomScrollView) { // Disable auto scrolling for mouse wheel - if (!isMouseWheel) { - final halfScrolledUnder = - _halfScrolledUnder(context, widget.targetKey); - - widget.controller.notifyScroll(halfScrolledUnder - ? _ScrollDirection.up - : _ScrollDirection.down); - } + _handleScrollEnd(context, widget.targetKey, widget.subTargetKey); } return false; }, @@ -722,37 +778,79 @@ class _VisibilityListenerState extends State<_VisibilityListener> { ), ); - bool _halfScrolledUnder(BuildContext context, GlobalKey key) { - final currentContext = key.currentContext; - if (currentContext != null) { - final RenderBox renderBox = - currentContext.findRenderObject() as RenderBox; - final appBarHeight = Scaffold.of(context).appBarMaxHeight!; - final targetHeight = renderBox.size.height; - final position = renderBox.localToGlobal(Offset.zero); + void _handleScrollUpdate( + BuildContext context, + GlobalKey targetKey, + GlobalKey? subTargetKey, + ) { + widget.controller + .setVisibility(_scrolledUnderState(context, targetKey, null)); - return appBarHeight - position.dy > targetHeight / 2; + if (widget.subController != null) { + widget.subController!.setVisibility( + _scrolledUnderState(context, subTargetKey!, targetKey)); } - return false; } - _Visibility _scrolledUnderState(BuildContext context, GlobalKey key) { - final currentContext = key.currentContext; - if (currentContext != null) { - final RenderBox renderBox = - currentContext.findRenderObject() as RenderBox; - final appBarHeight = Scaffold.of(context).appBarMaxHeight!; - final targetHeight = renderBox.size.height; - final position = renderBox.localToGlobal(Offset.zero); + void _handleScrollEnd( + BuildContext context, + GlobalKey targetKey, + GlobalKey? subTargetKey, + ) { + if (!isMouseWheel) { + widget.controller.notifyScroll(_getSrollDirection( + _scrolledUnderState(context, widget.targetKey, null))); - if (appBarHeight - position.dy > targetHeight - 10) { - return _Visibility.scrolledUnder; - } else if (appBarHeight - position.dy > 0) { - return _Visibility.topScrolledUnder; - } else { - return _Visibility.visible; + if (widget.subController != null) { + widget.subController!.notifyScroll(_getSrollDirection( + _scrolledUnderState( + context, widget.subTargetKey!, widget.targetKey))); } } - return _Visibility.visible; + } + + _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; + } } } From defefd8536f1e84a98ad4f9b1ead0c28c24fd82c Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Fri, 15 Mar 2024 16:19:53 +0100 Subject: [PATCH 16/34] Fix scrolled under calculation --- lib/app/views/app_page.dart | 59 ++++++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/lib/app/views/app_page.dart b/lib/app/views/app_page.dart index c22d8c07..ec2f5cbd 100755 --- a/lib/app/views/app_page.dart +++ b/lib/app/views/app_page.dart @@ -400,7 +400,10 @@ class _AppPageState extends ConsumerState { behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), child: SingleChildScrollView( - physics: const NeverScrollableScrollPhysics(), + physics: isAndroid + ? const ClampingScrollPhysics( + parent: AlwaysScrollableScrollPhysics()) + : null, child: safeArea, ), ), @@ -416,8 +419,13 @@ class _AppPageState extends ConsumerState { widget.headerSliver != null ? _headerSliverGlobalKey : null, subController: widget.headerSliver != null ? _headerSliverController : null, + subAnchorKey: + widget.headerSliver != null ? _sliverTitleWrapperGlobalKey : null, child: CustomScrollView( - physics: const ClampingScrollPhysics(), + physics: isAndroid + ? const ClampingScrollPhysics( + parent: AlwaysScrollableScrollPhysics()) + : null, controller: _sliverTitleScrollController, key: _mainContentGlobalKey, slivers: [ @@ -461,6 +469,9 @@ class _AppPageState extends ConsumerState { } return SingleChildScrollView( + physics: isAndroid + ? const ClampingScrollPhysics(parent: AlwaysScrollableScrollPhysics()) + : null, primary: false, child: safeArea, ); @@ -725,16 +736,23 @@ class _VisibilityListener extends StatefulWidget { final GlobalKey targetKey; final _VisibilityController? subController; final GlobalKey? subTargetKey; - const _VisibilityListener({ - required this.controller, - required this.child, - required this.targetKey, - this.subController, - this.subTargetKey, - }) : assert( - !((subController != null && subTargetKey == null) || - (subController == null && subTargetKey != null)), - 'Decalring subController requires subTargetKey and vice versa'); + 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(); @@ -763,14 +781,13 @@ class _VisibilityListenerState extends State<_VisibilityListener> { onNotification: (notification) { if (notification is ScrollMetricsNotification || notification is ScrollUpdateNotification) { - _handleScrollUpdate( - context, widget.targetKey, widget.subTargetKey); + _handleScrollUpdate(context); } if (notification is ScrollEndNotification && widget.child is CustomScrollView) { // Disable auto scrolling for mouse wheel - _handleScrollEnd(context, widget.targetKey, widget.subTargetKey); + _handleScrollEnd(context); } return false; }, @@ -780,22 +797,18 @@ class _VisibilityListenerState extends State<_VisibilityListener> { void _handleScrollUpdate( BuildContext context, - GlobalKey targetKey, - GlobalKey? subTargetKey, ) { widget.controller - .setVisibility(_scrolledUnderState(context, targetKey, null)); + .setVisibility(_scrolledUnderState(context, widget.targetKey, null)); if (widget.subController != null) { - widget.subController!.setVisibility( - _scrolledUnderState(context, subTargetKey!, targetKey)); + widget.subController!.setVisibility(_scrolledUnderState( + context, widget.subTargetKey!, widget.subAnchorKey)); } } void _handleScrollEnd( BuildContext context, - GlobalKey targetKey, - GlobalKey? subTargetKey, ) { if (!isMouseWheel) { widget.controller.notifyScroll(_getSrollDirection( @@ -804,7 +817,7 @@ class _VisibilityListenerState extends State<_VisibilityListener> { if (widget.subController != null) { widget.subController!.notifyScroll(_getSrollDirection( _scrolledUnderState( - context, widget.subTargetKey!, widget.targetKey))); + context, widget.subTargetKey!, widget.subAnchorKey))); } } } From ca13f22d5224964b3ebeb27cbdad8861f526c288 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Fri, 15 Mar 2024 17:33:28 +0100 Subject: [PATCH 17/34] Disable autoscroll when using scrollbar --- lib/app/views/app_page.dart | 22 +++++++++++----- lib/home/views/home_screen.dart | 45 +++++++++++++++++++++++---------- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/lib/app/views/app_page.dart b/lib/app/views/app_page.dart index ec2f5cbd..0b245c79 100755 --- a/lib/app/views/app_page.dart +++ b/lib/app/views/app_page.dart @@ -759,19 +759,29 @@ class _VisibilityListener extends StatefulWidget { } class _VisibilityListenerState extends State<_VisibilityListener> { - bool isMouseWheel = false; + 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 (!isMouseWheel) { + if (!disableScroll) { setState(() { - isMouseWheel = true; + disableScroll = true; }); Timer(const Duration(seconds: 1), () { setState(() { - isMouseWheel = false; + disableScroll = false; }); }); } @@ -786,7 +796,7 @@ class _VisibilityListenerState extends State<_VisibilityListener> { if (notification is ScrollEndNotification && widget.child is CustomScrollView) { - // Disable auto scrolling for mouse wheel + // Disable auto scrolling for mouse wheel and scrollbar _handleScrollEnd(context); } return false; @@ -810,7 +820,7 @@ class _VisibilityListenerState extends State<_VisibilityListener> { void _handleScrollEnd( BuildContext context, ) { - if (!isMouseWheel) { + if (!disableScroll) { widget.controller.notifyScroll(_getSrollDirection( _scrolledUnderState(context, widget.targetKey, null))); diff --git a/lib/home/views/home_screen.dart b/lib/home/views/home_screen.dart index 6ef74528..c37d57a9 100644 --- a/lib/home/views/home_screen.dart +++ b/lib/home/views/home_screen.dart @@ -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 createState() => _HomeScreenState(); +} + +class _HomeScreenState extends ConsumerState { + 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), ), ), From 0b37fe91e448bd04e70213e4fdb9be985a39756a Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Mon, 18 Mar 2024 09:57:04 +0100 Subject: [PATCH 18/34] Avoid calling `setState` when unmounted --- lib/app/views/app_page.dart | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/app/views/app_page.dart b/lib/app/views/app_page.dart index 0b245c79..e0f57943 100755 --- a/lib/app/views/app_page.dart +++ b/lib/app/views/app_page.dart @@ -115,6 +115,7 @@ class _AppPageState extends ConsumerState { @override void dispose() { _sliverTitleController.dispose(); + _headerSliverController.dispose(); _navController.dispose(); _detailsController.dispose(); _scrolledUnderController.dispose(); @@ -780,9 +781,11 @@ class _VisibilityListenerState extends State<_VisibilityListener> { disableScroll = true; }); Timer(const Duration(seconds: 1), () { - setState(() { - disableScroll = false; - }); + if (mounted) { + setState(() { + disableScroll = false; + }); + } }); } } From ff6f72ae16182b88aff9326f03ebc3a103ac308d Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Mon, 18 Mar 2024 11:32:03 +0100 Subject: [PATCH 19/34] Add command to delete key --- helper/helper/piv.py | 15 +- lib/desktop/piv/state.dart | 10 +- lib/l10n/app_de.arb | 21 +++ lib/l10n/app_en.arb | 21 +++ lib/l10n/app_fr.arb | 21 +++ lib/l10n/app_ja.arb | 21 +++ lib/l10n/app_pl.arb | 21 +++ lib/piv/state.dart | 3 +- lib/piv/views/actions.dart | 32 +++-- lib/piv/views/delete_certificate_dialog.dart | 137 ++++++++++++++++--- lib/piv/views/piv_screen.dart | 9 +- lib/piv/views/slot_dialog.dart | 2 +- 12 files changed, 273 insertions(+), 40 deletions(-) diff --git a/helper/helper/piv.py b/helper/helper/piv.py index 6e16608b..cbbb90b6 100644 --- a/helper/helper/piv.py +++ b/helper/helper/piv.py @@ -389,6 +389,13 @@ class SlotNode(RpcNode): self.certificate = certificate self._refresh = refresh + def _require_version(self, major, minor, micro): + try: + require_version(self.session.version, (major, minor, micro)) + return True + except NotSupportedError: + return False + def get_data(self): return dict( id=f"{int(self.slot):02x}", @@ -400,13 +407,19 @@ class SlotNode(RpcNode): ) @action(condition=lambda self: self.certificate) - def delete(self, params, event, signal): + def delete_certificate(self, params, event, signal): self.session.delete_certificate(self.slot) self.session.put_object(OBJECT_ID.CHUID, generate_chuid()) self._refresh() self.certificate = None return dict() + @action(condition=lambda self: self.metadata and self._require_version(5, 7, 0)) + def delete_key(self, params, event, signal): + self.session.delete_key(self.slot) + self._refresh() + return dict() + @action def import_file(self, params, event, signal): data = bytes.fromhex(params.pop("data")) diff --git a/lib/desktop/piv/state.dart b/lib/desktop/piv/state.dart index 75c2c51f..292b7f40 100644 --- a/lib/desktop/piv/state.dart +++ b/lib/desktop/piv/state.dart @@ -317,8 +317,14 @@ class _DesktopPivSlotsNotifier extends PivSlotsNotifier { } @override - Future delete(SlotId slot) async { - await _session.command('delete', target: ['slots', slot.hexId]); + Future deleteCertificate(SlotId slot) async { + await _session.command('delete_certificate', target: ['slots', slot.hexId]); + ref.invalidateSelf(); + } + + @override + Future deleteKey(SlotId slot) async { + await _session.command('delete_key', target: ['slots', slot.hexId]); ref.invalidateSelf(); } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 94dd57f3..94b43152 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -505,6 +505,10 @@ "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, "s_issuer": null, "s_serial": null, "s_certificate_fingerprint": null, @@ -522,14 +526,31 @@ }, "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, "p_password_protected_file": null, "p_import_items_desc": null, "@p_import_items_desc": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8a645170..7711c567 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -505,6 +505,10 @@ "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", "s_issuer": "Issuer", "s_serial": "Serial", "s_certificate_fingerprint": "Fingerprint", @@ -522,14 +526,31 @@ }, "l_generating_private_key": "Generating private key\u2026", "s_private_key_generated": "Private key generated", + "p_select_what_to_delete": "Please 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 key from your YubiKey.", + "p_warning_delete_certificate_and_key": "Warning! This action will delete the certificate and 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 key in PIV slot {slot}?", + "@q_delete_key_confirm": { + "placeholders": { + "slot": {} + } + }, + "q_delete_certificate_and_key_confirm": "Delete the certificate and 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", "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": { diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index c5c2af75..8564be9d 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -505,6 +505,10 @@ "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, "s_issuer": "Émetteur", "s_serial": "Série", "s_certificate_fingerprint": "Empreinte digitale", @@ -522,14 +526,31 @@ }, "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, "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": { diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 892b1cd0..d6d62b5f 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -505,6 +505,10 @@ "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, "s_issuer": "発行者", "s_serial": "シリアル番号", "s_certificate_fingerprint": "指紋", @@ -522,14 +526,31 @@ }, "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, "p_password_protected_file": "選択したファイルはパスワードで保護されています。パスワードを入力して続行します", "p_import_items_desc": "次のアイテムはPIVスロット{slot}にインポートされます", "@p_import_items_desc": { diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 8cea051d..1511069d 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -505,6 +505,10 @@ "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, "s_issuer": "Wydawca", "s_serial": "Nr. seryjny", "s_certificate_fingerprint": "Odcisk palca", @@ -522,14 +526,31 @@ }, "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, "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": { diff --git a/lib/piv/state.dart b/lib/piv/state.dart index 4a4b6771..cb74097a 100644 --- a/lib/piv/state.dart +++ b/lib/piv/state.dart @@ -66,5 +66,6 @@ abstract class PivSlotsNotifier PinPolicy pinPolicy = PinPolicy.dfault, TouchPolicy touchPolicy = TouchPolicy.dfault, }); - Future delete(SlotId slot); + Future deleteCertificate(SlotId slot); + Future deleteKey(SlotId slot); } diff --git a/lib/piv/views/actions.dart b/lib/piv/views/actions.dart index 78307345..2e202266 100644 --- a/lib/piv/views/actions.dart +++ b/lib/piv/views/actions.dart @@ -263,6 +263,7 @@ class PivActions extends ConsumerWidget { context: context, builder: (context) => DeleteCertificateDialog( devicePath, + pivState, intent.target, ), ) ?? @@ -283,9 +284,11 @@ class PivActions extends ConsumerWidget { } } -List buildSlotActions(PivSlot slot, AppLocalizations l10n) { +List buildSlotActions( + PivState pivState, PivSlot slot, AppLocalizations l10n) { final hasCert = slot.certInfo != null; final hasKey = slot.metadata != null; + final canDeleteKey = hasKey && pivState.version.isAtLeast(5, 7); return [ ActionItem( key: keys.generateAction, @@ -313,15 +316,6 @@ List 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, @@ -332,5 +326,23 @@ List buildSlotActions(PivSlot slot, AppLocalizations l10n) { intent: ExportIntent(slot), ), ], + if (hasCert || canDeleteKey) + ActionItem( + key: keys.deleteAction, + feature: features.slotsDelete, + actionStyle: ActionStyle.error, + icon: const Icon(Symbols.delete), + title: hasCert && canDeleteKey + ? l10n.l_delete_certificate_or_key + : hasCert + ? l10n.l_delete_certificate + : l10n.l_delete_key, + subtitle: hasCert && canDeleteKey + ? l10n.l_delete_certificate_or_key_desc + : hasCert + ? l10n.l_delete_certificate_desc + : l10n.l_delete_key_desc, + intent: DeleteIntent(slot), + ), ]; } diff --git a/lib/piv/views/delete_certificate_dialog.dart b/lib/piv/views/delete_certificate_dialog.dart index 0acc335e..09f82bec 100644 --- a/lib/piv/views/delete_certificate_dialog.dart +++ b/lib/piv/views/delete_certificate_dialog.dart @@ -27,34 +27,81 @@ 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 createState() => + _DeleteCertificateDialogState(); +} + +class _DeleteCertificateDialogState + extends ConsumerState { + 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 { + if (_deleteCertificate) { + await ref + .read(pivSlotsProvider(widget.devicePath).notifier) + .deleteCertificate(widget.pivSlot.slot); + } + if (_deleteKey) { + await ref + .read(pivSlotsProvider(widget.devicePath).notifier) + .deleteKey(widget.pivSlot.slot); + } + + 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 +110,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), diff --git a/lib/piv/views/piv_screen.dart b/lib/piv/views/piv_screen.dart index a4a06c3e..6a8c0f62 100644 --- a/lib/piv/views/piv_screen.dart +++ b/lib/piv/views/piv_screen.dart @@ -150,7 +150,8 @@ class _PivScreenState extends ConsumerState { ActionListSection.fromMenuActions( context, l10n.s_actions, - actions: buildSlotActions(selected, l10n), + actions: + buildSlotActions(pivState, selected, l10n), ), ], ) @@ -186,6 +187,7 @@ class _PivScreenState extends ConsumerState { if (pivSlots?.hasValue == true) ...pivSlots!.value.map( (e) => _CertificateListItem( + pivState, e, expanded: expanded, selected: e == selected, @@ -204,11 +206,12 @@ class _PivScreenState extends ConsumerState { } 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 @@ -245,7 +248,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, ); } diff --git a/lib/piv/views/slot_dialog.dart b/lib/piv/views/slot_dialog.dart index 9a632afa..193f6efc 100644 --- a/lib/piv/views/slot_dialog.dart +++ b/lib/piv/views/slot_dialog.dart @@ -111,7 +111,7 @@ class SlotDialog extends ConsumerWidget { ActionListSection.fromMenuActions( context, l10n.s_actions, - actions: buildSlotActions(slotData, l10n), + actions: buildSlotActions(pivState, slotData, l10n), ), ], ), From d70644e5993e88d454a8a6135fac0e0c3101fef6 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Tue, 19 Mar 2024 16:20:06 +0100 Subject: [PATCH 20/34] Use one command for delete cert/key --- helper/helper/piv.py | 30 +++++++++----------- lib/desktop/piv/state.dart | 12 +++----- lib/l10n/app_en.arb | 2 +- lib/piv/state.dart | 3 +- lib/piv/views/delete_certificate_dialog.dart | 14 +++------ 5 files changed, 23 insertions(+), 38 deletions(-) diff --git a/helper/helper/piv.py b/helper/helper/piv.py index cbbb90b6..b5f43b0d 100644 --- a/helper/helper/piv.py +++ b/helper/helper/piv.py @@ -389,13 +389,6 @@ class SlotNode(RpcNode): self.certificate = certificate self._refresh = refresh - def _require_version(self, major, minor, micro): - try: - require_version(self.session.version, (major, minor, micro)) - return True - except NotSupportedError: - return False - def get_data(self): return dict( id=f"{int(self.slot):02x}", @@ -406,17 +399,20 @@ class SlotNode(RpcNode): else None, ) - @action(condition=lambda self: self.certificate) - def delete_certificate(self, params, event, signal): - self.session.delete_certificate(self.slot) - self.session.put_object(OBJECT_ID.CHUID, generate_chuid()) - self._refresh() - self.certificate = None - return dict() + @action(condition=lambda self: self.certificate or self.metadata) + def delete(self, params, event, signal): + delete_cert = params.pop("delete_cert", False) + delete_key = params.pop("delete_key", False) - @action(condition=lambda self: self.metadata and self._require_version(5, 7, 0)) - def delete_key(self, params, event, signal): - self.session.delete_key(self.slot) + 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() diff --git a/lib/desktop/piv/state.dart b/lib/desktop/piv/state.dart index 292b7f40..17b38143 100644 --- a/lib/desktop/piv/state.dart +++ b/lib/desktop/piv/state.dart @@ -317,14 +317,10 @@ class _DesktopPivSlotsNotifier extends PivSlotsNotifier { } @override - Future deleteCertificate(SlotId slot) async { - await _session.command('delete_certificate', target: ['slots', slot.hexId]); - ref.invalidateSelf(); - } - - @override - Future deleteKey(SlotId slot) async { - await _session.command('delete_key', target: ['slots', slot.hexId]); + Future 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(); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7711c567..8568c435 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -526,7 +526,7 @@ }, "l_generating_private_key": "Generating private key\u2026", "s_private_key_generated": "Private key generated", - "p_select_what_to_delete": "Please select what to delete from the slot.", + "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 key from your YubiKey.", "p_warning_delete_certificate_and_key": "Warning! This action will delete the certificate and key from your YubiKey.", diff --git a/lib/piv/state.dart b/lib/piv/state.dart index cb74097a..7e99f7a0 100644 --- a/lib/piv/state.dart +++ b/lib/piv/state.dart @@ -66,6 +66,5 @@ abstract class PivSlotsNotifier PinPolicy pinPolicy = PinPolicy.dfault, TouchPolicy touchPolicy = TouchPolicy.dfault, }); - Future deleteCertificate(SlotId slot); - Future deleteKey(SlotId slot); + Future delete(SlotId slot, bool deleteCert, bool deleteKey); } diff --git a/lib/piv/views/delete_certificate_dialog.dart b/lib/piv/views/delete_certificate_dialog.dart index 09f82bec..5302844e 100644 --- a/lib/piv/views/delete_certificate_dialog.dart +++ b/lib/piv/views/delete_certificate_dialog.dart @@ -71,16 +71,10 @@ class _DeleteCertificateDialogState onPressed: _deleteKey || _deleteCertificate ? () async { try { - if (_deleteCertificate) { - await ref - .read(pivSlotsProvider(widget.devicePath).notifier) - .deleteCertificate(widget.pivSlot.slot); - } - if (_deleteKey) { - await ref - .read(pivSlotsProvider(widget.devicePath).notifier) - .deleteKey(widget.pivSlot.slot); - } + await ref + .read(pivSlotsProvider(widget.devicePath).notifier) + .delete(widget.pivSlot.slot, _deleteCertificate, + _deleteKey); await ref.read(withContextProvider)( (context) async { From a4d4fa538d66116b9448ce34d70376cf61b12989 Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Mon, 18 Mar 2024 09:24:18 +0100 Subject: [PATCH 21/34] Use CardMonitor to observe NFC card changes --- helper/helper/device.py | 56 +++++++++++++++++++++++++++++++++------- lib/desktop/devices.dart | 28 +++++++++++--------- 2 files changed, 62 insertions(+), 22 deletions(-) diff --git a/helper/helper/device.py b/helper/helper/device.py index 4bcf07b8..baae703b 100644 --- a/helper/helper/device.py +++ b/helper/helper/device.py @@ -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): diff --git a/lib/desktop/devices.dart b/lib/desktop/devices.dart index ad79952c..df28c0df 100755 --- a/lib/desktop/devices.dart +++ b/lib/desktop/devices.dart @@ -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> { } if (mounted) { - _pollTimer = Timer(_nfcPollDelay, _pollReaders); + _pollTimer = Timer(_nfcPollReadersDelay, _pollReaders); } } } @@ -260,7 +259,7 @@ class CurrentDeviceDataNotifier extends StateNotifier> { 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> { 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> { _log.error('Error polling NFC', jsonEncode(e)); } if (mounted) { - _pollTimer = Timer( - state is AsyncData ? _nfcDetachPollDelay : _nfcAttachPollDelay, - _pollReader); + _pollTimer = Timer(_nfcPollCardDelay, _pollCard); } } } From d6d01ba2278e7d8eb7bb3bcc08f48bd8a7af125f Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Mon, 18 Mar 2024 09:30:59 +0100 Subject: [PATCH 22/34] Don't update OATH credentials list when null --- lib/desktop/oath/state.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/desktop/oath/state.dart b/lib/desktop/oath/state.dart index 5bd070f6..cdd133f7 100755 --- a/lib/desktop/oath/state.dart +++ b/lib/desktop/oath/state.dart @@ -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); From 840dbb7b7c4d9acbbd6a2e244babb5be81ef7e15 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Wed, 20 Mar 2024 10:49:10 +0100 Subject: [PATCH 23/34] unlock with updated PIN to update session state --- lib/android/fido/state.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/android/fido/state.dart b/lib/android/fido/state.dart index 033e9d81..1d075a9d 100644 --- a/lib/android/fido/state.dart +++ b/lib/android/fido/state.dart @@ -109,7 +109,7 @@ class _FidoStateNotifier extends FidoStateNotifier { )); if (response['success'] == true) { _log.debug('FIDO pin set/change successful'); - return PinResult.success(); + return unlock(newPin); } _log.debug('FIDO pin set/change failed'); From aadf81a653b24f7aa29c70815b398d4718327412 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Wed, 20 Mar 2024 14:35:17 +0100 Subject: [PATCH 24/34] Add command to move key --- helper/helper/piv.py | 21 ++++ lib/desktop/piv/state.dart | 14 +++ lib/l10n/app_de.arb | 33 ++++++ lib/l10n/app_en.arb | 41 +++++++- lib/l10n/app_fr.arb | 33 ++++++ lib/l10n/app_ja.arb | 33 ++++++ lib/l10n/app_pl.arb | 33 ++++++ lib/piv/features.dart | 1 + lib/piv/keys.dart | 41 ++++++++ lib/piv/models.dart | 26 ++++- lib/piv/state.dart | 2 + lib/piv/views/actions.dart | 43 +++++++- lib/piv/views/move_key_dialog.dart | 153 ++++++++++++++++++++++++++++ lib/piv/views/piv_screen.dart | 60 +++++++++-- lib/widgets/choice_filter_chip.dart | 3 + 15 files changed, 518 insertions(+), 19 deletions(-) create mode 100644 lib/piv/views/move_key_dialog.dart diff --git a/helper/helper/piv.py b/helper/helper/piv.py index b5f43b0d..1618147a 100644 --- a/helper/helper/piv.py +++ b/helper/helper/piv.py @@ -416,6 +416,27 @@ class SlotNode(RpcNode): self._refresh() return dict() + @action(condition=lambda self: self.metadata) + def move_key(self, params, event, signal): + to_slot = params.pop("to_slot", None) + needs_overwrite = params.pop("needs_overwrite", False) + move_cert = params.pop("move_cert", False) + + if not to_slot: + raise ValueError("Missing destination slot") + + to_slot = SLOT(int(to_slot, base=16)) + if needs_overwrite: + self.session.delete_key(to_slot) + self.session.move_key(self.slot, to_slot) + if move_cert: + self.session.put_certificate(to_slot, self.certificate) + self.session.delete_certificate(self.slot) + self.session.put_object(OBJECT_ID.CHUID, generate_chuid()) + self.certificate = None + self._refresh() + return dict() + @action def import_file(self, params, event, signal): data = bytes.fromhex(params.pop("data")) diff --git a/lib/desktop/piv/state.dart b/lib/desktop/piv/state.dart index 17b38143..9b5c27d3 100644 --- a/lib/desktop/piv/state.dart +++ b/lib/desktop/piv/state.dart @@ -324,6 +324,20 @@ class _DesktopPivSlotsNotifier extends PivSlotsNotifier { ref.invalidateSelf(); } + @override + Future moveKey(SlotId fromSlot, SlotId toSlot, bool needsOverwrite, + bool moveCert) async { + await _session.command('move_key', target: [ + 'slots', + fromSlot.hexId + ], params: { + 'to_slot': toSlot.hexId, + 'needs_overwrite': needsOverwrite, + 'move_cert': moveCert + }); + ref.invalidateSelf(); + } + @override Future generate( SlotId slot, diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 94b43152..00e7d064 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -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", @@ -509,6 +510,8 @@ "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, @@ -551,6 +554,28 @@ "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": { @@ -558,6 +583,8 @@ "slot": {} } }, + "l_key_moved": null, + "l_key_and_certificate_moved": null, "p_subject_desc": null, "l_rfc4514_invalid": null, "rfc4514_examples": null, @@ -581,6 +608,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, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8568c435..8ae53bc3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", @@ -509,6 +510,8 @@ "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", @@ -528,21 +531,21 @@ "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 key from your YubiKey.", - "p_warning_delete_certificate_and_key": "Warning! This action will delete the certificate and key 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 key in PIV slot {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 key in PIV slot {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": {} @@ -551,6 +554,28 @@ "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": { @@ -558,6 +583,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", @@ -581,6 +608,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", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 8564be9d..8c7f20c4 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -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", @@ -509,6 +510,8 @@ "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", @@ -551,6 +554,28 @@ "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": { @@ -558,6 +583,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", @@ -581,6 +608,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", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index d6d62b5f..d5c8d323 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -28,6 +28,7 @@ "s_cancel": "キャンセル", "s_close": "閉じる", "s_delete": "消去", + "s_move": null, "s_quit": "終了", "s_status": null, "s_unlock": "ロック解除", @@ -509,6 +510,8 @@ "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": "指紋", @@ -551,6 +554,28 @@ "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": { @@ -558,6 +583,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", @@ -581,6 +608,12 @@ "hexid": {} } }, + "s_retired_slot_display_name": null, + "@s_retired_slot_display_name": { + "placeholders": { + "hexid": {} + } + }, "s_slot_9a": "認証", "s_slot_9c": "デジタル署名", "s_slot_9d": "鍵の管理", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 1511069d..52bea735 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -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", @@ -509,6 +510,8 @@ "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", @@ -551,6 +554,28 @@ "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": { @@ -558,6 +583,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", @@ -581,6 +608,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", diff --git a/lib/piv/features.dart b/lib/piv/features.dart index a96629e6..9d4c4ea9 100644 --- a/lib/piv/features.dart +++ b/lib/piv/features.dart @@ -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'); diff --git a/lib/piv/keys.dart b/lib/piv/keys.dart index 23394177..33293c12 100644 --- a/lib/piv/keys.dart +++ b/lib/piv/keys.dart @@ -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'); diff --git a/lib/piv/models.dart b/lib/piv/models.dart index 3d790024..bd28e24d 100644 --- a/lib/piv/models.dart +++ b/lib/piv/models.dart @@ -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) }; } diff --git a/lib/piv/state.dart b/lib/piv/state.dart index 7e99f7a0..55368b19 100644 --- a/lib/piv/state.dart +++ b/lib/piv/state.dart @@ -67,4 +67,6 @@ abstract class PivSlotsNotifier TouchPolicy touchPolicy = TouchPolicy.dfault, }); Future delete(SlotId slot, bool deleteCert, bool deleteKey); + Future moveKey( + SlotId fromSlot, SlotId toSlot, bool needsOverwrite, bool moveCert); } diff --git a/lib/piv/views/actions.dart b/lib/piv/views/actions.dart index 2e202266..117ac680 100644 --- a/lib/piv/views/actions.dart +++ b/lib/piv/views/actions.dart @@ -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,6 +53,11 @@ class ExportIntent extends Intent { const ExportIntent(this.slot); } +class MoveIntent extends Intent { + final PivSlot slot; + const MoveIntent(this.slot); +} + Future _authIfNeeded(BuildContext context, WidgetRef ref, DevicePath devicePath, PivState pivState) async { if (pivState.needsAuth) { @@ -270,6 +276,25 @@ class PivActions extends ConsumerWidget { false); return deleted; }), + if (hasFeature(features.slotsMove)) + MoveIntent: CallbackAction(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 @@ -288,7 +313,7 @@ List buildSlotActions( PivState pivState, PivSlot slot, AppLocalizations l10n) { final hasCert = slot.certInfo != null; final hasKey = slot.metadata != null; - final canDeleteKey = hasKey && pivState.version.isAtLeast(5, 7); + final canDeleteOrMoveKey = hasKey && pivState.version.isAtLeast(5, 7); return [ ActionItem( key: keys.generateAction, @@ -326,23 +351,33 @@ List buildSlotActions( intent: ExportIntent(slot), ), ], - if (hasCert || canDeleteKey) + if (hasCert || canDeleteOrMoveKey) ActionItem( key: keys.deleteAction, feature: features.slotsDelete, actionStyle: ActionStyle.error, icon: const Icon(Symbols.delete), - title: hasCert && canDeleteKey + title: hasCert && canDeleteOrMoveKey ? l10n.l_delete_certificate_or_key : hasCert ? l10n.l_delete_certificate : l10n.l_delete_key, - subtitle: hasCert && canDeleteKey + subtitle: hasCert && canDeleteOrMoveKey ? l10n.l_delete_certificate_or_key_desc : hasCert ? l10n.l_delete_certificate_desc : l10n.l_delete_key_desc, intent: DeleteIntent(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), + ), ]; } diff --git a/lib/piv/views/move_key_dialog.dart b/lib/piv/views/move_key_dialog.dart new file mode 100644 index 00000000..ab63ee9c --- /dev/null +++ b/lib/piv/views/move_key_dialog.dart @@ -0,0 +1,153 @@ +/* + * 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 createState() => _MoveKeyDialogState(); +} + +class _MoveKeyDialogState extends ConsumerState { + SlotId? _toSlot; + bool _moveCert = true; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return ResponsiveDialog( + title: Text(l10n.l_move_key), + actions: [ + TextButton( + key: keys.deleteButton, + onPressed: _toSlot != null + ? () async { + try { + final pivSlots = + ref.read(pivSlotsProvider(widget.devicePath)).asData; + if (pivSlots != null) { + final toSlot = pivSlots.value + .firstWhere((element) => element.slot == _toSlot); + + if (!await confirmOverwrite(context, toSlot, + writeKey: true, writeCert: _moveCert)) { + return; + } + + await ref + .read(pivSlotsProvider(widget.devicePath).notifier) + .moveKey(widget.pivSlot.slot, toSlot.slot, + toSlot.metadata != null, _moveCert); + + await ref.read(withContextProvider)( + (context) async { + String message; + if (_moveCert) { + 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(_toSlot == null + ? l10n.q_move_key_confirm( + widget.pivSlot.slot.getDisplayName(l10n)) + : widget.pivSlot.certInfo != null && _moveCert + ? l10n.q_move_key_and_certificate_to_slot_confirm( + widget.pivSlot.slot.getDisplayName(l10n), + _toSlot!.getDisplayName(l10n)) + : l10n.q_move_key_to_slot_confirm( + widget.pivSlot.slot.getDisplayName(l10n), + _toSlot!.getDisplayName(l10n))), + Wrap( + spacing: 4.0, + runSpacing: 8.0, + children: [ + ChoiceFilterChip( + menuConstraints: const BoxConstraints(maxHeight: 200), + value: _toSlot, + items: SlotId.values + .where((element) => element != widget.pivSlot.slot) + .toList(), + labelBuilder: (value) => Text(_toSlot == null + ? l10n.l_select_destination_slot + : _toSlot!.getDisplayName(l10n)), + itemBuilder: (value) => Text(value!.getDisplayName(l10n)), + onChanged: (value) { + setState(() { + _toSlot = value; + }); + }, + ), + if (widget.pivSlot.certInfo != null) + FilterChip( + label: Text(l10n.l_include_certificate), + selected: _moveCert, + onSelected: (value) { + setState(() { + _moveCert = value; + }); + }) + ], + ), + ] + .map((e) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: e, + )) + .toList(), + ), + ), + ); + } +} diff --git a/lib/piv/views/piv_screen.dart b/lib/piv/views/piv_screen.dart index 6a8c0f62..a80a2ec4 100644 --- a/lib/piv/views/piv_screen.dart +++ b/lib/piv/views/piv_screen.dart @@ -185,14 +185,16 @@ class _PivScreenState extends ConsumerState { child: Column( children: [ if (pivSlots?.hasValue == true) - ...pivSlots!.value.map( - (e) => _CertificateListItem( - pivState, - e, - expanded: expanded, - selected: e == selected, - ), - ), + ...pivSlots!.value + .where((element) => !element.slot.isRetired) + .map( + (e) => _CertificateListItem( + pivState, + e, + expanded: expanded, + selected: e == selected, + ), + ), ], ), ); @@ -258,12 +260,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 }; } diff --git a/lib/widgets/choice_filter_chip.dart b/lib/widgets/choice_filter_chip.dart index 93ef92d6..b66c82b3 100755 --- a/lib/widgets/choice_filter_chip.dart +++ b/lib/widgets/choice_filter_chip.dart @@ -29,6 +29,7 @@ class ChoiceFilterChip 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 extends StatefulWidget { this.selected = false, this.disableHover, this.labelBuilder, + this.menuConstraints, }); @override @@ -63,6 +65,7 @@ class _ChoiceFilterChipState extends State> { Offset.zero & overlay.size, ); return await showMenu( + constraints: widget.menuConstraints, context: context, position: position, shape: const RoundedRectangleBorder( From aa02886da2aea956d8b0bdb14a13be0f5e5a83f1 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Wed, 20 Mar 2024 15:05:40 +0100 Subject: [PATCH 25/34] only force context change for NFC keys --- .../com/yubico/authenticator/MainActivity.kt | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt index 6d99b39d..9ea65d15 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt @@ -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 { From c626f799792c8765091df1c39576a9f88841c332 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Wed, 20 Mar 2024 15:42:17 +0100 Subject: [PATCH 26/34] Show retired slots --- lib/piv/views/actions.dart | 56 ++++++++++++++++++----------------- lib/piv/views/piv_screen.dart | 39 ++++++++++++++++-------- 2 files changed, 56 insertions(+), 39 deletions(-) diff --git a/lib/piv/views/actions.dart b/lib/piv/views/actions.dart index 117ac680..d70bb3ac 100644 --- a/lib/piv/views/actions.dart +++ b/lib/piv/views/actions.dart @@ -315,23 +315,25 @@ List buildSlotActions( 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, @@ -351,6 +353,16 @@ List buildSlotActions( 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, @@ -369,15 +381,5 @@ List buildSlotActions( : l10n.l_delete_key_desc, intent: DeleteIntent(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), - ), ]; } diff --git a/lib/piv/views/piv_screen.dart b/lib/piv/views/piv_screen.dart index a80a2ec4..b52f2400 100644 --- a/lib/piv/views/piv_screen.dart +++ b/lib/piv/views/piv_screen.dart @@ -72,6 +72,16 @@ class _PivScreenState extends ConsumerState { 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 @@ -184,17 +194,22 @@ class _PivScreenState extends ConsumerState { }, child: Column( children: [ - if (pivSlots?.hasValue == true) - ...pivSlots!.value - .where((element) => !element.slot.isRetired) - .map( - (e) => _CertificateListItem( - pivState, - 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, + ), + ) ], ), ); @@ -231,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 From 029c85909c0b7db8ad9f1bf4d5fc1fa3a3ee4b5a Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Wed, 20 Mar 2024 17:01:26 +0100 Subject: [PATCH 27/34] Add search field to `PasskeysScreen` --- lib/app/shortcuts.dart | 17 +++-- lib/fido/keys.dart | 3 + lib/fido/state.dart | 32 +++++++++ lib/fido/views/passkeys_screen.dart | 104 +++++++++++++++++++++++++++- lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_ja.arb | 1 + lib/l10n/app_pl.arb | 1 + lib/oath/state.dart | 11 +-- lib/oath/views/oath_screen.dart | 11 +-- lib/piv/state.dart | 12 ++++ 12 files changed, 180 insertions(+), 15 deletions(-) diff --git a/lib/app/shortcuts.dart b/lib/app/shortcuts.dart index 43502a1e..0cbeb409 100755 --- a/lib/app/shortcuts.dart +++ b/lib/app/shortcuts.dart @@ -24,6 +24,7 @@ import 'package:window_manager/window_manager.dart'; import '../about_page.dart'; import '../core/state.dart'; import '../desktop/state.dart'; +import '../fido/keys.dart'; import '../oath/keys.dart'; import 'message.dart'; import 'models.dart'; @@ -130,11 +131,17 @@ class GlobalShortcuts extends ConsumerWidget { return null; }), SearchIntent: CallbackAction(onInvoke: (intent) { - // If the OATH view doesn't have focus, but is shown, find and select the search bar. - final searchContext = searchAccountsField.currentContext; - if (searchContext != null) { - if (!Navigator.of(searchContext).canPop()) { - return Actions.maybeInvoke(searchContext, intent); + // If the OATH or passkeys view doesn't have focus, but is shown, find and select the search bar. + final accountsSearchContext = searchAccountsField.currentContext; + if (accountsSearchContext != null) { + if (!Navigator.of(accountsSearchContext).canPop()) { + return Actions.maybeInvoke(accountsSearchContext, intent); + } + } + final passkeysSearchContext = searchPasskeysField.currentContext; + if (passkeysSearchContext != null) { + if (!Navigator.of(passkeysSearchContext).canPop()) { + return Actions.maybeInvoke(passkeysSearchContext, intent); } } return null; diff --git a/lib/fido/keys.dart b/lib/fido/keys.dart index ab6a7e11..c4a54746 100644 --- a/lib/fido/keys.dart +++ b/lib/fido/keys.dart @@ -21,6 +21,9 @@ const _keyAction = '$_prefix.actions'; const _credentialAction = '$_prefix.credential.actions'; const _fingerprintAction = '$_prefix.fingerprint.actions'; +// This is global so we can access it from the global Ctrl+F shortcut. +final searchPasskeysField = GlobalKey(); + // Key actions const managePinAction = Key('$_keyAction.manage_pin'); const addFingerprintAction = Key('$_keyAction.add_fingerprint'); diff --git a/lib/fido/state.dart b/lib/fido/state.dart index 4fb4ee25..367d5cb9 100755 --- a/lib/fido/state.dart +++ b/lib/fido/state.dart @@ -20,6 +20,18 @@ import '../app/models.dart'; import '../core/state.dart'; import 'models.dart'; +final passkeysSearchProvider = + StateNotifierProvider( + (ref) => PasskeysSearchNotifier()); + +class PasskeysSearchNotifier extends StateNotifier { + PasskeysSearchNotifier() : super(''); + + void setFilter(String value) { + state = value; + } +} + final fidoStateProvider = AsyncNotifierProvider.autoDispose .family( () => throw UnimplementedError(), @@ -52,3 +64,23 @@ abstract class FidoCredentialsNotifier extends AutoDisposeFamilyAsyncNotifier, DevicePath> { Future deleteCredential(FidoCredential credential); } + +final filteredFidoCredentialsProvider = StateNotifierProvider.autoDispose + .family, + List>( + (ref, full) { + return FilteredFidoCredentialsNotifier( + full, ref.watch(passkeysSearchProvider)); + }, +); + +class FilteredFidoCredentialsNotifier + extends StateNotifier> { + final String query; + FilteredFidoCredentialsNotifier(List full, this.query) + : super(full + .where((credential) => + credential.rpId.toLowerCase().contains(query.toLowerCase()) || + credential.userName.toLowerCase().contains(query.toLowerCase())) + .toList()); +} diff --git a/lib/fido/views/passkeys_screen.dart b/lib/fido/views/passkeys_screen.dart index 52a9dca9..f14d78ae 100644 --- a/lib/fido/views/passkeys_screen.dart +++ b/lib/fido/views/passkeys_screen.dart @@ -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'; @@ -34,8 +35,11 @@ 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 '../keys.dart' as keys; import '../models.dart'; import '../state.dart'; import 'actions.dart'; @@ -206,8 +210,23 @@ 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); + } + + void _onFocusChange() { + setState(() {}); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -237,6 +256,9 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> { } final credentials = data.value; + final filteredCredentials = + ref.watch(filteredFidoCredentialsProvider(credentials.toList())); + if (credentials.isEmpty) { return MessagePage( title: l10n.s_passkeys, @@ -273,6 +295,13 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> { return FidoActions( devicePath: widget.node.path, actions: (context) => { + SearchIntent: CallbackAction(onInvoke: (_) { + searchController.selection = TextSelection( + baseOffset: 0, extentOffset: searchController.text.length); + searchFocus.unfocus(); + Timer.run(() => searchFocus.requestFocus()); + return null; + }), EscapeIntent: CallbackAction(onInvoke: (intent) { if (_selected != null) { setState(() { @@ -309,6 +338,79 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> { title: l10n.s_passkeys, capabilities: const [Capability.fido2], footnote: l10n.l_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: keys.searchPasskeysField, + 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, @@ -390,7 +492,7 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> { }, child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: credentials + children: filteredCredentials .map( (cred) => _CredentialListItem( cred, diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 94b43152..38fdc5d3 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -427,6 +427,7 @@ "l_delete_passkey_desc": null, "s_passkey_deleted": null, "p_warning_delete_passkey": null, + "s_search_passkeys": null, "@_fingerprints": {}, "l_fingerprint": "Fingerabdruck: {label}", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8568c435..a832b893 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -427,6 +427,7 @@ "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", "@_fingerprints": {}, "l_fingerprint": "Fingerprint: {label}", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 8564be9d..5ae57bbe 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -427,6 +427,7 @@ "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, "@_fingerprints": {}, "l_fingerprint": "Empreinte: {label}", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index d6d62b5f..8132dad6 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -427,6 +427,7 @@ "l_delete_passkey_desc": "YubiKeyからパスキーの削除", "s_passkey_deleted": "パスキーが削除されました", "p_warning_delete_passkey": "これにより、YubiKeyからパスキーが削除されます", + "s_search_passkeys": null, "@_fingerprints": {}, "l_fingerprint": "指紋:{label}", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 1511069d..b539f33c 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -427,6 +427,7 @@ "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, "@_fingerprints": {}, "l_fingerprint": "Odcisk palca: {label}", diff --git a/lib/oath/state.dart b/lib/oath/state.dart index 9a5c0684..b77b4200 100755 --- a/lib/oath/state.dart +++ b/lib/oath/state.dart @@ -26,11 +26,12 @@ import '../app/state.dart'; import '../core/state.dart'; import 'models.dart'; -final searchProvider = - StateNotifierProvider((ref) => SearchNotifier()); +final accountsSearchProvider = + StateNotifierProvider( + (ref) => AccountsSearchNotifier()); -class SearchNotifier extends StateNotifier { - SearchNotifier() : super(''); +class AccountsSearchNotifier extends StateNotifier { + AccountsSearchNotifier() : super(''); void setFilter(String value) { state = value; @@ -184,7 +185,7 @@ class FavoritesNotifier extends StateNotifier> { final filteredCredentialsProvider = StateNotifierProvider.autoDispose .family, List>( (ref, full) { - return FilteredCredentialsNotifier(full, ref.watch(searchProvider)); + return FilteredCredentialsNotifier(full, ref.watch(accountsSearchProvider)); }); class FilteredCredentialsNotifier extends StateNotifier> { diff --git a/lib/oath/views/oath_screen.dart b/lib/oath/views/oath_screen.dart index 9f0afaba..2074ed21 100755 --- a/lib/oath/views/oath_screen.dart +++ b/lib/oath/views/oath_screen.dart @@ -121,7 +121,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); } @@ -362,7 +363,7 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> { } if (event.logicalKey == LogicalKeyboardKey.escape) { searchController.clear(); - ref.read(searchProvider.notifier).setFilter(''); + ref.read(accountsSearchProvider.notifier).setFilter(''); node.unfocus(); setState(() {}); return KeyEventResult.handled; @@ -406,14 +407,16 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> { iconSize: 16, onPressed: () { searchController.clear(); - ref.read(searchProvider.notifier).setFilter(''); + ref + .read(accountsSearchProvider.notifier) + .setFilter(''); setState(() {}); }, ) : null, ), onChanged: (value) { - ref.read(searchProvider.notifier).setFilter(value); + ref.read(accountsSearchProvider.notifier).setFilter(value); setState(() {}); }, textInputAction: TextInputAction.next, diff --git a/lib/piv/state.dart b/lib/piv/state.dart index 7e99f7a0..e71d0f55 100644 --- a/lib/piv/state.dart +++ b/lib/piv/state.dart @@ -20,6 +20,18 @@ import '../app/models.dart'; import '../core/state.dart'; import 'models.dart'; +final passkeysSearchProvider = + StateNotifierProvider( + (ref) => PasskeysSearchNotifier()); + +class PasskeysSearchNotifier extends StateNotifier { + PasskeysSearchNotifier() : super(''); + + void setFilter(String value) { + state = value; + } +} + final pivStateProvider = AsyncNotifierProvider.autoDispose .family( () => throw UnimplementedError(), From b46ae6082e1a5f36f4e6a75072398f1b5f33786a Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Wed, 20 Mar 2024 17:46:08 +0100 Subject: [PATCH 28/34] Show number of passkeys used --- lib/fido/models.dart | 2 ++ lib/fido/views/passkeys_screen.dart | 16 ++++++++++------ lib/l10n/app_de.arb | 10 ++++++++-- lib/l10n/app_en.arb | 10 ++++++++-- lib/l10n/app_fr.arb | 10 ++++++++-- lib/l10n/app_ja.arb | 10 ++++++++-- lib/l10n/app_pl.arb | 10 ++++++++-- 7 files changed, 52 insertions(+), 16 deletions(-) diff --git a/lib/fido/models.dart b/lib/fido/models.dart index 79b4d1cf..b6d2cff4 100755 --- a/lib/fido/models.dart +++ b/lib/fido/models.dart @@ -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; diff --git a/lib/fido/views/passkeys_screen.dart b/lib/fido/views/passkeys_screen.dart index f14d78ae..1cf2781c 100644 --- a/lib/fido/views/passkeys_screen.dart +++ b/lib/fido/views/passkeys_screen.dart @@ -141,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), ); @@ -153,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), ); @@ -241,7 +241,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) @@ -255,10 +255,13 @@ 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( title: l10n.s_passkeys, @@ -287,7 +290,7 @@ 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, ); } @@ -337,7 +340,8 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> { builder: (context) => AppPage( title: l10n.s_passkeys, 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) { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 38fdc5d3..919a0116 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -422,13 +422,19 @@ "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": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a832b893..d9593451 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -422,13 +422,19 @@ "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": { diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 5ae57bbe..fbcd7b1d 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -422,13 +422,19 @@ "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": { diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 8132dad6..126a01fe 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -422,13 +422,19 @@ "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": { diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index b539f33c..2fa66520 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -422,13 +422,19 @@ "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": { From 72f668689a8810f231084ba32cc21fda34320744 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Thu, 21 Mar 2024 08:29:19 +0100 Subject: [PATCH 29/34] reload session after unlock --- .../main/kotlin/com/yubico/authenticator/fido/FidoManager.kt | 2 +- lib/android/fido/state.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoManager.kt index 39c0401f..c146ecf6 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoManager.kt @@ -265,7 +265,7 @@ class FidoManager( fidoViewModel.setSessionState( Session( - fidoSession.cachedInfo, + fidoSession.info, pinStore.hasPin() ) ) diff --git a/lib/android/fido/state.dart b/lib/android/fido/state.dart index 1d075a9d..033e9d81 100644 --- a/lib/android/fido/state.dart +++ b/lib/android/fido/state.dart @@ -109,7 +109,7 @@ class _FidoStateNotifier extends FidoStateNotifier { )); if (response['success'] == true) { _log.debug('FIDO pin set/change successful'); - return unlock(newPin); + return PinResult.success(); } _log.debug('FIDO pin set/change failed'); From 5e91579460063d3c20d59495c8b45067bc64d599 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Thu, 21 Mar 2024 08:21:34 +0100 Subject: [PATCH 30/34] Change to passkeys icon --- lib/fido/views/credential_dialog.dart | 2 +- lib/fido/views/passkeys_screen.dart | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/fido/views/credential_dialog.dart b/lib/fido/views/credential_dialog.dart index f784d2eb..fb39d78a 100644 --- a/lib/fido/views/credential_dialog.dart +++ b/lib/fido/views/credential_dialog.dart @@ -75,7 +75,7 @@ class CredentialDialog extends ConsumerWidget { ), ), const SizedBox(height: 16), - const Icon(Symbols.person, size: 72), + const Icon(Symbols.passkey, size: 72), ], ), ), diff --git a/lib/fido/views/passkeys_screen.dart b/lib/fido/views/passkeys_screen.dart index 1cf2781c..a2512d47 100644 --- a/lib/fido/views/passkeys_screen.dart +++ b/lib/fido/views/passkeys_screen.dart @@ -453,7 +453,7 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> { ), ), const SizedBox(height: 16), - const Icon(Symbols.person, size: 72), + const Icon(Symbols.passkey, size: 72), ], ), ), @@ -529,13 +529,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, From 832051932c9eb902b036d471888f31165670b053 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Thu, 21 Mar 2024 08:57:12 +0100 Subject: [PATCH 31/34] Add `alternativeTitle` to `PasskeysScreen` --- lib/fido/views/passkeys_screen.dart | 25 ++++++++++++++++--------- lib/l10n/app_de.arb | 7 +++++++ lib/l10n/app_en.arb | 7 +++++++ lib/l10n/app_fr.arb | 7 +++++++ lib/l10n/app_ja.arb | 7 +++++++ lib/l10n/app_pl.arb | 7 +++++++ lib/oath/views/oath_screen.dart | 3 ++- 7 files changed, 53 insertions(+), 10 deletions(-) diff --git a/lib/fido/views/passkeys_screen.dart b/lib/fido/views/passkeys_screen.dart index a2512d47..1467ba86 100644 --- a/lib/fido/views/passkeys_screen.dart +++ b/lib/fido/views/passkeys_screen.dart @@ -295,6 +295,7 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> { } final credential = _selected; + final searchText = searchController.text; return FidoActions( devicePath: widget.node.path, actions: (context) => { @@ -339,6 +340,8 @@ 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.p_passkeys_used(credentials.length, maxCreds)} ${l10n.p_non_passkeys_note}', @@ -496,15 +499,19 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> { }, child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: filteredCredentials - .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, + ), + ), + ], ), ); }, diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 919a0116..87c05849 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -352,6 +352,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,6 +425,7 @@ } }, "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", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d9593451..d4626477 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -352,6 +352,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,6 +425,7 @@ } }, "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", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index fbcd7b1d..d6d1574f 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -352,6 +352,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,6 +425,7 @@ } }, "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", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 126a01fe..bba1c714 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -352,6 +352,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,6 +425,7 @@ } }, "s_passkeys": "パスキー", + "s_no_passkeys": null, "l_ready_to_use": "すぐに使用可能", "l_register_sk_on_websites": "Webサイトにセキュリティキーとして登録する", "l_no_discoverable_accounts": "パスキーは保存されていません", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 2fa66520..622bc4b5 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -352,6 +352,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,6 +425,7 @@ } }, "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", diff --git a/lib/oath/views/oath_screen.dart b/lib/oath/views/oath_screen.dart index 2074ed21..ce05bfbd 100755 --- a/lib/oath/views/oath_screen.dart +++ b/lib/oath/views/oath_screen.dart @@ -264,7 +264,8 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> { }, builder: (context) => AppPage( title: l10n.s_accounts, - alternativeTitle: searchText != '' ? 'Results for "$searchText"' : null, + alternativeTitle: + searchText != '' ? l10n.l_results_for(searchText) : null, capabilities: const [Capability.oath], keyActionsBuilder: hasActions ? (context) => oathBuildActions( From 1495dd0f9e07939a4f13b97ac8605efe62e21134 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Thu, 21 Mar 2024 12:22:46 +0100 Subject: [PATCH 32/34] Use one global key for search field --- lib/app/shortcuts.dart | 18 +++++------------- lib/app/views/keys.dart | 2 ++ lib/fido/keys.dart | 3 --- lib/fido/views/passkeys_screen.dart | 4 ++-- lib/oath/keys.dart | 3 --- lib/oath/views/oath_screen.dart | 3 ++- 6 files changed, 11 insertions(+), 22 deletions(-) diff --git a/lib/app/shortcuts.dart b/lib/app/shortcuts.dart index 0cbeb409..634baf0f 100755 --- a/lib/app/shortcuts.dart +++ b/lib/app/shortcuts.dart @@ -24,8 +24,6 @@ import 'package:window_manager/window_manager.dart'; import '../about_page.dart'; import '../core/state.dart'; import '../desktop/state.dart'; -import '../fido/keys.dart'; -import '../oath/keys.dart'; import 'message.dart'; import 'models.dart'; import 'state.dart'; @@ -131,17 +129,11 @@ class GlobalShortcuts extends ConsumerWidget { return null; }), SearchIntent: CallbackAction(onInvoke: (intent) { - // If the OATH or passkeys view doesn't have focus, but is shown, find and select the search bar. - final accountsSearchContext = searchAccountsField.currentContext; - if (accountsSearchContext != null) { - if (!Navigator.of(accountsSearchContext).canPop()) { - return Actions.maybeInvoke(accountsSearchContext, intent); - } - } - final passkeysSearchContext = searchPasskeysField.currentContext; - if (passkeysSearchContext != null) { - if (!Navigator.of(passkeysSearchContext).canPop()) { - return Actions.maybeInvoke(passkeysSearchContext, intent); + // 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); } } return null; diff --git a/lib/app/views/keys.dart b/lib/app/views/keys.dart index bb0e5079..399edd1d 100644 --- a/lib/app/views/keys.dart +++ b/lib/app/views/keys.dart @@ -18,6 +18,8 @@ import 'package:flutter/material.dart'; // global keys final scaffoldGlobalKey = GlobalKey(); +// 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'); diff --git a/lib/fido/keys.dart b/lib/fido/keys.dart index c4a54746..ab6a7e11 100644 --- a/lib/fido/keys.dart +++ b/lib/fido/keys.dart @@ -21,9 +21,6 @@ const _keyAction = '$_prefix.actions'; const _credentialAction = '$_prefix.credential.actions'; const _fingerprintAction = '$_prefix.fingerprint.actions'; -// This is global so we can access it from the global Ctrl+F shortcut. -final searchPasskeysField = GlobalKey(); - // Key actions const managePinAction = Key('$_keyAction.manage_pin'); const addFingerprintAction = Key('$_keyAction.add_fingerprint'); diff --git a/lib/fido/views/passkeys_screen.dart b/lib/fido/views/passkeys_screen.dart index 1467ba86..f42cc98b 100644 --- a/lib/fido/views/passkeys_screen.dart +++ b/lib/fido/views/passkeys_screen.dart @@ -30,6 +30,7 @@ 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'; @@ -39,7 +40,6 @@ import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_text_form_field.dart'; import '../../widgets/list_title.dart'; import '../features.dart' as features; -import '../keys.dart' as keys; import '../models.dart'; import '../state.dart'; import 'actions.dart'; @@ -367,7 +367,7 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> { padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), child: AppTextFormField( - key: keys.searchPasskeysField, + key: searchField, controller: searchController, focusNode: searchFocus, // Use the default style, but with a smaller font size: diff --git a/lib/oath/keys.dart b/lib/oath/keys.dart index 546de5b6..e1248bfd 100755 --- a/lib/oath/keys.dart +++ b/lib/oath/keys.dart @@ -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'); diff --git a/lib/oath/views/oath_screen.dart b/lib/oath/views/oath_screen.dart index ce05bfbd..9c91874d 100755 --- a/lib/oath/views/oath_screen.dart +++ b/lib/oath/views/oath_screen.dart @@ -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'; @@ -377,7 +378,7 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> { padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), child: AppTextFormField( - key: keys.searchAccountsField, + key: searchField, controller: searchController, focusNode: searchFocus, // Use the default style, but with a smaller font size: From ee3c07381b1b5b081bb7fc5c8a0c1e8cb7f78200 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Thu, 21 Mar 2024 12:31:24 +0100 Subject: [PATCH 33/34] Dispose searchFocus and searchController --- lib/fido/views/passkeys_screen.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/fido/views/passkeys_screen.dart b/lib/fido/views/passkeys_screen.dart index f42cc98b..2633613d 100644 --- a/lib/fido/views/passkeys_screen.dart +++ b/lib/fido/views/passkeys_screen.dart @@ -223,6 +223,13 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> { searchFocus.addListener(_onFocusChange); } + @override + void dispose() { + searchFocus.dispose(); + searchController.dispose(); + super.dispose(); + } + void _onFocusChange() { setState(() {}); } From 0fa89e27c187966d38e93a336ae1549768f5c502 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Thu, 21 Mar 2024 14:23:29 +0100 Subject: [PATCH 34/34] Skip parsing of cert to avoid compression failures --- helper/helper/piv.py | 23 +++++++------- lib/desktop/piv/state.dart | 12 ++++---- lib/piv/state.dart | 4 +-- lib/piv/views/move_key_dialog.dart | 49 ++++++++++++++++++------------ 4 files changed, 48 insertions(+), 40 deletions(-) diff --git a/helper/helper/piv.py b/helper/helper/piv.py index 1618147a..42787226 100644 --- a/helper/helper/piv.py +++ b/helper/helper/piv.py @@ -418,19 +418,18 @@ class SlotNode(RpcNode): @action(condition=lambda self: self.metadata) def move_key(self, params, event, signal): - to_slot = params.pop("to_slot", None) - needs_overwrite = params.pop("needs_overwrite", False) - move_cert = params.pop("move_cert", False) + destination = params.pop("destination") + overwrite_key = params.pop("overwrite_key") + include_certificate = params.pop("include_certificate") - if not to_slot: - raise ValueError("Missing destination slot") - - to_slot = SLOT(int(to_slot, base=16)) - if needs_overwrite: - self.session.delete_key(to_slot) - self.session.move_key(self.slot, to_slot) - if move_cert: - self.session.put_certificate(to_slot, self.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 diff --git a/lib/desktop/piv/state.dart b/lib/desktop/piv/state.dart index 9b5c27d3..6ba6638d 100644 --- a/lib/desktop/piv/state.dart +++ b/lib/desktop/piv/state.dart @@ -325,15 +325,15 @@ class _DesktopPivSlotsNotifier extends PivSlotsNotifier { } @override - Future moveKey(SlotId fromSlot, SlotId toSlot, bool needsOverwrite, - bool moveCert) async { + Future moveKey(SlotId source, SlotId destination, bool overwriteKey, + bool includeCertificate) async { await _session.command('move_key', target: [ 'slots', - fromSlot.hexId + source.hexId ], params: { - 'to_slot': toSlot.hexId, - 'needs_overwrite': needsOverwrite, - 'move_cert': moveCert + 'destination': destination.hexId, + 'overwrite_key': overwriteKey, + 'include_certificate': includeCertificate }); ref.invalidateSelf(); } diff --git a/lib/piv/state.dart b/lib/piv/state.dart index 55368b19..1ba4ca54 100644 --- a/lib/piv/state.dart +++ b/lib/piv/state.dart @@ -67,6 +67,6 @@ abstract class PivSlotsNotifier TouchPolicy touchPolicy = TouchPolicy.dfault, }); Future delete(SlotId slot, bool deleteCert, bool deleteKey); - Future moveKey( - SlotId fromSlot, SlotId toSlot, bool needsOverwrite, bool moveCert); + Future moveKey(SlotId source, SlotId destination, bool overwriteKey, + bool includeCertificate); } diff --git a/lib/piv/views/move_key_dialog.dart b/lib/piv/views/move_key_dialog.dart index ab63ee9c..e8a1b2a6 100644 --- a/lib/piv/views/move_key_dialog.dart +++ b/lib/piv/views/move_key_dialog.dart @@ -41,8 +41,14 @@ class MoveKeyDialog extends ConsumerStatefulWidget { } class _MoveKeyDialogState extends ConsumerState { - SlotId? _toSlot; - bool _moveCert = true; + SlotId? _destination; + late bool _includeCertificate; + + @override + void initState() { + super.initState(); + _includeCertificate = widget.pivSlot.certInfo != null; + } @override Widget build(BuildContext context) { @@ -53,29 +59,32 @@ class _MoveKeyDialogState extends ConsumerState { actions: [ TextButton( key: keys.deleteButton, - onPressed: _toSlot != null + onPressed: _destination != null ? () async { try { final pivSlots = ref.read(pivSlotsProvider(widget.devicePath)).asData; if (pivSlots != null) { - final toSlot = pivSlots.value - .firstWhere((element) => element.slot == _toSlot); + final destination = pivSlots.value.firstWhere( + (element) => element.slot == _destination); - if (!await confirmOverwrite(context, toSlot, - writeKey: true, writeCert: _moveCert)) { + if (!await confirmOverwrite(context, destination, + writeKey: true, writeCert: _includeCertificate)) { return; } await ref .read(pivSlotsProvider(widget.devicePath).notifier) - .moveKey(widget.pivSlot.slot, toSlot.slot, - toSlot.metadata != null, _moveCert); + .moveKey( + widget.pivSlot.slot, + destination.slot, + destination.metadata != null, + _includeCertificate); await ref.read(withContextProvider)( (context) async { String message; - if (_moveCert) { + if (_includeCertificate) { message = l10n.l_key_and_certificate_moved; } else { message = l10n.l_key_moved; @@ -99,43 +108,43 @@ class _MoveKeyDialogState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(_toSlot == null + Text(_destination == null ? l10n.q_move_key_confirm( widget.pivSlot.slot.getDisplayName(l10n)) - : widget.pivSlot.certInfo != null && _moveCert + : widget.pivSlot.certInfo != null && _includeCertificate ? l10n.q_move_key_and_certificate_to_slot_confirm( widget.pivSlot.slot.getDisplayName(l10n), - _toSlot!.getDisplayName(l10n)) + _destination!.getDisplayName(l10n)) : l10n.q_move_key_to_slot_confirm( widget.pivSlot.slot.getDisplayName(l10n), - _toSlot!.getDisplayName(l10n))), + _destination!.getDisplayName(l10n))), Wrap( spacing: 4.0, runSpacing: 8.0, children: [ ChoiceFilterChip( menuConstraints: const BoxConstraints(maxHeight: 200), - value: _toSlot, + value: _destination, items: SlotId.values .where((element) => element != widget.pivSlot.slot) .toList(), - labelBuilder: (value) => Text(_toSlot == null + labelBuilder: (value) => Text(_destination == null ? l10n.l_select_destination_slot - : _toSlot!.getDisplayName(l10n)), + : _destination!.getDisplayName(l10n)), itemBuilder: (value) => Text(value!.getDisplayName(l10n)), onChanged: (value) { setState(() { - _toSlot = value; + _destination = value; }); }, ), if (widget.pivSlot.certInfo != null) FilterChip( label: Text(l10n.l_include_certificate), - selected: _moveCert, + selected: _includeCertificate, onSelected: (value) { setState(() { - _moveCert = value; + _includeCertificate = value; }); }) ],