[auth] Trash, Notes, Compact Mode, & Sharing (#3258)

## Description

## Tests
This commit is contained in:
Neeraj Gupta
2024-09-13 17:29:55 +05:30
committed by GitHub
18 changed files with 931 additions and 268 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -27,6 +27,8 @@ class Code {
bool get isPinned => display.pinned;
bool get isTrashed => display.trashed;
final Object? err;
bool get hasError => err != null;

View File

@@ -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;
}
}

View 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,
),
),
);
}
}

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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());

View File

@@ -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();

View File

@@ -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, _) {

View File

@@ -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);

View File

@@ -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,

View File

@@ -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(() {});

View 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,
);
},
);
}

View File

@@ -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;
}
}

View File

@@ -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:

View File

@@ -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:

View File

@@ -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: