Skip to content

Commit 9e60b6e

Browse files
committed
Adds an autocomplete feature to the Username field
Allows users to select from previously used usernames to speed up the creation of new entries with common usernames such as their primary email address.
1 parent b327cd0 commit 9e60b6e

File tree

9 files changed

+225
-4
lines changed

9 files changed

+225
-4
lines changed

lib/cubit/autocomplete_cubit.dart

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import 'package:bloc/bloc.dart';
2+
import 'package:flutter/material.dart';
3+
4+
part 'autocomplete_state.dart';
5+
6+
class AutocompleteCubit extends Cubit<AutocompleteState> {
7+
List<String> _usernames = [];
8+
9+
AutocompleteCubit() : super(AutocompleteInitial());
10+
11+
void setUsernames(List<String> usernames) {
12+
_usernames = List.from(usernames);
13+
emit(AutocompleteUsernamesLoaded(List.unmodifiable(_usernames)));
14+
}
15+
16+
void addUsername(String username) {
17+
final trimmed = username.trim();
18+
if (trimmed.isEmpty) return;
19+
_usernames.removeWhere((u) => u == trimmed);
20+
_usernames.insert(0, trimmed);
21+
emit(AutocompleteUsernamesLoaded(List.unmodifiable(_usernames)));
22+
}
23+
24+
List<String> get usernames => List.unmodifiable(_usernames);
25+
}

lib/cubit/autocomplete_state.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
part of 'autocomplete_cubit.dart';
2+
3+
@immutable
4+
abstract class AutocompleteState {}
5+
6+
class AutocompleteInitial extends AutocompleteState {}
7+
8+
class AutocompleteUsernamesLoaded extends AutocompleteState {
9+
final List<String> usernames;
10+
AutocompleteUsernamesLoaded(this.usernames);
11+
}

lib/cubit/vault_cubit.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import 'package:shared_preferences/shared_preferences.dart';
2727
import 'package:keevault/generated/l10n.dart';
2828

2929
import 'account_cubit.dart';
30+
import 'autocomplete_cubit.dart';
3031

3132
part 'vault_state.dart';
3233

