Add different views for OATH

This commit is contained in:
Elias Bonnici 2024-06-13 09:29:30 +02:00
parent 3ada959563
commit 909c3d00bb
No known key found for this signature in database
GPG Key ID: 5EAC28EA3F980CCF
5 changed files with 287 additions and 104 deletions

View File

@ -24,6 +24,7 @@ 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 =
@ -38,6 +39,23 @@ class AccountsSearchNotifier extends StateNotifier<String> {
}
}
final pinnedLayoutProvider = StateNotifierProvider<LayoutNotifier, FlexLayout>(
(ref) => LayoutNotifier(initialLayout: FlexLayout.grid),
);
final layoutProvider = StateNotifierProvider<LayoutNotifier, FlexLayout>(
(ref) => LayoutNotifier(),
);
class LayoutNotifier extends StateNotifier<FlexLayout> {
final FlexLayout initialLayout;
LayoutNotifier({this.initialLayout = FlexLayout.list}) : super(initialLayout);
void setLayout(FlexLayout layout) {
state = layout;
}
}
final oathStateProvider = AsyncNotifierProvider.autoDispose
.family<OathStateNotifier, OathState, DevicePath>(
() => throw UnimplementedError(),

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';
@ -29,77 +30,6 @@ class AccountList extends ConsumerWidget {
const AccountList(this.accounts,
{super.key, required this.expanded, this.selected});
Widget _buildPinnedAccountList(List<OathPair> pinnedCreds) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
int itemsPerRow = 1;
if (width <= 420) {
// single column
itemsPerRow = 1;
} else if (width <= 620) {
// 2 column
itemsPerRow = 2;
} else {
// 3 column
itemsPerRow = 3;
}
List<List<OathPair>> chunks = [];
final numChunks = (pinnedCreds.length / itemsPerRow).ceil();
for (int i = 0; i < numChunks; i++) {
final index = i * itemsPerRow;
int endIndex = index + itemsPerRow;
if (endIndex > pinnedCreds.length) {
endIndex = pinnedCreds.length;
}
chunks.add(pinnedCreds.sublist(index, endIndex));
}
return Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0),
child: Column(
children: [
...chunks.map(
(c) => Row(
children: [
for (final entry in c) ...[
Flexible(
child: AccountView(
entry.credential,
expanded: expanded,
selected: entry.credential == selected,
pinned: true,
),
),
if (itemsPerRow != 1 && c.indexOf(entry) != c.length - 1)
const SizedBox(width: 8),
],
if (c.length < itemsPerRow) ...[
// Prevents resizing when an account is unpinned
SizedBox(width: 8 * (itemsPerRow - c.length).toDouble()),
Spacer(
flex: itemsPerRow - c.length,
)
]
],
),
)
]
.map(
(e) => Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: e,
),
)
.toList(),
),
);
},
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
@ -116,6 +46,9 @@ class AccountList extends ConsumerWidget {
final creds =
credentials.where((entry) => !favorites.contains(entry.credential.id));
final pinnedLayout = ref.watch(pinnedLayoutProvider);
final layout = ref.watch(layoutProvider);
return FocusTraversalGroup(
policy: WidgetOrderTraversalPolicy(),
child: Padding(
@ -123,12 +56,42 @@ class AccountList extends ConsumerWidget {
child: Column(
children: [
if (pinnedCreds.isNotEmpty)
_buildPinnedAccountList(pinnedCreds.toList()),
...creds.map(
(entry) => AccountView(
entry.credential,
expanded: expanded,
selected: entry.credential == selected,
Padding(
padding: pinnedLayout == FlexLayout.grid
? const EdgeInsets.only(left: 16.0, right: 16)
: const EdgeInsets.all(0),
child: FlexBox<OathPair>(
items: pinnedCreds.toList(),
itemBuilder: (value) => AccountView(
value.credential,
expanded: expanded,
selected: value.credential == selected,
large: pinnedLayout == FlexLayout.grid,
),
layout: pinnedLayout,
),
),
if (pinnedCreds.isNotEmpty && creds.isNotEmpty)
const SizedBox(height: 32),
// Padding(
// padding: const EdgeInsets.symmetric(horizontal: 16.0),
// child: Divider(
// color: Theme.of(context).colorScheme.secondaryContainer,
// ),
// ),
Padding(
padding: layout == FlexLayout.grid
? const EdgeInsets.only(left: 16.0, right: 16)
: const EdgeInsets.all(0),
child: FlexBox<OathPair>(
items: creds.toList(),
itemBuilder: (value) => AccountView(
value.credential,
expanded: expanded,
selected: value.credential == selected,
large: layout == FlexLayout.grid,
),
layout: layout,
),
),
],

View File

@ -30,12 +30,12 @@ class AccountView extends ConsumerStatefulWidget {
final OathCredential credential;
final bool expanded;
final bool selected;
final bool pinned;
final bool large;
const AccountView(this.credential,
{super.key,
required this.expanded,
required this.selected,
this.pinned = false});
this.large = false});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _AccountViewState();
@ -120,8 +120,8 @@ class _AccountViewState extends ConsumerState<AccountView> {
? CopyIntent<OathCredential>(credential)
: null,
buildPopupActions: (_) => helper.buildActions(),
borderRadius: widget.pinned ? BorderRadius.circular(16) : null,
itemBuilder: widget.pinned
borderRadius: widget.large ? BorderRadius.circular(16) : null,
itemBuilder: widget.large
? (context) {
return ListTile(
mouseCursor: !(isDesktop && !widget.expanded)
@ -184,19 +184,6 @@ class _AccountViewState extends ConsumerState<AccountView> {
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// IconButton(
// onPressed: Actions.handler(
// context, TogglePinIntent(credential)),
// icon: Icon(
// Symbols.keep_off,
// color: Theme.of(context)
// .colorScheme
// .onSecondaryContainer
// .withOpacity(0.4),
// ),
// tooltip:
// AppLocalizations.of(context)!.s_unpin_account,
// ),
helper.code != null
? FilledButton.tonalIcon(
icon: helper.buildCodeIcon(),

View File

@ -39,6 +39,7 @@ 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;
@ -123,6 +124,7 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
late FocusNode searchFocus;
late TextEditingController searchController;
OathCredential? _selected;
bool _canRequestFocus = true;
@override
void initState() {
@ -215,6 +217,16 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
);
}
final pinnedLayout = ref.watch(pinnedLayoutProvider);
final layout = ref.watch(layoutProvider);
final mixedView =
pinnedLayout == FlexLayout.grid && layout == FlexLayout.list;
final listView =
pinnedLayout == FlexLayout.list && layout == FlexLayout.list;
final gridView =
pinnedLayout == FlexLayout.grid && layout == FlexLayout.grid;
return OathActions(
devicePath: widget.devicePath,
actions: (context) => {
@ -384,6 +396,7 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
child: AppTextFormField(
key: searchField,
controller: searchController,
canRequestFocus: _canRequestFocus,
focusNode: searchFocus,
// Use the default style, but with a smaller font size:
style: textTheme.titleMedium
@ -407,20 +420,128 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
padding: EdgeInsetsDirectional.only(start: 8.0),
child: Icon(Icons.search_outlined),
),
suffixIcon: searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
iconSize: 16,
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) ...[
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: 40,
),
),
],
),
MouseRegion(
onEnter: (event) {
if (!searchFocus.hasFocus) {
setState(() {
_canRequestFocus = false;
});
}
},
onExit: (event) {
setState(() {
_canRequestFocus = true;
});
},
child: IconButton(
onPressed: () {
searchController.clear();
ref
.read(accountsSearchProvider.notifier)
.setFilter('');
setState(() {});
.read(pinnedLayoutProvider.notifier)
.setLayout(FlexLayout.list);
ref
.read(layoutProvider.notifier)
.setLayout(FlexLayout.list);
},
)
: null,
icon: Icon(
Symbols.list,
color: listView
? Theme.of(context).colorScheme.primary
: null,
),
),
),
MouseRegion(
onEnter: (event) {
if (!searchFocus.hasFocus) {
setState(() {
_canRequestFocus = false;
});
}
},
onExit: (event) {
setState(() {
_canRequestFocus = true;
});
},
child: IconButton(
onPressed: () {
ref
.read(pinnedLayoutProvider.notifier)
.setLayout(FlexLayout.grid);
ref
.read(layoutProvider.notifier)
.setLayout(FlexLayout.list);
},
icon: Icon(
Symbols.vertical_split,
color: mixedView
? Theme.of(context).colorScheme.primary
: null,
),
),
),
MouseRegion(
onEnter: (event) {
if (!searchFocus.hasFocus) {
setState(() {
_canRequestFocus = false;
});
}
},
onExit: (event) {
setState(() {
_canRequestFocus = true;
});
},
child: IconButton(
onPressed: () {
ref
.read(pinnedLayoutProvider.notifier)
.setLayout(FlexLayout.grid);
ref
.read(layoutProvider.notifier)
.setLayout(FlexLayout.grid);
},
icon: Icon(Symbols.grid_view,
color: gridView
? Theme.of(context).colorScheme.primary
: null),
),
)
]
],
),
onChanged: (value) {
ref.read(accountsSearchProvider.notifier).setFilter(value);
setState(() {});

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

@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
enum FlexLayout { grid, list }
class FlexBox<T> extends StatelessWidget {
final List<T> items;
final Widget Function(T value) itemBuilder;
final FlexLayout layout;
const FlexBox({
super.key,
required this.items,
required this.itemBuilder,
this.layout = FlexLayout.list,
});
int getItemsPerRow(double width) {
int itemsPerRow = 1;
if (layout == FlexLayout.grid) {
if (width <= 420) {
// single column
itemsPerRow = 1;
} else if (width <= 620) {
// 2 column
itemsPerRow = 2;
} else if (width < 860) {
// 3 column
itemsPerRow = 3;
} else if (width < 1200) {
// 4 column
itemsPerRow = 4;
} else if (width < 1600) {
// 5 column
itemsPerRow = 5;
} else {
itemsPerRow = 6;
}
}
return itemsPerRow;
}
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 = getItemsPerRow(width);
final chunks = getChunks(itemsPerRow);
return Column(
children: [
for (final c in chunks) ...[
if (chunks.indexOf(c) > 0 && layout == FlexLayout.grid)
const SizedBox(height: 8.0),
Row(
children: [
for (final entry in c) ...[
Flexible(
child: itemBuilder(entry),
),
if (itemsPerRow != 1 && c.indexOf(entry) != c.length - 1)
const SizedBox(width: 8),
],
if (c.length < itemsPerRow) ...[
// Prevents resizing when an items is removed
SizedBox(width: 8 * (itemsPerRow - c.length).toDouble()),
Spacer(
flex: itemsPerRow - c.length,
)
]
],
),
]
],
);
},
);
}
}