yubioath-flutter/lib/oath/views/oath_screen.dart

619 lines
24 KiB
Dart
Raw Normal View History

2022-10-04 13:12:54 +03:00
/*
2024-04-16 11:38:43 +03:00
* Copyright (C) 2022-2024 Yubico.
2022-10-04 13:12:54 +03:00
*
* 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 'dart:async';
2024-01-10 18:06:12 +03:00
import 'dart:io';
2023-12-22 18:46:29 +03:00
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
2022-09-06 15:30:18 +03:00
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
2024-03-08 11:30:47 +03:00
import 'package:material_symbols_icons/symbols.dart';
2023-12-22 18:46:29 +03:00
import '../../app/message.dart';
import '../../app/models.dart';
2022-06-10 14:49:02 +03:00
import '../../app/shortcuts.dart';
2023-12-22 18:46:29 +03:00
import '../../app/state.dart';
import '../../app/views/action_list.dart';
2022-05-20 18:19:39 +03:00
import '../../app/views/app_failure_page.dart';
import '../../app/views/app_page.dart';
2024-03-21 14:22:46 +03:00
import '../../app/views/keys.dart';
import '../../app/views/message_page.dart';
import '../../app/views/message_page_not_initialized.dart';
import '../../core/state.dart';
2024-03-13 12:36:50 +03:00
import '../../exception/no_data_exception.dart';
import '../../management/models.dart';
import '../../widgets/app_input_decoration.dart';
2023-11-10 17:24:53 +03:00
import '../../widgets/app_text_form_field.dart';
2024-01-08 16:11:32 +03:00
import '../../widgets/file_drop_overlay.dart';
import '../../widgets/list_title.dart';
2024-04-25 16:26:33 +03:00
import '../../widgets/tooltip_if_truncated.dart';
import '../features.dart' as features;
2022-09-12 13:58:17 +03:00
import '../keys.dart' as keys;
import '../models.dart';
import '../state.dart';
import 'account_dialog.dart';
import 'account_helper.dart';
import 'account_list.dart';
import 'actions.dart';
import 'key_actions.dart';
2022-09-14 14:19:39 +03:00
import 'unlock_form.dart';
2023-12-22 18:46:29 +03:00
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;
2022-10-18 12:52:02 +03:00
2022-05-12 10:56:55 +03:00
const OathScreen(this.devicePath, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
return ref.watch(oathStateProvider(devicePath)).when(
loading: () => MessagePage(
title: AppLocalizations.of(context)!.s_accounts,
capabilities: const [Capability.oath],
2024-03-12 20:00:06 +03:00
centered: true,
graphic: const CircularProgressIndicator(),
2024-03-12 20:00:06 +03:00
delayedContent: true,
),
2024-03-13 12:36:50 +03:00
error: (error, _) => error is NoDataException
2024-04-12 16:59:47 +03:00
? MessagePageNotInitialized(
title: l10n.s_accounts,
capabilities: const [Capability.oath],
)
2024-03-12 20:00:06 +03:00
: AppFailurePage(
cause: error,
),
data: (oathState) => oathState.locked
? _LockedView(devicePath, oathState)
: _UnlockedView(devicePath, oathState));
2022-03-31 12:41:28 +03:00
}
}
class _LockedView extends ConsumerWidget {
final DevicePath devicePath;
final OathState oathState;
const _LockedView(this.devicePath, this.oathState);
2022-03-31 12:41:28 +03:00
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasActions = ref.watch(featureProvider)(features.actions);
return AppPage(
2024-01-15 17:58:40 +03:00
title: AppLocalizations.of(context)!.s_accounts,
2024-01-26 13:59:37 +03:00
capabilities: const [Capability.oath],
keyActionsBuilder: hasActions
? (context) => oathBuildActions(context, devicePath, oathState, ref)
: null,
builder: (context, _) => Padding(
2024-01-15 17:58:40 +03:00
padding: const EdgeInsets.symmetric(vertical: 8),
child: UnlockForm(
devicePath,
keystore: oathState.keystore,
),
),
);
}
2022-03-31 12:41:28 +03:00
}
class _UnlockedView extends ConsumerStatefulWidget {
2022-03-31 12:41:28 +03:00
final DevicePath devicePath;
final OathState oathState;
const _UnlockedView(this.devicePath, this.oathState);
2022-03-31 12:41:28 +03:00
@override
ConsumerState<ConsumerStatefulWidget> createState() => _UnlockedViewState();
}
class _UnlockedViewState extends ConsumerState<_UnlockedView> {
late FocusNode searchFocus;
late TextEditingController searchController;
OathCredential? _selected;
2024-06-13 10:29:30 +03:00
bool _canRequestFocus = true;
@override
void initState() {
super.initState();
searchFocus = FocusNode();
2024-03-20 19:01:26 +03:00
searchController =
TextEditingController(text: ref.read(accountsSearchProvider));
2023-12-20 17:09:31 +03:00
searchFocus.addListener(_onFocusChange);
}
@override
void dispose() {
searchFocus.dispose();
searchController.dispose();
super.dispose();
}
2023-12-20 17:09:31 +03:00
void _onFocusChange() {
setState(() {});
}
@override
Widget build(BuildContext context) {
2023-02-28 21:05:46 +03:00
final l10n = AppLocalizations.of(context)!;
// ONLY rebuild if the number of credentials changes.
final numCreds = ref.watch(credentialListProvider(widget.devicePath)
.select((value) => value?.length));
final hasFeature = ref.watch(featureProvider);
final hasActions = hasFeature(features.actions);
final searchText = searchController.text;
2024-01-10 18:06:12 +03:00
Future<void> onFileDropped(File file) async {
final qrScanner = ref.read(qrScannerProvider);
if (qrScanner != null) {
final withContext = ref.read(withContextProvider);
final qrData =
await handleQrFile(file, context, withContext, qrScanner);
if (qrData != null) {
await withContext((context) async {
final credentials = ref.read(credentialsProvider);
await handleUri(context, credentials, qrData, widget.devicePath,
widget.oathState, l10n);
});
}
}
}
if (numCreds == 0) {
return MessagePage(
actionsBuilder: (context, expanded) => [
if (!expanded)
ActionChip(
label: Text(l10n.s_add_account),
onPressed: () async {
2024-02-05 19:07:35 +03:00
await addOathAccount(
context,
2024-02-05 18:55:36 +03:00
ref,
widget.devicePath,
widget.oathState,
);
},
2024-03-08 11:30:47 +03:00
avatar: const Icon(Symbols.person_add_alt),
)
],
title: l10n.s_accounts,
2024-01-26 13:59:37 +03:00
capabilities: const [Capability.oath],
2022-09-12 13:58:17 +03:00
key: keys.noAccountsView,
header: l10n.l_authenticator_get_started,
2024-01-29 16:58:00 +03:00
message: l10n.l_no_accounts_desc,
keyActionsBuilder: hasActions
? (context) => oathBuildActions(
context, widget.devicePath, widget.oathState, ref,
used: 0)
: null,
onFileDropped: onFileDropped,
2024-01-08 16:11:32 +03:00
fileDropOverlay: FileDropOverlay(
title: l10n.s_add_account,
subtitle: l10n.l_drop_qr_description,
2024-01-08 16:11:32 +03:00
),
2022-03-31 12:41:28 +03:00
);
}
2024-01-30 19:16:08 +03:00
if (numCreds == null) {
return AppPage(
title: AppLocalizations.of(context)!.s_accounts,
capabilities: const [Capability.oath],
2024-01-30 19:16:08 +03:00
centered: true,
delayedContent: true,
builder: (context, _) => const CircularProgressIndicator(),
);
}
return OathActions(
devicePath: widget.devicePath,
actions: (context) => {
SearchIntent: CallbackAction<SearchIntent>(onInvoke: (_) {
searchController.selection = TextSelection(
baseOffset: 0, extentOffset: searchController.text.length);
searchFocus.unfocus();
Timer.run(() => searchFocus.requestFocus());
return null;
}),
EscapeIntent: CallbackAction<EscapeIntent>(onInvoke: (intent) {
if (_selected != null) {
setState(() {
_selected = null;
});
} else {
Actions.invoke(context, intent);
}
return false;
}),
OpenIntent<OathCredential>: CallbackAction<OpenIntent<OathCredential>>(
onInvoke: (intent) async {
await showBlurDialog(
context: context,
barrierColor: Colors.transparent,
builder: (context) => AccountDialog(intent.target),
);
return null;
}),
if (hasFeature(features.accountsRename))
EditIntent<OathCredential>:
CallbackAction<EditIntent<OathCredential>>(
onInvoke: (intent) async {
final renamed =
await (Actions.invoke(context, intent) as Future<dynamic>?);
if (renamed is OathCredential && _selected == intent.target) {
setState(() {
_selected = renamed;
});
}
return renamed;
}),
if (hasFeature(features.accountsDelete))
DeleteIntent<OathCredential>:
CallbackAction<DeleteIntent<OathCredential>>(
onInvoke: (intent) async {
final deleted =
await (Actions.invoke(context, intent) as Future<dynamic>?);
if (deleted == true && _selected == intent.target) {
setState(() {
_selected = null;
});
2022-07-07 13:40:54 +03:00
}
return deleted;
}),
},
builder: (context) => AppPage(
2024-01-15 17:58:40 +03:00
title: l10n.s_accounts,
alternativeTitle:
searchText != '' ? l10n.l_results_for(searchText) : null,
2024-01-26 13:59:37 +03:00
capabilities: const [Capability.oath],
keyActionsBuilder: hasActions
? (context) => oathBuildActions(
context,
widget.devicePath,
widget.oathState,
ref,
2024-01-30 19:16:08 +03:00
used: numCreds,
)
: null,
onFileDropped: onFileDropped,
fileDropOverlay: FileDropOverlay(
title: l10n.s_add_account,
subtitle: l10n.l_drop_qr_description,
),
detailViewBuilder: _selected != null
? (context) {
final helper = AccountHelper(context, ref, _selected!);
final subtitle = helper.subtitle;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTitle(l10n.s_details),
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Card(
elevation: 0.0,
color: Theme.of(context).hoverColor,
child: Padding(
2024-04-25 16:26:33 +03:00
padding: const EdgeInsets.symmetric(
vertical: 24, horizontal: 16),
child: Column(
children: [
2024-04-25 16:26:33 +03:00
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconTheme(
data: IconTheme.of(context)
.copyWith(size: 24),
child: helper.buildCodeIcon(),
),
const SizedBox(width: 8.0),
DefaultTextStyle.merge(
style: const TextStyle(fontSize: 28),
child: helper.buildCodeLabel(),
),
],
),
2024-04-25 16:26:33 +03:00
const SizedBox(height: 16),
TooltipIfTruncated(
text: helper.title,
style: TextStyle(
fontSize: Theme.of(context)
.textTheme
.headlineSmall
?.fontSize),
),
if (subtitle != null)
2024-04-25 16:26:33 +03:00
TooltipIfTruncated(
text: subtitle,
// This is what ListTile uses for subtitle
style: Theme.of(context)
.textTheme
.bodyMedium!
.copyWith(
color: Theme.of(context)
2024-04-25 16:26:33 +03:00
.colorScheme
.onSurfaceVariant,
),
),
],
),
),
),
),
ActionListSection.fromMenuActions(
context,
AppLocalizations.of(context)!.s_actions,
actions: helper.buildActions(),
),
],
);
}
: null,
headerSliver: Focus(
canRequestFocus: false,
onKeyEvent: (node, event) {
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
node.focusInDirection(TraversalDirection.down);
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.escape) {
searchController.clear();
2024-03-20 19:01:26 +03:00
ref.read(accountsSearchProvider.notifier).setFilter('');
node.unfocus();
setState(() {});
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: LayoutBuilder(builder: (context, constraints) {
final width = constraints.maxWidth;
final textTheme = Theme.of(context).textTheme;
2024-06-13 12:09:06 +03:00
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));
2024-06-13 13:48:03 +03:00
final availableLayouts = pinnedCreds.isNotEmpty
? OathLayout.values
: OathLayout.values
.where((element) => element != OathLayout.mixed);
final oathLayout = ref.watch(oathLayoutProvider);
2024-06-13 13:48:03 +03:00
2024-06-13 12:09:06 +03:00
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0, vertical: 8.0),
child: AppTextFormField(
key: searchField,
controller: searchController,
canRequestFocus: _canRequestFocus,
focusNode: searchFocus,
// Use the default style, but with a smaller font size:
style: textTheme.titleMedium
?.copyWith(fontSize: textTheme.titleSmall?.fontSize),
decoration: AppInputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(48),
borderSide: BorderSide(
width: 0,
style: searchFocus.hasFocus
? BorderStyle.solid
: BorderStyle.none,
2024-06-13 10:29:30 +03:00
),
),
2024-06-13 12:09:06 +03:00
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),
2024-06-13 10:29:30 +03:00
),
2024-06-13 12:09:06 +03:00
suffixIcons: [
if (searchController.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
iconSize: 16,
onPressed: () {
searchController.clear();
ref
.read(accountsSearchProvider.notifier)
.setFilter('');
setState(() {});
},
2024-06-13 11:35:29 +03:00
),
2024-06-13 13:50:10 +03:00
if (searchController.text.isEmpty &&
!searchFocus.hasFocus) ...[
2024-06-13 12:09:06 +03:00
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
// need this to maintain consistent distance
// between icons
padding: const EdgeInsets.only(left: 17.0),
child: Container(
color:
Theme.of(context).colorScheme.background,
width: 1,
2024-06-13 13:50:10 +03:00
height: 45,
2024-06-13 12:09:06 +03:00
),
),
],
),
if (width >= 380)
...availableLayouts.map(
(e) => MouseRegion(
onEnter: (event) {
if (!searchFocus.hasFocus) {
setState(() {
_canRequestFocus = false;
});
}
},
onExit: (event) {
setState(() {
_canRequestFocus = true;
});
},
child: IconButton(
tooltip: e.getDisplayName(l10n),
onPressed: () {
ref
.read(oathLayoutProvider.notifier)
.setLayout(e);
},
icon: Icon(
e._icon,
color: e == oathLayout
? Theme.of(context).colorScheme.primary
: null,
),
),
2024-06-13 12:09:06 +03:00
),
),
if (width < 380)
2024-06-13 12:09:06 +03:00
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),
2024-06-13 12:09:06 +03:00
icon: Icon(
oathLayout._icon,
color: Theme.of(context).colorScheme.primary,
2024-06-13 12:09:06 +03:00
),
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);
},
),
)
],
2024-06-13 12:09:06 +03:00
),
)
2024-06-13 12:09:06 +03:00
]
],
),
2024-06-13 10:29:30 +03:00
2024-06-13 12:09:06 +03:00
onChanged: (value) {
ref
.read(accountsSearchProvider.notifier)
.setFilter(value);
setState(() {});
},
textInputAction: TextInputAction.next,
onFieldSubmitted: (value) {
Focus.of(context)
.focusInDirection(TraversalDirection.down);
},
).init(),
);
},
);
}),
),
builder: (context, expanded) {
// De-select if window is resized to be non-expanded.
if (!expanded && _selected != null) {
Timer.run(() {
setState(() {
_selected = null;
});
});
}
2024-01-30 19:16:08 +03:00
return Actions(
actions: {
if (expanded)
OpenIntent<OathCredential>:
CallbackAction<OpenIntent<OathCredential>>(
onInvoke: (OpenIntent<OathCredential> intent) {
setState(() {
_selected = intent.target;
});
return null;
}),
},
child: Column(
children: [
Consumer(
builder: (context, ref, _) {
return AccountList(
ref.watch(credentialListProvider(widget.devicePath)) ??
[],
expanded: expanded,
selected: _selected,
);
},
)
2024-01-30 19:16:08 +03:00
],
),
);
},
),
);
}
}