@@ -37,6 +38,7 @@ class VaultCubit extends Cubit<VaultState> {
3738
final QuickUnlocker _qu;
3839
final EntryCubit _entryCubit;
3940
final GeneratorProfilesCubit _generatorProfilesCubit;
41+
final AutocompleteCubit _autocompleteCubit;
4042
PersistentQueue? _persistentQueueAfAssociations;
4143
final bool Function() isAutofilling;
4244
bool autoFillMergeAttemptDue = true;
@@ -51,6 +53,7 @@ class VaultCubit extends Cubit<VaultState> {
5153
this.isAutofilling,
5254
this._generatorProfilesCubit,
5355
this._accountCubit,
56+
this._autocompleteCubit,
5457
) : super(const VaultInitial());
5558

5659
LocalVaultFile? get currentVaultFile {
@@ -72,6 +75,10 @@ class VaultCubit extends Cubit<VaultState> {
7275
}
7376
}
7477

78+
void _updateAutocompleteUsernames(KdbxFile kdbxFile) {
79+
_autocompleteCubit.setUsernames(kdbxFile.allUsernames);
80+
}
81+
7582
Future<void> _applyAutofillPersistentQueueItems(List<dynamic> list, LinkedHashMap<String, KdbxEntry> entries) async {
7683
for (var item in list) {
7784
final entryUuid = item['entry'];
@@ -285,6 +292,7 @@ class VaultCubit extends Cubit<VaultState> {
285292
bool immediateRemoteRefresh = true,
286293
required bool safe,
287294
}) async {
295+
_updateAutocompleteUsernames(vault.files.current);
288296
if (user?.subscriptionStatus == AccountSubscriptionStatus.expired ||
289297
user?.subscriptionStatus == AccountSubscriptionStatus.freeTrialAvailable) {
290298
return;
@@ -1195,6 +1203,8 @@ class VaultCubit extends Cubit<VaultState> {
11951203
vault,
11961204
applyAndConsumePendingAutofillAssociations,
11971205
);
1206+
// Update autocomplete usernames after save so we remove any stale ones
1207+
_updateAutocompleteUsernames(mergedOrCurrentVaultFile.files.current);
11981208
//TODO:f: Sync with iOS shared credentials keychain (also in other places like merge from autofill and refresh)
11991209
// if (KeeVaultPlatform.isIOS) {
12001210
// final entries = mergedOrCurrentVaultFile.files.current.body.rootGroup

lib/extension_methods.dart

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import 'dart:collection';
22
import 'dart:typed_data';
3-
43
import 'package:collection/collection.dart' show IterableExtension;
54
import 'package:dio/dio.dart';
65
import 'package:flutter/widgets.dart';
@@ -12,7 +11,6 @@ import 'package:keevault/config/platform.dart';
1211
import 'package:keevault/kdf_cache.dart';
1312
import 'package:keevault/vault_backend/exceptions.dart';
1413
import 'package:argon2_ffi_base/argon2_ffi_base.dart';
15-
1614
import 'colors.dart';
1715

1816
extension StringExt on String {
@@ -27,6 +25,19 @@ extension StringExt on String {
2725
String prepend(String prefix) => '$prefix$this';
2826
}
2927

28+
extension KdbxFileUsernames on KdbxFile {
29+
/// Returns all unique, non-empty usernames in the database.
30+
List<String> get allUsernames {
31+
final entries = body.rootGroup.getAllEntriesExceptBin().values;
32+
final usernames = entries
33+
.map((e) => e.getString(KdbxKeyCommon.USER_NAME)?.getText().trim() ?? '')
34+
.where((u) => u.isNotEmpty)
35+
.toSet()
36+
.toList(growable: false);
37+
return usernames;
38+
}
39+
}
40+
3041
extension StringToInt on String {
3142
int toInt() => int.parse(this);
3243
}

lib/widgets/autofill_save.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
22
import 'package:flutter_bloc/flutter_bloc.dart';
33
import 'package:kdbx/kdbx.dart';
44
import 'package:keevault/cubit/account_cubit.dart';
5+
import 'package:keevault/cubit/autocomplete_cubit.dart';
56
import 'package:keevault/cubit/autofill_cubit.dart';
67
import 'package:keevault/cubit/entry_cubit.dart';
78
import 'package:keevault/cubit/filter_cubit.dart';
@@ -79,6 +80,9 @@ class _AutofillSaveWidgetState extends State<AutofillSaveWidget> with TraceableC
7980
entryCubit.endEditing(newEntry);
8081

8182
final filterCubit = BlocProvider.of<FilterCubit>(context);
83+
final autocompleteCubit = BlocProvider.of<AutocompleteCubit>(context);
84+
85+
autocompleteCubit.addUsername(newEntry?.getString(KdbxKeyCommon.USER_NAME)?.getText().trim() ?? '');
8286

8387
// We skip remote upload for now because it could take a long time
8488
// and interrupt the user's priority task for too long.

lib/widgets/entry_field.dart

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import 'package:kdbx/kdbx.dart' hide FieldType;
88
import 'package:keevault/cubit/entry_cubit.dart';
99
import 'package:keevault/logging/logger.dart';
1010
import 'package:keevault/model/entry.dart';
11+
import 'package:keevault/cubit/autocomplete_cubit.dart';
1112

1213
import 'package:clock/clock.dart';
1314
import 'package:keevault/model/field.dart';
@@ -171,6 +172,17 @@ class _EntryTextFieldState extends _EntryFieldState implements FieldDelegate {
171172
void initState() {
172173
super.initState();
173174
_focusNode.addListener(_focusNodeChanged);
175+
176+
final isUsernameField = widget.field.key?.key == KdbxKeyCommon.KEY_USER_NAME;
177+
if (isUsernameField) {
178+
_focusNode.addListener(() {
179+
if (_focusNode.hasFocus) {
180+
_updateAutocompleteSuggestions(context);
181+
} else {
182+
_removeAutocompleteOverlay();
183+
}
184+
});
185+
}
174186
_initController();
175187
}
176188

@@ -184,10 +196,137 @@ class _EntryTextFieldState extends _EntryFieldState implements FieldDelegate {
184196
_controller = TextEditingController(text: widget.field.textValue);
185197
}
186198

199+
OverlayEntry? _autocompleteOverlay;
200+
List<String> _filteredUsernames = [];
201+
bool _showAutocomplete = false;
202+
203+
void _showAutocompleteOverlay(BuildContext context) {
204+
if (_autocompleteOverlay != null) {
205+
_removeAutocompleteOverlay();
206+
}
207+
final overlay = Overlay.of(context);
208+
// Find the RenderBox of the TextFormField
209+
final box = _formFieldKey.currentContext?.findRenderObject() as RenderBox?;
210+
final offset = box?.localToGlobal(Offset.zero) ?? Offset.zero;
211+
final width = box?.size.width ?? 300;
212+
_autocompleteOverlay = OverlayEntry(
213+
builder: (context) => Positioned(
214+
left: offset.dx,
215+
top: offset.dy + (box?.size.height ?? 56),
216+
width: width,
217+
child: Material(
218+
elevation: 4.0,
219+
color: Theme.of(context).colorScheme.surfaceContainer,
220+
child: ConstrainedBox(
221+
constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.4),
222+
child: ListView.builder(
223+
shrinkWrap: true,
224+
padding: EdgeInsets.zero,
225+
itemCount: _filteredUsernames.length,
226+
itemBuilder: (context, index) {
227+
final option = _filteredUsernames[index];
228+
return ListTile(
229+
title: Text(option),
230+
onTap: () {
231+
setState(() {
232+
_controller.text = option;
233+
_controller.selection = TextSelection.fromPosition(TextPosition(offset: option.length));
234+
_showAutocomplete = false;
235+
_filteredUsernames = [];
236+
_removeAutocompleteOverlay();
237+
_focusNode.unfocus();
238+
_onUsernameChanged(option);
239+
});
240+
},
241+
);
242+
},
243+
),
244+
),
245+
),
246+
),
247+
);
248+
overlay.insert(_autocompleteOverlay!);
249+
}
250+
251+
void _removeAutocompleteOverlay() {
252+
_autocompleteOverlay?.remove();
253+
_autocompleteOverlay = null;
254+
}
255+
256+
void _updateAutocompleteSuggestions(BuildContext context) {
257+
final cubit = BlocProvider.of<AutocompleteCubit>(context, listen: false);
258+
final usernamesState = cubit.state;
259+
List<String> usernames = [];
260+
if (usernamesState is AutocompleteUsernamesLoaded) {
261+
usernames = usernamesState.usernames;
262+
}
263+
final input = _controller.text.trim();
264+
if (_focusNode.hasFocus) {
265+
if (input.isEmpty) {
266+
_filteredUsernames = usernames;
267+
} else {
268+
final lcInput = input.toLowerCase();
269+
_filteredUsernames = usernames.where((u) => u != lcInput && u.toLowerCase().contains(lcInput)).toList();
270+
}
271+
_showAutocomplete = _filteredUsernames.isNotEmpty;
272+
} else {
273+
_showAutocomplete = false;
274+
_filteredUsernames = [];
275+
}
276+
if (_showAutocomplete) {
277+
WidgetsBinding.instance.addPostFrameCallback((_) => _showAutocompleteOverlay(context));
278+
} else {
279+
_removeAutocompleteOverlay();
280+
}
281+
}
282+
283+
_onUsernameChanged(value) {
284+
_updateAutocompleteSuggestions(context);
285+
final StringValue? newValue = value == null
286+
? null
287+
: _isProtected
288+
? ProtectedValue.fromString(value)
289+
: PlainValue(value);
290+
final cubit = BlocProvider.of<EntryCubit>(context);
291+
if (widget.field.fieldStorage == FieldStorage.JSON) {
292+
if (widget.field.browserModel!.value == value) {
293+
// Flutter can call onChange when no changes have occurred!
294+
return;
295+
}
296+
cubit.updateField(
297+
null,
298+
widget.field.browserModel!.name,
299+
value: newValue,
300+
browserModel: widget.field.browserModel!.copyWith(value: value),
301+
);
302+
} else {
303+
if (widget.field.value.getText() == value) {
304+
// Flutter can call onChange when no changes have occurred!
305+
return;
306+
}
307+
cubit.updateField(widget.field.key, null, value: newValue);
308+
}
309+
}
310+
187311
@override
188312
Widget build(BuildContext context) {
189313
l.d('building ${widget.key} ($_isValueObscured)');
190314
final str = S.of(context);
315+
final isUsernameField = widget.field.key?.key == KdbxKeyCommon.KEY_USER_NAME;
316+
Widget fieldEditor;
317+
if (isUsernameField) {
318+
fieldEditor = StringEntryFieldEditor(
319+
onChange: _onUsernameChanged,
320+
fieldKey: widget.field.key,
321+
controller: _controller,
322+
formFieldKey: _formFieldKey,
323+
focusNode: _focusNode,
324+
delegate: this,
325+
field: widget.field,
326+
);
327+
} else {
328+
fieldEditor = _buildEntryFieldEditor();
329+
}
191330
return Dismissible(
192331
key: ValueKey(widget.field.key),
193332
background: Container(
@@ -215,7 +354,7 @@ class _EntryTextFieldState extends _EntryFieldState implements FieldDelegate {
215354
padding: const EdgeInsets.symmetric(horizontal: 16),
216355
child: Row(
217356
children: <Widget>[
218-
Expanded(child: _buildEntryFieldEditor()),
357+
Expanded(child: fieldEditor),
219358
Container(
220359
width: 48,
221360
height: 48,
@@ -234,6 +373,12 @@ class _EntryTextFieldState extends _EntryFieldState implements FieldDelegate {
234373
}
235374

236375
void _focusNodeChanged() {
376+
// // For username field, trigger loading suggestions when focused
377+
// final isUsernameField = widget.field.key?.key == KdbxKeyCommon.KEY_USER_NAME;
378+
// if (isUsernameField && _focusNode.hasFocus) {
379+
// final autocompleteCubit = BlocProvider.of<AutocompleteCubit>(context, listen: false);
380+
// autocompleteCubit.loadUsernames();
381+
// }
237382
if (!_isProtected) {
238383
return;
239384
}
@@ -464,6 +609,7 @@ class _EntryTextFieldState extends _EntryFieldState implements FieldDelegate {
464609
@override
465610
void dispose() {
466611
l.d('EntryFieldState.dispose() - ${widget.key} (${widget.field.key})');
612+
_removeAutocompleteOverlay();
467613
_controller.dispose();
468614
_focusNode.dispose();
469615
super.dispose();

lib/widgets/entry_list.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:async';
33
import 'package:flutter/material.dart';
44
import 'package:flutter_bloc/flutter_bloc.dart';
55
import 'package:kdbx/kdbx.dart';
6+
import 'package:keevault/cubit/autocomplete_cubit.dart';
67
import 'package:keevault/cubit/autofill_cubit.dart';
78
import 'package:keevault/cubit/entry_cubit.dart';
89
import 'package:keevault/cubit/filter_cubit.dart';
@@ -269,7 +270,9 @@ class EntryListItemWidget extends StatelessWidget {
269270
if ((keepChanges == null || keepChanges) && (entryCubit.state as EntryLoaded).entry.isDirty) {
270271
entryCubit.endEditing(entry);
271272
final filterCubit = BlocProvider.of<FilterCubit>(context);
273+
final autocompleteCubit = BlocProvider.of<AutocompleteCubit>(context);
272274
await BlocProvider.of<InteractionCubit>(context).entrySaved();
275+
autocompleteCubit.addUsername(entry.getString(KdbxKeyCommon.USER_NAME)?.getText().trim() ?? '');
273276
await iam.showIfAppropriate(InAppMessageTrigger.entryChanged);
274277
filterCubit.reFilter(entry.file!.trimmedTags, entry.file!.body.rootGroup);
275278
//TODO:f: A separate cubit to track state of ELIVMs might provide better performance and scroll position stability than recreating them all from scratch every time we re-filter?

lib/widgets/kee_vault_app.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import '../vault_backend/user.dart';
2727
import '../vault_backend/user_service.dart';
2828
import '../cubit/account_cubit.dart';
2929
import '../cubit/vault_cubit.dart';
30+
import '../cubit/autocomplete_cubit.dart';
3031
import '../generated/l10n.dart';
3132
import 'package:fluro/fluro.dart';
3233
import '../config/app.dart';
@@ -55,6 +56,7 @@ class KeeVaultAppState extends State<KeeVaultApp> with WidgetsBindingObserver, T
5556
final entryCubit = EntryCubit();
5657
final generatorProfilesCubit = GeneratorProfilesCubit();
5758
final autofillCubit = AutofillCubit();
59+
final autocompleteCubit = AutocompleteCubit();
5860
late UserRepository userRepo;
5961
late AccountCubit accountCubit;
6062

@@ -154,6 +156,7 @@ class KeeVaultAppState extends State<KeeVaultApp> with WidgetsBindingObserver, T
154156
() => autofillCubit.isAutofilling() || autofillCubit.isAutofillSaving(),
155157
generatorProfilesCubit,
156158
accountCubit,
159+
autocompleteCubit,
157160
),
158161
),
159162
BlocProvider(create: (context) => accountCubit),
@@ -164,6 +167,7 @@ class KeeVaultAppState extends State<KeeVaultApp> with WidgetsBindingObserver, T
164167
BlocProvider(create: (context) => generatorProfilesCubit),
165168
BlocProvider(create: (context) => InteractionCubit()),
166169
BlocProvider(create: (context) => AppRatingCubit()),
170+
BlocProvider(create: (context) => autocompleteCubit),
167171
],
168172
child: InAppMessengerWidget(
169173
appSettingsState: appSettingsState,

0 commit comments

Comments
 (0)