mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-12-22 17:51:29 +03:00
Implement navigation rail with collapsed/expanded views.
This commit is contained in:
parent
e42f7e4e67
commit
05220e8089
@ -28,6 +28,7 @@ import '../oath/keys.dart';
|
||||
import 'message.dart';
|
||||
import 'models.dart';
|
||||
import 'state.dart';
|
||||
import 'views/keys.dart';
|
||||
import 'views/settings_page.dart';
|
||||
|
||||
class OpenIntent extends Intent {
|
||||
@ -100,7 +101,10 @@ Widget registerGlobalShortcuts(
|
||||
}),
|
||||
NextDeviceIntent: CallbackAction<NextDeviceIntent>(onInvoke: (_) {
|
||||
ref.read(withContextProvider)((context) async {
|
||||
if (!Navigator.of(context).canPop()) {
|
||||
// Only allow switching keys if no other views are open,
|
||||
// with the exception of the drawer.
|
||||
if (!Navigator.of(context).canPop() ||
|
||||
scaffoldGlobalKey.currentState?.isDrawerOpen == true) {
|
||||
final attached = ref
|
||||
.read(attachedDevicesProvider)
|
||||
.whereType<UsbYubiKeyNode>()
|
||||
|
@ -19,9 +19,12 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../../widgets/delayed_visibility.dart';
|
||||
import '../message.dart';
|
||||
import 'device_button.dart';
|
||||
import 'keys.dart';
|
||||
import 'main_drawer.dart';
|
||||
import 'navigation.dart';
|
||||
|
||||
// We use global keys here to maintain the NavigatorContent between AppPages.
|
||||
final _navKey = GlobalKey();
|
||||
final _navExpandedKey = GlobalKey();
|
||||
|
||||
class AppPage extends StatelessWidget {
|
||||
final Widget? title;
|
||||
@ -47,26 +50,33 @@ class AppPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth < 540) {
|
||||
// Single column layout
|
||||
return _buildScaffold(context, true);
|
||||
if (constraints.maxWidth < 600) {
|
||||
// Single column layout, maybe with rail
|
||||
final hasRail = constraints.maxWidth > 400;
|
||||
return _buildScaffold(context, true, hasRail);
|
||||
} else {
|
||||
// Two-column layout
|
||||
// Fully expanded layout
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 280,
|
||||
child: DrawerTheme(
|
||||
data: DrawerTheme.of(context).copyWith(
|
||||
// Don't color the drawer differently
|
||||
surfaceTintColor: Colors.transparent,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildLogo(context),
|
||||
NavigationContent(
|
||||
key: _navExpandedKey,
|
||||
shouldPop: false,
|
||||
extended: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const MainPageDrawer(shouldPop: false),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildScaffold(context, false),
|
||||
child: _buildScaffold(context, false, false),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -75,7 +85,47 @@ class AppPage extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
|
||||
Widget _buildScrollView() {
|
||||
Widget _buildLogo(BuildContext context) {
|
||||
final color =
|
||||
Theme.of(context).brightness == Brightness.dark ? 'white' : 'green';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 12),
|
||||
child: Image.asset(
|
||||
'assets/graphics/yubico-$color.png',
|
||||
alignment: Alignment.centerLeft,
|
||||
height: 28,
|
||||
filterQuality: FilterQuality.medium,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDrawer(BuildContext context) {
|
||||
return Drawer(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: DrawerButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
_buildLogo(context),
|
||||
const SizedBox(width: 48),
|
||||
],
|
||||
),
|
||||
NavigationContent(key: _navExpandedKey, extended: true),
|
||||
],
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
Widget _buildMainContent() {
|
||||
final content = Column(
|
||||
children: [
|
||||
child,
|
||||
@ -83,8 +133,7 @@ class AppPage extends StatelessWidget {
|
||||
Align(
|
||||
alignment: centered ? Alignment.center : Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 16.0, horizontal: 18.0),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 18),
|
||||
child: Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
@ -95,6 +144,7 @@ class AppPage extends StatelessWidget {
|
||||
],
|
||||
);
|
||||
return SingleChildScrollView(
|
||||
primary: false,
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
@ -112,7 +162,27 @@ class AppPage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Scaffold _buildScaffold(BuildContext context, bool hasDrawer) {
|
||||
Scaffold _buildScaffold(BuildContext context, bool hasDrawer, bool hasRail) {
|
||||
var body =
|
||||
centered ? Center(child: _buildMainContent()) : _buildMainContent();
|
||||
if (hasRail) {
|
||||
body = Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: SingleChildScrollView(
|
||||
child: NavigationContent(
|
||||
key: _navKey,
|
||||
shouldPop: false,
|
||||
extended: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: body),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Scaffold(
|
||||
key: scaffoldGlobalKey,
|
||||
appBar: AppBar(
|
||||
@ -120,6 +190,20 @@ class AppPage extends StatelessWidget {
|
||||
titleSpacing: hasDrawer ? 2 : 8,
|
||||
centerTitle: true,
|
||||
titleTextStyle: Theme.of(context).textTheme.titleLarge,
|
||||
leadingWidth: hasRail ? 84 : null,
|
||||
leading: hasRail
|
||||
? const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: DrawerButton(),
|
||||
)),
|
||||
SizedBox(width: 12),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
actions: [
|
||||
if (actionButtonBuilder == null && keyActionsBuilder != null)
|
||||
Padding(
|
||||
@ -139,14 +223,15 @@ class AppPage extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: actionButtonBuilder?.call(context) ?? const DeviceButton(),
|
||||
),
|
||||
if (actionButtonBuilder != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: actionButtonBuilder!.call(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
drawer: hasDrawer ? const MainPageDrawer() : null,
|
||||
body: centered ? Center(child: _buildScrollView()) : _buildScrollView(),
|
||||
drawer: hasDrawer ? _buildDrawer(context) : null,
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
402
lib/app/views/device_picker.dart
Normal file
402
lib/app/views/device_picker.dart
Normal file
@ -0,0 +1,402 @@
|
||||
/*
|
||||
* Copyright (C) 2022-2023 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:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../../core/state.dart';
|
||||
import '../../management/models.dart';
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
import 'device_avatar.dart';
|
||||
|
||||
final _hiddenDevicesProvider =
|
||||
StateNotifierProvider<_HiddenDevicesNotifier, List<String>>(
|
||||
(ref) => _HiddenDevicesNotifier(ref.watch(prefProvider)));
|
||||
|
||||
class _HiddenDevicesNotifier extends StateNotifier<List<String>> {
|
||||
static const String _key = 'DEVICE_PICKER_HIDDEN';
|
||||
final SharedPreferences _prefs;
|
||||
_HiddenDevicesNotifier(this._prefs) : super(_prefs.getStringList(_key) ?? []);
|
||||
|
||||
void showAll() {
|
||||
state = [];
|
||||
_prefs.setStringList(_key, state);
|
||||
}
|
||||
|
||||
void hideDevice(DevicePath devicePath) {
|
||||
state = [...state, devicePath.key];
|
||||
_prefs.setStringList(_key, state);
|
||||
}
|
||||
}
|
||||
|
||||
List<(Widget, bool)> buildDeviceList(
|
||||
BuildContext context, WidgetRef ref, bool extended) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final hidden = ref.watch(_hiddenDevicesProvider);
|
||||
final devices = ref
|
||||
.watch(attachedDevicesProvider)
|
||||
.where((e) => !hidden.contains(e.path.key))
|
||||
.toList();
|
||||
final currentNode = ref.watch(currentDeviceProvider);
|
||||
|
||||
final showUsb = isDesktop && devices.whereType<UsbYubiKeyNode>().isEmpty;
|
||||
|
||||
return [
|
||||
if (showUsb)
|
||||
(
|
||||
_DeviceRow(
|
||||
leading: const DeviceAvatar(child: Icon(Icons.usb)),
|
||||
title: l10n.s_usb,
|
||||
subtitle: l10n.l_no_yk_present,
|
||||
onTap: () {
|
||||
ref.read(currentDeviceProvider.notifier).setCurrentDevice(null);
|
||||
},
|
||||
selected: currentNode == null,
|
||||
extended: extended,
|
||||
),
|
||||
currentNode == null
|
||||
),
|
||||
...devices.map(
|
||||
(e) => e.path == currentNode?.path
|
||||
? (
|
||||
_buildCurrentDeviceRow(
|
||||
context,
|
||||
ref,
|
||||
e,
|
||||
ref.watch(currentDeviceDataProvider),
|
||||
extended,
|
||||
),
|
||||
true
|
||||
)
|
||||
: (
|
||||
e.map(
|
||||
usbYubiKey: (node) => _buildDeviceRow(
|
||||
context,
|
||||
ref,
|
||||
node,
|
||||
node.info,
|
||||
extended,
|
||||
),
|
||||
nfcReader: (node) => _NfcDeviceRow(node, extended: extended),
|
||||
),
|
||||
false
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
class DevicePickerContent extends ConsumerWidget {
|
||||
final bool extended;
|
||||
const DevicePickerContent({super.key, this.extended = true});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final hidden = ref.watch(_hiddenDevicesProvider);
|
||||
final devices = ref
|
||||
.watch(attachedDevicesProvider)
|
||||
.where((e) => !hidden.contains(e.path.key))
|
||||
.toList();
|
||||
final currentNode = ref.watch(currentDeviceProvider);
|
||||
|
||||
final showUsb = isDesktop && devices.whereType<UsbYubiKeyNode>().isEmpty;
|
||||
|
||||
List<Widget> children = [
|
||||
if (showUsb)
|
||||
_DeviceRow(
|
||||
leading: const DeviceAvatar(child: Icon(Icons.usb)),
|
||||
title: l10n.s_usb,
|
||||
subtitle: l10n.l_no_yk_present,
|
||||
onTap: () {
|
||||
ref.read(currentDeviceProvider.notifier).setCurrentDevice(null);
|
||||
},
|
||||
selected: currentNode == null,
|
||||
extended: extended,
|
||||
),
|
||||
...devices.map(
|
||||
(e) => e.path == currentNode?.path
|
||||
? _buildCurrentDeviceRow(
|
||||
context,
|
||||
ref,
|
||||
e,
|
||||
ref.watch(currentDeviceDataProvider),
|
||||
extended,
|
||||
)
|
||||
: e.map(
|
||||
usbYubiKey: (node) => _buildDeviceRow(
|
||||
context,
|
||||
ref,
|
||||
node,
|
||||
node.info,
|
||||
extended,
|
||||
),
|
||||
nfcReader: (node) => _NfcDeviceRow(node, extended: extended),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return GestureDetector(
|
||||
onSecondaryTapDown: hidden.isEmpty
|
||||
? null
|
||||
: (details) {
|
||||
showMenu(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(
|
||||
details.globalPosition.dx,
|
||||
details.globalPosition.dy,
|
||||
details.globalPosition.dx,
|
||||
0,
|
||||
),
|
||||
items: [
|
||||
PopupMenuItem(
|
||||
onTap: () {
|
||||
ref.read(_hiddenDevicesProvider.notifier).showAll();
|
||||
},
|
||||
child: ListTile(
|
||||
title: Text(l10n.s_show_hidden_devices),
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _getDeviceInfoString(BuildContext context, DeviceInfo info) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final serial = info.serial;
|
||||
return [
|
||||
if (serial != null) l10n.s_sn_serial(serial),
|
||||
if (info.version.isAtLeast(1))
|
||||
l10n.s_fw_version(info.version)
|
||||
else
|
||||
l10n.s_unknown_type,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
List<String> _getDeviceStrings(
|
||||
BuildContext context, DeviceNode node, AsyncValue<YubiKeyData> data) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final messages = data.whenOrNull(
|
||||
data: (data) => [data.name, _getDeviceInfoString(context, data.info)],
|
||||
error: (error, _) => switch (error) {
|
||||
'device-inaccessible' => [node.name, l10n.s_yk_inaccessible],
|
||||
'unknown-device' => [l10n.s_unknown_device],
|
||||
_ => null,
|
||||
},
|
||||
) ??
|
||||
[l10n.l_no_yk_present];
|
||||
|
||||
// Add the NFC reader name, unless it's already included (as device name, like on Android)
|
||||
if (node is NfcReaderNode && !messages.contains(node.name)) {
|
||||
messages.add(node.name);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
class _DeviceRow extends StatelessWidget {
|
||||
final Widget leading;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final bool extended;
|
||||
final bool selected;
|
||||
final void Function() onTap;
|
||||
|
||||
const _DeviceRow({
|
||||
super.key,
|
||||
required this.leading,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.extended,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tooltip = '$title\n$subtitle';
|
||||
if (extended) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: ListTile(
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(48)),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
|
||||
horizontalTitleGap: 8,
|
||||
leading: IconTheme(
|
||||
// Force the standard icon theme
|
||||
data: IconTheme.of(context),
|
||||
child: leading,
|
||||
),
|
||||
title: Text(title, overflow: TextOverflow.fade, softWrap: false),
|
||||
subtitle: Text(subtitle),
|
||||
dense: true,
|
||||
tileColor: selected ? colorScheme.primary : null,
|
||||
textColor: selected ? colorScheme.onPrimary : null,
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6.5),
|
||||
child: selected
|
||||
? IconButton.filled(
|
||||
tooltip: tooltip,
|
||||
icon: IconTheme(
|
||||
// Force the standard icon theme
|
||||
data: IconTheme.of(context),
|
||||
child: leading,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
onPressed: onTap,
|
||||
)
|
||||
: IconButton(
|
||||
tooltip: tooltip,
|
||||
icon: IconTheme(
|
||||
// Force the standard icon theme
|
||||
data: IconTheme.of(context),
|
||||
child: leading,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
onPressed: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_DeviceRow _buildDeviceRow(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
DeviceNode node,
|
||||
DeviceInfo? info,
|
||||
bool extended,
|
||||
) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final subtitle = node.when(
|
||||
usbYubiKey: (_, __, ___, info) => info == null
|
||||
? l10n.s_yk_inaccessible
|
||||
: _getDeviceInfoString(context, info),
|
||||
nfcReader: (_, __) => l10n.s_select_to_scan,
|
||||
);
|
||||
return _DeviceRow(
|
||||
key: ValueKey(node.path.key),
|
||||
leading: IconTheme(
|
||||
// Force the standard icon theme
|
||||
data: IconTheme.of(context),
|
||||
child: DeviceAvatar.deviceNode(node),
|
||||
),
|
||||
title: node.name,
|
||||
subtitle: subtitle,
|
||||
extended: extended,
|
||||
selected: false,
|
||||
onTap: () {
|
||||
ref.read(currentDeviceProvider.notifier).setCurrentDevice(node);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_DeviceRow _buildCurrentDeviceRow(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
DeviceNode node,
|
||||
AsyncValue<YubiKeyData> data,
|
||||
bool extended,
|
||||
) {
|
||||
final messages = _getDeviceStrings(context, node, data);
|
||||
if (messages.length > 2) {
|
||||
// Don't show readername
|
||||
messages.removeLast();
|
||||
}
|
||||
final title = messages.removeAt(0);
|
||||
final subtitle = messages.join('\n');
|
||||
|
||||
return _DeviceRow(
|
||||
leading: data.maybeWhen(
|
||||
data: (data) =>
|
||||
DeviceAvatar.yubiKeyData(data, radius: extended ? null : 16),
|
||||
orElse: () => DeviceAvatar.deviceNode(node, radius: extended ? null : 16),
|
||||
),
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
extended: extended,
|
||||
selected: true,
|
||||
onTap: () {},
|
||||
);
|
||||
}
|
||||
|
||||
class _NfcDeviceRow extends ConsumerWidget {
|
||||
final DeviceNode node;
|
||||
final bool extended;
|
||||
|
||||
const _NfcDeviceRow(this.node, {required this.extended});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final hidden = ref.watch(_hiddenDevicesProvider);
|
||||
return GestureDetector(
|
||||
onSecondaryTapDown: (details) {
|
||||
showMenu(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(
|
||||
details.globalPosition.dx,
|
||||
details.globalPosition.dy,
|
||||
details.globalPosition.dx,
|
||||
0,
|
||||
),
|
||||
items: [
|
||||
PopupMenuItem(
|
||||
enabled: hidden.isNotEmpty,
|
||||
onTap: () {
|
||||
ref.read(_hiddenDevicesProvider.notifier).showAll();
|
||||
},
|
||||
child: ListTile(
|
||||
title: Text(l10n.s_show_hidden_devices),
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
enabled: hidden.isNotEmpty,
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
onTap: () {
|
||||
ref.read(_hiddenDevicesProvider.notifier).hideDevice(node.path);
|
||||
},
|
||||
child: ListTile(
|
||||
title: Text(l10n.s_hide_device),
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: _buildDeviceRow(context, ref, node, null, extended),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,147 +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:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../management/views/management_screen.dart';
|
||||
import '../message.dart';
|
||||
import '../models.dart';
|
||||
import '../shortcuts.dart';
|
||||
import '../state.dart';
|
||||
import 'keys.dart';
|
||||
|
||||
extension on Application {
|
||||
IconData get _icon => switch (this) {
|
||||
Application.oath => Icons.supervisor_account_outlined,
|
||||
Application.fido => Icons.security_outlined,
|
||||
Application.otp => Icons.password_outlined,
|
||||
Application.piv => Icons.approval_outlined,
|
||||
Application.management => Icons.construction_outlined,
|
||||
Application.openpgp => Icons.key_outlined,
|
||||
Application.hsmauth => Icons.key_outlined,
|
||||
};
|
||||
|
||||
IconData get _filledIcon => switch (this) {
|
||||
Application.oath => Icons.supervisor_account,
|
||||
Application.fido => Icons.security,
|
||||
Application.otp => Icons.password,
|
||||
Application.piv => Icons.approval,
|
||||
Application.management => Icons.construction,
|
||||
Application.openpgp => Icons.key,
|
||||
Application.hsmauth => Icons.key,
|
||||
};
|
||||
}
|
||||
|
||||
class MainPageDrawer extends ConsumerWidget {
|
||||
final bool shouldPop;
|
||||
const MainPageDrawer({this.shouldPop = true, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final supportedApps = ref.watch(supportedAppsProvider);
|
||||
final data = ref.watch(currentDeviceDataProvider).valueOrNull;
|
||||
final color =
|
||||
Theme.of(context).brightness == Brightness.dark ? 'white' : 'green';
|
||||
|
||||
final availableApps = data != null
|
||||
? supportedApps
|
||||
.where(
|
||||
(app) => app.getAvailability(data) != Availability.unsupported)
|
||||
.toList()
|
||||
: <Application>[];
|
||||
final hasManagement = availableApps.remove(Application.management);
|
||||
|
||||
return NavigationDrawer(
|
||||
selectedIndex: availableApps.indexOf(ref.watch(currentAppProvider)),
|
||||
onDestinationSelected: (index) {
|
||||
if (shouldPop) Navigator.of(context).pop();
|
||||
|
||||
if (index < availableApps.length) {
|
||||
// Switch to selected app
|
||||
final app = availableApps[index];
|
||||
ref.read(currentAppProvider.notifier).setCurrentApp(app);
|
||||
} else {
|
||||
// Handle action
|
||||
index -= availableApps.length;
|
||||
|
||||
if (!hasManagement) {
|
||||
index++;
|
||||
}
|
||||
|
||||
switch (index) {
|
||||
case 0:
|
||||
showBlurDialog(
|
||||
context: context,
|
||||
// data must be non-null when index == 0
|
||||
builder: (context) => ManagementScreen(data!),
|
||||
);
|
||||
break;
|
||||
case 1:
|
||||
Actions.maybeInvoke(context, const SettingsIntent());
|
||||
break;
|
||||
case 2:
|
||||
Actions.maybeInvoke(context, const AboutIntent());
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 19.0, left: 30.0, bottom: 12.0),
|
||||
child: Image.asset(
|
||||
'assets/graphics/yubico-$color.png',
|
||||
alignment: Alignment.centerLeft,
|
||||
height: 28,
|
||||
filterQuality: FilterQuality.medium,
|
||||
),
|
||||
),
|
||||
const Divider(indent: 16.0, endIndent: 28.0),
|
||||
if (data != null) ...[
|
||||
// Normal YubiKey Applications
|
||||
...availableApps.map((app) => NavigationDrawerDestination(
|
||||
label: Text(app.getDisplayName(l10n)),
|
||||
icon: Icon(app._icon),
|
||||
selectedIcon: Icon(app._filledIcon),
|
||||
)),
|
||||
// Management app
|
||||
if (hasManagement) ...[
|
||||
NavigationDrawerDestination(
|
||||
key: managementAppDrawer,
|
||||
label: Text(
|
||||
l10n.s_toggle_applications,
|
||||
),
|
||||
icon: Icon(Application.management._icon),
|
||||
selectedIcon: Icon(Application.management._filledIcon),
|
||||
),
|
||||
],
|
||||
const Divider(indent: 16.0, endIndent: 28.0),
|
||||
],
|
||||
// Non-YubiKey pages
|
||||
NavigationDrawerDestination(
|
||||
label: Text(l10n.s_settings),
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
),
|
||||
NavigationDrawerDestination(
|
||||
label: Text(l10n.s_help_and_about),
|
||||
icon: const Icon(Icons.help_outline),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
213
lib/app/views/navigation.dart
Normal file
213
lib/app/views/navigation.dart
Normal file
@ -0,0 +1,213 @@
|
||||
/*
|
||||
* Copyright (C) 2023 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:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../../management/views/management_screen.dart';
|
||||
import '../message.dart';
|
||||
import '../models.dart';
|
||||
import '../shortcuts.dart';
|
||||
import '../state.dart';
|
||||
import 'device_picker.dart';
|
||||
import 'keys.dart';
|
||||
|
||||
class NavigationItem extends StatelessWidget {
|
||||
final Widget leading;
|
||||
final String title;
|
||||
final bool collapsed;
|
||||
final bool selected;
|
||||
final void Function() onTap;
|
||||
|
||||
const NavigationItem({
|
||||
super.key,
|
||||
required this.leading,
|
||||
required this.title,
|
||||
this.collapsed = false,
|
||||
this.selected = false,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
if (collapsed) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: selected
|
||||
? Theme(
|
||||
data: theme.copyWith(
|
||||
colorScheme: colorScheme.copyWith(
|
||||
primary: colorScheme.secondaryContainer,
|
||||
onPrimary: colorScheme.onSecondaryContainer)),
|
||||
child: IconButton.filled(
|
||||
icon: leading,
|
||||
tooltip: title,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
onPressed: onTap,
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
icon: leading,
|
||||
tooltip: title,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
onPressed: onTap,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return ListTile(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(48)),
|
||||
leading: leading,
|
||||
title: Text(title),
|
||||
minVerticalPadding: 16,
|
||||
onTap: onTap,
|
||||
tileColor: selected ? colorScheme.secondaryContainer : null,
|
||||
textColor: selected ? colorScheme.onSecondaryContainer : null,
|
||||
iconColor: selected ? colorScheme.onSecondaryContainer : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension on Application {
|
||||
IconData get _icon => switch (this) {
|
||||
Application.oath => Icons.supervisor_account_outlined,
|
||||
Application.fido => Icons.security_outlined,
|
||||
Application.otp => Icons.password_outlined,
|
||||
Application.piv => Icons.approval_outlined,
|
||||
Application.management => Icons.construction_outlined,
|
||||
Application.openpgp => Icons.key_outlined,
|
||||
Application.hsmauth => Icons.key_outlined,
|
||||
};
|
||||
|
||||
IconData get _filledIcon => switch (this) {
|
||||
Application.oath => Icons.supervisor_account,
|
||||
Application.fido => Icons.security,
|
||||
Application.otp => Icons.password,
|
||||
Application.piv => Icons.approval,
|
||||
Application.management => Icons.construction,
|
||||
Application.openpgp => Icons.key,
|
||||
Application.hsmauth => Icons.key,
|
||||
};
|
||||
}
|
||||
|
||||
class NavigationContent extends ConsumerWidget {
|
||||
final bool shouldPop;
|
||||
final bool extended;
|
||||
const NavigationContent(
|
||||
{super.key, this.shouldPop = true, this.extended = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final supportedApps = ref.watch(supportedAppsProvider);
|
||||
final data = ref.watch(currentDeviceDataProvider).valueOrNull;
|
||||
|
||||
final availableApps = data != null
|
||||
? supportedApps
|
||||
.where(
|
||||
(app) => app.getAvailability(data) != Availability.unsupported)
|
||||
.toList()
|
||||
: <Application>[];
|
||||
final hasManagement = availableApps.remove(Application.management);
|
||||
final currentApp = ref.watch(currentAppProvider);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
children: [
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: DevicePickerContent(extended: extended),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: Column(
|
||||
children: [
|
||||
if (data != null) ...[
|
||||
// Normal YubiKey Applications
|
||||
...availableApps.map((app) => NavigationItem(
|
||||
title: app.getDisplayName(l10n),
|
||||
leading: app == currentApp
|
||||
? Icon(app._filledIcon)
|
||||
: Icon(app._icon),
|
||||
collapsed: !extended,
|
||||
selected: app == currentApp,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(currentAppProvider.notifier)
|
||||
.setCurrentApp(app);
|
||||
if (shouldPop) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
)),
|
||||
// Management app
|
||||
if (hasManagement) ...[
|
||||
NavigationItem(
|
||||
key: managementAppDrawer,
|
||||
leading: Icon(Application.management._icon),
|
||||
title: l10n.s_toggle_applications,
|
||||
collapsed: !extended,
|
||||
onTap: () {
|
||||
showBlurDialog(
|
||||
context: context,
|
||||
// data must be non-null when index == 0
|
||||
builder: (context) => ManagementScreen(data),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Non-YubiKey pages
|
||||
NavigationItem(
|
||||
leading: const Icon(Icons.settings_outlined),
|
||||
title: l10n.s_settings,
|
||||
collapsed: !extended,
|
||||
onTap: () {
|
||||
if (shouldPop) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
Actions.maybeInvoke(context, const SettingsIntent());
|
||||
},
|
||||
),
|
||||
NavigationItem(
|
||||
leading: const Icon(Icons.help_outline),
|
||||
title: l10n.s_help_and_about,
|
||||
collapsed: !extended,
|
||||
onTap: () {
|
||||
if (shouldPop) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
Actions.maybeInvoke(context, const AboutIntent());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user