11import 'package:flutter/foundation.dart' ;
22import 'package:flutter/material.dart' ;
3+ import 'package:flutter/services.dart' ;
34import 'package:flutter_riverpod/flutter_riverpod.dart' ;
45import 'package:open_authenticator/i18n/translations.g.dart' ;
56import 'package:open_authenticator/main.dart' ;
@@ -12,19 +13,22 @@ import 'package:open_authenticator/model/totp/totp.dart';
1213import 'package:open_authenticator/pages/scan.dart' ;
1314import 'package:open_authenticator/pages/settings/page.dart' ;
1415import 'package:open_authenticator/pages/totp.dart' ;
16+ import 'package:open_authenticator/utils/brightness_listener.dart' ;
1517import 'package:open_authenticator/utils/master_password.dart' ;
1618import 'package:open_authenticator/utils/platform.dart' ;
1719import 'package:open_authenticator/utils/result.dart' ;
1820import 'package:open_authenticator/widgets/centered_circular_progress_indicator.dart' ;
1921import 'package:open_authenticator/widgets/dialog/confirmation_dialog.dart' ;
2022import 'package:open_authenticator/widgets/dialog/text_input_dialog.dart' ;
23+ import 'package:open_authenticator/widgets/smooth_highlight.dart' ;
2124import 'package:open_authenticator/widgets/snackbar_icon.dart' ;
2225import 'package:open_authenticator/widgets/title.dart' ;
2326import 'package:open_authenticator/widgets/totp/widget.dart' ;
2427import 'package:open_authenticator/widgets/waiting_overlay.dart' ;
28+ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart' ;
2529
2630/// The home page.
27- class HomePage extends ConsumerWidget {
31+ class HomePage extends ConsumerStatefulWidget {
2832 /// The home page name.
2933 static const String name = '/' ;
3034
@@ -34,10 +38,36 @@ class HomePage extends ConsumerWidget {
3438 });
3539
3640 @override
37- Widget build (BuildContext context, WidgetRef ref) => Scaffold (
41+ ConsumerState <ConsumerStatefulWidget > createState () => _HomePageState ();
42+ }
43+
44+ /// The home page state.
45+ class _HomePageState extends ConsumerState <HomePage > with BrightnessListener {
46+ /// Allows to scroll through the list of items.
47+ late final ItemScrollController itemScrollController = ItemScrollController ();
48+
49+ /// The TOTP to emphasis, if any.
50+ Totp ? emphasis;
51+
52+ @override
53+ Widget build (BuildContext context) => Scaffold (
3854 appBar: AppBar (
3955 title: const TitleWidget (),
4056 actions: [
57+ _SearchButton (
58+ onTotpFound: (totp) async {
59+ TotpList totps = await ref.read (totpRepositoryProvider.future);
60+ int index = totps.indexOf (totp);
61+ if (index >= 0 ) {
62+ itemScrollController.jumpTo (index: index);
63+ WidgetsBinding .instance.addPostFrameCallback ((_) {
64+ if (mounted) {
65+ setState (() => emphasis = totp);
66+ }
67+ });
68+ }
69+ },
70+ ),
4171 if (kDebugMode || currentPlatform != Platform .android)
4272 IconButton (
4373 onPressed: () => _onAddButtonPressed (context),
@@ -64,7 +94,15 @@ class HomePage extends ConsumerWidget {
6494 )
6595 : null ,
6696 floatingActionButtonLocation: FloatingActionButtonLocation .centerFloat,
67- body: _HomePageBody (),
97+ body: _HomePageBody (
98+ itemScrollController: itemScrollController,
99+ emphasis: emphasis,
100+ onHighlightFinished: () {
101+ if (mounted && emphasis != null ) {
102+ setState (() => emphasis = null );
103+ }
104+ },
105+ ),
68106 );
69107
70108 /// Triggered when the "Add" button is pressed.
@@ -86,6 +124,23 @@ class HomePage extends ConsumerWidget {
86124
87125/// The home page body, where all TOTPs are displayed.
88126class _HomePageBody extends ConsumerWidget {
127+ /// The item scroll controller.
128+ final ItemScrollController ? itemScrollController;
129+
130+ /// The TOTP to emphasis, if any.
131+ final Totp ? emphasis;
132+
133+ /// Triggered when the highlight has been finished.
134+ /// Should clear the [emphasis] .
135+ final VoidCallback ? onHighlightFinished;
136+
137+ /// Creates a new home page body instance.
138+ const _HomePageBody ({
139+ this .itemScrollController,
140+ this .emphasis,
141+ this .onHighlightFinished,
142+ });
143+
89144 @override
90145 Widget build (BuildContext context, WidgetRef ref) {
91146 AsyncValue <TotpList > totps = ref.watch (totpRepositoryProvider);
@@ -109,18 +164,27 @@ class _HomePageBody extends ConsumerWidget {
109164 ),
110165 ],
111166 )
112- : ListView .separated (
167+ : ScrollablePositionedList .separated (
168+ itemScrollController: itemScrollController,
113169 itemCount: value.length,
114170 itemBuilder: (context, position) {
115171 Totp totp = value[position];
116- return TotpWidget (
172+ Widget totpWidget = TotpWidget . adaptive (
117173 key: ValueKey (value[position].uuid),
118174 totp: totp,
119175 displayCode: isUnlocked.valueOrNull ?? false ,
120176 onDecryptPressed: () => _tryDecryptTotp (context, ref, totp),
121177 onEditPressed: () => _editTotp (context, ref, totp),
122178 onDeletePressed: () => _deleteTotp (context, ref, totp),
179+ onCopyPressed: totp.isDecrypted ? (() => _copyCode (context, totp as DecryptedTotp )) : null ,
123180 );
181+ return totp == emphasis
182+ ? SmoothHighlight (
183+ color: Theme .of (context).focusColor,
184+ useInitialHighLight: true ,
185+ child: totpWidget,
186+ )
187+ : totpWidget;
124188 },
125189 separatorBuilder: (context, position) => const Divider (),
126190 );
@@ -209,6 +273,14 @@ class _HomePageBody extends ConsumerWidget {
209273 }
210274 }
211275
276+ /// Allows to copy the code to the clipboard.
277+ Future <void > _copyCode (BuildContext context, DecryptedTotp totp) async {
278+ await Clipboard .setData (ClipboardData (text: totp.generateCode ()));
279+ if (context.mounted) {
280+ SnackBarIcon .showSuccessSnackBar (context, text: translations.totp.actions.copyConfirmation);
281+ }
282+ }
283+
212284 /// Tries to decrypt the current TOTP.
213285 Future <void > _tryDecryptTotp (BuildContext context, WidgetRef ref, Totp totp) async {
214286 String ? password = await TextInputDialog .prompt (
@@ -273,6 +345,104 @@ class _HomePageBody extends ConsumerWidget {
273345 }
274346}
275347
348+ /// Displays a search button if the TOTP list is available.
349+ class _SearchButton extends ConsumerWidget {
350+ /// Triggered when a TOTP has been found by the user.
351+ final Function (Totp totp) onTotpFound;
352+
353+ /// Creates a new search button instance.
354+ const _SearchButton ({
355+ required this .onTotpFound,
356+ });
357+
358+ @override
359+ Widget build (BuildContext context, WidgetRef ref) {
360+ AsyncValue <TotpList > totps = ref.watch (totpRepositoryProvider);
361+ return switch (totps) {
362+ AsyncData <TotpList >(: final value) => value.isEmpty
363+ ? const SizedBox .shrink ()
364+ : IconButton (
365+ onPressed: () async {
366+ Totp ? result = await showSearch (
367+ context: context,
368+ delegate: _TotpSearchDelegate (
369+ totpList: value,
370+ ),
371+ );
372+ if (result != null ) {
373+ onTotpFound (result);
374+ }
375+ },
376+ icon: const Icon (Icons .search),
377+ ),
378+ _ => const SizedBox .shrink (),
379+ };
380+ }
381+ }
382+
383+ /// Allows to search through the TOTP list.
384+ class _TotpSearchDelegate extends SearchDelegate <Totp > {
385+ /// The TOTP list.
386+ final TotpList totpList;
387+
388+ /// Creates a new TOTP search delegate instance.
389+ _TotpSearchDelegate ({
390+ required this .totpList,
391+ });
392+
393+ @override
394+ ThemeData appBarTheme (BuildContext context) {
395+ ThemeData theme = super .appBarTheme (context);
396+ return Theme .of (context).copyWith (
397+ appBarTheme: const AppBarTheme (
398+ backgroundColor: Colors .transparent,
399+ ),
400+ inputDecorationTheme: theme.inputDecorationTheme,
401+ );
402+ }
403+
404+ @override
405+ List <Widget >? buildActions (BuildContext context) => [
406+ IconButton (
407+ icon: const Icon (Icons .clear),
408+ onPressed: () => query = '' ,
409+ ),
410+ ];
411+
412+ @override
413+ Widget ? buildLeading (BuildContext context) => const BackButton ();
414+
415+ @override
416+ Widget buildResults (BuildContext context) {
417+ String lowercaseQuery = query.toLowerCase ();
418+ List <Totp > searchResults = [];
419+ for (Totp totp in totpList) {
420+ if (! totp.isDecrypted) {
421+ if (totp.uuid.contains (lowercaseQuery)) {
422+ searchResults.add (totp);
423+ }
424+ continue ;
425+ }
426+ DecryptedTotp decryptedTotp = totp as DecryptedTotp ;
427+ if ((decryptedTotp.label != null && decryptedTotp.label! .contains (lowercaseQuery)) || (decryptedTotp.issuer != null && decryptedTotp.issuer! .contains (lowercaseQuery))) {
428+ searchResults.add (decryptedTotp);
429+ }
430+ }
431+ return ListView .builder (
432+ itemCount: searchResults.length,
433+ itemBuilder: (context, index) {
434+ Totp totp = searchResults[index];
435+ return TotpWidget (
436+ totp: totp,
437+ onTap: (context) => close (context, totp),
438+ );
439+ });
440+ }
441+
442+ @override
443+ Widget buildSuggestions (BuildContext context) => buildResults (context);
444+ }
445+
276446/// A dialog that allows to choose a method to add a TOTP.
277447class _AddTotpDialog extends StatelessWidget {
278448 @override
0 commit comments