mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-11-25 23:14:18 +03:00
Add grid/list view to passkeys
This commit is contained in:
parent
6b773d446f
commit
0b8bcf8c67
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -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(),
|
||||||
|
@ -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,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -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(),
|
||||||
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -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(
|
||||||
|
@ -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) ...[
|
||||||
|
Loading…
Reference in New Issue
Block a user