Add grid/list view to passkeys

This commit is contained in:
Elias Bonnici 2024-06-14 13:04:55 +02:00
parent 6b773d446f
commit 0b8bcf8c67
No known key found for this signature in database
GPG Key ID: 5EAC28EA3F980CCF
7 changed files with 208 additions and 99 deletions

View File

@ -21,6 +21,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../app/models.dart'; import '../app/models.dart';
import '../widgets/flex_box.dart';
bool get isDesktop => const [ bool get isDesktop => const [
TargetPlatform.windows, TargetPlatform.windows,
@ -119,3 +120,22 @@ final featureProvider = Provider<FeatureProvider>((ref) {
return isEnabled; return isEnabled;
}); });
class LayoutNotifier extends StateNotifier<FlexLayout> {
final String _key;
final SharedPreferences _prefs;
final FlexLayout initialLayout;
LayoutNotifier(this._key, this._prefs, [this.initialLayout = FlexLayout.list])
: super(_fromName(_prefs.getString(_key), initialLayout));
void setLayout(FlexLayout layout) {
state = layout;
_prefs.setString(_key, layout.name);
}
static FlexLayout _fromName(String? name, FlexLayout initialLayout) =>
FlexLayout.values.firstWhere(
(element) => element.name == name,
orElse: () => initialLayout,
);
}

View File

@ -18,6 +18,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/models.dart'; import '../app/models.dart';
import '../core/state.dart'; import '../core/state.dart';
import '../widgets/flex_box.dart';
import 'models.dart'; import 'models.dart';
final passkeysSearchProvider = 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 final fidoStateProvider = AsyncNotifierProvider.autoDispose
.family<FidoStateNotifier, FidoState, DevicePath>( .family<FidoStateNotifier, FidoState, DevicePath>(
() => throw UnimplementedError(), () => throw UnimplementedError(),

View File

@ -38,6 +38,7 @@ import '../../exception/no_data_exception.dart';
import '../../management/models.dart'; import '../../management/models.dart';
import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_form_field.dart'; import '../../widgets/app_text_form_field.dart';
import '../../widgets/flex_box.dart';
import '../../widgets/list_title.dart'; import '../../widgets/list_title.dart';
import '../features.dart' as features; import '../features.dart' as features;
import '../models.dart'; import '../models.dart';
@ -219,6 +220,7 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
late FocusNode searchFocus; late FocusNode searchFocus;
late TextEditingController searchController; late TextEditingController searchController;
FidoCredential? _selected; FidoCredential? _selected;
bool _canRequestFocus = true;
@override @override
void initState() { void initState() {
@ -376,58 +378,144 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
}, },
child: Builder(builder: (context) { child: Builder(builder: (context) {
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
return Padding(
padding: return Consumer(
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), builder: (context, ref, child) {
child: AppTextFormField( final layout = ref.watch(passkeysLayoutProvider);
key: searchField,
controller: searchController, return Padding(
focusNode: searchFocus, padding: const EdgeInsets.symmetric(
// Use the default style, but with a smaller font size: horizontal: 16.0, vertical: 8.0),
style: textTheme.titleMedium child: AppTextFormField(
?.copyWith(fontSize: textTheme.titleSmall?.fontSize), key: searchField,
decoration: AppInputDecoration( controller: searchController,
border: OutlineInputBorder( canRequestFocus: _canRequestFocus,
borderRadius: BorderRadius.circular(48), focusNode: searchFocus,
borderSide: BorderSide( // Use the default style, but with a smaller font size:
width: 0, style: textTheme.titleMedium
style: searchFocus.hasFocus ?.copyWith(fontSize: textTheme.titleSmall?.fontSize),
? BorderStyle.solid decoration: AppInputDecoration(
: BorderStyle.none, 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 &&
!searchFocus.hasFocus) ...[
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
// need this to maintain consistent distance
// between icons
padding: const EdgeInsets.only(left: 17.0),
child: Container(
color:
Theme.of(context).colorScheme.background,
width: 1,
height: 45,
),
),
],
),
MouseRegion(
onEnter: (event) {
if (!searchFocus.hasFocus) {
setState(() {
_canRequestFocus = false;
});
}
},
onExit: (event) {
setState(() {
_canRequestFocus = true;
});
},
child: IconButton(
tooltip: l10n.s_list_layout,
onPressed: () {
ref
.read(passkeysLayoutProvider.notifier)
.setLayout(FlexLayout.list);
},
icon: Icon(
Symbols.list,
color: layout == FlexLayout.list
? Theme.of(context).colorScheme.primary
: null,
),
),
),
MouseRegion(
onEnter: (event) {
if (!searchFocus.hasFocus) {
setState(() {
_canRequestFocus = false;
});
}
},
onExit: (event) {
setState(() {
_canRequestFocus = true;
});
},
child: IconButton(
tooltip: l10n.s_grid_layout,
onPressed: () {
ref
.read(passkeysLayoutProvider.notifier)
.setLayout(FlexLayout.grid);
},
icon: Icon(Symbols.grid_view,
color: layout == FlexLayout.grid
? Theme.of(context).colorScheme.primary
: null),
),
),
]
],
), ),
), onChanged: (value) {
contentPadding: const EdgeInsets.all(16), ref
fillColor: Theme.of(context).hoverColor, .read(passkeysSearchProvider.notifier)
filled: true, .setFilter(value);
hintText: l10n.s_search_passkeys, setState(() {});
isDense: true, },
prefixIcon: const Padding( textInputAction: TextInputAction.next,
padding: EdgeInsetsDirectional.only(start: 8.0), onFieldSubmitted: (value) {
child: Icon(Icons.search_outlined), Focus.of(context)
), .focusInDirection(TraversalDirection.down);
suffixIcon: searchController.text.isNotEmpty },
? IconButton( ).init(),
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(),
); );
}), }),
), ),
@ -483,21 +571,28 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
}), }),
} }
}, },
child: Column( child: Consumer(
crossAxisAlignment: CrossAxisAlignment.start, builder: (context, ref, child) {
children: [ final layout = ref.watch(passkeysLayoutProvider);
if (filteredCredentials.isEmpty) return Column(
Center( crossAxisAlignment: CrossAxisAlignment.start,
child: Text(l10n.s_no_passkeys), children: [
), if (filteredCredentials.isEmpty)
...filteredCredentials.map( Center(
(cred) => _CredentialListItem( child: Text(l10n.s_no_passkeys),
cred, ),
expanded: expanded, FlexBox<FidoCredential>(
selected: _selected == cred, items: filteredCredentials,
), itemBuilder: (cred) => _CredentialListItem(
), cred,
], expanded: expanded,
selected: _selected == cred,
),
layout: layout,
)
],
);
},
), ),
); );
}, },

