fix: logics and ui (button, error code)

This commit is contained in:
Prateek Sunal 2024-05-04 14:16:05 +05:30
parent 8370d2a9f1
commit 838983ec61
16 changed files with 796 additions and 693 deletions

View file

@ -0,0 +1,11 @@
<svg width="286" height="44" viewBox="0 0 286 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="286" height="44" fill="#531DAB"/>
<rect width="286" height="44" fill="url(#paint0_linear_25656_80129)" style="mix-blend-mode:soft-light"/>
<defs>
<linearGradient id="paint0_linear_25656_80129" x1="286.174" y1="43.5575" x2="272.305" y2="-42.2227" gradientUnits="userSpaceOnUse">
<stop offset="0.00134129" stop-color="#B37FEB"/>
<stop offset="0.15" stop-color="#D2AEF5" stop-opacity="0"/>
<stop offset="0.829143" stop-color="#EFDBFF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 578 B

View file

@ -17,6 +17,7 @@ import 'package:ente_auth/services/update_service.dart';
import 'package:ente_auth/services/user_remote_flag_service.dart';
import 'package:ente_auth/services/user_service.dart';
import 'package:ente_auth/services/window_listener_service.dart';
import 'package:ente_auth/store/code_display_store.dart';
import 'package:ente_auth/store/code_store.dart';
import 'package:ente_auth/ui/tools/app_lock.dart';
import 'package:ente_auth/ui/tools/lock_screen.dart';
@ -145,6 +146,7 @@ Future<void> _init(bool bool, {String? via}) async {
await PreferenceService.instance.init();
await CodeStore.instance.init();
await CodeDisplayStore.instance.init();
await Configuration.instance.init();
await Network.instance.init();
await UserService.instance.init();

View file

@ -1,26 +1,13 @@
import 'package:ente_auth/models/code.dart';
class CodeState {
final Code? code;
final String? error;
class AllCodes {
final List<Code> codes;
final AllCodesState state;
CodeState({
required this.code,
required this.error,
}) : assert(code != null || error != null);
AllCodes({required this.codes, required this.state});
}
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();
enum AllCodesState {
value,
error,
}

View file

@ -0,0 +1,10 @@
enum TagChipState {
selected,
unselected,
}
enum TagChipAction {
none,
menu,
check,
}

View file

@ -0,0 +1,27 @@
import "package:flutter/material.dart";
class AddChip extends StatelessWidget {
final VoidCallback? onTap;
const AddChip({
super.key,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Icon(
Icons.add_circle_outline,
size: 30,
color: Theme.of(context).brightness == Brightness.dark
? const Color(0xFF9610D6)
: const Color(0xFF8232E1),
),
),
);
}
}

View file

@ -0,0 +1,77 @@
import "package:ente_auth/l10n/l10n.dart";
import "package:flutter/material.dart";
class AddTagDialog extends StatefulWidget {
const AddTagDialog({
super.key,
required this.onTap,
});
final void Function(String) onTap;
@override
State<AddTagDialog> createState() => _AddTagDialogState();
}
class _AddTagDialogState extends State<AddTagDialog> {
String _tag = "";
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return AlertDialog(
title: Text(l10n.createNewTag),
content: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
decoration: InputDecoration(
hintText: l10n.tag,
hintStyle: const TextStyle(
color: Colors.white30,
),
contentPadding: const EdgeInsets.all(12),
),
onChanged: (value) {
setState(() {
_tag = value;
});
},
autocorrect: false,
initialValue: _tag,
autofocus: true,
),
],
),
),
actions: [
TextButton(
child: Text(
l10n.cancel,
style: const TextStyle(
color: Colors.redAccent,
),
),
onPressed: () {
Navigator.pop(context);
},
),
TextButton(
child: Text(
l10n.create,
style: const TextStyle(
color: Colors.purple,
),
),
onPressed: () {
if (_tag.trim().isEmpty) return;
widget.onTap(_tag);
},
),
],
);
}
}

View file

@ -0,0 +1,89 @@
import "package:ente_auth/l10n/l10n.dart";
import 'package:ente_auth/store/code_display_store.dart';
import 'package:ente_auth/utils/dialog_util.dart';
import 'package:flutter/material.dart';
class EditTagDialog extends StatefulWidget {
const EditTagDialog({
super.key,
required this.tag,
});
final String tag;
@override
State<EditTagDialog> createState() => _EditTagDialogState();
}
class _EditTagDialogState extends State<EditTagDialog> {
late String _tag = widget.tag;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return AlertDialog(
title: Text(l10n.editTag),
content: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
decoration: InputDecoration(
hintText: l10n.tag,
hintStyle: const TextStyle(
color: Colors.white30,
),
contentPadding: const EdgeInsets.all(12),
),
onChanged: (value) {
setState(() {
_tag = value;
});
},
autocorrect: false,
initialValue: _tag,
autofocus: true,
),
],
),
),
actions: [
TextButton(
child: Text(
l10n.cancel,
style: const TextStyle(
color: Colors.redAccent,
),
),
onPressed: () {
Navigator.pop(context);
},
),
TextButton(
child: Text(
l10n.saveAction,
style: const TextStyle(
color: Colors.purple,
),
),
onPressed: () async {
if (_tag.trim().isEmpty) return;
final dialog = createProgressDialog(
context,
context.l10n.pleaseWait,
);
await dialog.show();
await CodeDisplayStore.instance.editTag(widget.tag, _tag);
await dialog.hide();
Navigator.pop(context);
},
),
],
);
}
}

