Skip to content

Commit 8f92304

Browse files
committed
feat: Implemented a search button.
1 parent ba1350b commit 8f92304

File tree

7 files changed

+417
-57
lines changed

7 files changed

+417
-57
lines changed

lib/model/totp/image_cache.dart

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,16 @@ class TotpImageCacheManager extends AutoDisposeAsyncNotifier<Map<String, String>
8181
}
8282

8383
/// Fills the cache with all TOTPs that can be read from the TOTP repository.
84-
Future<void> fillCache() async {
84+
Future<void> fillCache({bool checkSettings = true}) async {
85+
if (checkSettings) {
86+
bool cacheEnabled = await ref.read(cacheTotpPicturesSettingsEntryProvider.future);
87+
if (!cacheEnabled) {
88+
return;
89+
}
90+
}
8591
TotpList totps = await ref.read(totpRepositoryProvider.future);
8692
for (Totp totp in totps) {
87-
await cacheImage(totp);
93+
await cacheImage(totp, checkSettings: false);
8894
}
8995
}
9096

lib/model/totp/repository.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,9 @@ class TotpList extends Iterable<Totp> {
296296
@override
297297
Iterator<Totp> get iterator => _list.iterator;
298298

299+
/// Returns the index of the given [totp].
300+
int indexOf(Totp totp) => _list.indexOf(totp);
301+
299302
/// The next possible operation time.
300303
DateTime get nextPossibleOperationTime => updated.add(operationThreshold);
301304

lib/pages/home.dart

Lines changed: 175 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:flutter/foundation.dart';
22
import 'package:flutter/material.dart';
3+
import 'package:flutter/services.dart';
34
import 'package:flutter_riverpod/flutter_riverpod.dart';
45
import 'package:open_authenticator/i18n/translations.g.dart';
56
import 'package:open_authenticator/main.dart';
@@ -12,19 +13,22 @@ import 'package:open_authenticator/model/totp/totp.dart';
1213
import 'package:open_authenticator/pages/scan.dart';
1314
import 'package:open_authenticator/pages/settings/page.dart';
1415
import 'package:open_authenticator/pages/totp.dart';
16+
import 'package:open_authenticator/utils/brightness_listener.dart';
1517
import 'package:open_authenticator/utils/master_password.dart';
1618
import 'package:open_authenticator/utils/platform.dart';
1719
import 'package:open_authenticator/utils/result.dart';
1820
import 'package:open_authenticator/widgets/centered_circular_progress_indicator.dart';
1921
import 'package:open_authenticator/widgets/dialog/confirmation_dialog.dart';
2022
import 'package:open_authenticator/widgets/dialog/text_input_dialog.dart';
23+
import 'package:open_authenticator/widgets/smooth_highlight.dart';
2124
import 'package:open_authenticator/widgets/snackbar_icon.dart';
2225
import 'package:open_authenticator/widgets/title.dart';
2326
import 'package:open_authenticator/widgets/totp/widget.dart';
2427
import '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.
88126
class _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.
277447
class _AddTotpDialog extends StatelessWidget {
278448
@override

0 commit comments

Comments
 (0)