feat: tags selection by text field

This commit is contained in:
Prateek Sunal 2024-04-29 15:41:17 +05:30
parent 12635be0d2
commit 5a18fe3746
14 changed files with 312 additions and 62 deletions

View file

@ -427,6 +427,10 @@ extension CustomColorScheme on ColorScheme {
? const Color.fromRGBO(246, 246, 246, 1)
: const Color.fromRGBO(40, 40, 40, 0.6);
Color get primaryColor => brightness == Brightness.light
? const Color(0xFF9610D6)
: const Color(0xFF9610D6);
EnteTheme get enteTheme =>
brightness == Brightness.light ? lightTheme : darkTheme;

View file

@ -20,6 +20,7 @@
"codeIssuerHint": "Issuer",
"codeSecretKeyHint": "Secret Key",
"codeAccountHint": "Account (you@domain.com)",
"codeTagHint": "Tag",
"accountKeyType": "Type of key",
"sessionExpired": "Session expired",
"@sessionExpired": {

View file

@ -37,6 +37,7 @@ import 'package:window_manager/window_manager.dart';
final _logger = Logger("main");
Future<void> initSystemTray() async {
if (PlatformUtil.isMobile()) return;
String path = Platform.isWindows
? 'assets/icons/auth-icon.ico'
: 'assets/icons/auth-icon.png';

View file

@ -22,9 +22,9 @@ class Code {
final int counter;
bool? hasSynced;
final CodeDisplay? display;
final CodeDisplay display;
bool get isPinned => display?.pinned ?? false;
bool get isPinned => display.pinned;
Code(
this.account,
@ -37,7 +37,7 @@ class Code {
this.counter,
this.rawData, {
this.generatedID,
this.display,
required this.display,
});
Code copyWith({
@ -59,7 +59,7 @@ class Code {
final Algorithm updatedAlgo = algorithm ?? this.algorithm;
final Type updatedType = type ?? this.type;
final int updatedCounter = counter ?? this.counter;
final CodeDisplay? updatedDisplay = display ?? this.display;
final CodeDisplay updatedDisplay = display ?? this.display;
return Code(
updateAccount,
@ -80,6 +80,7 @@ class Code {
String account,
String issuer,
String secret,
CodeDisplay? display,
) {
return Code(
account,
@ -91,6 +92,7 @@ class Code {
Type.totp,
0,
"otpauth://totp/$issuer:$account?algorithm=SHA1&digits=6&issuer=$issuer&period=30&secret=$secret",
display: display ?? CodeDisplay(),
);
}
@ -107,7 +109,7 @@ class Code {
_getType(uri),
_getCounter(uri),
rawData,
display: CodeDisplay.fromUri(uri),
display: CodeDisplay.fromUri(uri) ?? CodeDisplay(),
);
} catch (e) {
// if account name contains # without encoding,
@ -154,7 +156,7 @@ class Code {
return jsonEncode(
Uri.parse(
"$rawData&codeDisplay="
"${jsonEncode((display ?? CodeDisplay()).toJson())}",
"${jsonEncode(display.toJson())}",
).toString(),
);
}
@ -221,7 +223,7 @@ class Code {
}
static Type _getType(Uri uri) {
if (uri.host == "totp" || uri.host == "steam") {
if (uri.host == "totp") {
return Type.totp;
} else if (uri.host == "hotp") {
return Type.hotp;

View file

@ -1,5 +1,7 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
/// Used to store the display settings of a code.
class CodeDisplay {
final bool pinned;
@ -48,6 +50,7 @@ class CodeDisplay {
trashed: json['trashed'] ?? false,
lastUsedAt: json['lastUsedAt'] ?? 0,
tapCount: json['tapCount'] ?? 0,
tags: List<String>.from(json['tags'] ?? []),
);
}
@ -65,6 +68,28 @@ class CodeDisplay {
'trashed': trashed,
'lastUsedAt': lastUsedAt,
'tapCount': tapCount,
'tags': tags,
};
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is CodeDisplay &&
other.pinned == pinned &&
other.trashed == trashed &&
other.lastUsedAt == lastUsedAt &&
other.tapCount == tapCount &&
listEquals(other.tags, tags);
}
@override
int get hashCode {
return pinned.hashCode ^
trashed.hashCode ^
lastUsedAt.hashCode ^
tapCount.hashCode ^
tags.hashCode;
}
}

View file

@ -0,0 +1,26 @@
import 'package:ente_auth/models/code.dart';
class CodeState {
final Code? code;
final String? error;
CodeState({
required this.code,
required this.error,
}) : assert(code != null || error != null);
}
class Codes {
final List<CodeState> allCodes;
final List<String> tags;
Codes({
required this.allCodes,
required this.tags,
});
List<Code> get validCodes => allCodes
.where((element) => element.code != null)
.map((e) => e.code!)
.toList();
}

View file

@ -1,5 +1,6 @@
import "package:ente_auth/l10n/l10n.dart";
import 'package:ente_auth/models/code.dart';
import 'package:ente_auth/models/code_display.dart';
import 'package:ente_auth/ui/components/buttons/button_widget.dart';
import 'package:ente_auth/ui/components/models/button_result.dart';
import 'package:ente_auth/utils/dialog_util.dart';
@ -8,8 +9,9 @@ import "package:flutter/material.dart";
class SetupEnterSecretKeyPage extends StatefulWidget {
final Code? code;
final List<String> tags;
SetupEnterSecretKeyPage({this.code, super.key});
SetupEnterSecretKeyPage({this.code, super.key, required this.tags});
@override
State<SetupEnterSecretKeyPage> createState() =>
@ -20,7 +22,9 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
late TextEditingController _issuerController;
late TextEditingController _accountController;
late TextEditingController _secretController;
late TextEditingController _tagController;
late bool _secretKeyObscured;
late List<String> tags = [...?widget.code?.display.tags];
@override
void initState() {
@ -34,6 +38,7 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
_secretController = TextEditingController(
text: widget.code?.secret,
);
_tagController = TextEditingController();
_secretKeyObscured = widget.code != null;
super.initState();
}
@ -50,6 +55,7 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 40.0, horizontal: 40),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
// The validator receives the text that the user has entered.
@ -108,6 +114,54 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
),
controller: _accountController,
),
const SizedBox(
height: 20,
),
TextFormField(
// The validator receives the text that the user has entered.
validator: (value) {
if (value == null || value.isEmpty) {
return "Please enter some text";
}
return null;
},
onFieldSubmitted: (str) {
if (str.trim().isEmpty) {
return;
}
if (!tags.contains(str)) {
tags.add(str);
}
// Clear the tag
_tagController.text = "";
setState(() {});
},
decoration: InputDecoration(
hintText: l10n.codeTagHint,
),
controller: _tagController,
),
if (tags.isNotEmpty) ...[
const SizedBox(height: 10),
Wrap(
spacing: 12,
alignment: WrapAlignment.start,
children: tags
.map(
(e) => TagChip(
label: e,
onTap: () {
tags.remove(e);
setState(() {});
},
),
)
.toList(),
),
const SizedBox(height: 10),
],
const SizedBox(
height: 40,
),
@ -166,16 +220,19 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
return;
}
}
final CodeDisplay display = widget.code!.display.copyWith(tags: tags);
final Code newCode = widget.code == null
? Code.fromAccountAndSecret(
account,
issuer,
secret,
display,
)
: widget.code!.copyWith(
account: account,
issuer: issuer,
secret: secret,
display: display,
);
// Verify the validity of the code
getOTP(newCode);
@ -196,3 +253,52 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
);
}
}
class TagChip extends StatelessWidget {
final String label;
final VoidCallback? onTap;
final bool isSelected;
const TagChip({
super.key,
required this.label,
this.isSelected = false,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Chip(
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
color: isSelected ? Colors.white : const Color(0xFF8232E1),
),
),
if (isSelected) ...[
const SizedBox(width: 4),
const Icon(
Icons.check,
size: 16,
color: Colors.white,
),
],
],
),
backgroundColor:
isSelected ? const Color(0xFF722ED1) : const Color(0xFFFCF5FF),
side: BorderSide(
color: isSelected ? const Color(0xFF722ED1) : const Color(0xFFFCF5FF),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(100),
),
),
);
}
}

