[auth] Trash, Notes, Compact Mode, & Sharing (#3258)
## Description ## Tests
This commit is contained in:
@@ -82,9 +82,9 @@ PODS:
|
||||
- qr_code_scanner (0.2.0):
|
||||
- Flutter
|
||||
- MTBBarcodeScanner
|
||||
- SDWebImage (5.19.2):
|
||||
- SDWebImage/Core (= 5.19.2)
|
||||
- SDWebImage/Core (5.19.2)
|
||||
- SDWebImage (5.19.7):
|
||||
- SDWebImage/Core (= 5.19.7)
|
||||
- SDWebImage/Core (5.19.7)
|
||||
- Sentry/HybridSDK (8.33.0)
|
||||
- sentry_flutter (8.7.0):
|
||||
- Flutter
|
||||
@@ -100,16 +100,16 @@ PODS:
|
||||
- sqflite (0.0.3):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- "sqlite3 (3.46.0+1)":
|
||||
- "sqlite3/common (= 3.46.0+1)"
|
||||
- "sqlite3/common (3.46.0+1)"
|
||||
- "sqlite3/dbstatvtab (3.46.0+1)":
|
||||
- "sqlite3 (3.46.1+1)":
|
||||
- "sqlite3/common (= 3.46.1+1)"
|
||||
- "sqlite3/common (3.46.1+1)"
|
||||
- "sqlite3/dbstatvtab (3.46.1+1)":
|
||||
- sqlite3/common
|
||||
- "sqlite3/fts5 (3.46.0+1)":
|
||||
- "sqlite3/fts5 (3.46.1+1)":
|
||||
- sqlite3/common
|
||||
- "sqlite3/perf-threadsafe (3.46.0+1)":
|
||||
- "sqlite3/perf-threadsafe (3.46.1+1)":
|
||||
- sqlite3/common
|
||||
- "sqlite3/rtree (3.46.0+1)":
|
||||
- "sqlite3/rtree (3.46.1+1)":
|
||||
- sqlite3/common
|
||||
- sqlite3_flutter_libs (0.0.1):
|
||||
- Flutter
|
||||
@@ -245,14 +245,14 @@ SPEC CHECKSUMS:
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
privacy_screen: 1a131c052ceb3c3659934b003b0d397c2381a24e
|
||||
qr_code_scanner: bb67d64904c3b9658ada8c402e8b4d406d5d796e
|
||||
SDWebImage: dfe95b2466a9823cf9f0c6d01217c06550d7b29a
|
||||
SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3
|
||||
Sentry: 8560050221424aef0bebc8e31eedf00af80f90a6
|
||||
sentry_flutter: e26b861f744e5037a3faf9bf56603ec65d658a61
|
||||
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sodium_libs: 1faae17af662384acbd13e41867a0008cd2e2318
|
||||
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
|
||||
sqlite3: 292c3e1bfe89f64e51ea7fc7dab9182a017c8630
|
||||
sqlite3: 0bb0e6389d824e40296f531b858a2a0b71c0d2fb
|
||||
sqlite3_flutter_libs: c00457ebd31e59fa6bb830380ddba24d44fbcd3b
|
||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||
Toast: 1f5ea13423a1e6674c4abdac5be53587ae481c4e
|
||||
|
||||
@@ -19,6 +19,21 @@
|
||||
"pleaseVerifyDetails": "Please verify the details and try again",
|
||||
"codeIssuerHint": "Issuer",
|
||||
"codeSecretKeyHint": "Secret Key",
|
||||
"secret": "Secret",
|
||||
"account": "Account",
|
||||
"advance": "Advance",
|
||||
"notes": "Notes",
|
||||
"notesLengthLimit": "Notes can be at most {count} characters long",
|
||||
"@notesLengthLimit": {
|
||||
"description": "Text to indicate the maximum number of characters allowed for notes",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"description": "The maximum number of characters allowed for notes",
|
||||
"type": "int",
|
||||
"example": "100"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codeAccountHint": "Account (you@domain.com)",
|
||||
"codeTagHint": "Tag",
|
||||
"accountKeyType": "Type of key",
|
||||
@@ -34,6 +49,9 @@
|
||||
"nextTotpTitle": "next",
|
||||
"deleteCodeTitle": "Delete code?",
|
||||
"deleteCodeMessage": "Are you sure you want to delete this code? This action is irreversible.",
|
||||
"trashCode": "Trash code?",
|
||||
"trashCodeMessage": "Are you sure you want to trash code for {account}?",
|
||||
"trash": "Trash",
|
||||
"viewLogsAction": "View logs",
|
||||
"sendLogsDescription": "This will send across logs to help us debug your issue. While we take precautions to ensure that sensitive information is not logged, we encourage you to view these logs before sharing them.",
|
||||
"preparingLogsTitle": "Preparing logs...",
|
||||
@@ -100,6 +118,7 @@
|
||||
"emailVerificationToggle": "Email verification",
|
||||
"emailVerificationEnableWarning": "To avoid getting locked out of your account, be sure to store a copy of your email 2FA outside of Ente Auth before enabling email verification.",
|
||||
"authToChangeEmailVerificationSetting": "Please authenticate to change email verification",
|
||||
"authenticateGeneric": "Please authenticate",
|
||||
"authToViewYourRecoveryKey": "Please authenticate to view your recovery key",
|
||||
"authToChangeYourEmail": "Please authenticate to change your email",
|
||||
"authToChangeYourPassword": "Please authenticate to change your password",
|
||||
@@ -193,6 +212,10 @@
|
||||
"scanAQrCode": "Scan a QR code",
|
||||
"enterDetailsManually": "Enter details manually",
|
||||
"edit": "Edit",
|
||||
"share": "Share",
|
||||
"shareCodes": "Share codes",
|
||||
"shareCodesDuration": "Select the duration for which you want to share codes.",
|
||||
"restore": "Restore",
|
||||
"copiedToClipboard": "Copied to clipboard",
|
||||
"copiedNextToClipboard": "Copied next code to clipboard",
|
||||
"error": "Error",
|
||||
@@ -346,6 +369,7 @@
|
||||
"sigInBackupReminder": "Please export your codes to ensure that you have a backup you can restore from.",
|
||||
"offlineModeWarning": "You have chosen to proceed without backups. Please take manual backups to make sure your codes are safe.",
|
||||
"showLargeIcons": "Show large icons",
|
||||
"compactMode": "Compact mode",
|
||||
"shouldHideCode": "Hide codes",
|
||||
"doubleTapToViewHiddenCode": "You can double tap on an entry to view code",
|
||||
"focusOnSearchBar": "Focus search on app start",
|
||||
|
||||
@@ -27,6 +27,8 @@ class Code {
|
||||
|
||||
bool get isPinned => display.pinned;
|
||||
|
||||
bool get isTrashed => display.trashed;
|
||||
|
||||
final Object? err;
|
||||
bool get hasError => err != null;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ class CodeDisplay {
|
||||
final bool trashed;
|
||||
final int lastUsedAt;
|
||||
final int tapCount;
|
||||
String note;
|
||||
final List<String> tags;
|
||||
|
||||
CodeDisplay({
|
||||
@@ -17,6 +18,7 @@ class CodeDisplay {
|
||||
this.lastUsedAt = 0,
|
||||
this.tapCount = 0,
|
||||
this.tags = const [],
|
||||
this.note = '',
|
||||
});
|
||||
|
||||
// copyWith
|
||||
@@ -26,12 +28,14 @@ class CodeDisplay {
|
||||
int? lastUsedAt,
|
||||
int? tapCount,
|
||||
List<String>? tags,
|
||||
String? note,
|
||||
}) {
|
||||
final bool updatedPinned = pinned ?? this.pinned;
|
||||
final bool updatedTrashed = trashed ?? this.trashed;
|
||||
final int updatedLastUsedAt = lastUsedAt ?? this.lastUsedAt;
|
||||
final int updatedTapCount = tapCount ?? this.tapCount;
|
||||
final List<String> updatedTags = tags ?? this.tags;
|
||||
final String updatedNote = note ?? this.note;
|
||||
|
||||
return CodeDisplay(
|
||||
pinned: updatedPinned,
|
||||
@@ -39,6 +43,7 @@ class CodeDisplay {
|
||||
lastUsedAt: updatedLastUsedAt,
|
||||
tapCount: updatedTapCount,
|
||||
tags: updatedTags,
|
||||
note: updatedNote,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,6 +57,7 @@ class CodeDisplay {
|
||||
lastUsedAt: json['lastUsedAt'] ?? 0,
|
||||
tapCount: json['tapCount'] ?? 0,
|
||||
tags: List<String>.from(json['tags'] ?? []),
|
||||
note: json['note'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,6 +98,7 @@ class CodeDisplay {
|
||||
'lastUsedAt': lastUsedAt,
|
||||
'tapCount': tapCount,
|
||||
'tags': tags,
|
||||
'note': note,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,6 +111,7 @@ class CodeDisplay {
|
||||
other.trashed == trashed &&
|
||||
other.lastUsedAt == lastUsedAt &&
|
||||
other.tapCount == tapCount &&
|
||||
other.note == note &&
|
||||
listEquals(other.tags, tags);
|
||||
}
|
||||
|
||||
@@ -113,6 +121,7 @@ class CodeDisplay {
|
||||
trashed.hashCode ^
|
||||
lastUsedAt.hashCode ^
|
||||
tapCount.hashCode ^
|
||||
note.hashCode ^
|
||||
tags.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
25
auth/lib/onboarding/view/common/field_label.dart
Normal file
25
auth/lib/onboarding/view/common/field_label.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:ente_auth/theme/ente_theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FieldLabel extends StatelessWidget {
|
||||
final String label;
|
||||
|
||||
const FieldLabel(
|
||||
this.label, {
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 12.0),
|
||||
child: SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
label,
|
||||
style: getEnteTextTheme(context).miniBoldMuted,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,15 @@ import 'package:ente_auth/models/code_display.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/field_label.dart';
|
||||
import 'package:ente_auth/onboarding/view/common/tag_chip.dart';
|
||||
import 'package:ente_auth/onboarding/view/view_qr_page.dart';
|
||||
import 'package:ente_auth/store/code_display_store.dart';
|
||||
import 'package:ente_auth/theme/ente_theme.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/toast_util.dart';
|
||||
import 'package:ente_auth/utils/totp_util.dart';
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
@@ -27,9 +31,12 @@ class SetupEnterSecretKeyPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
|
||||
final int _notesLimit = 500;
|
||||
final int _otherTextLimit = 200;
|
||||
late TextEditingController _issuerController;
|
||||
late TextEditingController _accountController;
|
||||
late TextEditingController _secretController;
|
||||
late TextEditingController _notesController;
|
||||
late bool _secretKeyObscured;
|
||||
late List<String> selectedTags = [...?widget.code?.display.tags];
|
||||
List<String> allTags = [];
|
||||
@@ -47,17 +54,52 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
|
||||
_secretController = TextEditingController(
|
||||
text: widget.code?.secret,
|
||||
);
|
||||
_notesController = TextEditingController(
|
||||
text: widget.code?.display.note,
|
||||
);
|
||||
_secretKeyObscured = widget.code != null;
|
||||
_loadTags();
|
||||
_streamSubscription = Bus.instance.on<CodesUpdatedEvent>().listen((event) {
|
||||
_loadTags();
|
||||
});
|
||||
_notesController.addListener(() {
|
||||
if (_notesController.text.length > _notesLimit) {
|
||||
_notesController.text = _notesController.text.substring(0, _notesLimit);
|
||||
_notesController.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: _notesController.text.length),
|
||||
);
|
||||
showToast(context, context.l10n.notesLengthLimit(_notesLimit));
|
||||
}
|
||||
});
|
||||
|
||||
if (widget.code == null ||
|
||||
(widget.code!.issuer.length < _otherTextLimit &&
|
||||
widget.code!.account.length < _otherTextLimit &&
|
||||
widget.code!.secret.length < _otherTextLimit)) {
|
||||
_limitTextLength(_issuerController, _otherTextLimit);
|
||||
_limitTextLength(_accountController, _otherTextLimit);
|
||||
_limitTextLength(_secretController, _otherTextLimit);
|
||||
}
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void _limitTextLength(TextEditingController controller, int limit) {
|
||||
controller.addListener(() {
|
||||
if (controller.text.length > limit) {
|
||||
controller.text = controller.text.substring(0, limit);
|
||||
controller.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: controller.text.length),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_streamSubscription?.cancel();
|
||||
_issuerController.dispose();
|
||||
_accountController.dispose();
|
||||
_notesController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -74,161 +116,235 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.importAccountPageTitle),
|
||||
),
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
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.
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return "Please enter some text";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.codeIssuerHint,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.auto,
|
||||
labelText: l10n.codeIssuerHint,
|
||||
actions: [
|
||||
if (widget.code != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.qr_code_2_outlined),
|
||||
enableFeedback: true,
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return ViewQrPage(code: widget.code);
|
||||
},
|
||||
),
|
||||
controller: _issuerController,
|
||||
autofocus: true,
|
||||
),
|
||||
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;
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.codeSecretKeyHint,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.auto,
|
||||
labelText: l10n.codeSecretKeyHint,
|
||||
suffixIcon: IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_secretKeyObscured = !_secretKeyObscured;
|
||||
});
|
||||
).ignore();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
FieldLabel(l10n.codeIssuerHint),
|
||||
Expanded(
|
||||
child: 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;
|
||||
},
|
||||
icon: _secretKeyObscured
|
||||
? const Icon(Icons.visibility_off_rounded)
|
||||
: const Icon(Icons.visibility_rounded),
|
||||
decoration: const InputDecoration(
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 12.0),
|
||||
),
|
||||
style: getEnteTextTheme(context).small,
|
||||
controller: _issuerController,
|
||||
autofocus: true,
|
||||
),
|
||||
),
|
||||
obscureText: _secretKeyObscured,
|
||||
controller: _secretController,
|
||||
),
|
||||
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;
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.codeAccountHint,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.auto,
|
||||
labelText: l10n.codeAccountHint,
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
FieldLabel(l10n.secret),
|
||||
Expanded(
|
||||
child: 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;
|
||||
},
|
||||
style: getEnteTextTheme(context).small,
|
||||
decoration: InputDecoration(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(vertical: 12.0),
|
||||
suffixIcon: GestureDetector(
|
||||
// padding: EdgeInsets.zero,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_secretKeyObscured = !_secretKeyObscured;
|
||||
});
|
||||
},
|
||||
child: _secretKeyObscured
|
||||
? const Icon(
|
||||
Icons.visibility_off_rounded,
|
||||
size: 18,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.visibility_rounded,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
obscureText: _secretKeyObscured,
|
||||
controller: _secretController,
|
||||
),
|
||||
),
|
||||
controller: _accountController,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
Text(
|
||||
l10n.tags,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
FieldLabel(l10n.account),
|
||||
Expanded(
|
||||
child: 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;
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 12.0),
|
||||
),
|
||||
style: getEnteTextTheme(context).small,
|
||||
controller: _accountController,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
alignment: WrapAlignment.start,
|
||||
children: [
|
||||
...allTags.map(
|
||||
(e) => TagChip(
|
||||
label: e,
|
||||
action: TagChipAction.check,
|
||||
state: selectedTags.contains(e)
|
||||
? TagChipState.selected
|
||||
: TagChipState.unselected,
|
||||
onTap: () {
|
||||
if (selectedTags.contains(e)) {
|
||||
selectedTags.remove(e);
|
||||
} else {
|
||||
selectedTags.add(e);
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
dividerColor: Colors
|
||||
.transparent, // Removes the default divider color
|
||||
),
|
||||
child: ExpansionTile(
|
||||
title: Text(
|
||||
l10n.advance,
|
||||
style: getEnteTextTheme(context).bodyMuted,
|
||||
),
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: [
|
||||
FieldLabel(l10n.notes),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
// The validator receives the text that the user has entered.
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return "Please enter some text";
|
||||
}
|
||||
if (value.length > _notesLimit) {
|
||||
return "Notes can't be more than 1000 characters";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
maxLength: _notesLimit,
|
||||
minLines: 1,
|
||||
maxLines: 5,
|
||||
decoration: const InputDecoration(
|
||||
contentPadding:
|
||||
EdgeInsets.symmetric(vertical: 12.0),
|
||||
),
|
||||
style: getEnteTextTheme(context).small,
|
||||
controller: _notesController,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
AddChip(
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
alignment: WrapAlignment.start,
|
||||
children: [
|
||||
...allTags.map(
|
||||
(e) => TagChip(
|
||||
label: e,
|
||||
action: TagChipAction.check,
|
||||
state: selectedTags.contains(e)
|
||||
? TagChipState.selected
|
||||
: TagChipState.unselected,
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AddTagDialog(
|
||||
onTap: (tag) {
|
||||
final exist = allTags.contains(tag);
|
||||
if (exist && selectedTags.contains(tag)) {
|
||||
return Navigator.pop(context);
|
||||
}
|
||||
if (!exist) allTags.add(tag);
|
||||
selectedTags.add(tag);
|
||||
setState(() {});
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
barrierColor: Colors.black.withOpacity(0.85),
|
||||
barrierDismissible: false,
|
||||
);
|
||||
if (selectedTags.contains(e)) {
|
||||
selectedTags.remove(e);
|
||||
} else {
|
||||
selectedTags.add(e);
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 40,
|
||||
),
|
||||
SizedBox(
|
||||
width: 400,
|
||||
child: OutlinedButton(
|
||||
onPressed: () async {
|
||||
if ((_accountController.text.trim().isEmpty &&
|
||||
_issuerController.text.trim().isEmpty) ||
|
||||
_secretController.text.trim().isEmpty) {
|
||||
String message;
|
||||
if (_secretController.text.trim().isEmpty) {
|
||||
message = context.l10n.secretCanNotBeEmpty;
|
||||
} else {
|
||||
message =
|
||||
context.l10n.bothIssuerAndAccountCanNotBeEmpty;
|
||||
}
|
||||
_showIncorrectDetailsDialog(context, message: message);
|
||||
return;
|
||||
}
|
||||
await _saveCode();
|
||||
},
|
||||
child: Text(l10n.saveAction),
|
||||
),
|
||||
AddChip(
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AddTagDialog(
|
||||
onTap: (tag) {
|
||||
final exist = allTags.contains(tag);
|
||||
if (exist && selectedTags.contains(tag)) {
|
||||
return Navigator.pop(context);
|
||||
}
|
||||
if (!exist) allTags.add(tag);
|
||||
selectedTags.add(tag);
|
||||
setState(() {});
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
barrierColor: Colors.black.withOpacity(0.85),
|
||||
barrierDismissible: false,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 40,
|
||||
),
|
||||
SizedBox(
|
||||
width: 400,
|
||||
child: OutlinedButton(
|
||||
onPressed: () async {
|
||||
if ((_accountController.text.trim().isEmpty &&
|
||||
_issuerController.text.trim().isEmpty) ||
|
||||
_secretController.text.trim().isEmpty) {
|
||||
String message;
|
||||
if (_secretController.text.trim().isEmpty) {
|
||||
message = context.l10n.secretCanNotBeEmpty;
|
||||
} else {
|
||||
message =
|
||||
context.l10n.bothIssuerAndAccountCanNotBeEmpty;
|
||||
}
|
||||
_showIncorrectDetailsDialog(context, message: message);
|
||||
return;
|
||||
}
|
||||
await _saveCode();
|
||||
},
|
||||
child: Text(l10n.saveAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -240,8 +356,13 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
|
||||
final account = _accountController.text.trim();
|
||||
final issuer = _issuerController.text.trim();
|
||||
final secret = _secretController.text.trim().replaceAll(' ', '');
|
||||
final notes = _notesController.text.trim();
|
||||
final isStreamCode = issuer.toLowerCase() == "steam" ||
|
||||
issuer.toLowerCase().contains('steampowered.com');
|
||||
final CodeDisplay display =
|
||||
widget.code?.display.copyWith(tags: selectedTags) ??
|
||||
CodeDisplay(tags: selectedTags);
|
||||
display.note = notes;
|
||||
if (widget.code != null && widget.code!.secret != secret) {
|
||||
ButtonResult? result = await showChoiceActionSheet(
|
||||
context,
|
||||
@@ -256,9 +377,7 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
final CodeDisplay display =
|
||||
widget.code?.display.copyWith(tags: selectedTags) ??
|
||||
CodeDisplay(tags: selectedTags);
|
||||
|
||||
final Code newCode = widget.code == null
|
||||
? Code.fromAccountAndSecret(
|
||||
isStreamCode ? Type.steam : Type.totp,
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:ente_auth/utils/auth_util.dart';
|
||||
import 'package:ente_auth/utils/dialog_util.dart';
|
||||
import 'package:ente_auth/utils/lock_screen_settings.dart';
|
||||
import 'package:ente_auth/utils/toast_util.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_local_authentication/flutter_local_authentication.dart';
|
||||
@@ -19,11 +20,19 @@ class LocalAuthenticationService {
|
||||
static final LocalAuthenticationService instance =
|
||||
LocalAuthenticationService._privateConstructor();
|
||||
final logger = Logger((LocalAuthenticationService).toString());
|
||||
int lastAuthTime = 0;
|
||||
|
||||
Future<bool> requestLocalAuthentication(
|
||||
BuildContext context,
|
||||
String infoMessage,
|
||||
) async {
|
||||
if (kDebugMode) {
|
||||
// if last auth time is less than 60 seconds, don't ask for auth again
|
||||
if (lastAuthTime != 0 &&
|
||||
DateTime.now().millisecondsSinceEpoch - lastAuthTime < 60000) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (await isLocalAuthSupportedOnDevice() ||
|
||||
LockScreenSettings.instance.getIsAppLockSet()) {
|
||||
AppLock.of(context)!.setEnabled(false);
|
||||
@@ -39,6 +48,7 @@ class LocalAuthenticationService {
|
||||
showToast(context, infoMessage);
|
||||
return false;
|
||||
} else {
|
||||
lastAuthTime = DateTime.now().millisecondsSinceEpoch;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ class PreferenceService {
|
||||
static const kShouldHideCodesKey = "should_hide_codes";
|
||||
static const kShouldAutoFocusOnSearchBar = "should_auto_focus_on_search_bar";
|
||||
static const kShouldMinimizeOnCopy = "should_minimize_on_copy";
|
||||
static const kCompactMode = "vi.compactMode";
|
||||
|
||||
Future<void> init() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
@@ -48,6 +49,14 @@ class PreferenceService {
|
||||
return _prefs.getBool(kShouldHideCodesKey) ?? false;
|
||||
}
|
||||
|
||||
bool isCompactMode() {
|
||||
return _prefs.getBool(kCompactMode) ?? false;
|
||||
}
|
||||
|
||||
Future<void> setCompactMode(bool value) async {
|
||||
await _prefs.setBool(kCompactMode, value);
|
||||
}
|
||||
|
||||
Future<void> setHideCodes(bool value) async {
|
||||
await _prefs.setBool(kShouldHideCodesKey, value);
|
||||
Bus.instance.fire(IconsChangedEvent());
|
||||
|
||||
@@ -30,6 +30,7 @@ class CodeDisplayStore {
|
||||
final tags = <String>{};
|
||||
for (final code in codes) {
|
||||
if (code.hasError) continue;
|
||||
if (code.isTrashed) continue;
|
||||
tags.addAll(code.display.tags);
|
||||
}
|
||||
return tags.toList()..sort();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:ente_auth/services/preference_service.dart';
|
||||
import 'package:ente_auth/theme/ente_theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
@@ -30,6 +31,7 @@ class _CodeTimerProgressState extends State<CodeTimerProgress>
|
||||
late final Ticker _ticker;
|
||||
late final ValueNotifier<double> _progress;
|
||||
late final int _microSecondsInPeriod;
|
||||
late bool _isCompactMode=false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -38,6 +40,7 @@ class _CodeTimerProgressState extends State<CodeTimerProgress>
|
||||
_progress = ValueNotifier<double>(0.0);
|
||||
_ticker = createTicker(_updateTimeRemaining);
|
||||
_ticker.start();
|
||||
_isCompactMode = PreferenceService.instance.isCompactMode();
|
||||
_updateTimeRemaining(Duration.zero);
|
||||
}
|
||||
|
||||
@@ -57,7 +60,7 @@ class _CodeTimerProgressState extends State<CodeTimerProgress>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 3,
|
||||
height: _isCompactMode ?1:3,
|
||||
child: ValueListenableBuilder<double>(
|
||||
valueListenable: _progress,
|
||||
builder: (context, progress, _) {
|
||||
|
||||
@@ -15,11 +15,13 @@ import 'package:ente_auth/services/preference_service.dart';
|
||||
import 'package:ente_auth/store/code_store.dart';
|
||||
import 'package:ente_auth/theme/ente_theme.dart';
|
||||
import 'package:ente_auth/ui/code_timer_progress.dart';
|
||||
import 'package:ente_auth/ui/share/code_share.dart';
|
||||
import 'package:ente_auth/ui/utils/icon_utils.dart';
|
||||
import 'package:ente_auth/utils/dialog_util.dart';
|
||||
import 'package:ente_auth/utils/platform_util.dart';
|
||||
import 'package:ente_auth/utils/toast_util.dart';
|
||||
import 'package:ente_auth/utils/totp_util.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_context_menu/flutter_context_menu.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
@@ -29,10 +31,12 @@ import 'package:move_to_background/move_to_background.dart';
|
||||
|
||||
class CodeWidget extends StatefulWidget {
|
||||
final Code code;
|
||||
final bool isCompactMode;
|
||||
|
||||
const CodeWidget(
|
||||
this.code, {
|
||||
super.key,
|
||||
required this.isCompactMode,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -49,12 +53,14 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
late bool _shouldShowLargeIcon;
|
||||
late bool _hideCode;
|
||||
bool isMaskingEnabled = false;
|
||||
bool isCompactMode = true;
|
||||
int _codeTimeStep = -1;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
isMaskingEnabled = PreferenceService.instance.shouldHideCodes();
|
||||
|
||||
_hideCode = isMaskingEnabled;
|
||||
_everySecondTimer =
|
||||
Timer.periodic(const Duration(milliseconds: 500), (Timer t) {
|
||||
@@ -115,6 +121,16 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
painter: PinBgPainter(
|
||||
color: colorScheme.pinnedBgColor,
|
||||
),
|
||||
size: isCompactMode ? const Size(24, 24) : const Size(39, 39),
|
||||
),
|
||||
),
|
||||
if (widget.code.isTrashed && kDebugMode)
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: CustomPaint(
|
||||
painter: PinBgPainter(
|
||||
color: colorScheme.warning700,
|
||||
),
|
||||
size: const Size(39, 39),
|
||||
),
|
||||
),
|
||||
@@ -126,7 +142,9 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
CodeTimerProgressCache.getCachedWidget(
|
||||
widget.code.period,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
widget.isCompactMode
|
||||
? const SizedBox(height: 4)
|
||||
: const SizedBox(height: 28),
|
||||
Row(
|
||||
children: [
|
||||
_shouldShowLargeIcon ? _getIcon() : const SizedBox.shrink(),
|
||||
@@ -134,22 +152,32 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
child: Column(
|
||||
children: [
|
||||
_getTopRow(),
|
||||
const SizedBox(height: 4),
|
||||
widget.isCompactMode
|
||||
? const SizedBox.shrink()
|
||||
: const SizedBox(height: 4),
|
||||
_getBottomRow(l10n),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
isCompactMode
|
||||
? const SizedBox(height: 4)
|
||||
: const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
if (widget.code.isPinned) ...[
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 6, top: 6),
|
||||
child: SvgPicture.asset("assets/svg/pin-card.svg"),
|
||||
padding: widget.isCompactMode
|
||||
? const EdgeInsets.only(right: 2, top: 2)
|
||||
: const EdgeInsets.only(right: 6, top: 6),
|
||||
child: SvgPicture.asset(
|
||||
"assets/svg/pin-card.svg",
|
||||
width: widget.isCompactMode ? 8 : null,
|
||||
height: widget.isCompactMode ? 8 : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -196,36 +224,59 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(left: 16, right: 16, bottom: 8, top: 8),
|
||||
margin: widget.isCompactMode
|
||||
? const EdgeInsets.only(left: 16, right: 16, bottom: 6, top: 6)
|
||||
: 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: () => _onPinPressed(null),
|
||||
),
|
||||
MenuItem(
|
||||
label: l10n.edit,
|
||||
icon: Icons.edit,
|
||||
onSelected: () => _onEditPressed(null),
|
||||
),
|
||||
if (!widget.code.isTrashed &&
|
||||
widget.code.type.isTOTPCompatible)
|
||||
MenuItem(
|
||||
label: context.l10n.share,
|
||||
icon: Icons.adaptive.share_outlined,
|
||||
onSelected: () => _onSharePressed(null),
|
||||
),
|
||||
if (!widget.code.isTrashed)
|
||||
MenuItem(
|
||||
label: 'QR',
|
||||
icon: Icons.qr_code_2_outlined,
|
||||
onSelected: () => _onShowQrPressed(null),
|
||||
),
|
||||
if (!widget.code.isTrashed)
|
||||
MenuItem(
|
||||
label:
|
||||
widget.code.isPinned ? l10n.unpinText : l10n.pinText,
|
||||
icon: widget.code.isPinned
|
||||
? Icons.push_pin
|
||||
: Icons.push_pin_outlined,
|
||||
onSelected: () => _onPinPressed(null),
|
||||
),
|
||||
if (!widget.code.isTrashed)
|
||||
MenuItem(
|
||||
label: l10n.edit,
|
||||
icon: Icons.edit,
|
||||
onSelected: () => _onEditPressed(null),
|
||||
),
|
||||
if (widget.code.isTrashed)
|
||||
MenuItem(
|
||||
label: l10n.restore,
|
||||
icon: Icons.restore_outlined,
|
||||
onSelected: () => _onRestoreClicked(null),
|
||||
),
|
||||
const MenuDivider(),
|
||||
MenuItem(
|
||||
label: l10n.delete,
|
||||
label: widget.code.isTrashed ? l10n.delete : l10n.trash,
|
||||
value: "Delete",
|
||||
icon: Icons.delete,
|
||||
onSelected: () => _onDeletePressed(null),
|
||||
icon: widget.code.isTrashed
|
||||
? Icons.delete_forever
|
||||
: Icons.delete,
|
||||
onSelected: () => widget.code.isTrashed
|
||||
? _onDeletePressed(null)
|
||||
: _onTrashPressed(null),
|
||||
),
|
||||
],
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
@@ -233,87 +284,105 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
child: clippedCard(l10n),
|
||||
);
|
||||
}
|
||||
final double slideSpace = isCompactMode ? 4 : 8;
|
||||
double extendRatio = isCompactMode ? 0.70 : 0.90;
|
||||
if (widget.code.isTrashed) {
|
||||
extendRatio = 0.50;
|
||||
}
|
||||
|
||||
return Slidable(
|
||||
key: ValueKey(widget.code.hashCode),
|
||||
endActionPane: ActionPane(
|
||||
extentRatio: 0.90,
|
||||
extentRatio: extendRatio,
|
||||
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,
|
||||
),
|
||||
],
|
||||
if (!widget.code.isTrashed && widget.code.type.isTOTPCompatible)
|
||||
SizedBox(width: slideSpace),
|
||||
if (!widget.code.isTrashed && widget.code.type.isTOTPCompatible)
|
||||
SlidableAction(
|
||||
onPressed: _onSharePressed,
|
||||
backgroundColor: Colors.grey.withOpacity(0.1),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
foregroundColor:
|
||||
Theme.of(context).colorScheme.inverseBackgroundColor,
|
||||
icon: Icons.adaptive.share_outlined,
|
||||
label: l10n.share,
|
||||
padding: const EdgeInsets.only(left: 4, right: 0),
|
||||
spacing: 8,
|
||||
),
|
||||
padding: const EdgeInsets.only(left: 4, right: 0),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 14,
|
||||
),
|
||||
if (!widget.code.isTrashed) SizedBox(width: slideSpace),
|
||||
if (!widget.code.isTrashed)
|
||||
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),
|
||||
),
|
||||
if (!widget.code.isTrashed) SizedBox(width: slideSpace),
|
||||
if (!widget.code.isTrashed)
|
||||
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,
|
||||
),
|
||||
if (widget.code.isTrashed) SizedBox(width: slideSpace),
|
||||
if (widget.code.isTrashed)
|
||||
SlidableAction(
|
||||
onPressed: _onRestoreClicked,
|
||||
backgroundColor: Colors.grey.withOpacity(0.1),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
foregroundColor:
|
||||
Theme.of(context).colorScheme.inverseBackgroundColor,
|
||||
icon: Icons.restore_outlined,
|
||||
label: l10n.restore,
|
||||
padding: const EdgeInsets.only(left: 4, right: 0),
|
||||
spacing: 8,
|
||||
),
|
||||
SizedBox(width: slideSpace),
|
||||
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,
|
||||
onPressed: widget.code.isTrashed
|
||||
? _onDeletePressed
|
||||
: _onTrashPressed,
|
||||
backgroundColor: Colors.grey.withOpacity(0.1),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
foregroundColor: colorScheme.deleteCodeTextColor,
|
||||
icon: Icons.delete,
|
||||
label: l10n.delete,
|
||||
icon: widget.code.isTrashed
|
||||
? Icons.delete_forever
|
||||
: Icons.delete,
|
||||
label: widget.code.isTrashed ? l10n.delete : l10n.trash,
|
||||
padding: const EdgeInsets.only(left: 0, right: 0),
|
||||
spacing: 8,
|
||||
),
|
||||
@@ -343,7 +412,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
type: MaterialType.transparency,
|
||||
child: AutoSizeText(
|
||||
_getFormattedCode(value),
|
||||
style: const TextStyle(fontSize: 24),
|
||||
style: TextStyle(fontSize: widget.isCompactMode ? 14 : 24),
|
||||
maxLines: 1,
|
||||
),
|
||||
);
|
||||
@@ -370,8 +439,8 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
type: MaterialType.transparency,
|
||||
child: Text(
|
||||
_getFormattedCode(value),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
style: TextStyle(
|
||||
fontSize: widget.isCompactMode ? 12 : 18,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
@@ -404,6 +473,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
}
|
||||
|
||||
Widget _getTopRow() {
|
||||
bool isCompactMode = widget.isCompactMode;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16),
|
||||
child: Row(
|
||||
@@ -415,13 +485,15 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
children: [
|
||||
Text(
|
||||
safeDecode(widget.code.issuer).trim(),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
style: isCompactMode
|
||||
? Theme.of(context).textTheme.bodyMedium
|
||||
: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
if (!isCompactMode) const SizedBox(height: 2),
|
||||
Text(
|
||||
safeDecode(widget.code.account).trim(),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontSize: 12,
|
||||
fontSize: isCompactMode ? 12 : 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
@@ -452,12 +524,14 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
Widget _getIcon() {
|
||||
return Padding(
|
||||
padding: _shouldShowLargeIcon
|
||||
? const EdgeInsets.only(left: 16)
|
||||
? EdgeInsets.only(left: widget.isCompactMode ? 12 : 16)
|
||||
: const EdgeInsets.all(0),
|
||||
child: IconUtils.instance.getIcon(
|
||||
context,
|
||||
safeDecode(widget.code.issuer).trim(),
|
||||
width: _shouldShowLargeIcon ? 42 : 24,
|
||||
width: widget.isCompactMode
|
||||
? (_shouldShowLargeIcon ? 32 : 24)
|
||||
: (_shouldShowLargeIcon ? 42 : 24),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -540,6 +614,16 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onSharePressed(_) async {
|
||||
bool isAuthSuccessful = await LocalAuthenticationService.instance
|
||||
.requestLocalAuthentication(context, context.l10n.authenticateGeneric);
|
||||
await PlatformUtil.refocusWindows();
|
||||
if (!isAuthSuccessful) {
|
||||
return;
|
||||
}
|
||||
showShareDialog(context, widget.code);
|
||||
}
|
||||
|
||||
Future<void> _onPinPressed(_) async {
|
||||
bool currentlyPinned = widget.code.isPinned;
|
||||
final display = widget.code.display;
|
||||
@@ -559,6 +643,10 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
}
|
||||
|
||||
void _onDeletePressed(_) async {
|
||||
if (!widget.code.isTrashed) {
|
||||
showToast(context, 'Code can only be deleted from trash');
|
||||
return;
|
||||
}
|
||||
bool isAuthSuccessful =
|
||||
await LocalAuthenticationService.instance.requestLocalAuthentication(
|
||||
context,
|
||||
@@ -581,6 +669,64 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
void _onTrashPressed(_) async {
|
||||
if (widget.code.isTrashed) {
|
||||
showToast(context, 'Code is already trashed');
|
||||
return;
|
||||
}
|
||||
bool isAuthSuccessful =
|
||||
await LocalAuthenticationService.instance.requestLocalAuthentication(
|
||||
context,
|
||||
context.l10n.deleteCodeAuthMessage,
|
||||
);
|
||||
if (!isAuthSuccessful) {
|
||||
return;
|
||||
}
|
||||
FocusScope.of(context).requestFocus();
|
||||
final l10n = context.l10n;
|
||||
await showChoiceActionSheet(
|
||||
context,
|
||||
title: l10n.trashCode,
|
||||
body: l10n
|
||||
.trashCodeMessage('${widget.code.issuer} (${widget.code.account})'),
|
||||
firstButtonLabel: l10n.trash,
|
||||
isCritical: true,
|
||||
firstButtonOnTap: () async {
|
||||
try {
|
||||
final display = widget.code.display;
|
||||
final Code code = widget.code.copyWith(
|
||||
display: display.copyWith(trashed: true),
|
||||
);
|
||||
await CodeStore.instance.addCode(code);
|
||||
} catch (e) {
|
||||
logger.severe('Failed to trash code: ${e.toString()}');
|
||||
showGenericErrorDialog(context: context, error: e).ignore();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onRestoreClicked(_) async {
|
||||
if (!widget.code.isTrashed) {
|
||||
showToast(context, 'Code is already restored');
|
||||
return;
|
||||
}
|
||||
FocusScope.of(context).requestFocus();
|
||||
|
||||
try {
|
||||
final display = widget.code.display;
|
||||
final Code code = widget.code.copyWith(
|
||||
display: display.copyWith(trashed: false),
|
||||
);
|
||||
await CodeStore.instance.addCode(code);
|
||||
} catch (e) {
|
||||
logger.severe('Failed to restore code: ${e.toString()}');
|
||||
if (mounted) {
|
||||
showGenericErrorDialog(context: context, error: e).ignore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _getCurrentOTP() {
|
||||
try {
|
||||
return getOTP(widget.code);
|
||||
|
||||
@@ -74,6 +74,10 @@ class _HomePageState extends State<HomePage> {
|
||||
StreamSubscription<TriggerLogoutEvent>? _triggerLogoutEvent;
|
||||
StreamSubscription<IconsChangedEvent>? _iconsChangedEvent;
|
||||
String selectedTag = "";
|
||||
bool _isTrashOpen = false;
|
||||
bool hasTrashedCodes = false;
|
||||
bool hasNonTrashedCodes = false;
|
||||
bool isCompactMode = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -103,10 +107,27 @@ class _HomePageState extends State<HomePage> {
|
||||
void _loadCodes() {
|
||||
CodeStore.instance.getAllCodes().then((codes) {
|
||||
_allCodes = codes;
|
||||
hasTrashedCodes = false;
|
||||
hasNonTrashedCodes = false;
|
||||
for (final c in _allCodes ?? []) {
|
||||
if (c.isTrashed) {
|
||||
hasTrashedCodes = true;
|
||||
} else {
|
||||
hasNonTrashedCodes = true;
|
||||
}
|
||||
if (hasNonTrashedCodes && hasTrashedCodes) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasTrashedCodes) {
|
||||
_isTrashOpen = false;
|
||||
}
|
||||
if (!hasNonTrashedCodes && hasTrashedCodes) {
|
||||
_isTrashOpen = true;
|
||||
}
|
||||
|
||||
CodeDisplayStore.instance.getAllTags(allCodes: _allCodes).then((value) {
|
||||
tags = value;
|
||||
|
||||
if (mounted) {
|
||||
if (!tags.contains(selectedTag)) {
|
||||
selectedTag = "";
|
||||
@@ -133,7 +154,8 @@ class _HomePageState extends State<HomePage> {
|
||||
for (final Code codeState in _allCodes!) {
|
||||
if (codeState.hasError ||
|
||||
selectedTag != "" &&
|
||||
!codeState.display.tags.contains(selectedTag)) {
|
||||
!codeState.display.tags.contains(selectedTag) ||
|
||||
(codeState.isTrashed != _isTrashOpen)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -145,11 +167,19 @@ class _HomePageState extends State<HomePage> {
|
||||
}
|
||||
_filteredCodes = issuerMatch;
|
||||
_filteredCodes.addAll(accountMatch);
|
||||
} else if (_isTrashOpen) {
|
||||
_filteredCodes = _allCodes
|
||||
?.where(
|
||||
(element) => !element.hasError && element.isTrashed,
|
||||
)
|
||||
.toList() ??
|
||||
[];
|
||||
} else {
|
||||
_filteredCodes = _allCodes
|
||||
?.where(
|
||||
(element) =>
|
||||
!element.hasError &&
|
||||
!element.isTrashed &&
|
||||
(selectedTag == "" ||
|
||||
element.display.tags.contains(selectedTag)),
|
||||
)
|
||||
@@ -228,6 +258,7 @@ class _HomePageState extends State<HomePage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
isCompactMode = PreferenceService.instance.isCompactMode();
|
||||
|
||||
return PopScope(
|
||||
onPopInvokedWithResult: (_, result) async {
|
||||
@@ -340,6 +371,8 @@ class _HomePageState extends State<HomePage> {
|
||||
final anyCodeHasError =
|
||||
_allCodes?.firstWhereOrNull((element) => element.hasError) != null;
|
||||
final indexOffset = anyCodeHasError ? 1 : 0;
|
||||
final itemCount = (hasNonTrashedCodes ? tags.length + 1 : 0) +
|
||||
(hasTrashedCodes ? 1 : 0);
|
||||
|
||||
final list = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -353,16 +386,31 @@ class _HomePageState extends State<HomePage> {
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(width: 8),
|
||||
itemCount: tags.length + 1,
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
if (index == 0 && hasNonTrashedCodes) {
|
||||
return TagChip(
|
||||
label: "All",
|
||||
state: selectedTag == ""
|
||||
state: selectedTag == "" && _isTrashOpen == false
|
||||
? TagChipState.selected
|
||||
: TagChipState.unselected,
|
||||
onTap: () {
|
||||
selectedTag = "";
|
||||
_isTrashOpen = false;
|
||||
setState(() {});
|
||||
_applyFilteringAndRefresh();
|
||||
},
|
||||
);
|
||||
}
|
||||
if (index == itemCount - 1 && hasTrashedCodes) {
|
||||
return TagChip(
|
||||
label: '🗑️ Trash',
|
||||
state: _isTrashOpen
|
||||
? TagChipState.selected
|
||||
: TagChipState.unselected,
|
||||
onTap: () {
|
||||
selectedTag = "";
|
||||
_isTrashOpen = !_isTrashOpen;
|
||||
setState(() {});
|
||||
_applyFilteringAndRefresh();
|
||||
},
|
||||
@@ -375,6 +423,7 @@ class _HomePageState extends State<HomePage> {
|
||||
? TagChipState.selected
|
||||
: TagChipState.unselected,
|
||||
onTap: () {
|
||||
_isTrashOpen = false;
|
||||
if (selectedTag == tags[index - 1]) {
|
||||
selectedTag = "";
|
||||
setState(() {});
|
||||
@@ -413,6 +462,7 @@ class _HomePageState extends State<HomePage> {
|
||||
child: CodeWidget(
|
||||
key: ValueKey(code.hashCode),
|
||||
code,
|
||||
isCompactMode: isCompactMode,
|
||||
),
|
||||
);
|
||||
}),
|
||||
@@ -443,6 +493,7 @@ class _HomePageState extends State<HomePage> {
|
||||
final codeState = _filteredCodes[index];
|
||||
return CodeWidget(
|
||||
codeState,
|
||||
isCompactMode: isCompactMode,
|
||||
);
|
||||
}),
|
||||
itemCount: _filteredCodes.length,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:ente_auth/app/view/app.dart';
|
||||
import 'package:ente_auth/core/event_bus.dart';
|
||||
import 'package:ente_auth/core/logging/super_logging.dart';
|
||||
import 'package:ente_auth/events/icons_changed_event.dart';
|
||||
import 'package:ente_auth/l10n/l10n.dart';
|
||||
import 'package:ente_auth/locale.dart';
|
||||
import 'package:ente_auth/services/preference_service.dart';
|
||||
@@ -78,6 +80,22 @@ class _AdvancedSectionWidgetState extends State<AdvancedSectionWidget> {
|
||||
),
|
||||
),
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: CaptionedTextWidget(
|
||||
title: l10n.compactMode,
|
||||
),
|
||||
trailingWidget: ToggleSwitchWidget(
|
||||
value: () => PreferenceService.instance.isCompactMode(),
|
||||
onChanged: () async {
|
||||
await PreferenceService.instance.setCompactMode(
|
||||
!PreferenceService.instance.isCompactMode(),
|
||||
);
|
||||
Bus.instance.fire(IconsChangedEvent());
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: CaptionedTextWidget(
|
||||
title: l10n.shouldHideCode,
|
||||
@@ -88,7 +106,7 @@ class _AdvancedSectionWidgetState extends State<AdvancedSectionWidget> {
|
||||
await PreferenceService.instance.setHideCodes(
|
||||
!PreferenceService.instance.shouldHideCodes(),
|
||||
);
|
||||
if(PreferenceService.instance.shouldHideCodes()) {
|
||||
if (PreferenceService.instance.shouldHideCodes()) {
|
||||
showToast(context, context.l10n.doubleTapToViewHiddenCode);
|
||||
}
|
||||
setState(() {});
|
||||
|
||||
154
auth/lib/ui/share/code_share.dart
Normal file
154
auth/lib/ui/share/code_share.dart
Normal file
@@ -0,0 +1,154 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dropdown_button2/dropdown_button2.dart';
|
||||
import 'package:ente_auth/l10n/l10n.dart';
|
||||
import 'package:ente_auth/models/code.dart';
|
||||
import 'package:ente_auth/ui/components/buttons/button_widget.dart';
|
||||
import 'package:ente_auth/ui/components/models/button_type.dart';
|
||||
import 'package:ente_auth/utils/dialog_util.dart';
|
||||
import 'package:ente_auth/utils/share_utils.dart';
|
||||
import 'package:ente_auth/utils/totp_util.dart';
|
||||
import 'package:ente_crypto_dart/ente_crypto_dart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class ShareCodeDialog extends StatefulWidget {
|
||||
final Code code;
|
||||
const ShareCodeDialog({super.key, required this.code});
|
||||
|
||||
@override
|
||||
State<ShareCodeDialog> createState() => _ShareCodeDialogState();
|
||||
}
|
||||
|
||||
class _ShareCodeDialogState extends State<ShareCodeDialog> {
|
||||
final Logger logger = Logger('_ShareCodeDialogState');
|
||||
final List<int> _durationInMins = [2, 5, 15, 30, 60];
|
||||
late int selectedValue;
|
||||
|
||||
String getItemLabel(int min) {
|
||||
if (min == 60) return '1 hour';
|
||||
if (min > 60) {
|
||||
var hour = '${min ~/ 60}';
|
||||
if (min % 60 == 0) return '$hour hour';
|
||||
var minx = '${min % 60}';
|
||||
return '$hour hr $minx min';
|
||||
}
|
||||
return '$min min';
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
selectedValue = _durationInMins[2];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(context.l10n.shareCodes),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.l10n.shareCodesDuration),
|
||||
const SizedBox(height: 10),
|
||||
DropdownButtonHideUnderline(
|
||||
child: DropdownButton2(
|
||||
hint: const Text('Select an option'),
|
||||
items: _durationInMins
|
||||
.map(
|
||||
(item) => DropdownMenuItem<int>(
|
||||
value: item,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(getItemLabel(item)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
value: selectedValue,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
selectedValue = value ?? 15;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.primary,
|
||||
buttonSize: ButtonSize.large,
|
||||
labelText: context.l10n.share,
|
||||
onTap: () async {
|
||||
try {
|
||||
await shareCode(selectedValue);
|
||||
Navigator.of(context).pop();
|
||||
} catch (e, s) {
|
||||
logger.severe('Failed to generate shared codes', e, s);
|
||||
showGenericErrorDialog(context: context, error: e).ignore();
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.secondary,
|
||||
buttonSize: ButtonSize.large,
|
||||
labelText: context.l10n.cancel,
|
||||
onTap: () async {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> shareCode(int durationInMin) async {
|
||||
final int count = ((durationInMin * 60.0) / widget.code.period).ceil();
|
||||
final result = generateFutureTotpCodes(widget.code, count);
|
||||
Map<String, dynamic> data = {
|
||||
'startTime': result.$1,
|
||||
'step': widget.code.period,
|
||||
'codes': result.$2.join(","),
|
||||
};
|
||||
final Uint8List key = _generate256BitKey();
|
||||
Uint8List input = utf8.encode(jsonEncode(data));
|
||||
final encResult = await CryptoUtil.encryptData(input, key);
|
||||
String url =
|
||||
'https://auth.ente.io/share?data=${_uint8ListToUrlSafeBase64(encResult.encryptedData!)}&header=${_uint8ListToUrlSafeBase64(encResult.header!)}#${_uint8ListToUrlSafeBase64(key)}';
|
||||
try {
|
||||
await shareText(url, context: context);
|
||||
} catch (e) {
|
||||
logger.warning('Failed to share code: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
String _uint8ListToUrlSafeBase64(Uint8List data) {
|
||||
String base64Str = base64UrlEncode(data);
|
||||
return base64Str.replaceAll('=', '');
|
||||
}
|
||||
|
||||
Uint8List _generate256BitKey() {
|
||||
final random = Random.secure();
|
||||
final bytes = Uint8List(32); // 32 bytes = 32 * 8 bits = 256 bits
|
||||
for (int i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = random
|
||||
.nextInt(256); // Generates a random number between 0 and 255 (1 byte)
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
void showShareDialog(BuildContext context, Code code) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return ShareCodeDialog(
|
||||
code: code,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import 'package:ente_auth/ui/components/buttons/button_widget.dart';
|
||||
import 'package:ente_auth/ui/components/dialog_widget.dart';
|
||||
import 'package:ente_auth/ui/components/models/button_type.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
Future<void> shareDialog(
|
||||
BuildContext context,
|
||||
@@ -49,3 +51,52 @@ Future<void> shareDialog(
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Rect _sharePosOrigin(BuildContext? context, GlobalKey? key) {
|
||||
late final Rect rect;
|
||||
if (context != null) {
|
||||
rect = shareButtonRect(context, key);
|
||||
} else {
|
||||
rect = const Offset(20.0, 20.0) & const Size(10, 10);
|
||||
}
|
||||
return rect;
|
||||
}
|
||||
|
||||
/// Returns the rect of button if context and key are not null
|
||||
/// If key is null, returned rect will be at the center of the screen
|
||||
Rect shareButtonRect(BuildContext context, GlobalKey? shareButtonKey) {
|
||||
Size size = MediaQuery.sizeOf(context);
|
||||
final RenderObject? renderObject =
|
||||
shareButtonKey?.currentContext?.findRenderObject();
|
||||
RenderBox? renderBox;
|
||||
if (renderObject != null && renderObject is RenderBox) {
|
||||
renderBox = renderObject;
|
||||
}
|
||||
if (renderBox == null) {
|
||||
return Rect.fromLTWH(0, 0, size.width, size.height / 2);
|
||||
}
|
||||
size = renderBox.size;
|
||||
final Offset position = renderBox.localToGlobal(Offset.zero);
|
||||
return Rect.fromCenter(
|
||||
center: position + Offset(size.width / 2, size.height / 2),
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
);
|
||||
}
|
||||
|
||||
Future<ShareResult> shareText(
|
||||
String text, {
|
||||
BuildContext? context,
|
||||
GlobalKey? key,
|
||||
}) async {
|
||||
try {
|
||||
final sharePosOrigin = _sharePosOrigin(context, key);
|
||||
return Share.share(
|
||||
text,
|
||||
sharePositionOrigin: sharePosOrigin,
|
||||
);
|
||||
} catch (e, s) {
|
||||
Logger("ShareUtil").severe("failed to share text", e, s);
|
||||
return ShareResult.unavailable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,38 @@ String getNextTotp(Code code) {
|
||||
);
|
||||
}
|
||||
|
||||
// generateFutureTotpCodes generates future TOTP codes based on the current time.
|
||||
// It returns the start time and a list of future codes.
|
||||
(int, List<String>) generateFutureTotpCodes(Code code, int count) {
|
||||
final int startTime =
|
||||
((DateTime.now().millisecondsSinceEpoch ~/ 1000) ~/ code.period) *
|
||||
code.period *
|
||||
1000;
|
||||
final String secret = getSanitizedSecret(code.secret);
|
||||
final List<String> codes = [];
|
||||
if (code.type == Type.steam || code.issuer.toLowerCase() == 'steam') {
|
||||
final SteamTOTP steamTotp = SteamTOTP(secret: code.secret);
|
||||
for (int i = 0; i < count; i++) {
|
||||
int generatedTime = startTime + code.period * 1000 * i;
|
||||
codes.add(steamTotp.generate(generatedTime ~/ 1000));
|
||||
}
|
||||
} else {
|
||||
for (int i = 0; i < count; i++) {
|
||||
int generatedTime = startTime + code.period * 1000 * i;
|
||||
final genCode = otp.OTP.generateTOTPCodeString(
|
||||
secret,
|
||||
generatedTime,
|
||||
length: code.digits,
|
||||
interval: code.period,
|
||||
algorithm: _getAlgorithm(code),
|
||||
isGoogle: true,
|
||||
);
|
||||
codes.add(genCode);
|
||||
}
|
||||
}
|
||||
return (startTime, codes);
|
||||
}
|
||||
|
||||
otp.Algorithm _getAlgorithm(Code code) {
|
||||
switch (code.algorithm) {
|
||||
case Algorithm.sha256:
|
||||
|
||||
@@ -362,6 +362,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
dropdown_button2:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dropdown_button2
|
||||
sha256: b0fe8d49a030315e9eef6c7ac84ca964250155a6224d491c1365061bc974a9e1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.9"
|
||||
email_validator:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -27,6 +27,7 @@ dependencies:
|
||||
device_info_plus: ^9.1.1
|
||||
dio: ^5.4.0
|
||||
dotted_border: ^2.0.0+2
|
||||
dropdown_button2: ^2.3.9
|
||||
email_validator: ^3.0.0
|
||||
ente_crypto_dart:
|
||||
git:
|
||||
|
||||
Reference in New Issue
Block a user