mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-11-22 00:12:09 +03:00
Add different views for OATH
This commit is contained in:
parent
3ada959563
commit
909c3d00bb
@ -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(),
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -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(),
|
||||
|
@ -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
94
lib/widgets/flex_box.dart
Normal 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,
|
||||
)
|
||||
]
|
||||
],
|
||||
),
|
||||
]
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user