Add dropdown layout selection for small screens

This commit is contained in:
Elias Bonnici 2024-07-01 16:21:21 +02:00
parent 35cc0ebb40
commit e45506427a
No known key found for this signature in database
GPG Key ID: 5EAC28EA3F980CCF
12 changed files with 268 additions and 248 deletions

View File

@ -124,18 +124,16 @@ final featureProvider = Provider<FeatureProvider>((ref) {
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));
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 initialLayout) =>
FlexLayout.values.firstWhere(
static FlexLayout _fromName(String? name) => FlexLayout.values.firstWhere(
(element) => element.name == name,
orElse: () => initialLayout,
orElse: () => FlexLayout.list,
);
}

View File

@ -376,153 +376,121 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
}
return KeyEventResult.ignored;
},
child: Builder(builder: (context) {
child: LayoutBuilder(builder: (context, constraints) {
final textTheme = Theme.of(context).textTheme;
final width = constraints.maxWidth;
final showLayoutOptions = width > 600;
return Consumer(
builder: (context, ref, child) {
final layout = ref.watch(passkeysLayoutProvider);
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final showLayoutOptions = width > 600;
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.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,
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.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 &&
!searchFocus.hasFocus &&
showLayoutOptions) ...[
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,
),
),
],
),
...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),
),
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 &&
showLayoutOptions) ...[
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) {
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(),
);
},
);

View File

@ -127,6 +127,7 @@
"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",

View File

@ -127,6 +127,7 @@
"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",

View File

@ -127,6 +127,7 @@
"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",

View File

@ -127,6 +127,7 @@
"s_list_layout": null,
"s_grid_layout": null,
"s_mixed_layout": null,
"s_select_layout": null,
"@_yubikey_selection": {},
"s_select_to_scan": "選択してスキャン",

View File

@ -127,6 +127,7 @@
"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ć",

View File

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

View File