View file

@ -6,6 +6,7 @@ import 'package:ente_auth/core/event_bus.dart';
import 'package:ente_auth/events/codes_updated_event.dart';
import 'package:ente_auth/models/authenticator/entity_result.dart';
import 'package:ente_auth/models/code.dart';
import 'package:ente_auth/models/codes.dart';
import 'package:ente_auth/services/authenticator_service.dart';
import 'package:ente_auth/store/offline_authenticator_db.dart';
import 'package:logging/logging.dart';
@ -22,14 +23,16 @@ class CodeStore {
_authenticatorService = AuthenticatorService.instance;
}
Future<List<Code>> getAllCodes({AccountMode? accountMode}) async {
Future<Codes> getAllCodes({AccountMode? accountMode}) async {
final mode = accountMode ?? _authenticatorService.getAccountMode();
final List<EntityResult> entities =
await _authenticatorService.getEntities(mode);
final List<Code> codes = [];
final List<CodeState> codes = [];
List<String> tags = [];
for (final entity in entities) {
try {
final decodeJson = jsonDecode(entity.rawData);
late Code code;
if (decodeJson is String && decodeJson.startsWith('otpauth://')) {
code = Code.fromOTPAuthUrl(decodeJson);
@ -38,24 +41,38 @@ class CodeStore {
}
code.generatedID = entity.generatedID;
code.hasSynced = entity.hasSynced;
codes.add(code);
codes.add(CodeState(code: code, error: null));
tags.addAll(code.display.tags);
} catch (e) {
codes.add(CodeState(code: null, error: e.toString()));
_logger.severe("Could not parse code", e);
}
}
// sort codes by issuer,account
codes.sort((a, b) {
if (b.isPinned && !a.isPinned) return 1;
if (!b.isPinned && a.isPinned) return -1;
if (a.code == null && b.code == null) return 0;
if (a.code == null) return 1;
if (b.code == null) return -1;
final issuerComparison = compareAsciiLowerCaseNatural(a.issuer, b.issuer);
final firstCode = a.code!;
final secondCode = b.code!;
if (secondCode.isPinned && !firstCode.isPinned) return 1;
if (!secondCode.isPinned && firstCode.isPinned) return -1;
final issuerComparison =
compareAsciiLowerCaseNatural(firstCode.issuer, secondCode.issuer);
if (issuerComparison != 0) {
return issuerComparison;
}
return compareAsciiLowerCaseNatural(a.account, b.account);
return compareAsciiLowerCaseNatural(
firstCode.account,
secondCode.account,
);
});
return codes;
tags = tags.toSet().toList();
return Codes(allCodes: codes, tags: tags);
}
Future<AddResult> addCode(
@ -67,7 +84,7 @@ class CodeStore {
final codes = await getAllCodes(accountMode: mode);
bool isExistingCode = false;
bool hasSameCode = false;
for (final existingCode in codes) {
for (final existingCode in codes.validCodes) {
if (code.generatedID != null &&
existingCode.generatedID == code.generatedID) {
isExistingCode = true;
@ -124,8 +141,9 @@ class CodeStore {
}
logger.info('start import');
List<Code> offlineCodes = await CodeStore.instance
.getAllCodes(accountMode: AccountMode.offline);
List<Code> offlineCodes = (await CodeStore.instance
.getAllCodes(accountMode: AccountMode.offline))
.validCodes;
if (offlineCodes.isEmpty) {
return;
}
@ -134,8 +152,9 @@ class CodeStore {
logger.info("skip as online sync is not done");
return;
}
final List<Code> onlineCodes =
await CodeStore.instance.getAllCodes(accountMode: AccountMode.online);
final List<Code> onlineCodes = (await CodeStore.instance
.getAllCodes(accountMode: AccountMode.online))
.validCodes;
logger.info(
'importing ${offlineCodes.length} offline codes with ${onlineCodes.length} online codes',
);

View file

@ -1,3 +1,4 @@
import 'package:ente_auth/theme/ente_theme.dart';
import 'package:ente_auth/ui/linear_progress_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
@ -47,9 +48,14 @@ class _CodeTimerProgressState extends State<CodeTimerProgress>
@override
Widget build(BuildContext context) {
return LinearProgressWidget(
color: _progress > 0.4 ? const Color(0xFF9610D6) : Colors.orange,
fractionOfStorage: _progress,
return SizedBox(
height: 3,
child: LinearProgressWidget(
color: _progress > 0.4
? getEnteColorScheme(context).primary700
: Colors.orange,
fractionOfStorage: _progress,
),
);
}
}

View file

@ -27,8 +27,9 @@ import 'package:move_to_background/move_to_background.dart';
class CodeWidget extends StatefulWidget {
final Code code;
final List<String> tags;
const CodeWidget(this.code, {super.key});
const CodeWidget(this.code, this.tags, {super.key});
@override
State<CodeWidget> createState() => _CodeWidgetState();
@ -500,7 +501,10 @@ class _CodeWidgetState extends State<CodeWidget> {
final Code? code = await Navigator.of(context).push(
MaterialPageRoute(
builder: (BuildContext context) {
return SetupEnterSecretKeyPage(code: widget.code);
return SetupEnterSecretKeyPage(
code: widget.code,
tags: widget.tags,
);
},
),
);

View file

@ -10,6 +10,7 @@ import 'package:ente_auth/events/icons_changed_event.dart';
import 'package:ente_auth/events/trigger_logout_event.dart';
import "package:ente_auth/l10n/l10n.dart";
import 'package:ente_auth/models/code.dart';
import 'package:ente_auth/models/codes.dart';
import 'package:ente_auth/onboarding/view/setup_enter_secret_key_page.dart';
import 'package:ente_auth/services/preference_service.dart';
import 'package:ente_auth/services/user_service.dart';
@ -54,8 +55,8 @@ class _HomePageState extends State<HomePage> {
final FocusNode searchInputFocusNode = FocusNode();
bool _showSearchBox = false;
String _searchText = "";
List<Code> _codes = [];
List<Code> _filteredCodes = [];
Codes? _codes;
List<CodeState> _filteredCodes = [];
StreamSubscription<CodesUpdatedEvent>? _streamSubscription;
StreamSubscription<TriggerLogoutEvent>? _triggerLogoutEvent;
StreamSubscription<IconsChangedEvent>? _iconsChangedEvent;
@ -105,26 +106,28 @@ class _HomePageState extends State<HomePage> {
}
void _applyFilteringAndRefresh() {
if (_searchText.isNotEmpty && _showSearchBox) {
if (_searchText.isNotEmpty && _showSearchBox && _codes != null) {
final String val = _searchText.toLowerCase();
// Prioritize issuer match above account for better UX while searching
// for a specific TOTP for email providers. Searching for "emailProvider" like (gmail, proton) should
// show the email provider first instead of other accounts where protonmail
// is the account name.
final List<Code> issuerMatch = [];
final List<Code> accountMatch = [];
final List<CodeState> issuerMatch = [];
final List<CodeState> accountMatch = [];
for (final Code code in _codes) {
if (code.issuer.toLowerCase().contains(val)) {
issuerMatch.add(code);
} else if (code.account.toLowerCase().contains(val)) {
accountMatch.add(code);
for (final CodeState codeState in _codes!.allCodes) {
if (codeState.error != null) continue;
if (codeState.code!.issuer.toLowerCase().contains(val)) {
issuerMatch.add(codeState);
} else if (codeState.code!.account.toLowerCase().contains(val)) {
accountMatch.add(codeState);
}
}
_filteredCodes = issuerMatch;
_filteredCodes.addAll(accountMatch);
} else {
_filteredCodes = _codes;
_filteredCodes = _codes?.allCodes ?? [];
}
if (mounted) {
setState(() {});
@ -151,7 +154,7 @@ class _HomePageState extends State<HomePage> {
if (code != null) {
await CodeStore.instance.addCode(code);
// Focus the new code by searching
if (_codes.length > 2) {
if ((_codes?.allCodes.length ?? 0) > 2) {
_focusNewCode(code);
}
}
@ -161,7 +164,9 @@ class _HomePageState extends State<HomePage> {
final Code? code = await Navigator.of(context).push(
MaterialPageRoute(
builder: (BuildContext context) {
return SetupEnterSecretKeyPage();
return SetupEnterSecretKeyPage(
tags: _codes?.tags ?? [],
);
},
),
);
@ -219,6 +224,7 @@ class _HomePageState extends State<HomePage> {
focusedBorder: InputBorder.none,
),
),
centerTitle: true,
actions: <Widget>[
IconButton(
icon: _showSearchBox
@ -243,7 +249,7 @@ class _HomePageState extends State<HomePage> {
],
),
floatingActionButton: !_hasLoaded ||
_codes.isEmpty ||
(_codes?.allCodes.isEmpty ?? true) ||
!PreferenceService.instance.hasShownCoachMark()
? null
: _getFab(),
@ -260,18 +266,61 @@ class _HomePageState extends State<HomePage> {
onManuallySetupTap: _redirectToManualEntryPage,
);
} else {
final list = AlignedGridView.count(
crossAxisCount: (MediaQuery.sizeOf(context).width ~/ 400)
.clamp(1, double.infinity)
.toInt(),
itemBuilder: ((context, index) {
try {
return ClipRect(child: CodeWidget(_filteredCodes[index]));
} catch (e) {
return const Text("Failed");
}
}),
itemCount: _filteredCodes.length,
final list = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 48,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
separatorBuilder: (context, index) => const SizedBox(width: 8),
itemCount: _codes?.tags == null ? 0 : _codes!.tags.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
return const TagChip(
label: "All",
isSelected: true,
);
}
return TagChip(label: _codes!.tags[index - 1]);
},
),
),
Expanded(
child: AlignedGridView.count(
crossAxisCount: (MediaQuery.sizeOf(context).width ~/ 400)
.clamp(1, double.infinity)
.toInt(),
itemBuilder: ((context, index) {
try {
if (_filteredCodes[index].error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
l10n.sorryUnableToGenCode(
_filteredCodes[index].code?.issuer ?? "",
),
),
),
);
}
return ClipRect(
child: CodeWidget(
_filteredCodes[index].code!,
_codes?.tags ?? [],
),
);
} catch (e) {
return const Text("Failed");
}
}),
itemCount: _filteredCodes.length,
),
),
],
);
if (!PreferenceService.instance.hasShownCoachMark()) {
return Stack(
@ -291,17 +340,22 @@ class _HomePageState extends State<HomePage> {
.clamp(1, double.infinity)
.toInt(),
itemBuilder: ((context, index) {
Code? code;
try {
code = _filteredCodes[index];
return CodeWidget(code);
} catch (e, s) {
_logger.severe("code widget error", e, s);
final codeState = _filteredCodes[index];
if (codeState.code != null) {
return CodeWidget(
codeState.code!,
_codes?.tags ?? [],
);
} else {
_logger.severe(
"code widget error",
codeState.error,
);
return Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
l10n.sorryUnableToGenCode(code?.issuer ?? ""),
l10n.sorryUnableToGenCode(""),
),
),
);

View file

@ -173,7 +173,7 @@ Future<void> _exportCodes(BuildContext context, String fileContent) async {
Future<String> _getAuthDataForExport() async {
final codes = await CodeStore.instance.getAllCodes();
String data = "";
for (final code in codes) {
for (final code in codes.validCodes) {
data += "${code.rawData}\n";
}

View file

@ -95,6 +95,7 @@ Future<int?> _processBitwardenExportFile(
account,
issuer,
totp,
null,
);
}

View file

@ -108,8 +108,9 @@ class SettingsPage extends StatelessWidget {
await handleExportClick(context);
} else {
if (result.action == ButtonAction.second) {
bool hasCodes =
(await CodeStore.instance.getAllCodes()).isNotEmpty;
bool hasCodes = (await CodeStore.instance.getAllCodes())
.validCodes
.isNotEmpty;
if (hasCodes) {
final hasAuthenticated = await LocalAuthenticationService
.instance