/* * 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/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/models.dart'; import '../../app/shortcuts.dart'; import '../../app/views/app_failure_page.dart'; import '../../app/views/app_page.dart'; import '../../app/views/message_page.dart'; import '../../core/state.dart'; import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_text_form_field.dart'; import '../features.dart' as features; import '../keys.dart' as keys; import '../models.dart'; import '../state.dart'; import 'account_list.dart'; import 'key_actions.dart'; import 'unlock_form.dart'; class OathScreen extends ConsumerWidget { final DevicePath devicePath; 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: Text(l10n.s_authenticator), graphic: const CircularProgressIndicator(), delayedContent: true, ), error: (error, _) => AppFailurePage( title: Text(l10n.s_authenticator), cause: error, ), data: (oathState) => oathState.locked ? _LockedView(devicePath, oathState) : _UnlockedView(devicePath, oathState), ); } } class _LockedView extends ConsumerWidget { final DevicePath devicePath; final OathState oathState; const _LockedView(this.devicePath, this.oathState); @override Widget build(BuildContext context, WidgetRef ref) { final hasActions = ref.watch(featureProvider)(features.actions); return AppPage( title: Text(AppLocalizations.of(context)!.s_authenticator), keyActionsBuilder: hasActions ? (context) => oathBuildActions(context, devicePath, oathState, ref) : null, child: Padding( padding: const EdgeInsets.symmetric(vertical: 18), child: UnlockForm( devicePath, keystore: oathState.keystore, ), ), ); } } class _UnlockedView extends ConsumerStatefulWidget { final DevicePath devicePath; final OathState oathState; const _UnlockedView(this.devicePath, this.oathState); @override ConsumerState createState() => _UnlockedViewState(); } class _UnlockedViewState extends ConsumerState<_UnlockedView> { late FocusNode searchFocus; late TextEditingController searchController; @override void initState() { super.initState(); searchFocus = FocusNode(); searchController = TextEditingController(text: ref.read(searchProvider)); searchFocus.addListener(_onFocusChange); } @override void dispose() { searchFocus.dispose(); searchController.dispose(); super.dispose(); } void _onFocusChange() { setState(() {}); } @override Widget build(BuildContext context) { 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 hasActions = ref.watch(featureProvider)(features.actions); if (numCreds == 0) { return MessagePage( title: Text(l10n.s_authenticator), key: keys.noAccountsView, graphic: Icon(Icons.people, size: 96, color: Theme.of(context).colorScheme.primary), header: l10n.s_no_accounts, keyActionsBuilder: hasActions ? (context) => oathBuildActions( context, widget.devicePath, widget.oathState, ref, used: 0) : null, ); } return Actions( actions: { SearchIntent: CallbackAction(onInvoke: (_) { searchController.selection = TextSelection( baseOffset: 0, extentOffset: searchController.text.length); searchFocus.requestFocus(); return null; }), }, child: AppPage( title: Focus( canRequestFocus: false, onKeyEvent: (node, event) { if (event.logicalKey == LogicalKeyboardKey.arrowDown) { node.focusInDirection(TraversalDirection.down); return KeyEventResult.handled; } return KeyEventResult.ignored; }, child: Builder(builder: (context) { final textTheme = Theme.of(context).textTheme; return AppTextFormField( key: keys.searchAccountsField, 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(32), 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), ), suffixIcon: searchController.text.isNotEmpty ? IconButton( icon: const Icon(Icons.clear), iconSize: 16, onPressed: () { searchController.clear(); ref.read(searchProvider.notifier).setFilter(''); setState(() {}); }, ) : null, ), onChanged: (value) { ref.read(searchProvider.notifier).setFilter(value); setState(() {}); }, textInputAction: TextInputAction.next, onFieldSubmitted: (value) { Focus.of(context).focusInDirection(TraversalDirection.down); }, ); }), ), keyActionsBuilder: hasActions ? (context) => oathBuildActions( context, widget.devicePath, widget.oathState, ref, used: numCreds ?? 0, ) : null, centered: numCreds == null, delayedContent: numCreds == null, child: numCreds != null ? Consumer( builder: (context, ref, _) { return AccountList( ref.watch(credentialListProvider(widget.devicePath)) ?? [], ); }, ) : const CircularProgressIndicator(), ), ); } }