View File

@ -39,34 +39,16 @@ class AccountsSearchNotifier extends StateNotifier<String> {
} }
} }
final pinnedLayoutProvider = StateNotifierProvider<LayoutNotifier, FlexLayout>( final oathPinnedLayoutProvider =
StateNotifierProvider<LayoutNotifier, FlexLayout>(
(ref) => LayoutNotifier( (ref) => LayoutNotifier(
'OATH_STATE_LAYOUT_PINNED', ref.watch(prefProvider), FlexLayout.grid), 'OATH_STATE_LAYOUT_PINNED', ref.watch(prefProvider), FlexLayout.grid),
); );
final layoutProvider = StateNotifierProvider<LayoutNotifier, FlexLayout>( final oathLayoutProvider = StateNotifierProvider<LayoutNotifier, FlexLayout>(
(ref) => LayoutNotifier('OATH_STATE_LAYOUT', ref.watch(prefProvider)), (ref) => LayoutNotifier('OATH_STATE_LAYOUT', ref.watch(prefProvider)),
); );
class LayoutNotifier extends StateNotifier<FlexLayout> {
final String _key;
final SharedPreferences _prefs;
final FlexLayout initialLayout;
LayoutNotifier(this._key, this._prefs, [this.initialLayout = FlexLayout.list])
: super(_fromName(_prefs.getString(_key), initialLayout));
void setLayout(FlexLayout layout) {
state = layout;
_prefs.setString(_key, layout.name);
}
static FlexLayout _fromName(String? name, FlexLayout initialLayout) =>
FlexLayout.values.firstWhere(
(element) => element.name == name,
orElse: () => initialLayout,
);
}
final oathStateProvider = AsyncNotifierProvider.autoDispose final oathStateProvider = AsyncNotifierProvider.autoDispose
.family<OathStateNotifier, OathState, DevicePath>( .family<OathStateNotifier, OathState, DevicePath>(
() => throw UnimplementedError(), () => throw UnimplementedError(),

View File

@ -46,8 +46,8 @@ class AccountList extends ConsumerWidget {
final creds = final creds =
credentials.where((entry) => !favorites.contains(entry.credential.id)); credentials.where((entry) => !favorites.contains(entry.credential.id));
final pinnedLayout = ref.watch(pinnedLayoutProvider); final pinnedLayout = ref.watch(oathPinnedLayoutProvider);
final layout = ref.watch(layoutProvider); final layout = ref.watch(oathLayoutProvider);
return FocusTraversalGroup( return FocusTraversalGroup(
policy: WidgetOrderTraversalPolicy(), policy: WidgetOrderTraversalPolicy(),
@ -69,6 +69,7 @@ class AccountList extends ConsumerWidget {
large: pinnedLayout == FlexLayout.grid, large: pinnedLayout == FlexLayout.grid,
), ),
layout: pinnedLayout, layout: pinnedLayout,
runSpacing: 8.0,
), ),
), ),
if (pinnedCreds.isNotEmpty && creds.isNotEmpty) if (pinnedCreds.isNotEmpty && creds.isNotEmpty)
@ -92,6 +93,7 @@ class AccountList extends ConsumerWidget {
large: layout == FlexLayout.grid, large: layout == FlexLayout.grid,
), ),
layout: layout, layout: layout,
runSpacing: 8.0,
), ),
), ),
], ],