View file

@ -0,0 +1,145 @@
import "package:ente_auth/l10n/l10n.dart";
import "package:ente_auth/onboarding/model/tag_enums.dart";
import "package:ente_auth/store/code_display_store.dart";
import "package:flutter/material.dart";
import "package:gradient_borders/box_borders/gradient_box_border.dart";
class TagChip extends StatelessWidget {
final String label;
final VoidCallback? onTap;
final TagChipState state;
final TagChipAction action;
const TagChip({
super.key,
required this.label,
this.state = TagChipState.unselected,
this.action = TagChipAction.none,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color: state == TagChipState.selected
? const Color(0xFF722ED1)
: Theme.of(context).brightness == Brightness.dark
? const Color(0xFF1C0F22)
: const Color(0xFFFCF5FF),
borderRadius: BorderRadius.circular(100),
border: GradientBoxBorder(
gradient: LinearGradient(
colors: state == TagChipState.selected
? [
const Color(0xFFB37FEB),
const Color(0xFFAE40E3).withOpacity(
Theme.of(context).brightness == Brightness.dark
? .53
: 1,
),
]
: [
Theme.of(context).brightness == Brightness.dark
? const Color(0xFFAD00FF)
: const Color(0xFFAD00FF).withOpacity(0.2),
Theme.of(context).brightness == Brightness.dark
? const Color(0xFFA269BD).withOpacity(0.53)
: const Color(0xFF8609C2).withOpacity(0.2),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
),
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16)
.copyWith(right: 0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
color: state == TagChipState.selected ||
Theme.of(context).brightness == Brightness.dark
? Colors.white
: const Color(0xFF8232E1),
),
),
if (state == TagChipState.selected &&
action == TagChipAction.check) ...[
const SizedBox(width: 16),
const Icon(
Icons.check,
size: 16,
color: Colors.white,
),
const SizedBox(width: 16),
] else if (state == TagChipState.selected &&
action == TagChipAction.menu) ...[
SizedBox(
width: 48,
child: PopupMenuButton<int>(
iconSize: 16,
padding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
surfaceTintColor: Theme.of(context).cardColor,
iconColor: Colors.white,
initialValue: -1,
onSelected: (value) {
if (value == 0) {
CodeDisplayStore.instance.showEditDialog(context, label);
} else if (value == 1) {
CodeDisplayStore.instance
.showDeleteTagDialog(context, label);
}
},
itemBuilder: (BuildContext context) {
return [
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.edit_outlined, size: 16),
const SizedBox(width: 12),
Text(context.l10n.edit),
],
),
value: 0,
),
PopupMenuItem(
child: Row(
children: [
const Icon(
Icons.delete_outline,
size: 16,
color: Color(0xFFF53434),
),
const SizedBox(width: 12),
Text(
context.l10n.delete,
style: const TextStyle(
color: Color(0xFFF53434),
),
),
],
),
value: 1,
),
];
},
),
),
] else ...[
const SizedBox(width: 16),
],
],
),
),
);
}
}

View file