@ -24,7 +24,6 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../app/models.dart';
import '../app/state.dart';
import '../core/state.dart';
import '../widgets/flex_box.dart';
import 'models.dart';
final accountsSearchProvider =
@ -39,15 +38,44 @@ class AccountsSearchNotifier extends StateNotifier<String> {
}
}
final oathPinnedLayoutProvider =
StateNotifierProvider<LayoutNotifier, FlexLayout>(
(ref) => LayoutNotifier(
'OATH_STATE_LAYOUT_PINNED', ref.watch(prefProvider), FlexLayout.grid),
);
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), pinnedCreds.isNotEmpty);
});
final oathLayoutProvider = StateNotifierProvider<LayoutNotifier, FlexLayout>(
(ref) => LayoutNotifier('OATH_STATE_LAYOUT', ref.watch(prefProvider)),
);
class OathLayoutNotfier extends StateNotifier<OathLayout> {
final String _key;
final SharedPreferences _prefs;
OathLayoutNotfier(this._key, this._prefs, bool _hasPinned)
: super(_fromName(_prefs.getString(_key), _hasPinned));
void setLayout(OathLayout layout) {
state = layout;
_prefs.setString(_key, layout.name);
}
static OathLayout _fromName(String? name, bool hasPinned) {
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 && !hasPinned) {
return OathLayout.list;
}
return layout;
}
}
final oathStateProvider = AsyncNotifierProvider.autoDispose
.family<OathStateNotifier, OathState, DevicePath>(

View File

@ -46,8 +46,13 @@ class AccountList extends ConsumerWidget {
final creds =
credentials.where((entry) => !favorites.contains(entry.credential.id));
final pinnedLayout = ref.watch(oathPinnedLayoutProvider);
final layout = ref.watch(oathLayoutProvider);
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(),
@ -81,7 +86,7 @@ class AccountList extends ConsumerWidget {
// ),
// ),
Padding(
padding: layout == FlexLayout.grid
padding: normalLayout == FlexLayout.grid
? const EdgeInsets.only(left: 16.0, right: 16)
: const EdgeInsets.all(0),
child: FlexBox<OathPair>(
@ -90,9 +95,9 @@ class AccountList extends ConsumerWidget {
value.credential,
expanded: expanded,
selected: value.credential == selected,
large: layout == FlexLayout.grid,
large: normalLayout == FlexLayout.grid,
),
layout: layout,
layout: normalLayout,
runSpacing: 8.0,
),
),

View File

@ -39,7 +39,6 @@ import '../../management/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_form_field.dart';
import '../../widgets/file_drop_overlay.dart';
import '../../widgets/flex_box.dart';
import '../../widgets/list_title.dart';
import '../../widgets/tooltip_if_truncated.dart';
import '../features.dart' as features;
@ -54,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;
@ -378,13 +390,11 @@ 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 Consumer(
builder: (context, ref, child) {
final pinnedLayout = ref.watch(oathPinnedLayoutProvider);
final layout = ref.watch(oathLayoutProvider);
final credentials = ref.watch(filteredCredentialsProvider(
ref.watch(credentialListProvider(widget.devicePath)) ??
[]));
@ -392,13 +402,11 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
final pinnedCreds = credentials
.where((entry) => favorites.contains(entry.credential.id));
final mixedView = pinnedLayout == FlexLayout.grid &&
layout == FlexLayout.list;
final listView =
(pinnedLayout == FlexLayout.list || pinnedCreds.isEmpty) &&
layout == FlexLayout.list;
final gridView = pinnedLayout == FlexLayout.grid &&
layout == FlexLayout.grid;
final availableLayouts = pinnedCreds.isNotEmpty
? OathLayout.values
: OathLayout.values
.where((element) => element != OathLayout.mixed);
final oathLayout = ref.watch(oathLayoutProvider);
return Padding(
padding: const EdgeInsets.symmetric(
@ -462,67 +470,38 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
),
],
),
MouseRegion(
onEnter: (event) {
if (!searchFocus.hasFocus) {
setState(() {
_canRequestFocus = false;
});
}
},
onExit: (event) {
setState(() {
_canRequestFocus = true;
});
},
child: IconButton(
tooltip: l10n.s_list_layout,
onPressed: () {
ref
.read(oathPinnedLayoutProvider.notifier)
.setLayout(FlexLayout.list);
ref
.read(oathLayoutProvider.notifier)
.setLayout(FlexLayout.list);
},
icon: Icon(
Symbols.list,
color: listView
? Theme.of(context).colorScheme.primary
: null,
if (width >= 380)
...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,
),
),
),
),
),
MouseRegion(
onEnter: (event) {
if (!searchFocus.hasFocus) {
setState(() {
_canRequestFocus = false;
});
}
},
onExit: (event) {
setState(() {
_canRequestFocus = true;
});
},
child: IconButton(
tooltip: l10n.s_grid_layout,
onPressed: () {
ref
.read(oathPinnedLayoutProvider.notifier)
.setLayout(FlexLayout.grid);
ref
.read(oathLayoutProvider.notifier)
.setLayout(FlexLayout.grid);
},
icon: Icon(Symbols.grid_view,
color: gridView
? Theme.of(context).colorScheme.primary
: null),
),
),
if (pinnedCreds.isNotEmpty)
if (width < 380)
MouseRegion(
onEnter: (event) {
if (!searchFocus.hasFocus) {
@ -536,24 +515,45 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
_canRequestFocus = true;
});
},
child: IconButton(
tooltip: l10n.s_mixed_layout,
onPressed: () {
ref
.read(oathPinnedLayoutProvider.notifier)
.setLayout(FlexLayout.grid);
ref
.read(oathLayoutProvider.notifier)
.setLayout(FlexLayout.list);
},
child: PopupMenuButton(
constraints: const BoxConstraints.tightFor(),
tooltip: 'Select layout',
popUpAnimationStyle:
AnimationStyle(duration: Duration.zero),
icon: Icon(
Symbols.vertical_split,
color: mixedView
? Theme.of(context).colorScheme.primary
: null,
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);
},
),
)
],
),
),
)
]
],
),

View File

@ -1,6 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:material_symbols_icons/symbols.dart';
enum FlexLayout { grid, list }
enum FlexLayout {
grid,
list;
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;