View File

@ -382,8 +382,8 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
return Consumer( return Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {
final pinnedLayout = ref.watch(pinnedLayoutProvider); final pinnedLayout = ref.watch(oathPinnedLayoutProvider);
final layout = ref.watch(layoutProvider); final layout = ref.watch(oathLayoutProvider);
final credentials = ref.watch(filteredCredentialsProvider( final credentials = ref.watch(filteredCredentialsProvider(
ref.watch(credentialListProvider(widget.devicePath)) ?? ref.watch(credentialListProvider(widget.devicePath)) ??
@ -479,10 +479,10 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
tooltip: l10n.s_list_layout, tooltip: l10n.s_list_layout,
onPressed: () { onPressed: () {
ref ref
.read(pinnedLayoutProvider.notifier) .read(oathPinnedLayoutProvider.notifier)
.setLayout(FlexLayout.list); .setLayout(FlexLayout.list);
ref ref
.read(layoutProvider.notifier) .read(oathLayoutProvider.notifier)
.setLayout(FlexLayout.list); .setLayout(FlexLayout.list);
}, },
icon: Icon( icon: Icon(
@ -510,10 +510,10 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
tooltip: l10n.s_grid_layout, tooltip: l10n.s_grid_layout,
onPressed: () { onPressed: () {
ref ref
.read(pinnedLayoutProvider.notifier) .read(oathPinnedLayoutProvider.notifier)
.setLayout(FlexLayout.grid); .setLayout(FlexLayout.grid);
ref ref
.read(layoutProvider.notifier) .read(oathLayoutProvider.notifier)
.setLayout(FlexLayout.grid); .setLayout(FlexLayout.grid);
}, },
icon: Icon(Symbols.grid_view, icon: Icon(Symbols.grid_view,
@ -540,10 +540,10 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
tooltip: l10n.s_mixed_layout, tooltip: l10n.s_mixed_layout,
onPressed: () { onPressed: () {
ref ref
.read(pinnedLayoutProvider.notifier) .read(oathPinnedLayoutProvider.notifier)
.setLayout(FlexLayout.grid); .setLayout(FlexLayout.grid);
ref ref
.read(layoutProvider.notifier) .read(oathLayoutProvider.notifier)
.setLayout(FlexLayout.list); .setLayout(FlexLayout.list);
}, },
icon: Icon( icon: Icon(

View File

@ -6,11 +6,13 @@ class FlexBox<T> extends StatelessWidget {
final List<T> items; final List<T> items;
final Widget Function(T value) itemBuilder; final Widget Function(T value) itemBuilder;
final FlexLayout layout; final FlexLayout layout;
final double? runSpacing;
const FlexBox({ const FlexBox({
super.key, super.key,
required this.items, required this.items,
required this.itemBuilder, required this.itemBuilder,
this.layout = FlexLayout.list, this.layout = FlexLayout.list,
this.runSpacing,
}); });
int getItemsPerRow(double width) { int getItemsPerRow(double width) {
@ -69,8 +71,10 @@ class FlexBox<T> extends StatelessWidget {
return Column( return Column(
children: [ children: [
for (final c in chunks) ...[ for (final c in chunks) ...[
if (chunks.indexOf(c) > 0 && layout == FlexLayout.grid) if (chunks.indexOf(c) > 0 &&
const SizedBox(height: 8.0), layout == FlexLayout.grid &&
runSpacing != null)
SizedBox(height: runSpacing),
Row( Row(
children: [ children: [
for (final entry in c) ...[ for (final entry in c) ...[