@ -1,19 +1,25 @@
import 'dart:async';
import 'package:ente_auth/core/event_bus.dart';
import 'package:ente_auth/events/codes_updated_event.dart';
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/store/code_store.dart';
import 'package:ente_auth/onboarding/model/tag_enums.dart';
import 'package:ente_auth/onboarding/view/common/add_chip.dart';
import 'package:ente_auth/onboarding/view/common/add_tag.dart';
import 'package:ente_auth/onboarding/view/common/tag_chip.dart';
import 'package:ente_auth/store/code_display_store.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';
import 'package:ente_auth/utils/totp_util.dart';
import "package:flutter/material.dart";
import 'package:gradient_borders/box_borders/gradient_box_border.dart';
class SetupEnterSecretKeyPage extends StatefulWidget {
final Code? code;
final List<String> tags;
SetupEnterSecretKeyPage({this.code, super.key, required this.tags});
SetupEnterSecretKeyPage({this.code, super.key});
@override
State<SetupEnterSecretKeyPage> createState() =>
@ -26,7 +32,8 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
late TextEditingController _secretController;
late bool _secretKeyObscured;
late List<String> tags = [...?widget.code?.display.tags];
late List<String> allTags = [...widget.tags];
List<String> allTags = [];
StreamSubscription<CodesUpdatedEvent>? _streamSubscription;
@override
void initState() {
@ -41,9 +48,24 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
text: widget.code?.secret,
);
_secretKeyObscured = widget.code != null;
_loadTags();
_streamSubscription = Bus.instance.on<CodesUpdatedEvent>().listen((event) {
_loadTags();
});
super.initState();
}
@override
void dispose() {
_streamSubscription?.cancel();
super.dispose();
}
Future<void> _loadTags() async {
allTags = await CodeDisplayStore.instance.getAllTags();
setState(() {});
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
@ -261,403 +283,3 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
);
}
}
class AddChip extends StatelessWidget {
final VoidCallback? onTap;
const AddChip({
super.key,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Icon(
Icons.add_circle_outline,
size: 30,
color: Theme.of(context).brightness == Brightness.dark
? const Color(0xFF9610D6)
: const Color(0xFF8232E1),
),
),
);
}
}
enum TagChipState {
selected,
unselected,
}
enum TagChipAction {
none,
menu,
check,
}
class TagChip extends StatelessWidget {
final String label;
final VoidCallback? onTap;
final TagChipState state;
final TagChipAction action;
const TagChip({
super.key,
required this.label,
this.state = TagChipState.unselected,
this.action = TagChipAction.none,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color: state == TagChipState.selected
? const Color(0xFF722ED1)
: Theme.of(context).brightness == Brightness.dark
? const Color(0xFF1C0F22)
: const Color(0xFFFCF5FF),
borderRadius: BorderRadius.circular(100),
border: GradientBoxBorder(
gradient: LinearGradient(
colors: state == TagChipState.selected
? [
const Color(0xFFB37FEB),
const Color(0xFFAE40E3).withOpacity(
Theme.of(context).brightness == Brightness.dark
? .53
: 1,
),
]
: [
Theme.of(context).brightness == Brightness.dark
? const Color(0xFFAD00FF)
: const Color(0xFFAD00FF).withOpacity(0.2),
Theme.of(context).brightness == Brightness.dark
? const Color(0xFFA269BD).withOpacity(0.53)
: const Color(0xFF8609C2).withOpacity(0.2),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
),
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16)
.copyWith(right: 0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
color: state == TagChipState.selected ||
Theme.of(context).brightness == Brightness.dark
? Colors.white
: const Color(0xFF8232E1),
),
),
if (state == TagChipState.selected &&
action == TagChipAction.check) ...[
const SizedBox(width: 16),
const Icon(
Icons.check,
size: 16,
color: Colors.white,
),
const SizedBox(width: 16),
] else if (state == TagChipState.selected &&
action == TagChipAction.menu) ...[
SizedBox(
width: 48,
child: PopupMenuButton<int>(
iconSize: 16,
padding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
surfaceTintColor: Theme.of(context).cardColor,
iconColor: Colors.white,
initialValue: -1,
onSelected: (value) {
if (value == 0) {
showEditDialog(context, label);
} else if (value == 1) {
showDeleteTagDialog(context, label);
}
},
itemBuilder: (BuildContext context) {
return [
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.edit_outlined, size: 16),
const SizedBox(width: 12),
Text(context.l10n.edit),
],
),
value: 0,
),
PopupMenuItem(
child: Row(
children: [
const Icon(
Icons.delete_outline,
size: 16,
color: Color(0xFFF53434),
),
const SizedBox(width: 12),
Text(
context.l10n.delete,
style: const TextStyle(
color: Color(0xFFF53434),
),
),
],
),
value: 1,
),
];
},
),
),
] else ...[
const SizedBox(width: 16),
],
],
),
),
);
}
}
class AddTagDialog extends StatefulWidget {
const AddTagDialog({
super.key,
required this.onTap,
});
final void Function(String) onTap;
@override
State<AddTagDialog> createState() => _AddTagDialogState();
}
class _AddTagDialogState extends State<AddTagDialog> {
String _tag = "";
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return AlertDialog(
title: Text(l10n.createNewTag),
content: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
decoration: InputDecoration(
hintText: l10n.tag,
hintStyle: const TextStyle(
color: Colors.white30,
),
contentPadding: const EdgeInsets.all(12),
),
onChanged: (value) {
setState(() {
_tag = value;
});
},
autocorrect: false,
initialValue: _tag,
autofocus: true,
),
],
),
),
actions: [
TextButton(
child: Text(
l10n.cancel,
style: const TextStyle(
color: Colors.redAccent,
),
),
onPressed: () {
Navigator.pop(context);
},
),
TextButton(
child: Text(
l10n.create,
style: const TextStyle(
color: Colors.purple,
),
),
onPressed: () {
if (_tag.trim().isEmpty) return;
widget.onTap(_tag);
},
),
],
);
}
}
class EditTagDialog extends StatefulWidget {
const EditTagDialog({
super.key,
required this.tag,
});
final String tag;
@override
State<EditTagDialog> createState() => _EditTagDialogState();
}
class _EditTagDialogState extends State<EditTagDialog> {
late String _tag = widget.tag;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return AlertDialog(
title: Text(l10n.editTag),
content: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
decoration: InputDecoration(
hintText: l10n.tag,
hintStyle: const TextStyle(
color: Colors.white30,
),
contentPadding: const EdgeInsets.all(12),
),
onChanged: (value) {
setState(() {
_tag = value;
});
},
autocorrect: false,
initialValue: _tag,
autofocus: true,
),
],
),
),
actions: [
TextButton(
child: Text(
l10n.cancel,
style: const TextStyle(
color: Colors.redAccent,
),
),
onPressed: () {
Navigator.pop(context);
},
),
TextButton(
child: Text(
l10n.saveAction,
style: const TextStyle(
color: Colors.purple,
),
),
onPressed: () async {
if (_tag.trim().isEmpty) return;
final dialog = createProgressDialog(
context,
context.l10n.pleaseWait,
);
await dialog.show();
// traverse through all the codes and edit this tag's value
final relevantCodes = (await CodeStore.instance.getAllCodes())
.validCodes
.where((element) => element.display.tags.contains(widget.tag));
final tasks = <Future>[];
for (final code in relevantCodes) {
final tags = code.display.tags;
tags.remove(widget.tag);
tags.add(_tag);
tasks.add(
CodeStore.instance.addCode(
code.copyWith(
display: code.display.copyWith(tags: tags),
),
),
);
}
await Future.wait(tasks);
await dialog.hide();
Navigator.pop(context);
},
),
],
);
}
}
Future<void> showDeleteTagDialog(BuildContext context, String tag) async {
FocusScope.of(context).requestFocus();
final l10n = context.l10n;
await showChoiceActionSheet(
context,
title: l10n.deleteTagTitle,
body: l10n.deleteTagMessage,
firstButtonLabel: l10n.delete,
isCritical: true,
firstButtonOnTap: () async {
// traverse through all the codes and edit this tag's value
final relevantCodes = (await CodeStore.instance.getAllCodes())
.validCodes
.where((element) => element.display.tags.contains(tag));
final tasks = <Future>[];
for (final code in relevantCodes) {
final tags = code.display.tags;
tags.remove(tag);
tasks.add(
CodeStore.instance.addCode(
code.copyWith(
display: code.display.copyWith(tags: tags),
),
),
);
}
await Future.wait(tasks);
},
);
}
Future<void> showEditDialog(BuildContext context, String tag) async {
await showDialog(
context: context,
builder: (BuildContext context) {
return EditTagDialog(tag: tag);
},
barrierColor: Colors.black.withOpacity(0.85),
barrierDismissible: false,
);
}

View file

@ -0,0 +1,123 @@
import 'dart:convert';
import 'package:ente_auth/l10n/l10n.dart';
import 'package:ente_auth/models/authenticator/entity_result.dart';
import 'package:ente_auth/models/code.dart';
import 'package:ente_auth/onboarding/view/common/edit_tag.dart';
import 'package:ente_auth/services/authenticator_service.dart';
import 'package:ente_auth/store/code_store.dart';
import 'package:ente_auth/utils/dialog_util.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
class CodeDisplayStore {
static final CodeDisplayStore instance =
CodeDisplayStore._privateConstructor();
CodeDisplayStore._privateConstructor();
late CodeStore _codeStore;
late AuthenticatorService _authenticatorService;
final _logger = Logger("CodeDisplayStore");
Future<void> init() async {
_authenticatorService = AuthenticatorService.instance;
_codeStore = CodeStore.instance;
}
Future<List<String>> getAllTags({AccountMode? accountMode}) async {
final mode = accountMode ?? _authenticatorService.getAccountMode();
final List<EntityResult> entities =
await _authenticatorService.getEntities(mode);
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);
} else {
code = Code.fromExportJson(decodeJson);
}
tags.addAll(code.display.tags);
} catch (e) {
_logger.severe("Could not parse code", e);
}
}
tags = tags.toSet().toList();
return tags;
}
Future<void> showDeleteTagDialog(BuildContext context, String tag) async {
FocusScope.of(context).requestFocus();
final l10n = context.l10n;
await showChoiceActionSheet(
context,
title: l10n.deleteTagTitle,
body: l10n.deleteTagMessage,
firstButtonLabel: l10n.delete,
isCritical: true,
firstButtonOnTap: () async {
// traverse through all the codes and edit this tag's value
final relevantCodes = (await CodeStore.instance.getAllCodes())
.codes
.where((element) => element.display.tags.contains(tag));
final tasks = <Future>[];
for (final code in relevantCodes) {
final tags = code.display.tags;
tags.remove(tag);
tasks.add(
_codeStore.addCode(
code.copyWith(
display: code.display.copyWith(tags: tags),
),
),
);
}
await Future.wait(tasks);
},
);
}
Future<void> showEditDialog(BuildContext context, String tag) async {
await showDialog(
context: context,
builder: (BuildContext context) {
return EditTagDialog(tag: tag);
},
barrierColor: Colors.black.withOpacity(0.85),
barrierDismissible: false,
);
}
Future<void> editTag(String previousTag, String updatedTag) async {
// traverse through all the codes and edit this tag's value
final relevantCodes = (await CodeStore.instance.getAllCodes())
.codes
.where((element) => element.display.tags.contains(previousTag));
final tasks = <Future>[];
for (final code in relevantCodes) {
final tags = code.display.tags;
tags.remove(previousTag);
tags.add(updatedTag);
tasks.add(
CodeStore.instance.addCode(
code.copyWith(
display: code.display.copyWith(tags: tags),
),
),
);
}
await Future.wait(tasks);
}
}

View file

@ -23,12 +23,13 @@ class CodeStore {
_authenticatorService = AuthenticatorService.instance;
}
Future<Codes> getAllCodes({AccountMode? accountMode}) async {
Future<AllCodes> getAllCodes({AccountMode? accountMode}) async {
final mode = accountMode ?? _authenticatorService.getAccountMode();
final List<EntityResult> entities =
await _authenticatorService.getEntities(mode);
final List<CodeState> codes = [];
List<String> tags = [];
final List<Code> codes = [];
bool hasError = false;
for (final entity in entities) {
try {
final decodeJson = jsonDecode(entity.rawData);
@ -41,23 +42,15 @@ class CodeStore {
}
code.generatedID = entity.generatedID;
code.hasSynced = entity.hasSynced;
codes.add(CodeState(code: code, error: null));
tags.addAll(code.display.tags);
codes.add(code);
} catch (e) {
codes.add(CodeState(code: null, error: e.toString()));
hasError = true;
_logger.severe("Could not parse code", e);
}
}
// sort codes by issuer,account
codes.sort((a, b) {
if (a.code == null && b.code == null) return 0;
if (a.code == null) return 1;
if (b.code == null) return -1;
final firstCode = a.code!;
final secondCode = b.code!;
codes.sort((firstCode, secondCode) {
if (secondCode.isPinned && !firstCode.isPinned) return 1;
if (!secondCode.isPinned && firstCode.isPinned) return -1;
@ -71,8 +64,10 @@ class CodeStore {
secondCode.account,
);
});
tags = tags.toSet().toList();
return Codes(allCodes: codes, tags: tags);
return AllCodes(
codes: codes,
state: hasError ? AllCodesState.error : AllCodesState.value,
);
}
Future<AddResult> addCode(
@ -81,10 +76,10 @@ class CodeStore {
AccountMode? accountMode,
}) async {
final mode = accountMode ?? _authenticatorService.getAccountMode();
final codes = await getAllCodes(accountMode: mode);
final allCodes = await getAllCodes(accountMode: mode);
bool isExistingCode = false;
bool hasSameCode = false;
for (final existingCode in codes.validCodes) {
for (final existingCode in allCodes.codes) {
if (code.generatedID != null &&
existingCode.generatedID == code.generatedID) {
isExistingCode = true;
@ -143,7 +138,7 @@ class CodeStore {
List<Code> offlineCodes = (await CodeStore.instance
.getAllCodes(accountMode: AccountMode.offline))
.validCodes;
.codes;
if (offlineCodes.isEmpty) {
return;
}
@ -154,7 +149,7 @@ class CodeStore {
}
final List<Code> onlineCodes = (await CodeStore.instance
.getAllCodes(accountMode: AccountMode.online))
.validCodes;
.codes;
logger.info(
'importing ${offlineCodes.length} offline codes with ${onlineCodes.length} online codes',
);

View file

@ -28,9 +28,13 @@ import 'package:move_to_background/move_to_background.dart';
class CodeWidget extends StatefulWidget {
final Code code;
final List<String> tags;
final bool hasError;
const CodeWidget(this.code, this.tags, {super.key});
const CodeWidget(
this.code, {
super.key,
this.hasError = false,
});
@override
State<CodeWidget> createState() => _CodeWidgetState();
@ -88,135 +92,145 @@ class _CodeWidgetState extends State<CodeWidget> {
_isInitialized = true;
}
final l10n = context.l10n;
return Container(
margin: const EdgeInsets.only(left: 16, right: 16, bottom: 8, top: 8),
child: Builder(
builder: (context) {
if (PlatformUtil.isDesktop()) {
return ContextMenuRegion(
contextMenu: ContextMenu(
entries: <ContextMenuEntry>[
MenuItem(
label: 'QR',
icon: Icons.qr_code_2_outlined,
onSelected: () => _onShowQrPressed(null),
),
MenuItem(
label: widget.code.isPinned ? l10n.unpinText : l10n.pinText,
icon: widget.code.isPinned
? Icons.push_pin
: Icons.push_pin_outlined,
onSelected: () => _onShowQrPressed(null),
),
MenuItem(
label: l10n.edit,
icon: Icons.edit,
onSelected: () => _onEditPressed(null),
),
const MenuDivider(),
MenuItem(
label: l10n.delete,
value: "Delete",
icon: Icons.delete,
onSelected: () => _onDeletePressed(null),
),
],
padding: const EdgeInsets.all(8.0),
),
child: _clippedCard(l10n),
);
}
return Slidable(
key: ValueKey(widget.code.hashCode),
endActionPane: ActionPane(
extentRatio: 0.90,
motion: const ScrollMotion(),
children: [
const SizedBox(
width: 14,
),
SlidableAction(
onPressed: _onShowQrPressed,
backgroundColor: Colors.grey.withOpacity(0.1),
borderRadius: const BorderRadius.all(Radius.circular(8)),
foregroundColor:
Theme.of(context).colorScheme.inverseBackgroundColor,
icon: Icons.qr_code_2_outlined,
label: "QR",
padding: const EdgeInsets.only(left: 4, right: 0),
spacing: 8,
),
const SizedBox(
width: 14,
),
CustomSlidableAction(
onPressed: _onPinPressed,
backgroundColor: Colors.grey.withOpacity(0.1),
borderRadius: const BorderRadius.all(Radius.circular(8)),
foregroundColor:
Theme.of(context).colorScheme.inverseBackgroundColor,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (widget.code.isPinned)
SvgPicture.asset(
"assets/svg/pin-active.svg",
colorFilter: ui.ColorFilter.mode(
Theme.of(context).colorScheme.primary,
BlendMode.srcIn,
),
)
else
SvgPicture.asset(
"assets/svg/pin-inactive.svg",
colorFilter: ui.ColorFilter.mode(
Theme.of(context).colorScheme.primary,
BlendMode.srcIn,
),
),
const SizedBox(height: 8),
Text(
widget.code.isPinned ? l10n.unpinText : l10n.pinText,
return IgnorePointer(
ignoring: widget.hasError,
child: Opacity(
opacity: widget.hasError ? 0.5 : 1.0,
child: Container(
margin: const EdgeInsets.only(left: 16, right: 16, bottom: 8, top: 8),
child: Builder(
builder: (context) {
if (PlatformUtil.isDesktop()) {
return ContextMenuRegion(
contextMenu: ContextMenu(
entries: <ContextMenuEntry>[
MenuItem(
label: 'QR',
icon: Icons.qr_code_2_outlined,
onSelected: () => _onShowQrPressed(null),
),
MenuItem(
label: widget.code.isPinned
? l10n.unpinText
: l10n.pinText,
icon: widget.code.isPinned
? Icons.push_pin
: Icons.push_pin_outlined,
onSelected: () => _onShowQrPressed(null),
),
MenuItem(
label: l10n.edit,
icon: Icons.edit,
onSelected: () => _onEditPressed(null),
),
const MenuDivider(),
MenuItem(
label: l10n.delete,
value: "Delete",
icon: Icons.delete,
onSelected: () => _onDeletePressed(null),
),
],
padding: const EdgeInsets.all(8.0),
),
padding: const EdgeInsets.only(left: 4, right: 0),
child: _clippedCard(l10n),
);
}
return Slidable(
key: ValueKey(widget.code.hashCode),
endActionPane: ActionPane(
extentRatio: 0.90,
motion: const ScrollMotion(),
children: [
const SizedBox(
width: 14,
),
SlidableAction(
onPressed: _onShowQrPressed,
backgroundColor: Colors.grey.withOpacity(0.1),
borderRadius: const BorderRadius.all(Radius.circular(8)),
foregroundColor:
Theme.of(context).colorScheme.inverseBackgroundColor,
icon: Icons.qr_code_2_outlined,
label: "QR",
padding: const EdgeInsets.only(left: 4, right: 0),
spacing: 8,
),
const SizedBox(
width: 14,
),
CustomSlidableAction(
onPressed: _onPinPressed,
backgroundColor: Colors.grey.withOpacity(0.1),
borderRadius: const BorderRadius.all(Radius.circular(8)),
foregroundColor:
Theme.of(context).colorScheme.inverseBackgroundColor,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (widget.code.isPinned)
SvgPicture.asset(
"assets/svg/pin-active.svg",
colorFilter: ui.ColorFilter.mode(
Theme.of(context).colorScheme.primary,
BlendMode.srcIn,
),
)
else
SvgPicture.asset(
"assets/svg/pin-inactive.svg",
colorFilter: ui.ColorFilter.mode(
Theme.of(context).colorScheme.primary,
BlendMode.srcIn,
),
),
const SizedBox(height: 8),
Text(
widget.code.isPinned
? l10n.unpinText
: l10n.pinText,
),
],
),
padding: const EdgeInsets.only(left: 4, right: 0),
),
const SizedBox(
width: 14,
),
SlidableAction(
onPressed: _onEditPressed,
backgroundColor: Colors.grey.withOpacity(0.1),
borderRadius: const BorderRadius.all(Radius.circular(8)),
foregroundColor:
Theme.of(context).colorScheme.inverseBackgroundColor,
icon: Icons.edit_outlined,
label: l10n.edit,
padding: const EdgeInsets.only(left: 4, right: 0),
spacing: 8,
),
const SizedBox(
width: 14,
),
SlidableAction(
onPressed: _onDeletePressed,
backgroundColor: Colors.grey.withOpacity(0.1),
borderRadius: const BorderRadius.all(Radius.circular(8)),
foregroundColor: const Color(0xFFFE4A49),
icon: Icons.delete,
label: l10n.delete,
padding: const EdgeInsets.only(left: 0, right: 0),
spacing: 8,
),
],
),
const SizedBox(
width: 14,
child: Builder(
builder: (context) => _clippedCard(l10n),
),
SlidableAction(
onPressed: _onEditPressed,
backgroundColor: Colors.grey.withOpacity(0.1),
borderRadius: const BorderRadius.all(Radius.circular(8)),
foregroundColor:
Theme.of(context).colorScheme.inverseBackgroundColor,
icon: Icons.edit_outlined,
label: l10n.edit,
padding: const EdgeInsets.only(left: 4, right: 0),
spacing: 8,
),
const SizedBox(
width: 14,
),
SlidableAction(
onPressed: _onDeletePressed,
backgroundColor: Colors.grey.withOpacity(0.1),
borderRadius: const BorderRadius.all(Radius.circular(8)),
foregroundColor: const Color(0xFFFE4A49),
icon: Icons.delete,
label: l10n.delete,
padding: const EdgeInsets.only(left: 0, right: 0),
spacing: 8,
),
],
),
child: Builder(
builder: (context) => _clippedCard(l10n),
),
);
},
);
},
),
),
),
);
}
@ -520,7 +534,6 @@ class _CodeWidgetState extends State<CodeWidget> {
builder: (BuildContext context) {
return SetupEnterSecretKeyPage(
code: widget.code,
tags: widget.tags,
);
},
),

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:gradient_borders/box_borders/gradient_box_border.dart';
class GradientButton extends StatefulWidget {
@ -113,22 +114,19 @@ class _GradientButtonState extends State<GradientButton> {
],
),
),
if (!isTapped)
ClipRRect(
borderRadius: BorderRadius.circular(widget.borderRadius),
child: SvgPicture.asset(
'assets/svg/button-tint.svg',
fit: BoxFit.fill,
width: double.infinity,
height: 56,
),
),
Container(
height: 56,
decoration: BoxDecoration(
gradient: isTapped
? null
: const LinearGradient(
begin: Alignment.bottomRight,
end: Alignment.topLeft,
stops: [0, 0.16, 0.88],
colors: [
Color.fromRGBO(179, 127, 235, 1),
Color.fromRGBO(210, 174, 245, 0),
Color.fromRGBO(239, 219, 255, 1),
],
),
backgroundBlendMode: isTapped ? null : BlendMode.hue,
border: GradientBoxBorder(
width: widget.borderWidth,
gradient: const LinearGradient(

View file

@ -11,9 +11,12 @@ 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/model/tag_enums.dart';
import 'package:ente_auth/onboarding/view/common/tag_chip.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';
import 'package:ente_auth/store/code_display_store.dart';
import 'package:ente_auth/store/code_store.dart';
import 'package:ente_auth/ui/account/logout_dialog.dart';
import 'package:ente_auth/ui/code_error_widget.dart';
@ -56,8 +59,9 @@ class _HomePageState extends State<HomePage> {
final FocusNode searchInputFocusNode = FocusNode();
bool _showSearchBox = false;
String _searchText = "";
Codes? _codes;
List<CodeState> _filteredCodes = [];
AllCodes? _allCodes;
List<String> tags = [];
List<Code> _filteredCodes = [];
StreamSubscription<CodesUpdatedEvent>? _streamSubscription;
StreamSubscription<TriggerLogoutEvent>? _triggerLogoutEvent;
StreamSubscription<IconsChangedEvent>? _iconsChangedEvent;
@ -99,47 +103,56 @@ class _HomePageState extends State<HomePage> {
void _loadCodes() {
CodeStore.instance.getAllCodes().then((codes) {
_codes = codes;
_allCodes = codes;
_hasLoaded = true;
_applyFilteringAndRefresh();
}).onError((error, stackTrace) {
_logger.severe('Error while loading codes', error, stackTrace);
});
CodeDisplayStore.instance.getAllTags().then((value) {
tags = value;
if (mounted) {
if (!tags.contains(selectedTag)) {
selectedTag = "";
}
setState(() {});
}
}).onError((error, stackTrace) {
_logger.severe('Error while loading tags', error, stackTrace);
});
}
void _applyFilteringAndRefresh() {
if (_searchText.isNotEmpty && _showSearchBox && _codes != null) {
if (_searchText.isNotEmpty && _showSearchBox && _allCodes != 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<CodeState> issuerMatch = [];
final List<CodeState> accountMatch = [];
for (final CodeState codeState in _codes!.allCodes) {
if (codeState.error != null) continue;
final List<Code> issuerMatch = [];
final List<Code> accountMatch = [];
for (final Code codeState in _allCodes!.codes) {
if (selectedTag != "" &&
!codeState.code!.display.tags.contains(selectedTag)) {
!codeState.display.tags.contains(selectedTag)) {
continue;
}
if (codeState.code!.issuer.toLowerCase().contains(val)) {
if (codeState.issuer.toLowerCase().contains(val)) {
issuerMatch.add(codeState);
} else if (codeState.code!.account.toLowerCase().contains(val)) {
} else if (codeState.account.toLowerCase().contains(val)) {
accountMatch.add(codeState);
}
}
_filteredCodes = issuerMatch;
_filteredCodes.addAll(accountMatch);
} else {
_filteredCodes = _codes?.allCodes
_filteredCodes = _allCodes?.codes
.where(
(element) =>
selectedTag == "" ||
element.code != null &&
element.code!.display.tags.contains(selectedTag),
element.display.tags.contains(selectedTag),
)
.toList() ??
[];
@ -169,7 +182,7 @@ class _HomePageState extends State<HomePage> {
if (code != null) {
await CodeStore.instance.addCode(code);
// Focus the new code by searching
if ((_codes?.allCodes.length ?? 0) > 2) {
if ((_allCodes?.codes.length ?? 0) > 2) {
_focusNewCode(code);
}
}
@ -179,9 +192,7 @@ class _HomePageState extends State<HomePage> {
final Code? code = await Navigator.of(context).push(
MaterialPageRoute(
builder: (BuildContext context) {
return SetupEnterSecretKeyPage(
tags: _codes?.tags ?? [],
);
return SetupEnterSecretKeyPage();
},
),
);
@ -193,10 +204,7 @@ class _HomePageState extends State<HomePage> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
if (!(_codes?.tags.contains(selectedTag) ?? true)) {
selectedTag = "";
setState(() {});
}
return PopScope(
onPopInvoked: (_) async {
if (_isSettingsOpen) {
@ -245,30 +253,32 @@ class _HomePageState extends State<HomePage> {
),
centerTitle: true,
actions: <Widget>[
IconButton(
icon: _showSearchBox
? const Icon(Icons.clear)
: const Icon(Icons.search),
tooltip: l10n.search,
onPressed: () {
setState(
() {
_showSearchBox = !_showSearchBox;
if (!_showSearchBox) {
_textController.clear();
_searchText = "";
} else {
_searchText = _textController.text;
}
_applyFilteringAndRefresh();
},
);
},
),
if (_allCodes?.state == AllCodesState.value)
IconButton(
icon: _showSearchBox
? const Icon(Icons.clear)
: const Icon(Icons.search),
tooltip: l10n.search,
onPressed: () {
setState(
() {
_showSearchBox = !_showSearchBox;
if (!_showSearchBox) {
_textController.clear();
_searchText = "";
} else {
_searchText = _textController.text;
}
_applyFilteringAndRefresh();
},
);
},
),
],
),
floatingActionButton: !_hasLoaded ||
(_codes?.allCodes.isEmpty ?? true) ||
(_allCodes?.codes.isEmpty ?? true) ||
_allCodes?.state == AllCodesState.error ||
!PreferenceService.instance.hasShownCoachMark()
? null
: _getFab(),
@ -288,71 +298,75 @@ class _HomePageState extends State<HomePage> {
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) {
if (_allCodes?.state == AllCodesState.value)
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: tags.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
return TagChip(
label: "All",
state: selectedTag == ""
? TagChipState.selected
: TagChipState.unselected,
onTap: () {
selectedTag = "";
setState(() {});
_applyFilteringAndRefresh();
},
);
}
return TagChip(
label: "All",
state: selectedTag == ""
label: tags[index - 1],
action: TagChipAction.menu,
state: selectedTag == tags[index - 1]
? TagChipState.selected
: TagChipState.unselected,
onTap: () {
selectedTag = "";
if (selectedTag == tags[index - 1]) {
selectedTag = "";
setState(() {});
_applyFilteringAndRefresh();
return;
}
selectedTag = tags[index - 1];
setState(() {});
_applyFilteringAndRefresh();
},
);
}
return TagChip(
label: _codes!.tags[index - 1],
action: TagChipAction.menu,
state: selectedTag == _codes!.tags[index - 1]
? TagChipState.selected
: TagChipState.unselected,
onTap: () {
if (selectedTag == _codes!.tags[index - 1]) {
selectedTag = "";
setState(() {});
_applyFilteringAndRefresh();
return;
}
selectedTag = _codes!.tags[index - 1];
setState(() {});
_applyFilteringAndRefresh();
},
);
},
},
),
),
),
Expanded(
child: AlignedGridView.count(
crossAxisCount: (MediaQuery.sizeOf(context).width ~/ 400)
.clamp(1, double.infinity)
.toInt(),
physics: _allCodes?.state == AllCodesState.value
? const AlwaysScrollableScrollPhysics()
: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 80),
itemBuilder: ((context, index) {
try {
if (_filteredCodes[index].error != null) {
return const CodeErrorWidget();
}
return ClipRect(
child: CodeWidget(
_filteredCodes[index].code!,
_codes?.tags ?? [],
),
);
} catch (e) {
return const Text("Failed");
if (index == 0 && _allCodes?.state == AllCodesState.error) {
return const CodeErrorWidget();
}
final newIndex =
index - (_allCodes?.state == AllCodesState.error ? 1 : 0);
return ClipRect(
child: CodeWidget(
_filteredCodes[newIndex],
hasError: _allCodes?.state == AllCodesState.error,
),
);
}),
itemCount: _filteredCodes.length,
itemCount: (_allCodes?.state == AllCodesState.error ? 1 : 0) +
_filteredCodes.length,
),
),
],
@ -377,18 +391,9 @@ class _HomePageState extends State<HomePage> {
padding: const EdgeInsets.only(bottom: 80),
itemBuilder: ((context, index) {
final codeState = _filteredCodes[index];
if (codeState.code != null) {
return CodeWidget(
codeState.code!,
_codes?.tags ?? [],
);
} else {
_logger.severe(
"code widget error",
codeState.error,
);
return const CodeErrorWidget();
}
return CodeWidget(
codeState,
);
}),
itemCount: _filteredCodes.length,
)

View file

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

View file

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