This commit is contained in:
Elias Bonnici 2024-08-13 16:38:32 +02:00
commit cfc5eb9f06
No known key found for this signature in database
GPG Key ID: 5EAC28EA3F980CCF
25 changed files with 1045 additions and 482 deletions

View File

@ -30,8 +30,10 @@ class AppListItem<T> extends ConsumerStatefulWidget {
final String? semanticTitle;
final Widget? trailing;
final List<ActionItem> 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<T> 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<T> extends ConsumerState<AppListItem> {
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<T> extends ConsumerState<AppListItem> {
: () {
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,
),
),
],
),
),
),
);

View File

@ -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<bool> {
_NavigationProvider() : super(true);
final _detailViewVisibilityProvider =
StateNotifierProvider<_VisibilityNotifier, bool>((ref) =>
_VisibilityNotifier('DETAIL_VIEW_VISIBILITY', ref.watch(prefProvider)));
class _VisibilityNotifier extends StateNotifier<bool> {
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<AppPage> {
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<AppPage> {
}
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<AppPage> {
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<AppPage> {
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<AppPage> {
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<AppPage> {
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<AppPage> {
);
}
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<AppPage> {
}
}
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;

View File

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

View File

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

View File

@ -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<FeatureProvider>((ref) {
return isEnabled;
});
class LayoutNotifier extends StateNotifier<FlexLayout> {
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,
);
}

View File

@ -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<String> {
}
}
final passkeysLayoutProvider =
StateNotifierProvider<LayoutNotifier, FlexLayout>(
(ref) => LayoutNotifier('FIDO_PASSKEYS_LAYOUT', ref.watch(prefProvider)),
);
final fidoStateProvider = AsyncNotifierProvider.autoDispose
.family<FidoStateNotifier, FidoState, DevicePath>(
() => throw UnimplementedError(),

View File

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

View File

@ -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<FidoCredential>(
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

View File

@ -72,7 +72,7 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
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: [

View File

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

View File

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

View File

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

View File

@ -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": "アカウントの発行者/名前を編集",

View File

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

View File

@ -258,3 +258,5 @@ class CredentialData with _$CredentialData {
},
);
}
enum OathLayout { list, grid, mixed }

View File

@ -38,6 +38,53 @@ class AccountsSearchNotifier extends StateNotifier<String> {
}
}
final oathLayoutProvider =
StateNotifierProvider.autoDispose<OathLayoutNotfier, OathLayout>((ref) {
final device = ref.watch(currentDeviceProvider);
List<OathPair> 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<OathLayout> {
final String _key;
final SharedPreferences _prefs;
OathLayoutNotfier(this._key, this._prefs, List<OathPair> credentials,
List<OathPair> 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<OathPair> credentials,
List<OathPair> 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<OathStateNotifier, OathState, DevicePath>(
() => throw UnimplementedError(),

View File

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

View File

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

View File

@ -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<ConsumerStatefulWidget> createState() => _AccountViewState();
@ -116,6 +120,95 @@ class _AccountViewState extends ConsumerState<AccountView> {
? CopyIntent<OathCredential>(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,
);
}
}

View File

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

View File

@ -180,13 +180,17 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
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,
))
]),
),
);
},
),

View File

@ -200,25 +200,28 @@ class _PivScreenState extends ConsumerState<PivScreen> {
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,
),
)
],
),
),
);
},

View File

@ -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<Path> {
@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<Path> 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;
}
}

111
lib/widgets/flex_box.dart Normal file
View File

@ -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<T> extends StatelessWidget {
final List<T> 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<List<T>> getChunks(int itemsPerChunk) {
List<List<T>> 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,
)
]
],
),
]
],
);
},
);
}
}

View File

@ -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: