diff --git a/lib/app/views/app_list_item.dart b/lib/app/views/app_list_item.dart index 6f04f752..76635958 100644 --- a/lib/app/views/app_list_item.dart +++ b/lib/app/views/app_list_item.dart @@ -30,8 +30,10 @@ class AppListItem extends ConsumerStatefulWidget { final String? semanticTitle; final Widget? trailing; final List Function(BuildContext context)? buildPopupActions; + final Widget Function(BuildContext context)? itemBuilder; final Intent? tapIntent; final Intent? doubleTapIntent; + final Color? tileColor; final bool selected; const AppListItem( @@ -43,8 +45,10 @@ class AppListItem extends ConsumerStatefulWidget { this.subtitle, this.trailing, this.buildPopupActions, + this.itemBuilder, this.tapIntent, this.doubleTapIntent, + this.tileColor, this.selected = false, }); @@ -78,7 +82,7 @@ class _AppListItemState extends ConsumerState { item: widget.item, child: InkWell( focusNode: _focusNode, - borderRadius: BorderRadius.circular(48), + borderRadius: BorderRadius.circular(16), onSecondaryTapDown: buildPopupActions == null ? null : (details) { @@ -118,57 +122,62 @@ class _AppListItemState extends ConsumerState { : () { Actions.invoke(context, doubleTapIntent); }, - child: Stack( - alignment: AlignmentDirectional.center, - children: [ - const SizedBox(height: 64), - ListTile( - mouseCursor: - widget.tapIntent != null ? SystemMouseCursors.click : null, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(48)), - selectedTileColor: colorScheme.secondaryContainer, - selectedColor: colorScheme.onSecondaryContainer, - selected: widget.selected, - leading: widget.leading, - title: subtitle == null - // We use SizedBox to fill entire space - ? SizedBox( - height: 48, - child: Align( - alignment: Alignment.centerLeft, - child: Text( - widget.title, - overflow: TextOverflow.fade, - maxLines: 1, - softWrap: false, - ), - ), - ) - : Text( - widget.title, - overflow: TextOverflow.fade, - maxLines: 1, - softWrap: false, - ), - subtitle: subtitle != null - ? Text( - subtitle, - overflow: TextOverflow.fade, - maxLines: 1, - softWrap: false, - ) - : null, - trailing: trailing == null - ? null - : Focus( - skipTraversal: true, - descendantsAreTraversable: false, - child: trailing, - ), - ), - ], - ), + child: widget.itemBuilder != null + ? widget.itemBuilder!.call(context) + : Stack( + alignment: AlignmentDirectional.center, + children: [ + const SizedBox(height: 64), + ListTile( + mouseCursor: widget.tapIntent != null + ? SystemMouseCursors.click + : null, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16)), + selectedTileColor: colorScheme.secondaryContainer, + selectedColor: colorScheme.onSecondaryContainer, + tileColor: widget.tileColor, + contentPadding: const EdgeInsets.symmetric(horizontal: 8), + selected: widget.selected, + leading: widget.leading, + title: subtitle == null + // We use SizedBox to fill entire space + ? SizedBox( + height: 48, + child: Align( + alignment: Alignment.centerLeft, + child: Text( + widget.title, + overflow: TextOverflow.fade, + maxLines: 1, + softWrap: false, + ), + ), + ) + : Text( + widget.title, + overflow: TextOverflow.fade, + maxLines: 1, + softWrap: false, + ), + subtitle: subtitle != null + ? Text( + subtitle, + overflow: TextOverflow.fade, + maxLines: 1, + softWrap: false, + ) + : null, + trailing: trailing == null + ? null + : Focus( + skipTraversal: true, + descendantsAreTraversable: false, + child: trailing, + ), + ), + ], + ), ), ), ); diff --git a/lib/app/views/app_page.dart b/lib/app/views/app_page.dart index ffc44778..5491a468 100755 --- a/lib/app/views/app_page.dart +++ b/lib/app/views/app_page.dart @@ -22,6 +22,7 @@ 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:shared_preferences/shared_preferences.dart'; import '../../core/state.dart'; import '../../management/models.dart'; @@ -33,14 +34,24 @@ import 'fs_dialog.dart'; import 'keys.dart'; import 'navigation.dart'; -final _navigationProvider = StateNotifierProvider<_NavigationProvider, bool>( - (ref) => _NavigationProvider()); +final _navigationVisibilityProvider = + StateNotifierProvider<_VisibilityNotifier, bool>((ref) => + _VisibilityNotifier('NAVIGATION_VISIBILITY', ref.watch(prefProvider))); -class _NavigationProvider extends StateNotifier { - _NavigationProvider() : super(true); +final _detailViewVisibilityProvider = + StateNotifierProvider<_VisibilityNotifier, bool>((ref) => + _VisibilityNotifier('DETAIL_VIEW_VISIBILITY', ref.watch(prefProvider))); + +class _VisibilityNotifier extends StateNotifier { + final String _key; + final SharedPreferences _prefs; + _VisibilityNotifier(this._key, this._prefs) + : super(_prefs.getBool(_key) ?? true); void toggleExpanded() { - state = !state; + final newValue = !state; + state = newValue; + _prefs.setBool(_key, newValue); } } @@ -308,14 +319,17 @@ class _AppPageState extends ConsumerState { Widget? _buildAppBarTitle( BuildContext context, bool hasRail, bool hasManage, bool fullyExpanded) { - final showNavigation = ref.watch(_navigationProvider); + final showNavigation = ref.watch(_navigationVisibilityProvider); + final showDetailView = ref.watch(_detailViewVisibilityProvider); + EdgeInsets padding; if (fullyExpanded) { - padding = EdgeInsets.only(left: showNavigation ? 280 : 72, right: 320); + padding = EdgeInsets.only( + left: showNavigation ? 280 : 72, right: showDetailView ? 320 : 0.0); } else if (!hasRail && hasManage) { padding = const EdgeInsets.only(right: 320); } else if (hasRail && hasManage) { - padding = const EdgeInsets.only(left: 72, right: 320); + padding = EdgeInsets.only(left: 72, right: showDetailView ? 320 : 0.0); } else if (hasRail && !hasManage) { padding = const EdgeInsets.only(left: 72); } else { @@ -344,21 +358,23 @@ class _AppPageState extends ConsumerState { } Widget _buildMainContent(BuildContext context, bool expanded) { - final actions = widget.actionsBuilder?.call(context, expanded) ?? []; + final showDetailView = ref.watch(_detailViewVisibilityProvider); + final actions = + widget.actionsBuilder?.call(context, expanded && showDetailView) ?? []; final content = Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: widget.centered ? CrossAxisAlignment.center : CrossAxisAlignment.start, children: [ - widget.builder(context, expanded), + widget.builder(context, expanded && showDetailView), if (actions.isNotEmpty) Align( alignment: widget.centered ? Alignment.center : Alignment.centerLeft, child: Padding( padding: const EdgeInsets.only( - top: 16, bottom: 0, left: 16, right: 16), + top: 16, bottom: 0, left: 18, right: 18), child: Wrap( spacing: 8, runSpacing: 4, @@ -369,7 +385,7 @@ class _AppPageState extends ConsumerState { if (widget.footnote != null) Padding( padding: - const EdgeInsets.only(bottom: 16, top: 33, left: 16, right: 16), + const EdgeInsets.only(bottom: 16, top: 33, left: 18, right: 18), child: Opacity( opacity: 0.6, child: Text( @@ -399,7 +415,7 @@ class _AppPageState extends ConsumerState { alignment: Alignment.topLeft, child: Padding( padding: const EdgeInsets.only( - left: 16.0, right: 16.0, bottom: 24.0, top: 4.0), + left: 18.0, right: 18.0, bottom: 24.0, top: 4.0), child: _buildTitle(context), ), ), @@ -452,7 +468,7 @@ class _AppPageState extends ConsumerState { child: Padding( key: _sliverTitleWrapperGlobalKey, padding: const EdgeInsets.only( - left: 16.0, right: 16.0, bottom: 12.0, top: 4.0), + left: 18.0, right: 18.0, bottom: 12.0, top: 4.0), child: _buildTitle(context), ), ), @@ -499,7 +515,8 @@ class _AppPageState extends ConsumerState { BuildContext context, bool hasDrawer, bool hasRail, bool hasManage) { final l10n = AppLocalizations.of(context)!; final fullyExpanded = !hasDrawer && hasRail && hasManage; - final showNavigation = ref.watch(_navigationProvider); + final showNavigation = ref.watch(_navigationVisibilityProvider); + final showDetailView = ref.watch(_detailViewVisibilityProvider); final hasDetailsOrKeyActions = widget.detailViewBuilder != null || widget.keyActionsBuilder != null; var body = _buildMainContent(context, hasManage); @@ -518,187 +535,211 @@ class _AppPageState extends ConsumerState { ); } if (hasRail || hasManage) { - body = Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (hasRail && (!fullyExpanded || !showNavigation)) - SizedBox( - width: 72, - child: _VisibilityListener( - targetKey: _navKey, - controller: _navController, - child: SingleChildScrollView( - child: NavigationContent( - key: _navKey, - shouldPop: false, - extended: false, + body = GestureDetector( + behavior: HitTestBehavior.deferToChild, + onTap: () { + Actions.invoke(context, const EscapeIntent()); + FocusManager.instance.primaryFocus?.unfocus(); + }, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (hasRail && (!fullyExpanded || !showNavigation)) + SizedBox( + width: 72, + child: _VisibilityListener( + targetKey: _navKey, + controller: _navController, + child: SingleChildScrollView( + child: NavigationContent( + key: _navKey, + shouldPop: false, + extended: false, + ), ), ), ), - ), - if (fullyExpanded && showNavigation) - SizedBox( - width: 280, - child: _VisibilityListener( - controller: _navController, - targetKey: _navExpandedKey, - child: SingleChildScrollView( - child: Material( - type: MaterialType.transparency, - child: NavigationContent( - key: _navExpandedKey, - shouldPop: false, - extended: true, + if (fullyExpanded && showNavigation) + SizedBox( + width: 280, + child: _VisibilityListener( + controller: _navController, + targetKey: _navExpandedKey, + child: SingleChildScrollView( + child: Material( + type: MaterialType.transparency, + child: NavigationContent( + key: _navExpandedKey, + shouldPop: false, + extended: true, + ), + ), + ), + )), + const SizedBox(width: 8), + Expanded(child: body), + if (hasManage && + !hasDetailsOrKeyActions && + widget.capabilities != null && + widget.capabilities?.first != Capability.u2f) + // Add a placeholder for the Manage/Details column. Exceptions are: + // - the "Security Key" because it does not have any actions/details. + // - pages without Capabilities + const SizedBox(width: 336), // simulate column + if (hasManage && hasDetailsOrKeyActions && showDetailView) + _VisibilityListener( + controller: _detailsController, + targetKey: _detailsViewGlobalKey, + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: SizedBox( + width: 320, + child: Column( + key: _detailsViewGlobalKey, + children: [ + if (widget.detailViewBuilder != null) + widget.detailViewBuilder!(context), + if (widget.keyActionsBuilder != null) + widget.keyActionsBuilder!(context), + ], ), ), ), - )), - const SizedBox(width: 8), - Expanded( - child: GestureDetector( - behavior: HitTestBehavior.deferToChild, - onTap: () { - Actions.invoke(context, const EscapeIntent()); - }, - child: Stack(children: [ - Container( - color: Colors.transparent, - ), - body - ]), - )), - if (hasManage && - !hasDetailsOrKeyActions && - widget.capabilities != null && - widget.capabilities?.first != Capability.u2f) - // Add a placeholder for the Manage/Details column. Exceptions are: - // - the "Security Key" because it does not have any actions/details. - // - pages without Capabilities - const SizedBox(width: 336), // simulate column - if (hasManage && hasDetailsOrKeyActions) - _VisibilityListener( - controller: _detailsController, - targetKey: _detailsViewGlobalKey, - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: SizedBox( - width: 320, - child: Column( - key: _detailsViewGlobalKey, - children: [ - if (widget.detailViewBuilder != null) - widget.detailViewBuilder!(context), - if (widget.keyActionsBuilder != null) - widget.keyActionsBuilder!(context), - ], - ), - ), ), ), - ), - ], + ], + ), ); } return Scaffold( key: scaffoldGlobalKey, - appBar: AppBar( - bottom: PreferredSize( - preferredSize: const Size.fromHeight(1.0), - child: ListenableBuilder( - listenable: _scrolledUnderController, - builder: (context, child) { - final visible = _scrolledUnderController.someIsScrolledUnder; - return AnimatedOpacity( - opacity: visible ? 1 : 0, - duration: const Duration(milliseconds: 300), - child: Container( - color: Theme.of(context).colorScheme.secondaryContainer, - height: 1.0, - ), - ); - }, + appBar: _GestureDetectorAppBar( + onTap: () { + Actions.invoke(context, const EscapeIntent()); + FocusManager.instance.primaryFocus?.unfocus(); + }, + appBar: AppBar( + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1.0), + child: ListenableBuilder( + listenable: _scrolledUnderController, + builder: (context, child) { + final visible = _scrolledUnderController.someIsScrolledUnder; + return AnimatedOpacity( + opacity: visible ? 1 : 0, + duration: const Duration(milliseconds: 300), + child: Container( + color: Theme.of(context).hoverColor, + height: 1.0, + ), + ); + }, + ), ), - ), - scrolledUnderElevation: 0.0, - leadingWidth: hasRail ? 84 : null, - backgroundColor: Theme.of(context).colorScheme.surface, - title: _buildAppBarTitle( - context, - hasRail, - hasManage, - fullyExpanded, - ), - leading: hasRail - ? Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: IconButton( - icon: Icon(Symbols.menu, semanticLabel: navigationText), - tooltip: navigationText, - onPressed: fullyExpanded - ? () { - ref - .read(_navigationProvider.notifier) - .toggleExpanded(); - } - : () { - scaffoldGlobalKey.currentState?.openDrawer(); - }, - ), - )), - const SizedBox(width: 12), - ], - ) - : Builder( - builder: (context) { - // Need to wrap with builder to get Scaffold context - return IconButton( - onPressed: () => Scaffold.of(context).openDrawer(), - icon: const Icon(Symbols.menu), - ); - }, - ), - actions: [ - if (widget.actionButtonBuilder == null && - (widget.keyActionsBuilder != null && !hasManage)) - Padding( - padding: const EdgeInsets.only(left: 4), - child: IconButton( - key: actionsIconButtonKey, - onPressed: () { - showBlurDialog( - context: context, - barrierColor: Colors.transparent, - builder: (context) => FsDialog( - child: Padding( - padding: const EdgeInsets.only(top: 32), - child: widget.keyActionsBuilder!(context), + scrolledUnderElevation: 0.0, + leadingWidth: hasRail ? 84 : null, + backgroundColor: Theme.of(context).colorScheme.surface, + title: _buildAppBarTitle( + context, + hasRail, + hasManage, + fullyExpanded, + ), + centerTitle: true, + leading: hasRail + ? Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: IconButton( + icon: Icon(Symbols.menu, semanticLabel: navigationText), + tooltip: navigationText, + onPressed: fullyExpanded + ? () { + ref + .read( + _navigationVisibilityProvider.notifier) + .toggleExpanded(); + } + : () { + scaffoldGlobalKey.currentState?.openDrawer(); + }, ), - ), - ); - }, - icon: widget.keyActionsBadge - ? Badge( - child: Icon(Symbols.more_vert, - semanticLabel: l10n.s_configure_yk), - ) - : Icon(Symbols.more_vert, - semanticLabel: l10n.s_configure_yk), - iconSize: 24, - tooltip: l10n.s_configure_yk, - padding: const EdgeInsets.all(12), + )), + const SizedBox(width: 12), + ], + ) + : Builder( + builder: (context) { + // Need to wrap with builder to get Scaffold context + return IconButton( + onPressed: () => Scaffold.of(context).openDrawer(), + icon: const Icon(Symbols.menu), + ); + }, + ), + actions: [ + if (widget.actionButtonBuilder == null && + (widget.keyActionsBuilder != null && + (!hasManage || !showDetailView))) + Padding( + padding: const EdgeInsets.only(left: 4), + child: IconButton( + key: actionsIconButtonKey, + onPressed: () { + showBlurDialog( + context: context, + barrierColor: Colors.transparent, + builder: (context) => FsDialog( + child: Padding( + padding: const EdgeInsets.only(top: 32), + child: widget.keyActionsBuilder!(context), + ), + ), + ); + }, + icon: widget.keyActionsBadge + ? Badge( + child: Icon(Symbols.more_vert, + semanticLabel: l10n.s_configure_yk), + ) + : Icon(Symbols.more_vert, + semanticLabel: l10n.s_configure_yk), + iconSize: 24, + tooltip: l10n.s_configure_yk, + padding: const EdgeInsets.all(12), + ), ), - ), - if (widget.actionButtonBuilder != null) - Padding( - padding: const EdgeInsets.only(right: 12), - child: widget.actionButtonBuilder!.call(context), - ), - ], + if (hasManage && + (widget.keyActionsBuilder != null || + widget.detailViewBuilder != null)) + Padding( + padding: const EdgeInsets.only(left: 4), + child: IconButton( + key: toggleDetailViewIconButtonKey, + onPressed: () { + ref + .read(_detailViewVisibilityProvider.notifier) + .toggleExpanded(); + }, + icon: const Icon(Symbols.view_sidebar), + iconSize: 24, + tooltip: showDetailView + ? l10n.s_collapse_sidebar + : l10n.s_expand_sidebar, + padding: const EdgeInsets.all(12), + ), + ), + if (widget.actionButtonBuilder != null) + Padding( + padding: const EdgeInsets.only(right: 12), + child: widget.actionButtonBuilder!.call(context), + ), + ], + ), ), drawer: hasDrawer ? _buildDrawer(context) : null, body: body, @@ -706,6 +747,23 @@ class _AppPageState extends ConsumerState { } } +class _GestureDetectorAppBar extends StatelessWidget + implements PreferredSizeWidget { + final AppBar appBar; + final void Function() onTap; + + const _GestureDetectorAppBar({required this.appBar, required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.deferToChild, onTap: onTap, child: appBar); + } + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); +} + class CapabilityBadge extends StatelessWidget { final Capability capability; diff --git a/lib/app/views/keys.dart b/lib/app/views/keys.dart index ebce196f..33f1e73c 100644 --- a/lib/app/views/keys.dart +++ b/lib/app/views/keys.dart @@ -25,6 +25,8 @@ const _prefix = 'app.keys'; const deviceInfoListTile = Key('$_prefix.device_info_list_tile'); const noDeviceAvatar = Key('$_prefix.no_device_avatar'); const actionsIconButtonKey = Key('$_prefix.actions_icon_button'); +const toggleDetailViewIconButtonKey = + Key('$_prefix.toggle_detail_view_icon_button'); // drawer items const homeDrawer = Key('$_prefix.drawer.home'); diff --git a/lib/app/views/message_page.dart b/lib/app/views/message_page.dart index 46a21110..9f07c9fd 100755 --- a/lib/app/views/message_page.dart +++ b/lib/app/views/message_page.dart @@ -71,9 +71,9 @@ class MessagePage extends StatelessWidget { delayedContent: delayedContent, builder: (context, _) => Padding( padding: EdgeInsets.only( - left: 16.0, + left: 18.0, top: 0.0, - right: 16.0, + right: 18.0, bottom: centered && actionsBuilder == null ? 96 : 0), child: SizedBox( width: centered ? 250 : 350, diff --git a/lib/core/state.dart b/lib/core/state.dart index 537eae61..f250a852 100644 --- a/lib/core/state.dart +++ b/lib/core/state.dart @@ -21,6 +21,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../app/models.dart'; +import '../widgets/flex_box.dart'; bool get isDesktop => const [ TargetPlatform.windows, @@ -119,3 +120,20 @@ final featureProvider = Provider((ref) { return isEnabled; }); + +class LayoutNotifier extends StateNotifier { + final String _key; + final SharedPreferences _prefs; + LayoutNotifier(this._key, this._prefs) + : super(_fromName(_prefs.getString(_key))); + + void setLayout(FlexLayout layout) { + state = layout; + _prefs.setString(_key, layout.name); + } + + static FlexLayout _fromName(String? name) => FlexLayout.values.firstWhere( + (element) => element.name == name, + orElse: () => FlexLayout.list, + ); +} diff --git a/lib/fido/state.dart b/lib/fido/state.dart index 367d5cb9..ed4fd74d 100755 --- a/lib/fido/state.dart +++ b/lib/fido/state.dart @@ -18,6 +18,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../app/models.dart'; import '../core/state.dart'; +import '../widgets/flex_box.dart'; import 'models.dart'; final passkeysSearchProvider = @@ -32,6 +33,11 @@ class PasskeysSearchNotifier extends StateNotifier { } } +final passkeysLayoutProvider = + StateNotifierProvider( + (ref) => LayoutNotifier('FIDO_PASSKEYS_LAYOUT', ref.watch(prefProvider)), +); + final fidoStateProvider = AsyncNotifierProvider.autoDispose .family( () => throw UnimplementedError(), diff --git a/lib/fido/views/fingerprints_screen.dart b/lib/fido/views/fingerprints_screen.dart index a92a1341..fae73a35 100644 --- a/lib/fido/views/fingerprints_screen.dart +++ b/lib/fido/views/fingerprints_screen.dart @@ -331,15 +331,18 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> { }), } }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: fingerprints - .map((fp) => _FingerprintListItem( - fp, - expanded: expanded, - selected: fp == _selected, - )) - .toList()), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: fingerprints + .map((fp) => _FingerprintListItem( + fp, + expanded: expanded, + selected: fp == _selected, + )) + .toList()), + ), ); }, ), diff --git a/lib/fido/views/passkeys_screen.dart b/lib/fido/views/passkeys_screen.dart index 4708e2b9..226c8b41 100644 --- a/lib/fido/views/passkeys_screen.dart +++ b/lib/fido/views/passkeys_screen.dart @@ -38,6 +38,7 @@ 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/flex_box.dart'; import '../../widgets/list_title.dart'; import '../features.dart' as features; import '../models.dart'; @@ -219,6 +220,7 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> { late FocusNode searchFocus; late TextEditingController searchController; FidoCredential? _selected; + bool _canRequestFocus = true; @override void initState() { @@ -374,60 +376,103 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> { } return KeyEventResult.ignored; }, - child: Builder(builder: (context) { + child: LayoutBuilder(builder: (context, constraints) { final textTheme = Theme.of(context).textTheme; - return Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: AppTextFormField( - key: searchField, - controller: searchController, - focusNode: searchFocus, - // Use the default style, but with a smaller font size: - style: textTheme.titleMedium - ?.copyWith(fontSize: textTheme.titleSmall?.fontSize), - decoration: AppInputDecoration( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(48), - borderSide: BorderSide( - width: 0, - style: searchFocus.hasFocus - ? BorderStyle.solid - : BorderStyle.none, + final width = constraints.maxWidth; + final showLayoutOptions = width > 600; + return Consumer( + builder: (context, ref, child) { + final layout = ref.watch(passkeysLayoutProvider); + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10.0, vertical: 8.0), + child: AppTextFormField( + key: searchField, + controller: searchController, + canRequestFocus: _canRequestFocus, + 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), + ), + suffixIcons: [ + if (searchController.text.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear), + iconSize: 16, + onPressed: () { + searchController.clear(); + ref + .read(passkeysSearchProvider.notifier) + .setFilter(''); + setState(() {}); + }, + ), + if (searchController.text.isEmpty && showLayoutOptions) + ...FlexLayout.values.map( + (e) => MouseRegion( + onEnter: (event) { + if (!searchFocus.hasFocus) { + setState(() { + _canRequestFocus = false; + }); + } + }, + onExit: (event) { + setState(() { + _canRequestFocus = true; + }); + }, + child: IconButton( + tooltip: e.getDisplayName(l10n), + onPressed: () { + ref + .read(passkeysLayoutProvider.notifier) + .setLayout(e); + }, + icon: Icon( + e.icon, + color: e == layout + ? Theme.of(context).colorScheme.primary + : null, + ), + ), + ), + ), + ], ), - ), - 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); - }, - ).init(), + onChanged: (value) { + ref + .read(passkeysSearchProvider.notifier) + .setFilter(value); + setState(() {}); + }, + textInputAction: TextInputAction.next, + onFieldSubmitted: (value) { + Focus.of(context) + .focusInDirection(TraversalDirection.down); + }, + ).init(), + ); + }, ); }), ), @@ -483,21 +528,37 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> { }), } }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (filteredCredentials.isEmpty) - Center( - child: Text(l10n.s_no_passkeys), + child: Consumer( + builder: (context, ref, child) { + final layout = ref.watch(passkeysLayoutProvider); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (filteredCredentials.isEmpty) + Center( + child: Text(l10n.s_no_passkeys), + ), + FlexBox( + items: filteredCredentials, + itemBuilder: (cred) => _CredentialListItem( + cred, + expanded: expanded, + selected: _selected == cred, + tileColor: layout == FlexLayout.grid + ? Theme.of(context).hoverColor + : null, + ), + layout: layout, + cellMinWidth: 265, + spacing: layout == FlexLayout.grid ? 4.0 : 0.0, + runSpacing: layout == FlexLayout.grid ? 4.0 : 0.0, + ) + ], ), - ...filteredCredentials.map( - (cred) => _CredentialListItem( - cred, - expanded: expanded, - selected: _selected == cred, - ), - ), - ], + ); + }, ), ); }, @@ -518,9 +579,10 @@ class _CredentialListItem extends StatelessWidget { final FidoCredential credential; final bool selected; final bool expanded; + final Color? tileColor; const _CredentialListItem(this.credential, - {required this.expanded, required this.selected}); + {required this.expanded, required this.selected, this.tileColor}); @override Widget build(BuildContext context) { @@ -533,6 +595,7 @@ class _CredentialListItem extends StatelessWidget { backgroundColor: colorScheme.secondary, child: const Icon(Symbols.passkey), ), + tileColor: tileColor, title: credential.rpId, subtitle: credential.userName, trailing: expanded diff --git a/lib/home/views/home_screen.dart b/lib/home/views/home_screen.dart index 2b2416ba..7eab887a 100644 --- a/lib/home/views/home_screen.dart +++ b/lib/home/views/home_screen.dart @@ -72,7 +72,7 @@ class _HomeScreenState extends ConsumerState { homeBuildActions(context, widget.deviceData, ref), builder: (context, expanded) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), + padding: const EdgeInsets.symmetric(horizontal: 18.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 9362f20e..403e95ed 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -45,6 +45,8 @@ "s_hide_window": "Fenster verstecken", "s_expand_navigation": null, "s_collapse_navigation": null, + "s_expand_sidebar": null, + "s_collapse_sidebar": null, "q_rename_target": "{label} umbenennen?", "@q_rename_target": { "placeholders": { @@ -121,6 +123,12 @@ "s_light_mode": "Heller Modus", "s_dark_mode": "Dunkler Modus", + "@_layout": {}, + "s_list_layout": null, + "s_grid_layout": null, + "s_mixed_layout": null, + "s_select_layout": null, + "@_yubikey_selection": {}, "s_select_to_scan": "Zum Scannen auswählen", "s_hide_device": "Gerät verstecken", @@ -410,6 +418,7 @@ "s_pin_account": "Konto anpinnen", "s_unpin_account": "Konto nicht mehr anpinnen", "s_no_pinned_accounts": "Keine angepinnten Konten", + "s_pinned": null, "l_pin_account_desc": null, "s_rename_account": "Konto umbenennen", "l_rename_account_desc": null, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9ddf34ef..2f64d676 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -45,6 +45,8 @@ "s_hide_window": "Hide window", "s_expand_navigation": "Expand navigation", "s_collapse_navigation": "Collapse navigation", + "s_expand_sidebar": "Expand sidebar", + "s_collapse_sidebar": "Collapse sidebar", "q_rename_target": "Rename {label}?", "@q_rename_target": { "placeholders": { @@ -121,6 +123,12 @@ "s_light_mode": "Light mode", "s_dark_mode": "Dark mode", + "@_layout": {}, + "s_list_layout": "List layout", + "s_grid_layout": "Grid layout", + "s_mixed_layout": "Mixed layout", + "s_select_layout": "Select layout", + "@_yubikey_selection": {}, "s_select_to_scan": "Select to scan", "s_hide_device": "Hide device", @@ -410,6 +418,7 @@ "s_pin_account": "Pin account", "s_unpin_account": "Unpin account", "s_no_pinned_accounts": "No pinned accounts", + "s_pinned": "Pinned", "l_pin_account_desc": "Keep your important accounts together", "s_rename_account": "Rename account", "l_rename_account_desc": "Edit the issuer/name of the account", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 49eea1a9..1208c41a 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -45,6 +45,8 @@ "s_hide_window": "Masquer fenêtre", "s_expand_navigation": "Développer la navigation", "s_collapse_navigation": "Réduire la navigation", + "s_expand_sidebar": null, + "s_collapse_sidebar": null, "q_rename_target": "Renommer {label}\u00a0?", "@q_rename_target": { "placeholders": { @@ -121,6 +123,12 @@ "s_light_mode": "Thème clair", "s_dark_mode": "Thème sombre", + "@_layout": {}, + "s_list_layout": null, + "s_grid_layout": null, + "s_mixed_layout": null, + "s_select_layout": null, + "@_yubikey_selection": {}, "s_select_to_scan": "Sélectionner pour scanner", "s_hide_device": "Masquer appareil", @@ -410,6 +418,7 @@ "s_pin_account": "Épingler compte", "s_unpin_account": "Détacher compte", "s_no_pinned_accounts": "Aucun compte épinglé", + "s_pinned": null, "l_pin_account_desc": "Conserver vos comptes importants ensemble", "s_rename_account": "Renommer compte", "l_rename_account_desc": "Modifier émetteur/nom du compte", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 381f5c7b..6731869e 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -45,6 +45,8 @@ "s_hide_window": "ウィンドウを非表示", "s_expand_navigation": "ナビゲーションを展開", "s_collapse_navigation": "ナビゲーションを閉じる", + "s_expand_sidebar": null, + "s_collapse_sidebar": null, "q_rename_target": "{label}の名前を変更しますか?", "@q_rename_target": { "placeholders": { @@ -121,6 +123,12 @@ "s_light_mode": "ライトモード", "s_dark_mode": "ダークモード", + "@_layout": {}, + "s_list_layout": null, + "s_grid_layout": null, + "s_mixed_layout": null, + "s_select_layout": null, + "@_yubikey_selection": {}, "s_select_to_scan": "選択してスキャン", "s_hide_device": "デバイスを非表示", @@ -410,6 +418,7 @@ "s_pin_account": "アカウントをピン留めする", "s_unpin_account": "アカウントのピン留めを解除", "s_no_pinned_accounts": "ピン留めされたアカウントはありません", + "s_pinned": null, "l_pin_account_desc": "重要なアカウントをまとめて保持", "s_rename_account": "アカウント名を変更", "l_rename_account_desc": "アカウントの発行者/名前を編集", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index c7756ae3..7980886d 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -45,6 +45,8 @@ "s_hide_window": "Ukryj okno", "s_expand_navigation": null, "s_collapse_navigation": null, + "s_expand_sidebar": null, + "s_collapse_sidebar": null, "q_rename_target": "Zmienić nazwę {label}?", "@q_rename_target": { "placeholders": { @@ -121,6 +123,12 @@ "s_light_mode": "Jasny", "s_dark_mode": "Ciemny", + "@_layout": {}, + "s_list_layout": null, + "s_grid_layout": null, + "s_mixed_layout": null, + "s_select_layout": null, + "@_yubikey_selection": {}, "s_select_to_scan": "Wybierz, aby skanować", "s_hide_device": "Ukryj urządzenie", @@ -410,6 +418,7 @@ "s_pin_account": "Przypnij konto", "s_unpin_account": "Odepnij konto", "s_no_pinned_accounts": "Brak przypiętych kont", + "s_pinned": null, "l_pin_account_desc": "Przechowuj ważne konta razem", "s_rename_account": "Zmień nazwę konta", "l_rename_account_desc": "Edytuj wydawcę/nazwę konta", diff --git a/lib/oath/models.dart b/lib/oath/models.dart index ffcd085d..bc9a59a1 100755 --- a/lib/oath/models.dart +++ b/lib/oath/models.dart @@ -258,3 +258,5 @@ class CredentialData with _$CredentialData { }, ); } + +enum OathLayout { list, grid, mixed } diff --git a/lib/oath/state.dart b/lib/oath/state.dart index b77b4200..22188e1e 100755 --- a/lib/oath/state.dart +++ b/lib/oath/state.dart @@ -38,6 +38,53 @@ class AccountsSearchNotifier extends StateNotifier { } } +final oathLayoutProvider = + StateNotifierProvider.autoDispose((ref) { + final device = ref.watch(currentDeviceProvider); + List credentials = device != null + ? ref.read(filteredCredentialsProvider( + ref.read(credentialListProvider(device.path)) ?? [])) + : []; + final favorites = ref.watch(favoritesProvider); + final pinnedCreds = + credentials.where((entry) => favorites.contains(entry.credential.id)); + return OathLayoutNotfier('OATH_STATE_LAYOUT', ref.watch(prefProvider), + credentials, pinnedCreds.toList()); +}); + +class OathLayoutNotfier extends StateNotifier { + final String _key; + final SharedPreferences _prefs; + OathLayoutNotfier(this._key, this._prefs, List credentials, + List pinnedCredentials) + : super( + _fromName(_prefs.getString(_key), credentials, pinnedCredentials)); + + void setLayout(OathLayout layout) { + state = layout; + _prefs.setString(_key, layout.name); + } + + static OathLayout _fromName(String? name, List credentials, + List pinnedCredentials) { + final layout = OathLayout.values.firstWhere( + (element) => element.name == name, + orElse: () => OathLayout.list, + ); + // Default to list view if current key does not have + // pinned credentials + if (layout == OathLayout.mixed) { + if (pinnedCredentials.isEmpty) { + return OathLayout.list; + } + if (pinnedCredentials.length == credentials.length) { + return OathLayout.grid; + } + } + return layout; + } +} + final oathStateProvider = AsyncNotifierProvider.autoDispose .family( () => throw UnimplementedError(), diff --git a/lib/oath/views/account_helper.dart b/lib/oath/views/account_helper.dart index c82dfe87..1669836a 100755 --- a/lib/oath/views/account_helper.dart +++ b/lib/oath/views/account_helper.dart @@ -26,7 +26,6 @@ import '../../app/shortcuts.dart'; import '../../app/state.dart'; import '../../core/state.dart'; import '../../widgets/circle_timer.dart'; -import '../../widgets/custom_icons.dart'; import '../features.dart' as features; import '../keys.dart' as keys; import '../models.dart'; @@ -90,7 +89,7 @@ class AccountHelper { ActionItem( key: keys.togglePinAction, feature: features.accountsPin, - icon: pinned ? pushPinStrokeIcon : const Icon(Symbols.push_pin), + icon: Icon(pinned ? Symbols.keep_off : Symbols.keep), title: pinned ? l10n.s_unpin_account : l10n.s_pin_account, subtitle: l10n.l_pin_account_desc, intent: TogglePinIntent(credential), diff --git a/lib/oath/views/account_list.dart b/lib/oath/views/account_list.dart index e0c88bb2..90e79a9c 100755 --- a/lib/oath/views/account_list.dart +++ b/lib/oath/views/account_list.dart @@ -18,6 +18,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../widgets/flex_box.dart'; import '../models.dart'; import '../state.dart'; import 'account_view.dart'; @@ -32,6 +33,9 @@ class AccountList extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + final labelStyle = + theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.primary); final credentials = ref.watch(filteredCredentialsProvider(accounts)); final favorites = ref.watch(favoritesProvider); if (credentials.isEmpty) { @@ -45,32 +49,71 @@ class AccountList extends ConsumerWidget { final creds = credentials.where((entry) => !favorites.contains(entry.credential.id)); + final oathLayout = ref.watch(oathLayoutProvider); + final pinnedLayout = + (oathLayout == OathLayout.grid || oathLayout == OathLayout.mixed) + ? FlexLayout.grid + : FlexLayout.list; + final normalLayout = + oathLayout == OathLayout.grid ? FlexLayout.grid : FlexLayout.list; + return FocusTraversalGroup( policy: WidgetOrderTraversalPolicy(), - child: Column( - children: [ - ...pinnedCreds.map( - (entry) => AccountView( - entry.credential, - expanded: expanded, - selected: entry.credential == selected, - ), - ), - if (pinnedCreds.isNotEmpty && creds.isNotEmpty) + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (pinnedCreds.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.only(left: 18, bottom: 8), + child: Text(l10n.s_pinned, style: labelStyle), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0), + child: FlexBox( + items: pinnedCreds.toList(), + itemBuilder: (value) => AccountView( + value.credential, + expanded: expanded, + selected: value.credential == selected, + large: pinnedLayout == FlexLayout.grid, + ), + cellMinWidth: 250, + spacing: pinnedLayout == FlexLayout.grid ? 4.0 : 0.0, + runSpacing: pinnedLayout == FlexLayout.grid ? 4.0 : 0.0, + layout: pinnedLayout, + ), + ), + ], + if (pinnedCreds.isNotEmpty && creds.isNotEmpty) ...[ + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.only(left: 18, bottom: 8), + child: Text( + l10n.s_accounts, + style: labelStyle, + ), + ), + ], Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Divider( - color: Theme.of(context).colorScheme.secondaryContainer, + padding: const EdgeInsets.symmetric(horizontal: 10.0), + child: FlexBox( + items: creds.toList(), + itemBuilder: (value) => AccountView( + value.credential, + expanded: expanded, + selected: value.credential == selected, + large: normalLayout == FlexLayout.grid, + ), + cellMinWidth: 250, + spacing: normalLayout == FlexLayout.grid ? 4.0 : 0.0, + runSpacing: normalLayout == FlexLayout.grid ? 4.0 : 0.0, + layout: normalLayout, ), ), - ...creds.map( - (entry) => AccountView( - entry.credential, - expanded: expanded, - selected: entry.credential == selected, - ), - ), - ], + ], + ), ), ); } diff --git a/lib/oath/views/account_view.dart b/lib/oath/views/account_view.dart index 5a320656..43da516e 100755 --- a/lib/oath/views/account_view.dart +++ b/lib/oath/views/account_view.dart @@ -30,8 +30,12 @@ class AccountView extends ConsumerStatefulWidget { final OathCredential credential; final bool expanded; final bool selected; + final bool large; const AccountView(this.credential, - {super.key, required this.expanded, required this.selected}); + {super.key, + required this.expanded, + required this.selected, + this.large = false}); @override ConsumerState createState() => _AccountViewState(); @@ -116,6 +120,95 @@ class _AccountViewState extends ConsumerState { ? CopyIntent(credential) : null, buildPopupActions: (_) => helper.buildActions(), + itemBuilder: widget.large + ? (context) { + return ListTile( + mouseCursor: !(isDesktop && !widget.expanded) + ? SystemMouseCursors.click + : null, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16)), + selectedTileColor: + Theme.of(context).colorScheme.secondaryContainer, + selectedColor: + Theme.of(context).colorScheme.onSecondaryContainer, + selected: widget.selected, + tileColor: Theme.of(context).hoverColor, + contentPadding: + const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + title: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AccountIcon( + issuer: credential.issuer, + defaultWidget: circleAvatar), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + helper.title, + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface), + overflow: TextOverflow.fade, + maxLines: 1, + softWrap: false, + ), + if (subtitle != null) + Text( + subtitle, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant), + overflow: TextOverflow.fade, + maxLines: 1, + softWrap: false, + ) + ], + ), + ) + ], + ), + const SizedBox(height: 8.0), + Focus( + skipTraversal: true, + descendantsAreTraversable: false, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + helper.code != null + ? FilledButton.tonalIcon( + icon: helper.buildCodeIcon(), + label: helper.buildCodeLabel(), + style: buttonStyle, + onPressed: + Actions.handler(context, openIntent), + ) + : FilledButton.tonal( + style: buttonStyle, + onPressed: + Actions.handler(context, openIntent), + child: helper.buildCodeIcon()), + ], + ), + ), + ], + ), + ); + } + : null, ); } } diff --git a/lib/oath/views/oath_screen.dart b/lib/oath/views/oath_screen.dart index 51087cde..db22f227 100755 --- a/lib/oath/views/oath_screen.dart +++ b/lib/oath/views/oath_screen.dart @@ -53,6 +53,19 @@ import 'key_actions.dart'; import 'unlock_form.dart'; import 'utils.dart'; +extension on OathLayout { + IconData get _icon => switch (this) { + OathLayout.list => Symbols.list, + OathLayout.grid => Symbols.grid_view, + OathLayout.mixed => Symbols.vertical_split + }; + String getDisplayName(AppLocalizations l10n) => switch (this) { + OathLayout.list => l10n.s_list_layout, + OathLayout.grid => l10n.s_grid_layout, + OathLayout.mixed => l10n.s_mixed_layout + }; +} + class OathScreen extends ConsumerWidget { final DevicePath devicePath; @@ -123,6 +136,7 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> { late FocusNode searchFocus; late TextEditingController searchController; OathCredential? _selected; + bool _canRequestFocus = true; @override void initState() { @@ -376,60 +390,171 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> { } return KeyEventResult.ignored; }, - child: Builder(builder: (context) { + child: LayoutBuilder(builder: (context, constraints) { + final width = constraints.maxWidth; final textTheme = Theme.of(context).textTheme; - return Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: AppTextFormField( - key: searchField, - controller: searchController, - focusNode: searchFocus, - // Use the default style, but with a smaller font size: - style: textTheme.titleMedium - ?.copyWith(fontSize: textTheme.titleSmall?.fontSize), - decoration: AppInputDecoration( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(48), - borderSide: BorderSide( - width: 0, - style: searchFocus.hasFocus - ? BorderStyle.solid - : BorderStyle.none, + return Consumer( + builder: (context, ref, child) { + final credentials = ref.watch(filteredCredentialsProvider( + ref.watch(credentialListProvider(widget.devicePath)) ?? + [])); + final favorites = ref.watch(favoritesProvider); + final pinnedCreds = credentials + .where((entry) => favorites.contains(entry.credential.id)); + + final availableLayouts = pinnedCreds.isEmpty || + pinnedCreds.length == credentials.length + ? OathLayout.values + .where((element) => element != OathLayout.mixed) + : OathLayout.values; + final oathLayout = ref.watch(oathLayoutProvider); + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10.0, vertical: 8.0), + child: AppTextFormField( + key: searchField, + controller: searchController, + canRequestFocus: _canRequestFocus, + focusNode: searchFocus, + // Use the default style, but with a smaller font size: + style: textTheme.titleMedium + ?.copyWith(fontSize: textTheme.titleSmall?.fontSize), + decoration: AppInputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(48), + borderSide: BorderSide( + width: 0, + style: searchFocus.hasFocus + ? BorderStyle.solid + : BorderStyle.none, + ), + ), + contentPadding: const EdgeInsets.all(16), + fillColor: Theme.of(context).hoverColor, + filled: true, + hintText: l10n.s_search_accounts, + isDense: true, + prefixIcon: const Padding( + padding: EdgeInsetsDirectional.only(start: 8.0), + child: Icon(Icons.search_outlined), + ), + suffixIcons: [ + if (searchController.text.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear), + iconSize: 16, + onPressed: () { + searchController.clear(); + ref + .read(accountsSearchProvider.notifier) + .setFilter(''); + setState(() {}); + }, + ), + if (searchController.text.isEmpty) ...[ + if (width >= 450) + ...availableLayouts.map( + (e) => MouseRegion( + onEnter: (event) { + if (!searchFocus.hasFocus) { + setState(() { + _canRequestFocus = false; + }); + } + }, + onExit: (event) { + setState(() { + _canRequestFocus = true; + }); + }, + child: IconButton( + tooltip: e.getDisplayName(l10n), + onPressed: () { + ref + .read(oathLayoutProvider.notifier) + .setLayout(e); + }, + icon: Icon( + e._icon, + color: e == oathLayout + ? Theme.of(context).colorScheme.primary + : null, + ), + ), + ), + ), + if (width < 450) + MouseRegion( + onEnter: (event) { + if (!searchFocus.hasFocus) { + setState(() { + _canRequestFocus = false; + }); + } + }, + onExit: (event) { + setState(() { + _canRequestFocus = true; + }); + }, + child: PopupMenuButton( + constraints: const BoxConstraints.tightFor(), + tooltip: 'Select layout', + popUpAnimationStyle: + AnimationStyle(duration: Duration.zero), + icon: Icon( + oathLayout._icon, + color: Theme.of(context).colorScheme.primary, + ), + itemBuilder: (context) => [ + ...availableLayouts.map( + (e) => PopupMenuItem( + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Tooltip( + message: e.getDisplayName(l10n), + child: Icon( + e._icon, + color: e == oathLayout + ? Theme.of(context) + .colorScheme + .primary + : null, + ), + ), + ], + ), + onTap: () { + ref + .read(oathLayoutProvider.notifier) + .setLayout(e); + }, + ), + ) + ], + ), + ) + ] + ], ), - ), - contentPadding: const EdgeInsets.all(16), - fillColor: Theme.of(context).hoverColor, - filled: true, - hintText: l10n.s_search_accounts, - isDense: true, - prefixIcon: const Padding( - padding: EdgeInsetsDirectional.only(start: 8.0), - child: Icon(Icons.search_outlined), - ), - suffixIcon: searchController.text.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - iconSize: 16, - onPressed: () { - searchController.clear(); - ref - .read(accountsSearchProvider.notifier) - .setFilter(''); - setState(() {}); - }, - ) - : null, - ), - onChanged: (value) { - ref.read(accountsSearchProvider.notifier).setFilter(value); - setState(() {}); - }, - textInputAction: TextInputAction.next, - onFieldSubmitted: (value) { - Focus.of(context).focusInDirection(TraversalDirection.down); - }, - ).init(), + + onChanged: (value) { + ref + .read(accountsSearchProvider.notifier) + .setFilter(value); + setState(() {}); + }, + textInputAction: TextInputAction.next, + onFieldSubmitted: (value) { + Focus.of(context) + .focusInDirection(TraversalDirection.down); + }, + ).init(), + ); + }, ); }), ), diff --git a/lib/otp/views/otp_screen.dart b/lib/otp/views/otp_screen.dart index eaba53f0..e0157454 100644 --- a/lib/otp/views/otp_screen.dart +++ b/lib/otp/views/otp_screen.dart @@ -180,13 +180,17 @@ class _OtpScreenState extends ConsumerState { return null; }), }, - child: Column(children: [ - ...otpState.slots.map((e) => _SlotListItem( - e, - expanded: expanded, - selected: e == selected, - )) - ]), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 10.0), + child: Column(children: [ + ...otpState.slots.map((e) => _SlotListItem( + e, + expanded: expanded, + selected: e == selected, + )) + ]), + ), ); }, ), diff --git a/lib/piv/views/piv_screen.dart b/lib/piv/views/piv_screen.dart index 7fc25637..444b4171 100644 --- a/lib/piv/views/piv_screen.dart +++ b/lib/piv/views/piv_screen.dart @@ -200,25 +200,28 @@ class _PivScreenState extends ConsumerState { return null; }), }, - child: Column( - children: [ - ...normalSlots.map( - (e) => _CertificateListItem( - pivState, - e, - expanded: expanded, - selected: e == selected, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0), + child: Column( + children: [ + ...normalSlots.map( + (e) => _CertificateListItem( + pivState, + e, + expanded: expanded, + selected: e == selected, + ), ), - ), - ...shownRetiredSlots.map( - (e) => _CertificateListItem( - pivState, - e, - expanded: expanded, - selected: e == selected, - ), - ) - ], + ...shownRetiredSlots.map( + (e) => _CertificateListItem( + pivState, + e, + expanded: expanded, + selected: e == selected, + ), + ) + ], + ), ), ); }, diff --git a/lib/widgets/custom_icons.dart b/lib/widgets/custom_icons.dart deleted file mode 100755 index 44a053d9..00000000 --- a/lib/widgets/custom_icons.dart +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (C) 2022 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:material_symbols_icons/symbols.dart'; - -final Widget pushPinStrokeIcon = Builder(builder: (context) { - return CustomPaint( - painter: _StrikethroughPainter(IconTheme.of(context).color ?? Colors.black), - child: ClipPath( - clipper: _StrikethroughClipper(), child: const Icon(Symbols.push_pin)), - ); -}); - -class _StrikethroughClipper extends CustomClipper { - @override - Path getClip(Size size) { - Path path = Path() - ..moveTo(0, 2) - ..lineTo(0, size.height) - ..lineTo(size.width - 2, size.height) - ..lineTo(0, 2) - ..moveTo(2, 0) - ..lineTo(size.width, size.height - 2) - ..lineTo(size.width, 0) - ..lineTo(2, 0) - ..close(); - return path; - } - - @override - bool shouldReclip(covariant CustomClipper oldClipper) { - return false; - } -} - -class _StrikethroughPainter extends CustomPainter { - final Color color; - _StrikethroughPainter(this.color); - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = color - ..strokeWidth = 1.3; - - canvas.drawLine(Offset(size.width * 0.15, size.height * 0.15), - Offset(size.width * 0.8, size.height * 0.8), paint); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) { - return false; - } -} diff --git a/lib/widgets/flex_box.dart b/lib/widgets/flex_box.dart new file mode 100644 index 00000000..4b2f9ca1 --- /dev/null +++ b/lib/widgets/flex_box.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +enum FlexLayout { + list, + grid; + + IconData get icon => switch (this) { + FlexLayout.list => Symbols.list, + FlexLayout.grid => Symbols.grid_view + }; + String getDisplayName(AppLocalizations l10n) => switch (this) { + FlexLayout.list => l10n.s_list_layout, + FlexLayout.grid => l10n.s_grid_layout + }; +} + +class FlexBox extends StatelessWidget { + final List items; + final Widget Function(T value) itemBuilder; + final FlexLayout layout; + final double cellMinWidth; + final double spacing; + final double runSpacing; + const FlexBox({ + super.key, + required this.items, + required this.itemBuilder, + required this.cellMinWidth, + this.layout = FlexLayout.list, + this.spacing = 0.0, + this.runSpacing = 0.0, + }); + + int _getItemsPerRow(double width) { + // Calculate the maximum number of cells that can fit in one row + int cellsPerRow = (width / (cellMinWidth + spacing)).floor(); + + // Ensure there's at least one cell per row + if (cellsPerRow < 1) { + cellsPerRow = 1; + } + + // Calculate the total width needed for the calculated number of cells and spacing + double totalWidthNeeded = + cellsPerRow * cellMinWidth + (cellsPerRow - 1) * spacing; + + // Adjust the number of cells per row if the calculated total width exceeds the available width + if (totalWidthNeeded > width) { + cellsPerRow = cellsPerRow - 1 > 0 ? cellsPerRow - 1 : 1; + } + + return cellsPerRow; + } + + List> getChunks(int itemsPerChunk) { + List> chunks = []; + final numChunks = (items.length / itemsPerChunk).ceil(); + for (int i = 0; i < numChunks; i++) { + final index = i * itemsPerChunk; + int endIndex = index + itemsPerChunk; + + if (endIndex > items.length) { + endIndex = items.length; + } + + chunks.add(items.sublist(index, endIndex)); + } + return chunks; + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final itemsPerRow = + layout == FlexLayout.grid ? _getItemsPerRow(width) : 1; + final chunks = getChunks(itemsPerRow); + + return Column( + children: [ + for (final c in chunks) ...[ + if (chunks.indexOf(c) > 0) SizedBox(height: runSpacing), + Row( + children: [ + for (final entry in c) ...[ + Flexible( + child: itemBuilder(entry), + ), + if (itemsPerRow != 1 && c.indexOf(entry) != c.length - 1) + SizedBox(width: spacing), + ], + if (c.length < itemsPerRow) ...[ + // Prevents resizing when an item is removed + SizedBox(width: 8 * (itemsPerRow - c.length).toDouble()), + Spacer( + flex: itemsPerRow - c.length, + ) + ] + ], + ), + ] + ], + ); + }, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 576e3f86..aa71eb5b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -70,7 +70,7 @@ dependencies: io: ^1.0.4 base32: ^2.1.3 convert: ^3.1.1 - material_symbols_icons: ^4.2719.3 + material_symbols_icons: ^4.2741.0 dev_dependencies: integration_test: