mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-11-22 00:12:09 +03:00
Merge PR #1554
This commit is contained in:
commit
cfc5eb9f06
@ -30,8 +30,10 @@ class AppListItem<T> extends ConsumerStatefulWidget {
|
||||
final String? semanticTitle;
|
||||
final Widget? trailing;
|
||||
final List<ActionItem> Function(BuildContext context)? buildPopupActions;
|
||||
final Widget Function(BuildContext context)? itemBuilder;
|
||||
final Intent? tapIntent;
|
||||
final Intent? doubleTapIntent;
|
||||
final Color? tileColor;
|
||||
final bool selected;
|
||||
|
||||
const AppListItem(
|
||||
@ -43,8 +45,10 @@ class AppListItem<T> extends ConsumerStatefulWidget {
|
||||
this.subtitle,
|
||||
this.trailing,
|
||||
this.buildPopupActions,
|
||||
this.itemBuilder,
|
||||
this.tapIntent,
|
||||
this.doubleTapIntent,
|
||||
this.tileColor,
|
||||
this.selected = false,
|
||||
});
|
||||
|
||||
@ -78,7 +82,7 @@ class _AppListItemState<T> extends ConsumerState<AppListItem> {
|
||||
item: widget.item,
|
||||
child: InkWell(
|
||||
focusNode: _focusNode,
|
||||
borderRadius: BorderRadius.circular(48),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onSecondaryTapDown: buildPopupActions == null
|
||||
? null
|
||||
: (details) {
|
||||
@ -118,57 +122,62 @@ class _AppListItemState<T> extends ConsumerState<AppListItem> {
|
||||
: () {
|
||||
Actions.invoke(context, doubleTapIntent);
|
||||
},
|
||||
child: Stack(
|
||||
alignment: AlignmentDirectional.center,
|
||||
children: [
|
||||
const SizedBox(height: 64),
|
||||
ListTile(
|
||||
mouseCursor:
|
||||
widget.tapIntent != null ? SystemMouseCursors.click : null,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(48)),
|
||||
selectedTileColor: colorScheme.secondaryContainer,
|
||||
selectedColor: colorScheme.onSecondaryContainer,
|
||||
selected: widget.selected,
|
||||
leading: widget.leading,
|
||||
title: subtitle == null
|
||||
// We use SizedBox to fill entire space
|
||||
? SizedBox(
|
||||
height: 48,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
widget.title,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
widget.title,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
),
|
||||
subtitle: subtitle != null
|
||||
? Text(
|
||||
subtitle,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
)
|
||||
: null,
|
||||
trailing: trailing == null
|
||||
? null
|
||||
: Focus(
|
||||
skipTraversal: true,
|
||||
descendantsAreTraversable: false,
|
||||
child: trailing,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: widget.itemBuilder != null
|
||||
? widget.itemBuilder!.call(context)
|
||||
: Stack(
|
||||
alignment: AlignmentDirectional.center,
|
||||
children: [
|
||||
const SizedBox(height: 64),
|
||||
ListTile(
|
||||
mouseCursor: widget.tapIntent != null
|
||||
? SystemMouseCursors.click
|
||||
: null,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
selectedTileColor: colorScheme.secondaryContainer,
|
||||
selectedColor: colorScheme.onSecondaryContainer,
|
||||
tileColor: widget.tileColor,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
selected: widget.selected,
|
||||
leading: widget.leading,
|
||||
title: subtitle == null
|
||||
// We use SizedBox to fill entire space
|
||||
? SizedBox(
|
||||
height: 48,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
widget.title,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
widget.title,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
),
|
||||
subtitle: subtitle != null
|
||||
? Text(
|
||||
subtitle,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
)
|
||||
: null,
|
||||
trailing: trailing == null
|
||||
? null
|
||||
: Focus(
|
||||
skipTraversal: true,
|
||||
descendantsAreTraversable: false,
|
||||
child: trailing,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -22,6 +22,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../core/state.dart';
|
||||
import '../../management/models.dart';
|
||||
@ -33,14 +34,24 @@ import 'fs_dialog.dart';
|
||||
import 'keys.dart';
|
||||
import 'navigation.dart';
|
||||
|
||||
final _navigationProvider = StateNotifierProvider<_NavigationProvider, bool>(
|
||||
(ref) => _NavigationProvider());
|
||||
final _navigationVisibilityProvider =
|
||||
StateNotifierProvider<_VisibilityNotifier, bool>((ref) =>
|
||||
_VisibilityNotifier('NAVIGATION_VISIBILITY', ref.watch(prefProvider)));
|
||||
|
||||
class _NavigationProvider extends StateNotifier<bool> {
|
||||
_NavigationProvider() : super(true);
|
||||
final _detailViewVisibilityProvider =
|
||||
StateNotifierProvider<_VisibilityNotifier, bool>((ref) =>
|
||||
_VisibilityNotifier('DETAIL_VIEW_VISIBILITY', ref.watch(prefProvider)));
|
||||
|
||||
class _VisibilityNotifier extends StateNotifier<bool> {
|
||||
final String _key;
|
||||
final SharedPreferences _prefs;
|
||||
_VisibilityNotifier(this._key, this._prefs)
|
||||
: super(_prefs.getBool(_key) ?? true);
|
||||
|
||||
void toggleExpanded() {
|
||||
state = !state;
|
||||
final newValue = !state;
|
||||
state = newValue;
|
||||
_prefs.setBool(_key, newValue);
|
||||
}
|
||||
}
|
||||
|
||||
@ -308,14 +319,17 @@ class _AppPageState extends ConsumerState<AppPage> {
|
||||
|
||||
Widget? _buildAppBarTitle(
|
||||
BuildContext context, bool hasRail, bool hasManage, bool fullyExpanded) {
|
||||
final showNavigation = ref.watch(_navigationProvider);
|
||||
final showNavigation = ref.watch(_navigationVisibilityProvider);
|
||||
final showDetailView = ref.watch(_detailViewVisibilityProvider);
|
||||
|
||||
EdgeInsets padding;
|
||||
if (fullyExpanded) {
|
||||
padding = EdgeInsets.only(left: showNavigation ? 280 : 72, right: 320);
|
||||
padding = EdgeInsets.only(
|
||||
left: showNavigation ? 280 : 72, right: showDetailView ? 320 : 0.0);
|
||||
} else if (!hasRail && hasManage) {
|
||||
padding = const EdgeInsets.only(right: 320);
|
||||
} else if (hasRail && hasManage) {
|
||||
padding = const EdgeInsets.only(left: 72, right: 320);
|
||||
padding = EdgeInsets.only(left: 72, right: showDetailView ? 320 : 0.0);
|
||||
} else if (hasRail && !hasManage) {
|
||||
padding = const EdgeInsets.only(left: 72);
|
||||
} else {
|
||||
@ -344,21 +358,23 @@ class _AppPageState extends ConsumerState<AppPage> {
|
||||
}
|
||||
|
||||
Widget _buildMainContent(BuildContext context, bool expanded) {
|
||||
final actions = widget.actionsBuilder?.call(context, expanded) ?? [];
|
||||
final showDetailView = ref.watch(_detailViewVisibilityProvider);
|
||||
final actions =
|
||||
widget.actionsBuilder?.call(context, expanded && showDetailView) ?? [];
|
||||
final content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: widget.centered
|
||||
? CrossAxisAlignment.center
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
widget.builder(context, expanded),
|
||||
widget.builder(context, expanded && showDetailView),
|
||||
if (actions.isNotEmpty)
|
||||
Align(
|
||||
alignment:
|
||||
widget.centered ? Alignment.center : Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 16, bottom: 0, left: 16, right: 16),
|
||||
top: 16, bottom: 0, left: 18, right: 18),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
@ -369,7 +385,7 @@ class _AppPageState extends ConsumerState<AppPage> {
|
||||
if (widget.footnote != null)
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(bottom: 16, top: 33, left: 16, right: 16),
|
||||
const EdgeInsets.only(bottom: 16, top: 33, left: 18, right: 18),
|
||||
child: Opacity(
|
||||
opacity: 0.6,
|
||||
child: Text(
|
||||
@ -399,7 +415,7 @@ class _AppPageState extends ConsumerState<AppPage> {
|
||||
alignment: Alignment.topLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16.0, right: 16.0, bottom: 24.0, top: 4.0),
|
||||
left: 18.0, right: 18.0, bottom: 24.0, top: 4.0),
|
||||
child: _buildTitle(context),
|
||||
),
|
||||
),
|
||||
@ -452,7 +468,7 @@ class _AppPageState extends ConsumerState<AppPage> {
|
||||
child: Padding(
|
||||
key: _sliverTitleWrapperGlobalKey,
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16.0, right: 16.0, bottom: 12.0, top: 4.0),
|
||||
left: 18.0, right: 18.0, bottom: 12.0, top: 4.0),
|
||||
child: _buildTitle(context),
|
||||
),
|
||||
),
|
||||
@ -499,7 +515,8 @@ class _AppPageState extends ConsumerState<AppPage> {
|
||||
BuildContext context, bool hasDrawer, bool hasRail, bool hasManage) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final fullyExpanded = !hasDrawer && hasRail && hasManage;
|
||||
final showNavigation = ref.watch(_navigationProvider);
|
||||
final showNavigation = ref.watch(_navigationVisibilityProvider);
|
||||
final showDetailView = ref.watch(_detailViewVisibilityProvider);
|
||||
final hasDetailsOrKeyActions =
|
||||
widget.detailViewBuilder != null || widget.keyActionsBuilder != null;
|
||||
var body = _buildMainContent(context, hasManage);
|
||||
@ -518,187 +535,211 @@ class _AppPageState extends ConsumerState<AppPage> {
|
||||
);
|
||||
}
|
||||
if (hasRail || hasManage) {
|
||||
body = Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (hasRail && (!fullyExpanded || !showNavigation))
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: _VisibilityListener(
|
||||
targetKey: _navKey,
|
||||
controller: _navController,
|
||||
child: SingleChildScrollView(
|
||||
child: NavigationContent(
|
||||
key: _navKey,
|
||||
shouldPop: false,
|
||||
extended: false,
|
||||
body = GestureDetector(
|
||||
behavior: HitTestBehavior.deferToChild,
|
||||
onTap: () {
|
||||
Actions.invoke(context, const EscapeIntent());
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
},
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (hasRail && (!fullyExpanded || !showNavigation))
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: _VisibilityListener(
|
||||
targetKey: _navKey,
|
||||
controller: _navController,
|
||||
child: SingleChildScrollView(
|
||||
child: NavigationContent(
|
||||
key: _navKey,
|
||||
shouldPop: false,
|
||||
extended: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (fullyExpanded && showNavigation)
|
||||
SizedBox(
|
||||
width: 280,
|
||||
child: _VisibilityListener(
|
||||
controller: _navController,
|
||||
targetKey: _navExpandedKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: NavigationContent(
|
||||
key: _navExpandedKey,
|
||||
shouldPop: false,
|
||||
extended: true,
|
||||
if (fullyExpanded && showNavigation)
|
||||
SizedBox(
|
||||
width: 280,
|
||||
child: _VisibilityListener(
|
||||
controller: _navController,
|
||||
targetKey: _navExpandedKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: NavigationContent(
|
||||
key: _navExpandedKey,
|
||||
shouldPop: false,
|
||||
extended: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: body),
|
||||
if (hasManage &&
|
||||
!hasDetailsOrKeyActions &&
|
||||
widget.capabilities != null &&
|
||||
widget.capabilities?.first != Capability.u2f)
|
||||
// Add a placeholder for the Manage/Details column. Exceptions are:
|
||||
// - the "Security Key" because it does not have any actions/details.
|
||||
// - pages without Capabilities
|
||||
const SizedBox(width: 336), // simulate column
|
||||
if (hasManage && hasDetailsOrKeyActions && showDetailView)
|
||||
_VisibilityListener(
|
||||
controller: _detailsController,
|
||||
targetKey: _detailsViewGlobalKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: SizedBox(
|
||||
width: 320,
|
||||
child: Column(
|
||||
key: _detailsViewGlobalKey,
|
||||
children: [
|
||||
if (widget.detailViewBuilder != null)
|
||||
widget.detailViewBuilder!(context),
|
||||
if (widget.keyActionsBuilder != null)
|
||||
widget.keyActionsBuilder!(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.deferToChild,
|
||||
onTap: () {
|
||||
Actions.invoke(context, const EscapeIntent());
|
||||
},
|
||||
child: Stack(children: [
|
||||
Container(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
body
|
||||
]),
|
||||
)),
|
||||
if (hasManage &&
|
||||
!hasDetailsOrKeyActions &&
|
||||
widget.capabilities != null &&
|
||||
widget.capabilities?.first != Capability.u2f)
|
||||
// Add a placeholder for the Manage/Details column. Exceptions are:
|
||||
// - the "Security Key" because it does not have any actions/details.
|
||||
// - pages without Capabilities
|
||||
const SizedBox(width: 336), // simulate column
|
||||
if (hasManage && hasDetailsOrKeyActions)
|
||||
_VisibilityListener(
|
||||
controller: _detailsController,
|
||||
targetKey: _detailsViewGlobalKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: SizedBox(
|
||||
width: 320,
|
||||
child: Column(
|
||||
key: _detailsViewGlobalKey,
|
||||
children: [
|
||||
if (widget.detailViewBuilder != null)
|
||||
widget.detailViewBuilder!(context),
|
||||
if (widget.keyActionsBuilder != null)
|
||||
widget.keyActionsBuilder!(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Scaffold(
|
||||
key: scaffoldGlobalKey,
|
||||
appBar: AppBar(
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(1.0),
|
||||
child: ListenableBuilder(
|
||||
listenable: _scrolledUnderController,
|
||||
builder: (context, child) {
|
||||
final visible = _scrolledUnderController.someIsScrolledUnder;
|
||||
return AnimatedOpacity(
|
||||
opacity: visible ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Container(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
height: 1.0,
|
||||
),
|
||||
);
|
||||
},
|
||||
appBar: _GestureDetectorAppBar(
|
||||
onTap: () {
|
||||
Actions.invoke(context, const EscapeIntent());
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
},
|
||||
appBar: AppBar(
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(1.0),
|
||||
child: ListenableBuilder(
|
||||
listenable: _scrolledUnderController,
|
||||
builder: (context, child) {
|
||||
final visible = _scrolledUnderController.someIsScrolledUnder;
|
||||
return AnimatedOpacity(
|
||||
opacity: visible ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Container(
|
||||
color: Theme.of(context).hoverColor,
|
||||
height: 1.0,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
scrolledUnderElevation: 0.0,
|
||||
leadingWidth: hasRail ? 84 : null,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
title: _buildAppBarTitle(
|
||||
context,
|
||||
hasRail,
|
||||
hasManage,
|
||||
fullyExpanded,
|
||||
),
|
||||
leading: hasRail
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: IconButton(
|
||||
icon: Icon(Symbols.menu, semanticLabel: navigationText),
|
||||
tooltip: navigationText,
|
||||
onPressed: fullyExpanded
|
||||
? () {
|
||||
ref
|
||||
.read(_navigationProvider.notifier)
|
||||
.toggleExpanded();
|
||||
}
|
||||
: () {
|
||||
scaffoldGlobalKey.currentState?.openDrawer();
|
||||
},
|
||||
),
|
||||
)),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
)
|
||||
: Builder(
|
||||
builder: (context) {
|
||||
// Need to wrap with builder to get Scaffold context
|
||||
return IconButton(
|
||||
onPressed: () => Scaffold.of(context).openDrawer(),
|
||||
icon: const Icon(Symbols.menu),
|
||||
);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
if (widget.actionButtonBuilder == null &&
|
||||
(widget.keyActionsBuilder != null && !hasManage))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: IconButton(
|
||||
key: actionsIconButtonKey,
|
||||
onPressed: () {
|
||||
showBlurDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.transparent,
|
||||
builder: (context) => FsDialog(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 32),
|
||||
child: widget.keyActionsBuilder!(context),
|
||||
scrolledUnderElevation: 0.0,
|
||||
leadingWidth: hasRail ? 84 : null,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
title: _buildAppBarTitle(
|
||||
context,
|
||||
hasRail,
|
||||
hasManage,
|
||||
fullyExpanded,
|
||||
),
|
||||
centerTitle: true,
|
||||
leading: hasRail
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: IconButton(
|
||||
icon: Icon(Symbols.menu, semanticLabel: navigationText),
|
||||
tooltip: navigationText,
|
||||
onPressed: fullyExpanded
|
||||
? () {
|
||||
ref
|
||||
.read(
|
||||
_navigationVisibilityProvider.notifier)
|
||||
.toggleExpanded();
|
||||
}
|
||||
: () {
|
||||
scaffoldGlobalKey.currentState?.openDrawer();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: widget.keyActionsBadge
|
||||
? Badge(
|
||||
child: Icon(Symbols.more_vert,
|
||||
semanticLabel: l10n.s_configure_yk),
|
||||
)
|
||||
: Icon(Symbols.more_vert,
|
||||
semanticLabel: l10n.s_configure_yk),
|
||||
iconSize: 24,
|
||||
tooltip: l10n.s_configure_yk,
|
||||
padding: const EdgeInsets.all(12),
|
||||
)),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
)
|
||||
: Builder(
|
||||
builder: (context) {
|
||||
// Need to wrap with builder to get Scaffold context
|
||||
return IconButton(
|
||||
onPressed: () => Scaffold.of(context).openDrawer(),
|
||||
icon: const Icon(Symbols.menu),
|
||||
);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
if (widget.actionButtonBuilder == null &&
|
||||
(widget.keyActionsBuilder != null &&
|
||||
(!hasManage || !showDetailView)))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: IconButton(
|
||||
key: actionsIconButtonKey,
|
||||
onPressed: () {
|
||||
showBlurDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.transparent,
|
||||
builder: (context) => FsDialog(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 32),
|
||||
child: widget.keyActionsBuilder!(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: widget.keyActionsBadge
|
||||
? Badge(
|
||||
child: Icon(Symbols.more_vert,
|
||||
semanticLabel: l10n.s_configure_yk),
|
||||
)
|
||||
: Icon(Symbols.more_vert,
|
||||
semanticLabel: l10n.s_configure_yk),
|
||||
iconSize: 24,
|
||||
tooltip: l10n.s_configure_yk,
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.actionButtonBuilder != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: widget.actionButtonBuilder!.call(context),
|
||||
),
|
||||
],
|
||||
if (hasManage &&
|
||||
(widget.keyActionsBuilder != null ||
|
||||
widget.detailViewBuilder != null))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: IconButton(
|
||||
key: toggleDetailViewIconButtonKey,
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(_detailViewVisibilityProvider.notifier)
|
||||
.toggleExpanded();
|
||||
},
|
||||
icon: const Icon(Symbols.view_sidebar),
|
||||
iconSize: 24,
|
||||
tooltip: showDetailView
|
||||
? l10n.s_collapse_sidebar
|
||||
: l10n.s_expand_sidebar,
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
),
|
||||
if (widget.actionButtonBuilder != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: widget.actionButtonBuilder!.call(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
drawer: hasDrawer ? _buildDrawer(context) : null,
|
||||
body: body,
|
||||
@ -706,6 +747,23 @@ class _AppPageState extends ConsumerState<AppPage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _GestureDetectorAppBar extends StatelessWidget
|
||||
implements PreferredSizeWidget {
|
||||
final AppBar appBar;
|
||||
final void Function() onTap;
|
||||
|
||||
const _GestureDetectorAppBar({required this.appBar, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.deferToChild, onTap: onTap, child: appBar);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
|
||||
class CapabilityBadge extends StatelessWidget {
|
||||
final Capability capability;
|
||||
|
||||
|
@ -25,6 +25,8 @@ const _prefix = 'app.keys';
|
||||
const deviceInfoListTile = Key('$_prefix.device_info_list_tile');
|
||||
const noDeviceAvatar = Key('$_prefix.no_device_avatar');
|
||||
const actionsIconButtonKey = Key('$_prefix.actions_icon_button');
|
||||
const toggleDetailViewIconButtonKey =
|
||||
Key('$_prefix.toggle_detail_view_icon_button');
|
||||
|
||||
// drawer items
|
||||
const homeDrawer = Key('$_prefix.drawer.home');
|
||||
|
@ -71,9 +71,9 @@ class MessagePage extends StatelessWidget {
|
||||
delayedContent: delayedContent,
|
||||
builder: (context, _) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16.0,
|
||||
left: 18.0,
|
||||
top: 0.0,
|
||||
right: 16.0,
|
||||
right: 18.0,
|
||||
bottom: centered && actionsBuilder == null ? 96 : 0),
|
||||
child: SizedBox(
|
||||
width: centered ? 250 : 350,
|
||||
|
@ -21,6 +21,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../app/models.dart';
|
||||
import '../widgets/flex_box.dart';
|
||||
|
||||
bool get isDesktop => const [
|
||||
TargetPlatform.windows,
|
||||
@ -119,3 +120,20 @@ final featureProvider = Provider<FeatureProvider>((ref) {
|
||||
|
||||
return isEnabled;
|
||||
});
|
||||
|
||||
class LayoutNotifier extends StateNotifier<FlexLayout> {
|
||||
final String _key;
|
||||
final SharedPreferences _prefs;
|
||||
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.values.firstWhere(
|
||||
(element) => element.name == name,
|
||||
orElse: () => FlexLayout.list,
|
||||
);
|
||||
}
|
||||
|
@ -18,6 +18,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../app/models.dart';
|
||||
import '../core/state.dart';
|
||||
import '../widgets/flex_box.dart';
|
||||
import 'models.dart';
|
||||
|
||||
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
|
||||
.family<FidoStateNotifier, FidoState, DevicePath>(
|
||||
() => throw UnimplementedError(),
|
||||
|
@ -331,15 +331,18 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
|
||||
}),
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: fingerprints
|
||||
.map((fp) => _FingerprintListItem(
|
||||
fp,
|
||||
expanded: expanded,
|
||||
selected: fp == _selected,
|
||||
))
|
||||
.toList()),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: fingerprints
|
||||
.map((fp) => _FingerprintListItem(
|
||||
fp,
|
||||
expanded: expanded,
|
||||
selected: fp == _selected,
|
||||
))
|
||||
.toList()),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
@ -38,6 +38,7 @@ import '../../exception/no_data_exception.dart';
|
||||
import '../../management/models.dart';
|
||||
import '../../widgets/app_input_decoration.dart';
|
||||
import '../../widgets/app_text_form_field.dart';
|
||||
import '../../widgets/flex_box.dart';
|
||||
import '../../widgets/list_title.dart';
|
||||
import '../features.dart' as features;
|
||||
import '../models.dart';
|
||||
@ -219,6 +220,7 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
|
||||
late FocusNode searchFocus;
|
||||
late TextEditingController searchController;
|
||||
FidoCredential? _selected;
|
||||
bool _canRequestFocus = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -374,60 +376,103 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: Builder(builder: (context) {
|
||||
child: LayoutBuilder(builder: (context, constraints) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: AppTextFormField(
|
||||
key: searchField,
|
||||
controller: searchController,
|
||||
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,
|
||||
final width = constraints.maxWidth;
|
||||
final showLayoutOptions = width > 600;
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final layout = ref.watch(passkeysLayoutProvider);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10.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 && showLayoutOptions)
|
||||
...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),
|
||||
),
|
||||
suffixIcon: searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
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(),
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(passkeysSearchProvider.notifier)
|
||||
.setFilter(value);
|
||||
setState(() {});
|
||||
},
|
||||
textInputAction: TextInputAction.next,
|
||||
onFieldSubmitted: (value) {
|
||||
Focus.of(context)
|
||||
.focusInDirection(TraversalDirection.down);
|
||||
},
|
||||
).init(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
@ -483,21 +528,37 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
|
||||
}),
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (filteredCredentials.isEmpty)
|
||||
Center(
|
||||
child: Text(l10n.s_no_passkeys),
|
||||
child: Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final layout = ref.watch(passkeysLayoutProvider);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (filteredCredentials.isEmpty)
|
||||
Center(
|
||||
child: Text(l10n.s_no_passkeys),
|
||||
),
|
||||
FlexBox<FidoCredential>(
|
||||
items: filteredCredentials,
|
||||
itemBuilder: (cred) => _CredentialListItem(
|
||||
cred,
|
||||
expanded: expanded,
|
||||
selected: _selected == cred,
|
||||
tileColor: layout == FlexLayout.grid
|
||||
? Theme.of(context).hoverColor
|
||||
: null,
|
||||
),
|
||||
layout: layout,
|
||||
cellMinWidth: 265,
|
||||
spacing: layout == FlexLayout.grid ? 4.0 : 0.0,
|
||||
runSpacing: layout == FlexLayout.grid ? 4.0 : 0.0,
|
||||
)
|
||||
],
|
||||
),
|
||||
...filteredCredentials.map(
|
||||
(cred) => _CredentialListItem(
|
||||
cred,
|
||||
expanded: expanded,
|
||||
selected: _selected == cred,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
@ -518,9 +579,10 @@ class _CredentialListItem extends StatelessWidget {
|
||||
final FidoCredential credential;
|
||||
final bool selected;
|
||||
final bool expanded;
|
||||
final Color? tileColor;
|
||||
|
||||
const _CredentialListItem(this.credential,
|
||||
{required this.expanded, required this.selected});
|
||||
{required this.expanded, required this.selected, this.tileColor});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -533,6 +595,7 @@ class _CredentialListItem extends StatelessWidget {
|
||||
backgroundColor: colorScheme.secondary,
|
||||
child: const Icon(Symbols.passkey),
|
||||
),
|
||||
tileColor: tileColor,
|
||||
title: credential.rpId,
|
||||
subtitle: credential.userName,
|
||||
trailing: expanded
|
||||
|
@ -72,7 +72,7 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
homeBuildActions(context, widget.deviceData, ref),
|
||||
builder: (context, expanded) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
@ -45,6 +45,8 @@
|
||||
"s_hide_window": "Fenster verstecken",
|
||||
"s_expand_navigation": null,
|
||||
"s_collapse_navigation": null,
|
||||
"s_expand_sidebar": null,
|
||||
"s_collapse_sidebar": null,
|
||||
"q_rename_target": "{label} umbenennen?",
|
||||
"@q_rename_target": {
|
||||
"placeholders": {
|
||||
@ -121,6 +123,12 @@
|
||||
"s_light_mode": "Heller Modus",
|
||||
"s_dark_mode": "Dunkler Modus",
|
||||
|
||||
"@_layout": {},
|
||||
"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",
|
||||
"s_hide_device": "Gerät verstecken",
|
||||
@ -410,6 +418,7 @@
|
||||
"s_pin_account": "Konto anpinnen",
|
||||
"s_unpin_account": "Konto nicht mehr anpinnen",
|
||||
"s_no_pinned_accounts": "Keine angepinnten Konten",
|
||||
"s_pinned": null,
|
||||
"l_pin_account_desc": null,
|
||||
"s_rename_account": "Konto umbenennen",
|
||||
"l_rename_account_desc": null,
|
||||
|
@ -45,6 +45,8 @@
|
||||
"s_hide_window": "Hide window",
|
||||
"s_expand_navigation": "Expand navigation",
|
||||
"s_collapse_navigation": "Collapse navigation",
|
||||
"s_expand_sidebar": "Expand sidebar",
|
||||
"s_collapse_sidebar": "Collapse sidebar",
|
||||
"q_rename_target": "Rename {label}?",
|
||||
"@q_rename_target": {
|
||||
"placeholders": {
|
||||
@ -121,6 +123,12 @@
|
||||
"s_light_mode": "Light mode",
|
||||
"s_dark_mode": "Dark mode",
|
||||
|
||||
"@_layout": {},
|
||||
"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",
|
||||
"s_hide_device": "Hide device",
|
||||
@ -410,6 +418,7 @@
|
||||
"s_pin_account": "Pin account",
|
||||
"s_unpin_account": "Unpin account",
|
||||
"s_no_pinned_accounts": "No pinned accounts",
|
||||
"s_pinned": "Pinned",
|
||||
"l_pin_account_desc": "Keep your important accounts together",
|
||||
"s_rename_account": "Rename account",
|
||||
"l_rename_account_desc": "Edit the issuer/name of the account",
|
||||
|
@ -45,6 +45,8 @@
|
||||
"s_hide_window": "Masquer fenêtre",
|
||||
"s_expand_navigation": "Développer la navigation",
|
||||
"s_collapse_navigation": "Réduire la navigation",
|
||||
"s_expand_sidebar": null,
|
||||
"s_collapse_sidebar": null,
|
||||
"q_rename_target": "Renommer {label}\u00a0?",
|
||||
"@q_rename_target": {
|
||||
"placeholders": {
|
||||
@ -121,6 +123,12 @@
|
||||
"s_light_mode": "Thème clair",
|
||||
"s_dark_mode": "Thème sombre",
|
||||
|
||||
"@_layout": {},
|
||||
"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",
|
||||
"s_hide_device": "Masquer appareil",
|
||||
@ -410,6 +418,7 @@
|
||||
"s_pin_account": "Épingler compte",
|
||||
"s_unpin_account": "Détacher compte",
|
||||
"s_no_pinned_accounts": "Aucun compte épinglé",
|
||||
"s_pinned": null,
|
||||
"l_pin_account_desc": "Conserver vos comptes importants ensemble",
|
||||
"s_rename_account": "Renommer compte",
|
||||
"l_rename_account_desc": "Modifier émetteur/nom du compte",
|
||||
|
@ -45,6 +45,8 @@
|
||||
"s_hide_window": "ウィンドウを非表示",
|
||||
"s_expand_navigation": "ナビゲーションを展開",
|
||||
"s_collapse_navigation": "ナビゲーションを閉じる",
|
||||
"s_expand_sidebar": null,
|
||||
"s_collapse_sidebar": null,
|
||||
"q_rename_target": "{label}の名前を変更しますか?",
|
||||
"@q_rename_target": {
|
||||
"placeholders": {
|
||||
@ -121,6 +123,12 @@
|
||||
"s_light_mode": "ライトモード",
|
||||
"s_dark_mode": "ダークモード",
|
||||
|
||||
"@_layout": {},
|
||||
"s_list_layout": null,
|
||||
"s_grid_layout": null,
|
||||
"s_mixed_layout": null,
|
||||
"s_select_layout": null,
|
||||
|
||||
"@_yubikey_selection": {},
|
||||
"s_select_to_scan": "選択してスキャン",
|
||||
"s_hide_device": "デバイスを非表示",
|
||||
@ -410,6 +418,7 @@
|
||||
"s_pin_account": "アカウントをピン留めする",
|
||||
"s_unpin_account": "アカウントのピン留めを解除",
|
||||
"s_no_pinned_accounts": "ピン留めされたアカウントはありません",
|
||||
"s_pinned": null,
|
||||
"l_pin_account_desc": "重要なアカウントをまとめて保持",
|
||||
"s_rename_account": "アカウント名を変更",
|
||||
"l_rename_account_desc": "アカウントの発行者/名前を編集",
|
||||
|
@ -45,6 +45,8 @@
|
||||
"s_hide_window": "Ukryj okno",
|
||||
"s_expand_navigation": null,
|
||||
"s_collapse_navigation": null,
|
||||
"s_expand_sidebar": null,
|
||||
"s_collapse_sidebar": null,
|
||||
"q_rename_target": "Zmienić nazwę {label}?",
|
||||
"@q_rename_target": {
|
||||
"placeholders": {
|
||||
@ -121,6 +123,12 @@
|
||||
"s_light_mode": "Jasny",
|
||||
"s_dark_mode": "Ciemny",
|
||||
|
||||
"@_layout": {},
|
||||
"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ć",
|
||||
"s_hide_device": "Ukryj urządzenie",
|
||||
@ -410,6 +418,7 @@
|
||||
"s_pin_account": "Przypnij konto",
|
||||
"s_unpin_account": "Odepnij konto",
|
||||
"s_no_pinned_accounts": "Brak przypiętych kont",
|
||||
"s_pinned": null,
|
||||
"l_pin_account_desc": "Przechowuj ważne konta razem",
|
||||
"s_rename_account": "Zmień nazwę konta",
|
||||
"l_rename_account_desc": "Edytuj wydawcę/nazwę konta",
|
||||
|
@ -258,3 +258,5 @@ class CredentialData with _$CredentialData {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
enum OathLayout { list, grid, mixed }
|
||||
|
@ -38,6 +38,53 @@ class AccountsSearchNotifier extends StateNotifier<String> {
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
credentials, pinnedCreds.toList());
|
||||
});
|
||||
|
||||
class OathLayoutNotfier extends StateNotifier<OathLayout> {
|
||||
final String _key;
|
||||
final SharedPreferences _prefs;
|
||||
OathLayoutNotfier(this._key, this._prefs, List<OathPair> credentials,
|
||||
List<OathPair> pinnedCredentials)
|
||||
: super(
|
||||
_fromName(_prefs.getString(_key), credentials, pinnedCredentials));
|
||||
|
||||
void setLayout(OathLayout layout) {
|
||||
state = layout;
|
||||
_prefs.setString(_key, layout.name);
|
||||
}
|
||||
|
||||
static OathLayout _fromName(String? name, List<OathPair> credentials,
|
||||
List<OathPair> pinnedCredentials) {
|
||||
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) {
|
||||
if (pinnedCredentials.isEmpty) {
|
||||
return OathLayout.list;
|
||||
}
|
||||
if (pinnedCredentials.length == credentials.length) {
|
||||
return OathLayout.grid;
|
||||
}
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
}
|
||||
|
||||
final oathStateProvider = AsyncNotifierProvider.autoDispose
|
||||
.family<OathStateNotifier, OathState, DevicePath>(
|
||||
() => throw UnimplementedError(),
|
||||
|
@ -26,7 +26,6 @@ import '../../app/shortcuts.dart';
|
||||
import '../../app/state.dart';
|
||||
import '../../core/state.dart';
|
||||
import '../../widgets/circle_timer.dart';
|
||||
import '../../widgets/custom_icons.dart';
|
||||
import '../features.dart' as features;
|
||||
import '../keys.dart' as keys;
|
||||
import '../models.dart';
|
||||
@ -90,7 +89,7 @@ class AccountHelper {
|
||||
ActionItem(
|
||||
key: keys.togglePinAction,
|
||||
feature: features.accountsPin,
|
||||
icon: pinned ? pushPinStrokeIcon : const Icon(Symbols.push_pin),
|
||||
icon: Icon(pinned ? Symbols.keep_off : Symbols.keep),
|
||||
title: pinned ? l10n.s_unpin_account : l10n.s_pin_account,
|
||||
subtitle: l10n.l_pin_account_desc,
|
||||
intent: TogglePinIntent(credential),
|
||||
|
@ -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';
|
||||
@ -32,6 +33,9 @@ class AccountList extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final theme = Theme.of(context);
|
||||
final labelStyle =
|
||||
theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.primary);
|
||||
final credentials = ref.watch(filteredCredentialsProvider(accounts));
|
||||
final favorites = ref.watch(favoritesProvider);
|
||||
if (credentials.isEmpty) {
|
||||
@ -45,32 +49,71 @@ class AccountList extends ConsumerWidget {
|
||||
final creds =
|
||||
credentials.where((entry) => !favorites.contains(entry.credential.id));
|
||||
|
||||
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(),
|
||||
child: Column(
|
||||
children: [
|
||||
...pinnedCreds.map(
|
||||
(entry) => AccountView(
|
||||
entry.credential,
|
||||
expanded: expanded,
|
||||
selected: entry.credential == selected,
|
||||
),
|
||||
),
|
||||
if (pinnedCreds.isNotEmpty && creds.isNotEmpty)
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (pinnedCreds.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 18, bottom: 8),
|
||||
child: Text(l10n.s_pinned, style: labelStyle),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
child: FlexBox<OathPair>(
|
||||
items: pinnedCreds.toList(),
|
||||
itemBuilder: (value) => AccountView(
|
||||
value.credential,
|
||||
expanded: expanded,
|
||||
selected: value.credential == selected,
|
||||
large: pinnedLayout == FlexLayout.grid,
|
||||
),
|
||||
cellMinWidth: 250,
|
||||
spacing: pinnedLayout == FlexLayout.grid ? 4.0 : 0.0,
|
||||
runSpacing: pinnedLayout == FlexLayout.grid ? 4.0 : 0.0,
|
||||
layout: pinnedLayout,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (pinnedCreds.isNotEmpty && creds.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 18, bottom: 8),
|
||||
child: Text(
|
||||
l10n.s_accounts,
|
||||
style: labelStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Divider(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
child: FlexBox<OathPair>(
|
||||
items: creds.toList(),
|
||||
itemBuilder: (value) => AccountView(
|
||||
value.credential,
|
||||
expanded: expanded,
|
||||
selected: value.credential == selected,
|
||||
large: normalLayout == FlexLayout.grid,
|
||||
),
|
||||
cellMinWidth: 250,
|
||||
spacing: normalLayout == FlexLayout.grid ? 4.0 : 0.0,
|
||||
runSpacing: normalLayout == FlexLayout.grid ? 4.0 : 0.0,
|
||||
layout: normalLayout,
|
||||
),
|
||||
),
|
||||
...creds.map(
|
||||
(entry) => AccountView(
|
||||
entry.credential,
|
||||
expanded: expanded,
|
||||
selected: entry.credential == selected,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -30,8 +30,12 @@ class AccountView extends ConsumerStatefulWidget {
|
||||
final OathCredential credential;
|
||||
final bool expanded;
|
||||
final bool selected;
|
||||
final bool large;
|
||||
const AccountView(this.credential,
|
||||
{super.key, required this.expanded, required this.selected});
|
||||
{super.key,
|
||||
required this.expanded,
|
||||
required this.selected,
|
||||
this.large = false});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _AccountViewState();
|
||||
@ -116,6 +120,95 @@ class _AccountViewState extends ConsumerState<AccountView> {
|
||||
? CopyIntent<OathCredential>(credential)
|
||||
: null,
|
||||
buildPopupActions: (_) => helper.buildActions(),
|
||||
itemBuilder: widget.large
|
||||
? (context) {
|
||||
return ListTile(
|
||||
mouseCursor: !(isDesktop && !widget.expanded)
|
||||
? SystemMouseCursors.click
|
||||
: null,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
selectedTileColor:
|
||||
Theme.of(context).colorScheme.secondaryContainer,
|
||||
selectedColor:
|
||||
Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
selected: widget.selected,
|
||||
tileColor: Theme.of(context).hoverColor,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
|
||||
title: Column(
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AccountIcon(
|
||||
issuer: credential.issuer,
|
||||
defaultWidget: circleAvatar),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
helper.title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface),
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
),
|
||||
if (subtitle != null)
|
||||
Text(
|
||||
subtitle,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant),
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8.0),
|
||||
Focus(
|
||||
skipTraversal: true,
|
||||
descendantsAreTraversable: false,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
helper.code != null
|
||||
? FilledButton.tonalIcon(
|
||||
icon: helper.buildCodeIcon(),
|
||||
label: helper.buildCodeLabel(),
|
||||
style: buttonStyle,
|
||||
onPressed:
|
||||
Actions.handler(context, openIntent),
|
||||
)
|
||||
: FilledButton.tonal(
|
||||
style: buttonStyle,
|
||||
onPressed:
|
||||
Actions.handler(context, openIntent),
|
||||
child: helper.buildCodeIcon()),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -53,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;
|
||||
|
||||
@ -123,6 +136,7 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
|
||||
late FocusNode searchFocus;
|
||||
late TextEditingController searchController;
|
||||
OathCredential? _selected;
|
||||
bool _canRequestFocus = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -376,60 +390,171 @@ 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 Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: AppTextFormField(
|
||||
key: searchField,
|
||||
controller: searchController,
|
||||
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 Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final credentials = ref.watch(filteredCredentialsProvider(
|
||||
ref.watch(credentialListProvider(widget.devicePath)) ??
|
||||
[]));
|
||||
final favorites = ref.watch(favoritesProvider);
|
||||
final pinnedCreds = credentials
|
||||
.where((entry) => favorites.contains(entry.credential.id));
|
||||
|
||||
final availableLayouts = pinnedCreds.isEmpty ||
|
||||
pinnedCreds.length == credentials.length
|
||||
? OathLayout.values
|
||||
.where((element) => element != OathLayout.mixed)
|
||||
: OathLayout.values;
|
||||
final oathLayout = ref.watch(oathLayoutProvider);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10.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_accounts,
|
||||
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(accountsSearchProvider.notifier)
|
||||
.setFilter('');
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
if (searchController.text.isEmpty) ...[
|
||||
if (width >= 450)
|
||||
...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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (width < 450)
|
||||
MouseRegion(
|
||||
onEnter: (event) {
|
||||
if (!searchFocus.hasFocus) {
|
||||
setState(() {
|
||||
_canRequestFocus = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
onExit: (event) {
|
||||
setState(() {
|
||||
_canRequestFocus = true;
|
||||
});
|
||||
},
|
||||
child: PopupMenuButton(
|
||||
constraints: const BoxConstraints.tightFor(),
|
||||
tooltip: 'Select layout',
|
||||
popUpAnimationStyle:
|
||||
AnimationStyle(duration: Duration.zero),
|
||||
icon: Icon(
|
||||
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);
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
]
|
||||
],
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
fillColor: Theme.of(context).hoverColor,
|
||||
filled: true,
|
||||
hintText: l10n.s_search_accounts,
|
||||
isDense: true,
|
||||
prefixIcon: const Padding(
|
||||
padding: EdgeInsetsDirectional.only(start: 8.0),
|
||||
child: Icon(Icons.search_outlined),
|
||||
),
|
||||
suffixIcon: searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
iconSize: 16,
|
||||
onPressed: () {
|
||||
searchController.clear();
|
||||
ref
|
||||
.read(accountsSearchProvider.notifier)
|
||||
.setFilter('');
|
||||
setState(() {});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: (value) {
|
||||
ref.read(accountsSearchProvider.notifier).setFilter(value);
|
||||
setState(() {});
|
||||
},
|
||||
textInputAction: TextInputAction.next,
|
||||
onFieldSubmitted: (value) {
|
||||
Focus.of(context).focusInDirection(TraversalDirection.down);
|
||||
},
|
||||
).init(),
|
||||
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(accountsSearchProvider.notifier)
|
||||
.setFilter(value);
|
||||
setState(() {});
|
||||
},
|
||||
textInputAction: TextInputAction.next,
|
||||
onFieldSubmitted: (value) {
|
||||
Focus.of(context)
|
||||
.focusInDirection(TraversalDirection.down);
|
||||
},
|
||||
).init(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
@ -180,13 +180,17 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
child: Column(children: [
|
||||
...otpState.slots.map((e) => _SlotListItem(
|
||||
e,
|
||||
expanded: expanded,
|
||||
selected: e == selected,
|
||||
))
|
||||
]),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
child: Column(children: [
|
||||
...otpState.slots.map((e) => _SlotListItem(
|
||||
e,
|
||||
expanded: expanded,
|
||||
selected: e == selected,
|
||||
))
|
||||
]),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
@ -200,25 +200,28 @@ class _PivScreenState extends ConsumerState<PivScreen> {
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
...normalSlots.map(
|
||||
(e) => _CertificateListItem(
|
||||
pivState,
|
||||
e,
|
||||
expanded: expanded,
|
||||
selected: e == selected,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
child: Column(
|
||||
children: [
|
||||
...normalSlots.map(
|
||||
(e) => _CertificateListItem(
|
||||
pivState,
|
||||
e,
|
||||
expanded: expanded,
|
||||
selected: e == selected,
|
||||
),
|
||||
),
|
||||
),
|
||||
...shownRetiredSlots.map(
|
||||
(e) => _CertificateListItem(
|
||||
pivState,
|
||||
e,
|
||||
expanded: expanded,
|
||||
selected: e == selected,
|
||||
),
|
||||
)
|
||||
],
|
||||
...shownRetiredSlots.map(
|
||||
(e) => _CertificateListItem(
|
||||
pivState,
|
||||
e,
|
||||
expanded: expanded,
|
||||
selected: e == selected,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@ -1,68 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
final Widget pushPinStrokeIcon = Builder(builder: (context) {
|
||||
return CustomPaint(
|
||||
painter: _StrikethroughPainter(IconTheme.of(context).color ?? Colors.black),
|
||||
child: ClipPath(
|
||||
clipper: _StrikethroughClipper(), child: const Icon(Symbols.push_pin)),
|
||||
);
|
||||
});
|
||||
|
||||
class _StrikethroughClipper extends CustomClipper<Path> {
|
||||
@override
|
||||
Path getClip(Size size) {
|
||||
Path path = Path()
|
||||
..moveTo(0, 2)
|
||||
..lineTo(0, size.height)
|
||||
..lineTo(size.width - 2, size.height)
|
||||
..lineTo(0, 2)
|
||||
..moveTo(2, 0)
|
||||
..lineTo(size.width, size.height - 2)
|
||||
..lineTo(size.width, 0)
|
||||
..lineTo(2, 0)
|
||||
..close();
|
||||
return path;
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(covariant CustomClipper<Path> oldClipper) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class _StrikethroughPainter extends CustomPainter {
|
||||
final Color color;
|
||||
_StrikethroughPainter(this.color);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = 1.3;
|
||||
|
||||
canvas.drawLine(Offset(size.width * 0.15, size.height * 0.15),
|
||||
Offset(size.width * 0.8, size.height * 0.8), paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) {
|
||||
return false;
|
||||
}
|
||||
}
|
111
lib/widgets/flex_box.dart
Normal file
111
lib/widgets/flex_box.dart
Normal file
@ -0,0 +1,111 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
enum FlexLayout {
|
||||
list,
|
||||
grid;
|
||||
|
||||
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;
|
||||
final Widget Function(T value) itemBuilder;
|
||||
final FlexLayout layout;
|
||||
final double cellMinWidth;
|
||||
final double spacing;
|
||||
final double runSpacing;
|
||||
const FlexBox({
|
||||
super.key,
|
||||
required this.items,
|
||||
required this.itemBuilder,
|
||||
required this.cellMinWidth,
|
||||
this.layout = FlexLayout.list,
|
||||
this.spacing = 0.0,
|
||||
this.runSpacing = 0.0,
|
||||
});
|
||||
|
||||
int _getItemsPerRow(double width) {
|
||||
// Calculate the maximum number of cells that can fit in one row
|
||||
int cellsPerRow = (width / (cellMinWidth + spacing)).floor();
|
||||
|
||||
// Ensure there's at least one cell per row
|
||||
if (cellsPerRow < 1) {
|
||||
cellsPerRow = 1;
|
||||
}
|
||||
|
||||
// Calculate the total width needed for the calculated number of cells and spacing
|
||||
double totalWidthNeeded =
|
||||
cellsPerRow * cellMinWidth + (cellsPerRow - 1) * spacing;
|
||||
|
||||
// Adjust the number of cells per row if the calculated total width exceeds the available width
|
||||
if (totalWidthNeeded > width) {
|
||||
cellsPerRow = cellsPerRow - 1 > 0 ? cellsPerRow - 1 : 1;
|
||||
}
|
||||
|
||||
return cellsPerRow;
|
||||
}
|
||||
|
||||
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 =
|
||||
layout == FlexLayout.grid ? _getItemsPerRow(width) : 1;
|
||||
final chunks = getChunks(itemsPerRow);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
for (final c in chunks) ...[
|
||||
if (chunks.indexOf(c) > 0) SizedBox(height: runSpacing),
|
||||
Row(
|
||||
children: [
|
||||
for (final entry in c) ...[
|
||||
Flexible(
|
||||
child: itemBuilder(entry),
|
||||
),
|
||||
if (itemsPerRow != 1 && c.indexOf(entry) != c.length - 1)
|
||||
SizedBox(width: spacing),
|
||||
],
|
||||
if (c.length < itemsPerRow) ...[
|
||||
// Prevents resizing when an item is removed
|
||||
SizedBox(width: 8 * (itemsPerRow - c.length).toDouble()),
|
||||
Spacer(
|
||||
flex: itemsPerRow - c.length,
|
||||
)
|
||||
]
|
||||
],
|
||||
),
|
||||
]
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -70,7 +70,7 @@ dependencies:
|
||||
io: ^1.0.4
|
||||
base32: ^2.1.3
|
||||
convert: ^3.1.1
|
||||
material_symbols_icons: ^4.2719.3
|
||||
material_symbols_icons: ^4.2741.0
|
||||
|
||||
dev_dependencies:
|
||||
integration_test:
|
||||
|
Loading…
Reference in New Issue
Block a user