Compare commits
19 Commits
main
...
multiselec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46db03c07b | ||
|
|
b877b0a4cc | ||
|
|
920b3b1931 | ||
|
|
3026ec5be3 | ||
|
|
440bafa56a | ||
|
|
87c236b629 | ||
|
|
fe732f2778 | ||
|
|
ca8a067966 | ||
|
|
5e3a779925 | ||
|
|
d1b06abada | ||
|
|
9e70dc4312 | ||
|
|
541d71f65c | ||
|
|
d8fc369a21 | ||
|
|
8efbebe9c4 | ||
|
|
a7300b7ac7 | ||
|
|
9224cea96f | ||
|
|
9fbc618d69 | ||
|
|
4614428f76 | ||
|
|
6fde4ee45f |
1
mobile/apps/auth/.gitignore
vendored
1
mobile/apps/auth/.gitignore
vendored
@@ -8,6 +8,7 @@
|
|||||||
.buildlog/
|
.buildlog/
|
||||||
.history
|
.history
|
||||||
.svn/
|
.svn/
|
||||||
|
android/app/build/
|
||||||
|
|
||||||
# Editors
|
# Editors
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
Submodule mobile/apps/auth/flutter updated: edada7c56e...2663184aa7
@@ -11,6 +11,7 @@
|
|||||||
"setupFirstAccount": "Setup your first account",
|
"setupFirstAccount": "Setup your first account",
|
||||||
"importScanQrCode": "Scan a QR Code",
|
"importScanQrCode": "Scan a QR Code",
|
||||||
"qrCode": "QR Code",
|
"qrCode": "QR Code",
|
||||||
|
"qr": "QR",
|
||||||
"importEnterSetupKey": "Enter a setup key",
|
"importEnterSetupKey": "Enter a setup key",
|
||||||
"importAccountPageTitle": "Enter account details",
|
"importAccountPageTitle": "Enter account details",
|
||||||
"secretCanNotBeEmpty": "Secret can not be empty",
|
"secretCanNotBeEmpty": "Secret can not be empty",
|
||||||
@@ -139,6 +140,7 @@
|
|||||||
"existingUser": "Existing User",
|
"existingUser": "Existing User",
|
||||||
"newUser": "New to Ente",
|
"newUser": "New to Ente",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"addTag": "Add tag",
|
||||||
"enterYourPasswordHint": "Enter your password",
|
"enterYourPasswordHint": "Enter your password",
|
||||||
"forgotPassword": "Forgot password",
|
"forgotPassword": "Forgot password",
|
||||||
"oops": "Oops",
|
"oops": "Oops",
|
||||||
@@ -527,5 +529,28 @@
|
|||||||
"errorInvalidQRCodeBody": "The scanned QR code is not a valid 2FA account.",
|
"errorInvalidQRCodeBody": "The scanned QR code is not a valid 2FA account.",
|
||||||
"errorNoQRCode": "No QR code found",
|
"errorNoQRCode": "No QR code found",
|
||||||
"errorGenericTitle": "An Error Occurred",
|
"errorGenericTitle": "An Error Occurred",
|
||||||
"errorGenericBody": "An unexpected error occurred while importing."
|
"errorGenericBody": "An unexpected error occurred while importing.",
|
||||||
|
"localBackupSettingsTitle": "Local backup",
|
||||||
|
"localBackupSidebarTitle": "Local backup",
|
||||||
|
"enableAutomaticBackups": "Enable automatic backups",
|
||||||
|
"backupDescription": "This will automatically backup your data to an on-device location. Backups are updated whenever entries are added, edited or deleted",
|
||||||
|
"currentLocation": "Current backup location:",
|
||||||
|
"securityNotice": "Security notice",
|
||||||
|
"backupSecurityNotice": "This encrypted backup holds your 2FA keys. If lost, you may not be able to recover your accounts. Keep it safe!",
|
||||||
|
"locationUpdatedAndBackupCreated": "Location updated and initial backup created!",
|
||||||
|
"initialBackupCreated": "Initial backup created!",
|
||||||
|
"passwordTooShort": "Password must be at least 8 characters long.",
|
||||||
|
"noDefaultBackupFolder": "Could not create default backup folder.",
|
||||||
|
"backupLocationChoiceDescription": "Where do you want to save your backups?",
|
||||||
|
"chooseBackupLocation": "Choose a backup location",
|
||||||
|
"loadDefaultLocation": "Loading default location...",
|
||||||
|
"couldNotDetermineLocation":"Could not determine location...",
|
||||||
|
"saveAction":"Save",
|
||||||
|
"saveBackup":"Save backup",
|
||||||
|
"changeLocation": "Change location",
|
||||||
|
"changeCurrentLocation": "Change current location",
|
||||||
|
"done": "Done",
|
||||||
|
"addNew": "Add new",
|
||||||
|
"selected": "selected",
|
||||||
|
"moveMultipleToTrashMessage": "Are you sure you want to move {count} item(s) to the trash?"
|
||||||
}
|
}
|
||||||
@@ -54,9 +54,14 @@ class ViewQrPage extends StatelessWidget {
|
|||||||
const SizedBox(
|
const SizedBox(
|
||||||
width: 10,
|
width: 10,
|
||||||
),
|
),
|
||||||
Text(
|
Expanded(
|
||||||
code?.account ?? '',
|
child: SingleChildScrollView(
|
||||||
style: enteTextTheme.largeBold,
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Text(
|
||||||
|
code?.account ?? '',
|
||||||
|
style: enteTextTheme.largeBold,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -73,9 +78,14 @@ class ViewQrPage extends StatelessWidget {
|
|||||||
const SizedBox(
|
const SizedBox(
|
||||||
width: 10,
|
width: 10,
|
||||||
),
|
),
|
||||||
Text(
|
Expanded(
|
||||||
code?.issuer ?? '',
|
child: SingleChildScrollView(
|
||||||
style: enteTextTheme.largeBold,
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Text(
|
||||||
|
code?.issuer ?? '',
|
||||||
|
style: enteTextTheme.largeBold,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
155
mobile/apps/auth/lib/services/local_backup_service.dart
Normal file
155
mobile/apps/auth/lib/services/local_backup_service.dart
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:ente_auth/models/export/ente.dart';
|
||||||
|
import 'package:ente_auth/store/code_store.dart';
|
||||||
|
import 'package:ente_crypto_dart/ente_crypto_dart.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:intl/intl.dart'; //for time based file naming
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class LocalBackupService {
|
||||||
|
final _logger = Logger('LocalBackupService');
|
||||||
|
static final LocalBackupService instance =
|
||||||
|
LocalBackupService._privateConstructor();
|
||||||
|
LocalBackupService._privateConstructor();
|
||||||
|
|
||||||
|
static const int _maxBackups = 2;
|
||||||
|
|
||||||
|
// to create an encrypted backup file if the toggle is on
|
||||||
|
Future<void> triggerAutomaticBackup() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
final isEnabled = prefs.getBool('isAutoBackupEnabled') ?? false;
|
||||||
|
if (!isEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final backupPath = prefs.getString('autoBackupPath');
|
||||||
|
if (backupPath == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = FlutterSecureStorage();
|
||||||
|
final password = await storage.read(key: 'autoBackupPassword');
|
||||||
|
if (password == null || password.isEmpty) {
|
||||||
|
_logger.warning("Automatic backup skipped: password not set.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.info("Change detected, triggering automatic encrypted backup...");
|
||||||
|
|
||||||
|
final plainTextContent = await CodeStore.instance.getCodesForExport();
|
||||||
|
|
||||||
|
if (plainTextContent.trim().isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final kekSalt = CryptoUtil.getSaltToDeriveKey();
|
||||||
|
final derivedKeyResult = await CryptoUtil.deriveSensitiveKey(
|
||||||
|
utf8.encode(password),
|
||||||
|
kekSalt,
|
||||||
|
);
|
||||||
|
|
||||||
|
final encResult = await CryptoUtil.encryptData(
|
||||||
|
utf8.encode(plainTextContent),
|
||||||
|
derivedKeyResult.key,
|
||||||
|
);
|
||||||
|
|
||||||
|
final encContent = CryptoUtil.bin2base64(encResult.encryptedData!);
|
||||||
|
final encNonce = CryptoUtil.bin2base64(encResult.header!);
|
||||||
|
|
||||||
|
final EnteAuthExport data = EnteAuthExport(
|
||||||
|
version: 1,
|
||||||
|
encryptedData: encContent,
|
||||||
|
encryptionNonce: encNonce,
|
||||||
|
kdfParams: KDFParams(
|
||||||
|
memLimit: derivedKeyResult.memLimit,
|
||||||
|
opsLimit: derivedKeyResult.opsLimit,
|
||||||
|
salt: CryptoUtil.bin2base64(kekSalt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final encryptedJson = jsonEncode(data.toJson());
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final formatter = DateFormat('yyyy-MM-dd_HH-mm-ss');
|
||||||
|
final formattedDate = formatter.format(now);
|
||||||
|
final fileName = 'ente-auth-auto-backup-$formattedDate.json';
|
||||||
|
|
||||||
|
final filePath = '$backupPath/$fileName';
|
||||||
|
final backupFile = File(filePath);
|
||||||
|
|
||||||
|
await backupFile.writeAsString(encryptedJson);
|
||||||
|
await _manageOldBackups(backupPath);
|
||||||
|
|
||||||
|
_logger.info('Automatic encrypted backup successful! Saved to: $filePath');
|
||||||
|
} catch (e, s) {
|
||||||
|
_logger.severe('Silent error during automatic backup', e, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _manageOldBackups(String backupPath) async {
|
||||||
|
try {
|
||||||
|
_logger.info("Checking for old backups to clean up...");
|
||||||
|
final directory = Directory(backupPath);
|
||||||
|
|
||||||
|
// fetch all filenames in the folder, filter out ente backup files
|
||||||
|
final files = directory.listSync()
|
||||||
|
.where((entity) =>
|
||||||
|
entity is File &&
|
||||||
|
entity.path.split('/').last.startsWith('ente-auth-auto-backup-'),)
|
||||||
|
.map((entity) => entity as File)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// sort the fetched files in asc order (oldest first because the name is a timestamp)
|
||||||
|
files.sort((a, b) => a.path.compareTo(b.path));
|
||||||
|
|
||||||
|
// if we have more files than our limit, delete the oldest ones (current limit=_maxBackups)
|
||||||
|
while (files.length > _maxBackups) {
|
||||||
|
// remove the oldest file (at index 0) from the list
|
||||||
|
final fileToDelete = files.removeAt(0);
|
||||||
|
// and delete it from the device's storage..
|
||||||
|
await fileToDelete.delete();
|
||||||
|
_logger.info('Deleted old backup: ${fileToDelete.path}');
|
||||||
|
}
|
||||||
|
_logger.info('Backup count is now ${files.length}. Cleanup complete.');
|
||||||
|
} catch (e, s) {
|
||||||
|
_logger.severe('Error during old backup cleanup', e, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteAllBackupsIn(String path) async {
|
||||||
|
try {
|
||||||
|
_logger.info("Deleting all backups in old location: $path");
|
||||||
|
final directory = Directory(path);
|
||||||
|
|
||||||
|
if (!await directory.exists()) {
|
||||||
|
_logger.warning("Old backup directory not found. Nothing to delete.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final files = directory.listSync()
|
||||||
|
.where((entity) =>
|
||||||
|
entity is File &&
|
||||||
|
entity.path.split('/').last.startsWith('ente-auth-auto-backup-'),)
|
||||||
|
.map((entity) => entity as File)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (files.isEmpty) {
|
||||||
|
_logger.info("No old backup files found to delete.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final file in files) {
|
||||||
|
await file.delete();
|
||||||
|
_logger.info('Deleted: ${file.path}');
|
||||||
|
}
|
||||||
|
_logger.info("Successfully cleaned up old backup location.");
|
||||||
|
|
||||||
|
} catch (e, s) {
|
||||||
|
_logger.severe('Error during full backup cleanup of old directory', e, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,31 @@ class CodeDisplayStore {
|
|||||||
|
|
||||||
late CodeStore _codeStore;
|
late CodeStore _codeStore;
|
||||||
|
|
||||||
|
final ValueNotifier<bool> isSelectionModeActive = ValueNotifier(false);
|
||||||
|
final ValueNotifier<Set<String>> selectedCodeIds = ValueNotifier(<String>{});
|
||||||
|
|
||||||
|
// toggles the selection status of a code
|
||||||
|
void toggleSelection(String codeId){
|
||||||
|
final newSelection = Set<String>.from(selectedCodeIds.value);
|
||||||
|
|
||||||
|
if(newSelection.contains(codeId)){
|
||||||
|
newSelection.remove(codeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
else{
|
||||||
|
newSelection.add(codeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedCodeIds.value = newSelection; //if we selected atleast one code, then we're in selection mode.. else: exit selection mode
|
||||||
|
isSelectionModeActive.value = newSelection.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
//method to clear the entire selection
|
||||||
|
void clearSelection(){
|
||||||
|
selectedCodeIds.value = <String>{};
|
||||||
|
isSelectionModeActive.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
_codeStore = CodeStore.instance;
|
_codeStore = CodeStore.instance;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:ente_auth/events/codes_updated_event.dart';
|
|||||||
import 'package:ente_auth/models/authenticator/entity_result.dart';
|
import 'package:ente_auth/models/authenticator/entity_result.dart';
|
||||||
import 'package:ente_auth/models/code.dart';
|
import 'package:ente_auth/models/code.dart';
|
||||||
import 'package:ente_auth/services/authenticator_service.dart';
|
import 'package:ente_auth/services/authenticator_service.dart';
|
||||||
|
import 'package:ente_auth/services/local_backup_service.dart';
|
||||||
import 'package:ente_auth/store/offline_authenticator_db.dart';
|
import 'package:ente_auth/store/offline_authenticator_db.dart';
|
||||||
import 'package:ente_events/event_bus.dart';
|
import 'package:ente_events/event_bus.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
@@ -64,6 +65,27 @@ class CodeStore {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updateCode(Code originalCode, Code updatedCode, {bool shouldSync = true}) async {
|
||||||
|
if (updatedCode.generatedID == null) return;
|
||||||
|
|
||||||
|
await _authenticatorService.updateEntry(
|
||||||
|
updatedCode.generatedID!,
|
||||||
|
updatedCode.toOTPAuthUrlFormat(),
|
||||||
|
shouldSync,
|
||||||
|
_authenticatorService.getAccountMode(),
|
||||||
|
);
|
||||||
|
Bus.instance.fire(CodesUpdatedEvent());
|
||||||
|
|
||||||
|
final bool isMajorChange = originalCode.issuer != updatedCode.issuer ||
|
||||||
|
originalCode.account != updatedCode.account ||
|
||||||
|
originalCode.secret != updatedCode.secret ||
|
||||||
|
originalCode.display.note != updatedCode.display.note;
|
||||||
|
|
||||||
|
if (isMajorChange) {
|
||||||
|
LocalBackupService.instance.triggerAutomaticBackup().ignore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<Code>> getAllCodes({
|
Future<List<Code>> getAllCodes({
|
||||||
AccountMode? accountMode,
|
AccountMode? accountMode,
|
||||||
bool sortCodes = true,
|
bool sortCodes = true,
|
||||||
@@ -95,7 +117,6 @@ class CodeStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sortCodes) {
|
if (sortCodes) {
|
||||||
// sort codes by issuer,account
|
|
||||||
codes.sort((firstCode, secondCode) {
|
codes.sort((firstCode, secondCode) {
|
||||||
if (secondCode.isPinned && !firstCode.isPinned) return 1;
|
if (secondCode.isPinned && !firstCode.isPinned) return 1;
|
||||||
if (!secondCode.isPinned && firstCode.isPinned) return -1;
|
if (!secondCode.isPinned && firstCode.isPinned) return -1;
|
||||||
@@ -121,12 +142,15 @@ class CodeStore {
|
|||||||
AccountMode? accountMode,
|
AccountMode? accountMode,
|
||||||
List<Code>? existingAllCodes,
|
List<Code>? existingAllCodes,
|
||||||
}) async {
|
}) async {
|
||||||
|
|
||||||
final mode = accountMode ?? _authenticatorService.getAccountMode();
|
final mode = accountMode ?? _authenticatorService.getAccountMode();
|
||||||
final allCodes = existingAllCodes ?? (await getAllCodes(accountMode: mode));
|
final allCodes = existingAllCodes ?? (await getAllCodes(accountMode: mode));
|
||||||
bool isExistingCode = false;
|
bool isExistingCode = false;
|
||||||
bool hasSameCode = false;
|
bool hasSameCode = false;
|
||||||
|
|
||||||
for (final existingCode in allCodes) {
|
for (final existingCode in allCodes) {
|
||||||
if (existingCode.hasError) continue;
|
if (existingCode.hasError) continue;
|
||||||
|
|
||||||
if (code.generatedID != null &&
|
if (code.generatedID != null &&
|
||||||
existingCode.generatedID == code.generatedID) {
|
existingCode.generatedID == code.generatedID) {
|
||||||
isExistingCode = true;
|
isExistingCode = true;
|
||||||
@@ -155,6 +179,7 @@ class CodeStore {
|
|||||||
shouldSync,
|
shouldSync,
|
||||||
mode,
|
mode,
|
||||||
);
|
);
|
||||||
|
LocalBackupService.instance.triggerAutomaticBackup().ignore();
|
||||||
}
|
}
|
||||||
Bus.instance.fire(CodesUpdatedEvent());
|
Bus.instance.fire(CodesUpdatedEvent());
|
||||||
return result;
|
return result;
|
||||||
@@ -164,6 +189,7 @@ class CodeStore {
|
|||||||
final mode = accountMode ?? _authenticatorService.getAccountMode();
|
final mode = accountMode ?? _authenticatorService.getAccountMode();
|
||||||
await _authenticatorService.deleteEntry(code.generatedID!, mode);
|
await _authenticatorService.deleteEntry(code.generatedID!, mode);
|
||||||
Bus.instance.fire(CodesUpdatedEvent());
|
Bus.instance.fire(CodesUpdatedEvent());
|
||||||
|
LocalBackupService.instance.triggerAutomaticBackup().ignore();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isOfflineImportRunning = false;
|
bool _isOfflineImportRunning = false;
|
||||||
@@ -214,7 +240,6 @@ class CodeStore {
|
|||||||
'importingCode: genID ${eachCode.generatedID} & isAlreadyPresent $alreadyPresent',
|
'importingCode: genID ${eachCode.generatedID} & isAlreadyPresent $alreadyPresent',
|
||||||
);
|
);
|
||||||
if (!alreadyPresent) {
|
if (!alreadyPresent) {
|
||||||
// Avoid conflict with generatedID of online codes
|
|
||||||
eachCode.generatedID = null;
|
eachCode.generatedID = null;
|
||||||
final AddResult result = await CodeStore.instance.addCode(
|
final AddResult result = await CodeStore.instance.addCode(
|
||||||
eachCode,
|
eachCode,
|
||||||
@@ -236,10 +261,21 @@ class CodeStore {
|
|||||||
_isOfflineImportRunning = false;
|
_isOfflineImportRunning = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String> getCodesForExport() async {
|
||||||
|
final allCodes = await getAllCodes(sortCodes: false);
|
||||||
|
String data = "";
|
||||||
|
for (final code in allCodes) {
|
||||||
|
if (code.hasError) continue;
|
||||||
|
data += "${code.toOTPAuthUrlFormat()}\n";
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AddResult {
|
enum AddResult {
|
||||||
newCode,
|
newCode,
|
||||||
duplicate,
|
duplicate,
|
||||||
updateCode,
|
updateCode,
|
||||||
}
|
}
|
||||||
@@ -10,10 +10,10 @@ import 'package:ente_auth/models/code.dart';
|
|||||||
import 'package:ente_auth/onboarding/view/setup_enter_secret_key_page.dart';
|
import 'package:ente_auth/onboarding/view/setup_enter_secret_key_page.dart';
|
||||||
import 'package:ente_auth/onboarding/view/view_qr_page.dart';
|
import 'package:ente_auth/onboarding/view/view_qr_page.dart';
|
||||||
import 'package:ente_auth/services/preference_service.dart';
|
import 'package:ente_auth/services/preference_service.dart';
|
||||||
|
import 'package:ente_auth/store/code_display_store.dart';
|
||||||
import 'package:ente_auth/store/code_store.dart';
|
import 'package:ente_auth/store/code_store.dart';
|
||||||
import 'package:ente_auth/theme/ente_theme.dart';
|
import 'package:ente_auth/theme/ente_theme.dart';
|
||||||
import 'package:ente_auth/ui/code_timer_progress.dart';
|
import 'package:ente_auth/ui/code_timer_progress.dart';
|
||||||
import 'package:ente_auth/ui/components/bottom_action_bar_widget.dart';
|
|
||||||
import 'package:ente_auth/ui/components/models/button_type.dart';
|
import 'package:ente_auth/ui/components/models/button_type.dart';
|
||||||
import 'package:ente_auth/ui/share/code_share.dart';
|
import 'package:ente_auth/ui/share/code_share.dart';
|
||||||
import 'package:ente_auth/ui/utils/icon_utils.dart';
|
import 'package:ente_auth/ui/utils/icon_utils.dart';
|
||||||
@@ -103,7 +103,6 @@ class _CodeWidgetState extends State<CodeWidget> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
ignorePin = widget.sortKey != null && widget.sortKey == CodeSortKey.manual;
|
ignorePin = widget.sortKey != null && widget.sortKey == CodeSortKey.manual;
|
||||||
final colorScheme = getEnteColorScheme(context);
|
|
||||||
if (isMaskingEnabled != PreferenceService.instance.shouldHideCodes()) {
|
if (isMaskingEnabled != PreferenceService.instance.shouldHideCodes()) {
|
||||||
isMaskingEnabled = PreferenceService.instance.shouldHideCodes();
|
isMaskingEnabled = PreferenceService.instance.shouldHideCodes();
|
||||||
_hideCode = isMaskingEnabled;
|
_hideCode = isMaskingEnabled;
|
||||||
@@ -118,96 +117,110 @@ class _CodeWidgetState extends State<CodeWidget> {
|
|||||||
}
|
}
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
|
||||||
Widget getCardContents(AppLocalizations l10n) {
|
Widget getCardContents(AppLocalizations l10n, {required bool isSelected}) {
|
||||||
return Stack(
|
final colorScheme = getEnteColorScheme(context);
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
if (!ignorePin && widget.code.isPinned)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: PinBgPainter(
|
||||||
|
color: colorScheme.pinnedBgColor,
|
||||||
|
),
|
||||||
|
size: widget.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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
if (!ignorePin && widget.code.isPinned)
|
if (widget.code.type.isTOTPCompatible)
|
||||||
Align(
|
CodeTimerProgress(
|
||||||
alignment: Alignment.topRight,
|
key: ValueKey('period_${widget.code.period}'),
|
||||||
child: CustomPaint(
|
period: widget.code.period,
|
||||||
painter: PinBgPainter(
|
isCompactMode: widget.isCompactMode,
|
||||||
color: colorScheme.pinnedBgColor,
|
timeOffsetInMilliseconds:
|
||||||
),
|
PreferenceService.instance.timeOffsetInMilliSeconds(),
|
||||||
size: widget.isCompactMode
|
|
||||||
? const Size(24, 24)
|
|
||||||
: const Size(39, 39),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (widget.code.isTrashed && kDebugMode)
|
widget.isCompactMode
|
||||||
Align(
|
? const SizedBox(height: 4)
|
||||||
alignment: Alignment.topLeft,
|
: const SizedBox(height: 28),
|
||||||
child: CustomPaint(
|
Row(
|
||||||
painter: PinBgPainter(
|
|
||||||
color: colorScheme.warning700,
|
|
||||||
),
|
|
||||||
size: const Size(39, 39),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
children: [
|
||||||
if (widget.code.type.isTOTPCompatible)
|
_shouldShowLargeIcon ? _getIcon() : const SizedBox.shrink(),
|
||||||
CodeTimerProgress(
|
Expanded(
|
||||||
key: ValueKey('period_${widget.code.period}'),
|
child: Column(
|
||||||
period: widget.code.period,
|
children: [
|
||||||
isCompactMode: widget.isCompactMode,
|
_getTopRow(isSelected: isSelected),
|
||||||
timeOffsetInMilliseconds:
|
widget.isCompactMode
|
||||||
PreferenceService.instance.timeOffsetInMilliSeconds(),
|
? const SizedBox.shrink()
|
||||||
|
: const SizedBox(height: 4),
|
||||||
|
_getBottomRow(l10n),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
widget.isCompactMode
|
|
||||||
? const SizedBox(height: 4)
|
|
||||||
: const SizedBox(height: 28),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
_shouldShowLargeIcon ? _getIcon() : const SizedBox.shrink(),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
_getTopRow(),
|
|
||||||
widget.isCompactMode
|
|
||||||
? const SizedBox.shrink()
|
|
||||||
: const SizedBox(height: 4),
|
|
||||||
_getBottomRow(l10n),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
widget.isCompactMode
|
|
||||||
? const SizedBox(height: 4)
|
|
||||||
: const SizedBox(height: 32),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (!ignorePin && widget.code.isPinned) ...[
|
widget.isCompactMode
|
||||||
Align(
|
? const SizedBox(height: 4)
|
||||||
alignment: Alignment.topRight,
|
: const SizedBox(height: 32),
|
||||||
child: Padding(
|
|
||||||
padding: widget.isCompactMode
|
|
||||||
? const EdgeInsets.only(right: 4, top: 4)
|
|
||||||
: 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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
);
|
),
|
||||||
}
|
if (!ignorePin && widget.code.isPinned) ...[
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
child: Padding(
|
||||||
|
padding: widget.isCompactMode
|
||||||
|
? const EdgeInsets.only(right: 4, top: 4)
|
||||||
|
: 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget clippedCard(AppLocalizations l10n) {
|
||||||
|
final colorScheme = getEnteColorScheme(context);
|
||||||
|
|
||||||
|
return ValueListenableBuilder<Set<String>>(
|
||||||
|
valueListenable: CodeDisplayStore.instance.selectedCodeIds,
|
||||||
|
builder: (context, selectedIds, child) {
|
||||||
|
final isSelected = selectedIds.contains(widget.code.secret);
|
||||||
|
|
||||||
Widget clippedCard(AppLocalizations l10n) {
|
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
color: Theme.of(context).colorScheme.codeCardBackgroundColor,
|
color: isSelected
|
||||||
|
? colorScheme.primary400.withValues(alpha: 0.08)
|
||||||
|
: Theme.of(context).colorScheme.codeCardBackgroundColor,
|
||||||
|
//add purple overlay when selected
|
||||||
|
border: isSelected
|
||||||
|
? Border.all(color: colorScheme.primary400, width: 2)
|
||||||
|
: null,
|
||||||
boxShadow:
|
boxShadow:
|
||||||
widget.code.isPinned ? colorScheme.pinnedCardBoxShadow : [],
|
(widget.code.isPinned && !isSelected) ? colorScheme.pinnedCardBoxShadow : [],
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(6),
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
@@ -215,7 +228,12 @@ class _CodeWidgetState extends State<CodeWidget> {
|
|||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
_copyCurrentOTPToClipboard();
|
final store = CodeDisplayStore.instance;
|
||||||
|
if (store.isSelectionModeActive.value) {
|
||||||
|
store.toggleSelection(widget.code.secret);
|
||||||
|
} else {
|
||||||
|
_copyCurrentOTPToClipboard();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDoubleTap: isMaskingEnabled
|
onDoubleTap: isMaskingEnabled
|
||||||
? () {
|
? () {
|
||||||
@@ -229,30 +247,16 @@ class _CodeWidgetState extends State<CodeWidget> {
|
|||||||
onLongPress: widget.isReordering
|
onLongPress: widget.isReordering
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
showModalBottomSheet(
|
CodeDisplayStore.instance.toggleSelection(widget.code.secret);
|
||||||
context: context,
|
|
||||||
builder: (_) {
|
|
||||||
return BottomActionBarWidget(
|
|
||||||
code: widget.code,
|
|
||||||
showPin: !ignorePin,
|
|
||||||
onEdit: () => _onEditPressed(true),
|
|
||||||
onShare: () => _onSharePressed(true),
|
|
||||||
onPin: () => _onPinPressed(true),
|
|
||||||
onTrashed: () => _onTrashPressed(true),
|
|
||||||
onDelete: () => _onDeletePressed(true),
|
|
||||||
onRestore: () => _onRestoreClicked(true),
|
|
||||||
onShowQR: () => _onShowQrPressed(true),
|
|
||||||
onCancel: () => Navigator.of(context).pop(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
child: getCardContents(l10n),
|
child: getCardContents(l10n, isSelected: isSelected),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: widget.isCompactMode
|
margin: widget.isCompactMode
|
||||||
@@ -273,7 +277,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
|||||||
),
|
),
|
||||||
if (!widget.code.isTrashed)
|
if (!widget.code.isTrashed)
|
||||||
MenuItem(
|
MenuItem(
|
||||||
label: 'QR',
|
label: context.l10n.qr,
|
||||||
icon: Icons.qr_code_2_outlined,
|
icon: Icons.qr_code_2_outlined,
|
||||||
onSelected: () => _onShowQrPressed(null),
|
onSelected: () => _onShowQrPressed(null),
|
||||||
),
|
),
|
||||||
@@ -307,7 +311,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
|||||||
const MenuDivider(),
|
const MenuDivider(),
|
||||||
MenuItem(
|
MenuItem(
|
||||||
label: widget.code.isTrashed ? l10n.delete : l10n.trash,
|
label: widget.code.isTrashed ? l10n.delete : l10n.trash,
|
||||||
value: "Delete",
|
value: l10n.delete,
|
||||||
icon: widget.code.isTrashed
|
icon: widget.code.isTrashed
|
||||||
? Icons.delete_forever
|
? Icons.delete_forever
|
||||||
: Icons.delete,
|
: Icons.delete,
|
||||||
@@ -403,54 +407,64 @@ class _CodeWidgetState extends State<CodeWidget> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _getTopRow() {
|
Widget _getTopRow({required bool isSelected}) {
|
||||||
bool isCompactMode = widget.isCompactMode;
|
final colorScheme = getEnteColorScheme(context);
|
||||||
return Padding(
|
bool isCompactMode = widget.isCompactMode;
|
||||||
padding: const EdgeInsets.only(left: 16, right: 16),
|
return Padding(
|
||||||
child: Row(
|
padding: const EdgeInsets.only(left: 16, right: 16),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Row(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Expanded(
|
children: [
|
||||||
child: Column(
|
if (isSelected)
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Padding(
|
||||||
children: [
|
padding: const EdgeInsets.only(right: 8.0),
|
||||||
Text(
|
child: Icon(
|
||||||
safeDecode(widget.code.issuer).trim(),
|
Icons.check_circle,
|
||||||
style: isCompactMode
|
color: colorScheme.primary400,
|
||||||
? Theme.of(context).textTheme.bodyMedium
|
size: isCompactMode ? 20 : 24,
|
||||||
: Theme.of(context).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
if (!isCompactMode) const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
safeDecode(widget.code.account).trim(),
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
fontSize: isCompactMode ? 12 : 12,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
Expanded(
|
||||||
Row(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
(widget.code.hasSynced != null && widget.code.hasSynced!) ||
|
Text(
|
||||||
!hasConfiguredAccount
|
safeDecode(widget.code.issuer).trim(),
|
||||||
? const SizedBox.shrink()
|
style: isCompactMode
|
||||||
: const Icon(
|
? Theme.of(context).textTheme.bodyMedium
|
||||||
Icons.sync_disabled,
|
: Theme.of(context).textTheme.titleLarge,
|
||||||
size: 20,
|
),
|
||||||
color: Colors.amber,
|
if (!isCompactMode) const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
safeDecode(widget.code.account).trim(),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
fontSize: isCompactMode ? 12 : 12,
|
||||||
|
color: Colors.grey,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
),
|
||||||
_shouldShowLargeIcon ? const SizedBox.shrink() : _getIcon(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
);
|
Row(
|
||||||
}
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
(widget.code.hasSynced != null && widget.code.hasSynced!) ||
|
||||||
|
!hasConfiguredAccount
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: const Icon(
|
||||||
|
Icons.sync_disabled,
|
||||||
|
size: 20,
|
||||||
|
color: Colors.amber,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_shouldShowLargeIcon ? const SizedBox.shrink() : _getIcon(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _getIcon() {
|
Widget _getIcon() {
|
||||||
final String iconData;
|
final String iconData;
|
||||||
@@ -478,7 +492,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
|||||||
_getCurrentOTP(),
|
_getCurrentOTP(),
|
||||||
confirmationMessage: context.l10n.copiedToClipboard,
|
confirmationMessage: context.l10n.copiedToClipboard,
|
||||||
);
|
);
|
||||||
_udateCodeMetadata().ignore();
|
_updateCodeMetadata().ignore();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _copyNextToClipboard() {
|
void _copyNextToClipboard() {
|
||||||
@@ -486,10 +500,10 @@ class _CodeWidgetState extends State<CodeWidget> {
|
|||||||
_getNextTotp(),
|
_getNextTotp(),
|
||||||
confirmationMessage: context.l10n.copiedNextToClipboard,
|
confirmationMessage: context.l10n.copiedNextToClipboard,
|
||||||
);
|
);
|
||||||
_udateCodeMetadata().ignore();
|
_updateCodeMetadata().ignore();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _udateCodeMetadata() async {
|
Future<void> _updateCodeMetadata() async {
|
||||||
if (widget.sortKey == null) return;
|
if (widget.sortKey == null) return;
|
||||||
Future.delayed(const Duration(milliseconds: 100), () {
|
Future.delayed(const Duration(milliseconds: 100), () {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -502,7 +516,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
|||||||
lastUsedAt: DateTime.now().microsecondsSinceEpoch,
|
lastUsedAt: DateTime.now().microsecondsSinceEpoch,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
unawaited(CodeStore.instance.addCode(code));
|
unawaited(CodeStore.instance.updateCode(widget.code, code));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -568,7 +582,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (code != null) {
|
if (code != null) {
|
||||||
await CodeStore.instance.addCode(code);
|
await CodeStore.instance.updateCode(widget.code, code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,7 +629,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
|||||||
display: display.copyWith(pinned: !currentlyPinned),
|
display: display.copyWith(pinned: !currentlyPinned),
|
||||||
);
|
);
|
||||||
unawaited(
|
unawaited(
|
||||||
CodeStore.instance.addCode(code).then(
|
CodeStore.instance.updateCode(widget.code,code).then(
|
||||||
(value) => showToast(
|
(value) => showToast(
|
||||||
context,
|
context,
|
||||||
!currentlyPinned
|
!currentlyPinned
|
||||||
@@ -694,7 +708,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
|||||||
final Code code = widget.code.copyWith(
|
final Code code = widget.code.copyWith(
|
||||||
display: display.copyWith(trashed: true),
|
display: display.copyWith(trashed: true),
|
||||||
);
|
);
|
||||||
await CodeStore.instance.addCode(code);
|
await CodeStore.instance.updateCode(widget.code, code);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.severe('Failed to trash code: ${e.toString()}');
|
logger.severe('Failed to trash code: ${e.toString()}');
|
||||||
showGenericErrorDialog(context: context, error: e).ignore();
|
showGenericErrorDialog(context: context, error: e).ignore();
|
||||||
@@ -718,7 +732,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
|||||||
final Code code = widget.code.copyWith(
|
final Code code = widget.code.copyWith(
|
||||||
display: display.copyWith(trashed: false),
|
display: display.copyWith(trashed: false),
|
||||||
);
|
);
|
||||||
await CodeStore.instance.addCode(code);
|
await CodeStore.instance.updateCode(widget.code, code);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.severe('Failed to restore code: ${e.toString()}');
|
logger.severe('Failed to restore code: ${e.toString()}');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|||||||
243
mobile/apps/auth/lib/ui/home/add_tag_sheet.dart
Normal file
243
mobile/apps/auth/lib/ui/home/add_tag_sheet.dart
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:ente_auth/l10n/l10n.dart';
|
||||||
|
import 'package:ente_auth/models/code.dart';
|
||||||
|
import 'package:ente_auth/store/code_display_store.dart';
|
||||||
|
import 'package:ente_auth/store/code_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_type.dart';
|
||||||
|
import 'package:ente_auth/ui/utils/icon_utils.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class AddTagSheet extends StatefulWidget {
|
||||||
|
final List<Code> selectedCodes;
|
||||||
|
|
||||||
|
const AddTagSheet({
|
||||||
|
super.key,
|
||||||
|
required this.selectedCodes,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AddTagSheet> createState() => _AddTagSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddTagSheetState extends State<AddTagSheet> {
|
||||||
|
List<String> _allTags = [];
|
||||||
|
final Set<String> _selectedTagsInSheet = {};
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadInitialState();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadInitialState() async {
|
||||||
|
final allTagsFromServer = await CodeDisplayStore.instance.getAllTags();
|
||||||
|
final initialTagsForSelection = <String>{};
|
||||||
|
|
||||||
|
for (final code in widget.selectedCodes) {
|
||||||
|
initialTagsForSelection.addAll(code.display.tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_allTags = allTagsFromServer;
|
||||||
|
_selectedTagsInSheet.addAll(initialTagsForSelection);
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onDonePressed() async {
|
||||||
|
final List<Future> updateFutures = [];
|
||||||
|
for (final code in widget.selectedCodes) {
|
||||||
|
final updatedCode = code.copyWith(
|
||||||
|
display: code.display.copyWith(tags: _selectedTagsInSheet.toList()),
|
||||||
|
);
|
||||||
|
updateFutures.add(CodeStore.instance.updateCode(code, updatedCode));
|
||||||
|
}
|
||||||
|
await Future.wait(updateFutures);
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showCreateTagDialog() async {
|
||||||
|
final textController = TextEditingController();
|
||||||
|
final newTag = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setState) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(context.l10n.createNewTag),
|
||||||
|
content: TextField(
|
||||||
|
controller: textController,
|
||||||
|
autofocus: true,
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ButtonWidget(
|
||||||
|
buttonType: ButtonType.secondary,
|
||||||
|
labelText: context.l10n.cancel,
|
||||||
|
onTap: () async => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: ButtonWidget(
|
||||||
|
buttonType: ButtonType.primary,
|
||||||
|
labelText: context.l10n.create,
|
||||||
|
isDisabled: textController.text.trim().isEmpty,
|
||||||
|
onTap: () async => Navigator.of(context).pop(textController.text.trim()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newTag != null && newTag.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
if (!_allTags.contains(newTag)) {
|
||||||
|
_allTags.add(newTag);
|
||||||
|
_allTags.sort();
|
||||||
|
}
|
||||||
|
_selectedTagsInSheet.add(newTag);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = getEnteColorScheme(context);
|
||||||
|
final textTheme = getEnteTextTheme(context);
|
||||||
|
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + bottomPadding),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${widget.selectedCodes.length} ${context.l10n.selected}',
|
||||||
|
style: textTheme.large,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
height: 80,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: widget.selectedCodes.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(width: 16),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final code = widget.selectedCodes[index];
|
||||||
|
final iconData =
|
||||||
|
code.display.isCustomIcon ? code.display.iconID : code.issuer;
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: 60,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
IconUtils.instance.getIcon(context, iconData.trim(), width: 40),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
code.issuer,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: textTheme.mini.copyWith(color: colorScheme.textMuted,),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(context.l10n.tags, style: textTheme.body),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Flexible(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: _isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: Wrap(
|
||||||
|
spacing: 8.0,
|
||||||
|
runSpacing: 4.0,
|
||||||
|
children: [
|
||||||
|
..._allTags.map((tag) {
|
||||||
|
final isSelected = _selectedTagsInSheet.contains(tag);
|
||||||
|
return ChoiceChip(
|
||||||
|
label: Text(tag),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (selected) {
|
||||||
|
setState(() {
|
||||||
|
if (selected) {
|
||||||
|
_selectedTagsInSheet.add(tag);
|
||||||
|
} else {
|
||||||
|
_selectedTagsInSheet.remove(tag);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
selectedColor: colorScheme.primary400,
|
||||||
|
backgroundColor: colorScheme.fillFaint,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: isSelected ? Colors.white : colorScheme.textBase,
|
||||||
|
),
|
||||||
|
avatar: isSelected ? const Icon(Icons.check, color: Colors.white, size: 16) : null,
|
||||||
|
side: BorderSide.none,
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
ActionChip(
|
||||||
|
avatar: const Icon(Icons.add, size: 18),
|
||||||
|
label: Text(context.l10n.addNew),
|
||||||
|
onPressed: _showCreateTagDialog,
|
||||||
|
side: BorderSide.none,
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
backgroundColor: colorScheme.fillFaint,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.fillBase,
|
||||||
|
foregroundColor: colorScheme.backgroundBase,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
onPressed: _onDonePressed,
|
||||||
|
child: Text(context.l10n.done),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import 'package:ente_auth/models/code.dart';
|
|||||||
import 'package:ente_auth/onboarding/model/tag_enums.dart';
|
import 'package:ente_auth/onboarding/model/tag_enums.dart';
|
||||||
import 'package:ente_auth/onboarding/view/common/tag_chip.dart';
|
import 'package:ente_auth/onboarding/view/common/tag_chip.dart';
|
||||||
import 'package:ente_auth/onboarding/view/setup_enter_secret_key_page.dart';
|
import 'package:ente_auth/onboarding/view/setup_enter_secret_key_page.dart';
|
||||||
|
import 'package:ente_auth/onboarding/view/view_qr_page.dart';
|
||||||
import 'package:ente_auth/services/preference_service.dart';
|
import 'package:ente_auth/services/preference_service.dart';
|
||||||
import 'package:ente_auth/store/code_display_store.dart';
|
import 'package:ente_auth/store/code_display_store.dart';
|
||||||
import 'package:ente_auth/store/code_store.dart';
|
import 'package:ente_auth/store/code_store.dart';
|
||||||
@@ -26,17 +27,22 @@ import 'package:ente_auth/ui/common/loading_widget.dart';
|
|||||||
import 'package:ente_auth/ui/components/buttons/button_widget.dart';
|
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/dialog_widget.dart';
|
||||||
import 'package:ente_auth/ui/components/models/button_type.dart';
|
import 'package:ente_auth/ui/components/models/button_type.dart';
|
||||||
|
import 'package:ente_auth/ui/home/add_tag_sheet.dart';
|
||||||
import 'package:ente_auth/ui/home/coach_mark_widget.dart';
|
import 'package:ente_auth/ui/home/coach_mark_widget.dart';
|
||||||
import 'package:ente_auth/ui/home/home_empty_state.dart';
|
import 'package:ente_auth/ui/home/home_empty_state.dart';
|
||||||
import 'package:ente_auth/ui/home/speed_dial_label_widget.dart';
|
import 'package:ente_auth/ui/home/speed_dial_label_widget.dart';
|
||||||
import 'package:ente_auth/ui/reorder_codes_page.dart';
|
import 'package:ente_auth/ui/reorder_codes_page.dart';
|
||||||
import 'package:ente_auth/ui/scanner_page.dart';
|
import 'package:ente_auth/ui/scanner_page.dart';
|
||||||
import 'package:ente_auth/ui/settings_page.dart';
|
import 'package:ente_auth/ui/settings_page.dart';
|
||||||
|
import 'package:ente_auth/ui/share/code_share.dart';
|
||||||
import 'package:ente_auth/ui/sort_option_menu.dart';
|
import 'package:ente_auth/ui/sort_option_menu.dart';
|
||||||
|
import 'package:ente_auth/ui/utils/icon_utils.dart';
|
||||||
import 'package:ente_auth/utils/dialog_util.dart';
|
import 'package:ente_auth/utils/dialog_util.dart';
|
||||||
import 'package:ente_auth/utils/platform_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:ente_auth/utils/totp_util.dart';
|
||||||
import 'package:ente_events/event_bus.dart';
|
import 'package:ente_events/event_bus.dart';
|
||||||
|
import 'package:ente_lock_screen/local_authentication_service.dart';
|
||||||
import 'package:ente_lock_screen/lock_screen_settings.dart';
|
import 'package:ente_lock_screen/lock_screen_settings.dart';
|
||||||
import 'package:ente_lock_screen/ui/app_lock.dart';
|
import 'package:ente_lock_screen/ui/app_lock.dart';
|
||||||
import 'package:ente_qr/ente_qr.dart';
|
import 'package:ente_qr/ente_qr.dart';
|
||||||
@@ -58,6 +64,7 @@ class HomePage extends BaseHomePage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomePageState extends State<HomePage> {
|
class _HomePageState extends State<HomePage> {
|
||||||
|
final _codeDisplayStore = CodeDisplayStore.instance;
|
||||||
late final _settingsPage = SettingsPage(
|
late final _settingsPage = SettingsPage(
|
||||||
emailNotifier: UserService.instance.emailValueNotifier,
|
emailNotifier: UserService.instance.emailValueNotifier,
|
||||||
scaffoldKey: scaffoldKey,
|
scaffoldKey: scaffoldKey,
|
||||||
@@ -94,6 +101,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
_codeSortKey = PreferenceService.instance.codeSortKey();
|
_codeSortKey = PreferenceService.instance.codeSortKey();
|
||||||
_textController.addListener(_applyFilteringAndRefresh);
|
_textController.addListener(_applyFilteringAndRefresh);
|
||||||
_loadCodes();
|
_loadCodes();
|
||||||
@@ -119,6 +127,631 @@ class _HomePageState extends State<HomePage> {
|
|||||||
ServicesBinding.instance.keyboard.addHandler(_handleKeyEvent);
|
ServicesBinding.instance.keyboard.addHandler(_handleKeyEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onAddTagPressed() {
|
||||||
|
final selectedIds = _codeDisplayStore.selectedCodeIds.value;
|
||||||
|
final selectedCodes = _allCodes?.where((c) => selectedIds.contains(c.secret)).toList() ?? [];
|
||||||
|
|
||||||
|
if (selectedCodes.isEmpty) return;
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) {
|
||||||
|
return AddTagSheet(selectedCodes: selectedCodes);
|
||||||
|
},
|
||||||
|
).then((_) {
|
||||||
|
_codeDisplayStore.clearSelection();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onRestoreSelectedPressed() async {
|
||||||
|
final selectedIds = _codeDisplayStore.selectedCodeIds.value;
|
||||||
|
if (selectedIds.isEmpty) return;
|
||||||
|
|
||||||
|
FocusScope.of(context).requestFocus();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final codesToRestore = _allCodes?.where((c) => selectedIds.contains(c.secret)).toList() ?? [];
|
||||||
|
for (final code in codesToRestore) {
|
||||||
|
final updatedCode = code.copyWith(display: code.display.copyWith(trashed: false));
|
||||||
|
unawaited(CodeStore.instance.updateCode(code, updatedCode));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
showGenericErrorDialog(context: context, error: e).ignore();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_codeDisplayStore.clearSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onDeleteForeverPressed() async {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final selectedIds = _codeDisplayStore.selectedCodeIds.value;
|
||||||
|
if (selectedIds.isEmpty) return;
|
||||||
|
|
||||||
|
bool isAuthSuccessful =
|
||||||
|
await LocalAuthenticationService.instance.requestLocalAuthentication(
|
||||||
|
context,
|
||||||
|
context.l10n.deleteCodeAuthMessage,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isAuthSuccessful) return;
|
||||||
|
|
||||||
|
FocusScope.of(context).requestFocus();
|
||||||
|
await showChoiceActionSheet(
|
||||||
|
context,
|
||||||
|
title: l10n.deleteCodeTitle,
|
||||||
|
body: l10n.deleteCodeMessage,
|
||||||
|
firstButtonLabel: l10n.delete,
|
||||||
|
isCritical: true,
|
||||||
|
firstButtonOnTap: () async {
|
||||||
|
try {
|
||||||
|
final codesToDelete = _allCodes?.where((c) => selectedIds.contains(c.secret)).toList() ?? [];
|
||||||
|
for (final code in codesToDelete) {
|
||||||
|
await CodeStore.instance.removeCode(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
showGenericErrorDialog(context: context, error: e).ignore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finally {
|
||||||
|
_codeDisplayStore.clearSelection();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrashSelectActions() {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).brightness == Brightness.light
|
||||||
|
? const Color(0xFFF7F7F7)
|
||||||
|
: const Color(0xFF1E1E1E),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_buildClearActionButton(Icons.restore,context.l10n.restore, _onRestoreSelectedPressed,),
|
||||||
|
_buildClearActionButton(Icons.delete_forever,context.l10n.delete, _onDeleteForeverPressed),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onPinSelectedPressed() async {
|
||||||
|
final selectedIds = _codeDisplayStore.selectedCodeIds.value;
|
||||||
|
if (selectedIds.isEmpty) return;
|
||||||
|
|
||||||
|
final codesToUpdate = _allCodes?.where((c) => selectedIds.contains(c.secret)).toList() ?? [];
|
||||||
|
if (codesToUpdate.isEmpty) return;
|
||||||
|
|
||||||
|
// Determine the state of the current selection (pinned/unpinned)
|
||||||
|
final bool allArePinned = codesToUpdate.every((code) => code.isPinned);
|
||||||
|
|
||||||
|
if (allArePinned) {
|
||||||
|
// if all are pinned, unpin all
|
||||||
|
for (final code in codesToUpdate) {
|
||||||
|
final updatedCode = code.copyWith(display: code.display.copyWith(pinned: false));
|
||||||
|
unawaited(CodeStore.instance.updateCode(code, updatedCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codesToUpdate.length == 1) {
|
||||||
|
showToast(context, context.l10n.unpinnedCodeMessage(codesToUpdate.first.issuer));
|
||||||
|
} else {
|
||||||
|
showToast(context, 'Unpinned ${codesToUpdate.length} item(s)');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
int pinnedCount = 0;
|
||||||
|
for (final code in codesToUpdate) {
|
||||||
|
if (!code.isPinned) { // Only pin the codes that are currently unpinned
|
||||||
|
final updatedCode = code.copyWith(display: code.display.copyWith(pinned: true));
|
||||||
|
unawaited(CodeStore.instance.updateCode(code, updatedCode));
|
||||||
|
pinnedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pinnedCount == 1) {
|
||||||
|
final pinnedCode = codesToUpdate.firstWhere((c) => !c.isPinned);
|
||||||
|
showToast(context, context.l10n.pinnedCodeMessage(pinnedCode.issuer));
|
||||||
|
} else if (pinnedCount > 0) {
|
||||||
|
showToast(context, 'Pinned $pinnedCount item(s)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_codeDisplayStore.clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Future<void> _onUnpinSelectedPressed() async {
|
||||||
|
final selectedIds = _codeDisplayStore.selectedCodeIds.value;
|
||||||
|
if (selectedIds.isEmpty) return;
|
||||||
|
|
||||||
|
final codesToUpdate = _allCodes?.where((c) => selectedIds.contains(c.secret)).toList() ?? [];
|
||||||
|
if (codesToUpdate.isEmpty) return;
|
||||||
|
|
||||||
|
int unpinnedCount = 0;
|
||||||
|
for (final code in codesToUpdate) {
|
||||||
|
if (code.isPinned) { // only unpin the codes that are currently pinned
|
||||||
|
final updatedCode = code.copyWith(display: code.display.copyWith(pinned: false));
|
||||||
|
unawaited(CodeStore.instance.updateCode(code, updatedCode));
|
||||||
|
unpinnedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unpinnedCount == 1) {
|
||||||
|
final unpinnedCode = codesToUpdate.firstWhere((c) => c.isPinned);
|
||||||
|
showToast(context, context.l10n.unpinnedCodeMessage(unpinnedCode.issuer));
|
||||||
|
} else if (unpinnedCount > 0) {
|
||||||
|
showToast(context, 'Unpinned $unpinnedCount item(s)');
|
||||||
|
}
|
||||||
|
|
||||||
|
_codeDisplayStore.clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Future<void> _onTrashSelectedPressed() async {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final selectedIds = _codeDisplayStore.selectedCodeIds.value;
|
||||||
|
if (selectedIds.isEmpty) return;
|
||||||
|
|
||||||
|
bool isAuthSuccessful =
|
||||||
|
await LocalAuthenticationService.instance.requestLocalAuthentication(
|
||||||
|
context,
|
||||||
|
context.l10n.deleteCodeAuthMessage,
|
||||||
|
);
|
||||||
|
if (!isAuthSuccessful) return;
|
||||||
|
|
||||||
|
FocusScope.of(context).requestFocus();
|
||||||
|
await showChoiceActionSheet(
|
||||||
|
context,
|
||||||
|
title: l10n.trashCode,
|
||||||
|
|
||||||
|
body: ((){
|
||||||
|
if (selectedIds.length == 1){
|
||||||
|
final code = _allCodes!.firstWhere((c) => c.secret == selectedIds.first);
|
||||||
|
final issuerAccount = code.account.isNotEmpty ? '${code.issuer} (${code.account})' : code.issuer;
|
||||||
|
return l10n.trashCodeMessage(issuerAccount);
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
return l10n.moveMultipleToTrashMessage(selectedIds.length);
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
|
||||||
|
firstButtonLabel: l10n.trash,
|
||||||
|
isCritical: true,
|
||||||
|
firstButtonOnTap: () async {
|
||||||
|
try {
|
||||||
|
final codesToTrash = _allCodes?.where((c) => selectedIds.contains(c.secret)).toList() ?? [];
|
||||||
|
|
||||||
|
for (final code in codesToTrash) {
|
||||||
|
final updatedCode = code.copyWith(
|
||||||
|
display: code.display.copyWith(trashed: true),
|
||||||
|
);
|
||||||
|
unawaited(CodeStore.instance.updateCode(code, updatedCode));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.severe('Failed to trash code(s): ${e.toString()}');
|
||||||
|
if (mounted) {
|
||||||
|
showGenericErrorDialog(context: context, error: e).ignore();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_codeDisplayStore.clearSelection();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Future<void> _onEditPressed(Code code) async {
|
||||||
|
bool isAuthSuccessful = await LocalAuthenticationService.instance
|
||||||
|
.requestLocalAuthentication(context, context.l10n.editCodeAuthMessage);
|
||||||
|
await PlatformUtil.refocusWindows();
|
||||||
|
if (!isAuthSuccessful) return;
|
||||||
|
|
||||||
|
_codeDisplayStore.clearSelection();
|
||||||
|
final Code? updatedCode = await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return SetupEnterSecretKeyPage(code: code);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updatedCode != null){
|
||||||
|
await CodeStore.instance.updateCode(code, updatedCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSharePressed(Code code) async {
|
||||||
|
bool isAuthSuccessful = await LocalAuthenticationService.instance
|
||||||
|
.requestLocalAuthentication(context, context.l10n.authenticateGeneric);
|
||||||
|
await PlatformUtil.refocusWindows();
|
||||||
|
if (!isAuthSuccessful) return;
|
||||||
|
|
||||||
|
_codeDisplayStore.clearSelection();
|
||||||
|
showShareDialog(context, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onShowQrPressed(Code code) async {
|
||||||
|
bool isAuthSuccessful = await LocalAuthenticationService.instance
|
||||||
|
.requestLocalAuthentication(context, context.l10n.showQRAuthMessage);
|
||||||
|
await PlatformUtil.refocusWindows();
|
||||||
|
if (!isAuthSuccessful) return;
|
||||||
|
|
||||||
|
_codeDisplayStore.clearSelection();
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return ViewQrPage(code: code);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildClearActionButton(IconData icon, String label, VoidCallback onTap) {
|
||||||
|
final colorScheme = getEnteColorScheme(context);
|
||||||
|
final textTheme = getEnteTextTheme(context);
|
||||||
|
|
||||||
|
return Expanded(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
highlightColor: colorScheme.textBase.withValues(alpha: 0.1),
|
||||||
|
splashColor: colorScheme.textBase.withValues(alpha: 0.1),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: colorScheme.textBase, size: 18), //bottom row icon props
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(label, style: textTheme.small.copyWith(color: colorScheme.textBase, fontSize: 11)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Widget _buildSingleSelectActions(Code code) {
|
||||||
|
final colorScheme = getEnteColorScheme(context);
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_buildActionButton(Icons.edit_outlined, context.l10n.edit, () => _onEditPressed(code)),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
_buildActionButton(Icons.share_outlined, context.l10n.share, () => _onSharePressed(code)),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
_buildActionButton(Icons.qr_code, context.l10n.qrCode, () => _onShowQrPressed(code)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).brightness == Brightness.dark
|
||||||
|
? colorScheme.backgroundElevated2
|
||||||
|
: const Color(0xFFF7F7F7),
|
||||||
|
//color of the bottom button row on single select
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ValueListenableBuilder<Set<String>>(
|
||||||
|
valueListenable: _codeDisplayStore.selectedCodeIds,
|
||||||
|
builder: (context, selectedIds, child) {
|
||||||
|
if (selectedIds.isEmpty) return const Expanded(child: SizedBox.shrink());
|
||||||
|
|
||||||
|
final selectedCodes = _allCodes?.where((c) => selectedIds.contains(c.secret)).toList() ?? [];
|
||||||
|
if (selectedCodes.isEmpty) return const Expanded(child: SizedBox.shrink());
|
||||||
|
|
||||||
|
final bool allArePinned = selectedCodes.every((code) => code.isPinned);
|
||||||
|
|
||||||
|
return _buildClearActionButton(
|
||||||
|
allArePinned ? Icons.push_pin : Icons.push_pin_outlined,
|
||||||
|
allArePinned ? context.l10n.unpinText : context.l10n.pinText,
|
||||||
|
_onPinSelectedPressed,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_buildClearActionButton(Icons.label_outline, context.l10n.addTag, _onAddTagPressed),
|
||||||
|
_buildClearActionButton(Icons.delete_outline, context.l10n.trash, _onTrashSelectedPressed),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMultiSelectActions(Set<String> selectedIds) {
|
||||||
|
final colorScheme = getEnteColorScheme(context);
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).brightness == Brightness.dark
|
||||||
|
? colorScheme.backgroundElevated2
|
||||||
|
: const Color(0xFFF7F7F7),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: ValueListenableBuilder<Set<String>>(
|
||||||
|
valueListenable: _codeDisplayStore.selectedCodeIds,
|
||||||
|
builder: (context, selectedIds, child) {
|
||||||
|
if (selectedIds.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final selectedCodes = _allCodes?.where((c) => selectedIds.contains(c.secret)).toList() ?? [];
|
||||||
|
if (selectedCodes.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final bool allArePinned = selectedCodes.every((code) => code.isPinned);
|
||||||
|
final bool allAreUnpinned = selectedCodes.every((code) => !code.isPinned);
|
||||||
|
final bool isMixed = !allArePinned && !allAreUnpinned;
|
||||||
|
|
||||||
|
if (isMixed) {
|
||||||
|
//mixed state: when selection contains both pinned and unpinned codes
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
_buildClearActionButton(
|
||||||
|
Icons.push_pin_outlined,
|
||||||
|
context.l10n.pinText,
|
||||||
|
_onPinSelectedPressed,
|
||||||
|
),
|
||||||
|
_buildClearActionButton(
|
||||||
|
Icons.push_pin,
|
||||||
|
context.l10n.unpinText,
|
||||||
|
_onUnpinSelectedPressed,
|
||||||
|
),
|
||||||
|
_buildClearActionButton(
|
||||||
|
Icons.label_outline,
|
||||||
|
context.l10n.addTag,
|
||||||
|
_onAddTagPressed,
|
||||||
|
),
|
||||||
|
_buildClearActionButton(
|
||||||
|
Icons.delete_outline,
|
||||||
|
context.l10n.trash,
|
||||||
|
_onTrashSelectedPressed,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
//when selection contains either only pinned OR only unpinned codes
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
_buildClearActionButton(
|
||||||
|
allArePinned ? Icons.push_pin : Icons.push_pin_outlined,
|
||||||
|
allArePinned ? context.l10n.unpinText : context.l10n.pinText,
|
||||||
|
_onPinSelectedPressed,
|
||||||
|
),
|
||||||
|
_buildClearActionButton(
|
||||||
|
Icons.label_outline,
|
||||||
|
context.l10n.addTag,
|
||||||
|
_onAddTagPressed,
|
||||||
|
),
|
||||||
|
_buildClearActionButton(
|
||||||
|
Icons.delete_outline,
|
||||||
|
context.l10n.trash,
|
||||||
|
_onTrashSelectedPressed,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActionButton(IconData icon, String label, VoidCallback onTap) {
|
||||||
|
final colorScheme = getEnteColorScheme(context);
|
||||||
|
final textTheme = getEnteTextTheme(context);
|
||||||
|
|
||||||
|
return Expanded(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).brightness == Brightness.dark
|
||||||
|
? colorScheme.backgroundElevated2
|
||||||
|
: const Color(0xFFF7F7F7),
|
||||||
|
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
highlightColor: colorScheme.textBase.withValues(alpha: 0.7),
|
||||||
|
splashColor: colorScheme.textBase.withValues(alpha: 0.7),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: colorScheme.textBase, size: 18), //top row icon props
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: textTheme.small.copyWith(color: colorScheme.textBase, fontSize: 11),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActionButtons() {
|
||||||
|
if (_isTrashOpen) {
|
||||||
|
return _buildTrashSelectActions();
|
||||||
|
}
|
||||||
|
return ValueListenableBuilder<Set<String>>(
|
||||||
|
valueListenable: _codeDisplayStore.selectedCodeIds,
|
||||||
|
builder: (context, selectedIds, child) {
|
||||||
|
if (selectedIds.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedIds.length == 1) {
|
||||||
|
final selectedCode = _allCodes?.firstWhereOrNull(
|
||||||
|
(c) => c.secret == selectedIds.first,
|
||||||
|
);
|
||||||
|
if (selectedCode == null) return const SizedBox.shrink();
|
||||||
|
return _buildSingleSelectActions(selectedCode);
|
||||||
|
} else {
|
||||||
|
return _buildMultiSelectActions(selectedIds);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSelectionActionBar() {
|
||||||
|
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||||
|
final colorScheme = getEnteColorScheme(context);
|
||||||
|
|
||||||
|
return ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: MediaQuery.of(context).size.height * 0.4,
|
||||||
|
),
|
||||||
|
child: Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(16),
|
||||||
|
topRight: Radius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
elevation: 4,
|
||||||
|
color: Theme.of(context).brightness == Brightness.dark
|
||||||
|
? colorScheme.fillFaint
|
||||||
|
: colorScheme.backgroundElevated2,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + bottomPadding),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
//Select all pill
|
||||||
|
Material(
|
||||||
|
shape: StadiumBorder(
|
||||||
|
side: BorderSide(color: colorScheme.strokeMuted, width: 0.5),
|
||||||
|
),
|
||||||
|
color: colorScheme.backgroundElevated2,
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
final allVisibleCodeIds =
|
||||||
|
_filteredCodes.map((c) => c.secret).toSet();
|
||||||
|
_codeDisplayStore.selectedCodeIds.value = allVisibleCodeIds;
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.check_circle_outline_outlined,
|
||||||
|
color: Colors.grey,
|
||||||
|
size: 15,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(context.l10n.selectAll, style: const TextStyle(fontSize: 11)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Center code logo icon
|
||||||
|
Expanded(
|
||||||
|
child: ValueListenableBuilder<Set<String>>(
|
||||||
|
valueListenable: _codeDisplayStore.selectedCodeIds,
|
||||||
|
builder: (context, selectedIds, child) {
|
||||||
|
if (selectedIds.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
final selectedCodes = _allCodes
|
||||||
|
?.where((c) => selectedIds.contains(c.secret))
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
final codesToShow = selectedCodes.take(3).toList();
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
...codesToShow.map((code) {
|
||||||
|
final iconData = code.display.isCustomIcon
|
||||||
|
? code.display.iconID
|
||||||
|
: code.issuer;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||||
|
child: IconUtils.instance
|
||||||
|
.getIcon(context, iconData.trim(), width: 17),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
if (selectedIds.length > 3)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 4.0),
|
||||||
|
child: Text(
|
||||||
|
'+${selectedIds.length - 3}',
|
||||||
|
style: const TextStyle(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// N selected pill
|
||||||
|
ValueListenableBuilder<Set<String>>(
|
||||||
|
valueListenable: _codeDisplayStore.selectedCodeIds,
|
||||||
|
builder: (context, selectedIds, child) {
|
||||||
|
return Material(
|
||||||
|
shape: StadiumBorder(
|
||||||
|
side: BorderSide(color: colorScheme.strokeMuted, width: 0.5),
|
||||||
|
),
|
||||||
|
color: colorScheme.backgroundElevated2,
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
_codeDisplayStore.clearSelection();
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${selectedIds.length} selected',
|
||||||
|
style: const TextStyle(fontSize: 11),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
const Icon(
|
||||||
|
Icons.close,
|
||||||
|
size: 15,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildActionButtons(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
bool _handleKeyEvent(KeyEvent event) {
|
bool _handleKeyEvent(KeyEvent event) {
|
||||||
if (event is KeyDownEvent) {
|
if (event is KeyDownEvent) {
|
||||||
_pressedKeys.add(event.logicalKey);
|
_pressedKeys.add(event.logicalKey);
|
||||||
@@ -153,6 +786,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _loadCodes() {
|
void _loadCodes() {
|
||||||
|
debugPrint("[HOME_DEBUG] _loadCodes triggered!");
|
||||||
CodeStore.instance.getAllCodes().then((codes) {
|
CodeStore.instance.getAllCodes().then((codes) {
|
||||||
_allCodes = codes;
|
_allCodes = codes;
|
||||||
hasTrashedCodes = false;
|
hasTrashedCodes = false;
|
||||||
@@ -419,120 +1053,135 @@ class _HomePageState extends State<HomePage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
LockScreenSettings.instance
|
LockScreenSettings.instance
|
||||||
.setLightMode(getEnteColorScheme(context).isLightTheme);
|
.setLightMode(getEnteColorScheme(context).isLightTheme);
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
isCompactMode = PreferenceService.instance.isCompactMode();
|
isCompactMode = PreferenceService.instance.isCompactMode();
|
||||||
|
|
||||||
return PopScope(
|
return ValueListenableBuilder<bool>(
|
||||||
onPopInvokedWithResult: (_, result) async {
|
valueListenable: _codeDisplayStore.isSelectionModeActive,
|
||||||
if (_isSettingsOpen) {
|
builder: (context, isSelecting, child) {
|
||||||
scaffoldKey.currentState!.closeDrawer();
|
return PopScope(
|
||||||
return;
|
canPop: false,
|
||||||
} else if (!Platform.isAndroid) {
|
onPopInvokedWithResult: (_, result) async {
|
||||||
Navigator.of(context).pop();
|
if (isSelecting) {
|
||||||
return;
|
_codeDisplayStore.clearSelection();
|
||||||
}
|
return;
|
||||||
await MoveToBackground.moveTaskToBack();
|
}
|
||||||
},
|
|
||||||
canPop: false,
|
if (_isSettingsOpen) {
|
||||||
child: Scaffold(
|
scaffoldKey.currentState!.closeDrawer();
|
||||||
key: scaffoldKey,
|
return;
|
||||||
drawerEnableOpenDragGesture: !Platform.isAndroid,
|
} else if (!Platform.isAndroid) {
|
||||||
drawer: Drawer(
|
Navigator.of(context).pop();
|
||||||
width: 428,
|
return;
|
||||||
child: _settingsPage,
|
}
|
||||||
),
|
await MoveToBackground.moveTaskToBack();
|
||||||
onDrawerChanged: (isOpened) => _isSettingsOpen = isOpened,
|
},
|
||||||
body: SafeArea(
|
child: Scaffold(
|
||||||
|
key: scaffoldKey,
|
||||||
|
drawerEnableOpenDragGesture: !Platform.isAndroid,
|
||||||
|
drawer: Drawer(
|
||||||
|
width: 428,
|
||||||
|
child: _settingsPage,
|
||||||
|
),
|
||||||
|
onDrawerChanged: (isOpened) => _isSettingsOpen = isOpened,
|
||||||
|
body: SafeArea(
|
||||||
bottom: false,
|
bottom: false,
|
||||||
child: Builder(
|
child: Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return _getBody();
|
return _getBody();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
resizeToAvoidBottomInset: false,
|
bottomNavigationBar: isSelecting ? _buildSelectionActionBar() : null,
|
||||||
appBar: AppBar(
|
resizeToAvoidBottomInset: false,
|
||||||
title: !_showSearchBox
|
appBar: AppBar(
|
||||||
? const Text('Ente Auth', style: brandStyleMedium)
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||||
: TextField(
|
surfaceTintColor: Colors.transparent,
|
||||||
autocorrect: false,
|
title: !_showSearchBox
|
||||||
enableSuggestions: false,
|
? const Text('Ente Auth', style: brandStyleMedium)
|
||||||
autofocus: _autoFocusSearch,
|
: TextField(
|
||||||
controller: _textController,
|
autocorrect: false,
|
||||||
onChanged: (val) {
|
enableSuggestions: false,
|
||||||
_searchText = val;
|
autofocus: _autoFocusSearch,
|
||||||
_applyFilteringAndRefresh();
|
controller: _textController,
|
||||||
},
|
onChanged: (val) {
|
||||||
decoration: InputDecoration(
|
_searchText = val;
|
||||||
hintText: l10n.searchHint,
|
_applyFilteringAndRefresh();
|
||||||
border: InputBorder.none,
|
},
|
||||||
focusedBorder: InputBorder.none,
|
decoration: InputDecoration(
|
||||||
|
hintText: l10n.searchHint,
|
||||||
|
border: InputBorder.none,
|
||||||
|
focusedBorder: InputBorder.none,
|
||||||
|
),
|
||||||
|
focusNode: searchBoxFocusNode,
|
||||||
),
|
),
|
||||||
focusNode: searchBoxFocusNode,
|
centerTitle: PlatformUtil.isDesktop() ? false : true,
|
||||||
),
|
actions: <Widget>[
|
||||||
centerTitle: PlatformUtil.isDesktop() ? false : true,
|
Padding(
|
||||||
actions: <Widget>[
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: SortCodeMenuWidget(
|
|
||||||
currentKey: PreferenceService.instance.codeSortKey(),
|
|
||||||
onSelected: (newOrder) async {
|
|
||||||
await PreferenceService.instance.setCodeSortKey(newOrder);
|
|
||||||
if (newOrder == CodeSortKey.manual &&
|
|
||||||
newOrder == _codeSortKey) {
|
|
||||||
await navigateToReorderPage(_allCodes!);
|
|
||||||
}
|
|
||||||
setState(() {
|
|
||||||
_codeSortKey = newOrder;
|
|
||||||
});
|
|
||||||
if (mounted) {
|
|
||||||
_applyFilteringAndRefresh();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (PlatformUtil.isDesktop())
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.lock),
|
|
||||||
tooltip: l10n.appLock,
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
onPressed: () async {
|
child: SortCodeMenuWidget(
|
||||||
await navigateToLockScreen();
|
currentKey: PreferenceService.instance.codeSortKey(),
|
||||||
|
onSelected: (newOrder) async {
|
||||||
|
await PreferenceService.instance.setCodeSortKey(newOrder);
|
||||||
|
if (newOrder == CodeSortKey.manual &&
|
||||||
|
newOrder == _codeSortKey) {
|
||||||
|
await navigateToReorderPage(_allCodes!);
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_codeSortKey = newOrder;
|
||||||
|
});
|
||||||
|
if (mounted) {
|
||||||
|
_applyFilteringAndRefresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (PlatformUtil.isDesktop())
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.lock),
|
||||||
|
tooltip: l10n.appLock,
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
onPressed: () async {
|
||||||
|
await navigateToLockScreen();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: _showSearchBox
|
||||||
|
? const Icon(Icons.clear)
|
||||||
|
: const Icon(Icons.search),
|
||||||
|
tooltip: l10n.search,
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_showSearchBox = !_showSearchBox;
|
||||||
|
if (!_showSearchBox) {
|
||||||
|
_textController.clear();
|
||||||
|
_searchText = "";
|
||||||
|
} else {
|
||||||
|
_searchText = _textController.text;
|
||||||
|
searchBoxFocusNode.requestFocus();
|
||||||
|
}
|
||||||
|
_applyFilteringAndRefresh();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
],
|
||||||
icon: _showSearchBox
|
),
|
||||||
? const Icon(Icons.clear)
|
floatingActionButton: isSelecting
|
||||||
: const Icon(Icons.search),
|
? null
|
||||||
tooltip: l10n.search,
|
: (!_hasLoaded ||
|
||||||
padding: const EdgeInsets.all(8.0),
|
(_allCodes?.isEmpty ?? true) ||
|
||||||
onPressed: () {
|
!PreferenceService.instance.hasShownCoachMark()
|
||||||
setState(() {
|
? null
|
||||||
_showSearchBox = !_showSearchBox;
|
: _getFab()),
|
||||||
if (!_showSearchBox) {
|
|
||||||
_textController.clear();
|
|
||||||
_searchText = "";
|
|
||||||
} else {
|
|
||||||
_searchText = _textController.text;
|
|
||||||
searchBoxFocusNode.requestFocus();
|
|
||||||
}
|
|
||||||
_applyFilteringAndRefresh();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
floatingActionButton: !_hasLoaded ||
|
);
|
||||||
(_allCodes?.isEmpty ?? true) ||
|
},
|
||||||
!PreferenceService.instance.hasShownCoachMark()
|
);
|
||||||
? null
|
}
|
||||||
: _getFab(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _getBody() {
|
Widget _getBody() {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
@@ -570,6 +1219,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
? TagChipState.selected
|
? TagChipState.selected
|
||||||
: TagChipState.unselected,
|
: TagChipState.unselected,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
_codeDisplayStore.clearSelection();
|
||||||
selectedTag = "";
|
selectedTag = "";
|
||||||
_isTrashOpen = false;
|
_isTrashOpen = false;
|
||||||
|
|
||||||
@@ -586,6 +1236,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
? TagChipState.selected
|
? TagChipState.selected
|
||||||
: TagChipState.unselected,
|
: TagChipState.unselected,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
_codeDisplayStore.clearSelection();
|
||||||
selectedTag = "";
|
selectedTag = "";
|
||||||
_isTrashOpen = !_isTrashOpen;
|
_isTrashOpen = !_isTrashOpen;
|
||||||
setState(() {});
|
setState(() {});
|
||||||
@@ -603,6 +1254,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
? TagChipState.selected
|
? TagChipState.selected
|
||||||
: TagChipState.unselected,
|
: TagChipState.unselected,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
_codeDisplayStore.clearSelection();
|
||||||
_isTrashOpen = false;
|
_isTrashOpen = false;
|
||||||
if (selectedTag == tags[customTagIndex]) {
|
if (selectedTag == tags[customTagIndex]) {
|
||||||
selectedTag = "";
|
selectedTag = "";
|
||||||
@@ -817,4 +1469,4 @@ class _HomePageState extends State<HomePage> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@ import 'package:ente_auth/ui/settings/common_settings.dart';
|
|||||||
import 'package:ente_auth/ui/settings/data/duplicate_code_page.dart';
|
import 'package:ente_auth/ui/settings/data/duplicate_code_page.dart';
|
||||||
import 'package:ente_auth/ui/settings/data/export_widget.dart';
|
import 'package:ente_auth/ui/settings/data/export_widget.dart';
|
||||||
import 'package:ente_auth/ui/settings/data/import_page.dart';
|
import 'package:ente_auth/ui/settings/data/import_page.dart';
|
||||||
|
import 'package:ente_auth/ui/settings/data/local_backup_settings_page.dart'; //for local backup
|
||||||
import 'package:ente_auth/utils/dialog_util.dart';
|
import 'package:ente_auth/utils/dialog_util.dart';
|
||||||
import 'package:ente_auth/utils/navigation_util.dart';
|
import 'package:ente_auth/utils/navigation_util.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -29,6 +30,10 @@ class DataSectionWidget extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleLocalBackupClick(BuildContext context) async {
|
||||||
|
await routeToPage(context, const LocalBackupSettingsPage());
|
||||||
|
}
|
||||||
|
|
||||||
Column _getSectionOptions(BuildContext context) {
|
Column _getSectionOptions(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
List<Widget> children = [];
|
List<Widget> children = [];
|
||||||
@@ -86,10 +91,21 @@ class DataSectionWidget extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
MenuItemWidget(
|
||||||
|
captionedTextWidget: CaptionedTextWidget(
|
||||||
|
title: l10n.localBackupSidebarTitle,
|
||||||
|
),
|
||||||
|
pressedColor: getEnteColorScheme(context).fillFaint,
|
||||||
|
trailingIcon: Icons.chevron_right_outlined,
|
||||||
|
trailingIconIsMuted: true,
|
||||||
|
onTap: () async {
|
||||||
|
await _handleLocalBackupClick(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
sectionOptionSpacing,
|
sectionOptionSpacing,
|
||||||
]);
|
]);
|
||||||
return Column(
|
return Column(
|
||||||
children: children,
|
children: children,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,6 @@ import 'dart:io';
|
|||||||
import 'package:ente_auth/l10n/l10n.dart';
|
import 'package:ente_auth/l10n/l10n.dart';
|
||||||
import 'package:ente_auth/models/code.dart';
|
import 'package:ente_auth/models/code.dart';
|
||||||
import 'package:ente_auth/models/export/ente.dart';
|
import 'package:ente_auth/models/export/ente.dart';
|
||||||
import 'package:ente_auth/services/authenticator_service.dart';
|
|
||||||
import 'package:ente_auth/store/code_store.dart';
|
import 'package:ente_auth/store/code_store.dart';
|
||||||
import 'package:ente_auth/ui/components/buttons/button_widget.dart';
|
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/dialog_widget.dart';
|
||||||
@@ -46,7 +45,7 @@ Future<void> showEncryptedImportInstruction(BuildContext context) async {
|
|||||||
if (result?.action != null && result!.action != ButtonAction.cancel) {
|
if (result?.action != null && result!.action != ButtonAction.cancel) {
|
||||||
if (result.action == ButtonAction.first) {
|
if (result.action == ButtonAction.first) {
|
||||||
await _pickEnteJsonFile(context);
|
await _pickEnteJsonFile(context);
|
||||||
} else {}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +57,9 @@ Future<void> _decryptExportData(
|
|||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
bool isPasswordIncorrect = false;
|
bool isPasswordIncorrect = false;
|
||||||
int? importedCodeCount;
|
int? importedCodeCount;
|
||||||
|
|
||||||
|
bool importHasRun = false;
|
||||||
|
|
||||||
await showTextInputDialog(
|
await showTextInputDialog(
|
||||||
context,
|
context,
|
||||||
title: l10n.passwordForDecryptingExport,
|
title: l10n.passwordForDecryptingExport,
|
||||||
@@ -67,6 +69,11 @@ Future<void> _decryptExportData(
|
|||||||
alwaysShowSuccessState: false,
|
alwaysShowSuccessState: false,
|
||||||
showOnlyLoadingState: true,
|
showOnlyLoadingState: true,
|
||||||
onSubmit: (String password) async {
|
onSubmit: (String password) async {
|
||||||
|
if (importHasRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
importHasRun = true;
|
||||||
|
|
||||||
if (password.isEmpty) {
|
if (password.isEmpty) {
|
||||||
showToast(context, l10n.passwordEmptyError);
|
showToast(context, l10n.passwordEmptyError);
|
||||||
Future.delayed(const Duration(seconds: 0), () {
|
Future.delayed(const Duration(seconds: 0), () {
|
||||||
@@ -78,6 +85,7 @@ Future<void> _decryptExportData(
|
|||||||
final progressDialog = createProgressDialog(context, l10n.pleaseWait);
|
final progressDialog = createProgressDialog(context, l10n.pleaseWait);
|
||||||
try {
|
try {
|
||||||
await progressDialog.show();
|
await progressDialog.show();
|
||||||
|
|
||||||
final derivedKey = await CryptoUtil.deriveKey(
|
final derivedKey = await CryptoUtil.deriveKey(
|
||||||
utf8.encode(password),
|
utf8.encode(password),
|
||||||
CryptoUtil.base642bin(enteAuthExport.kdfParams.salt),
|
CryptoUtil.base642bin(enteAuthExport.kdfParams.salt),
|
||||||
@@ -85,7 +93,6 @@ Future<void> _decryptExportData(
|
|||||||
enteAuthExport.kdfParams.opsLimit,
|
enteAuthExport.kdfParams.opsLimit,
|
||||||
);
|
);
|
||||||
Uint8List? decryptedContent;
|
Uint8List? decryptedContent;
|
||||||
// Encrypt the key with this derived key
|
|
||||||
try {
|
try {
|
||||||
decryptedContent = await CryptoUtil.decryptData(
|
decryptedContent = await CryptoUtil.decryptData(
|
||||||
CryptoUtil.base642bin(enteAuthExport.encryptedData),
|
CryptoUtil.base642bin(enteAuthExport.encryptedData),
|
||||||
@@ -99,27 +106,62 @@ Future<void> _decryptExportData(
|
|||||||
}
|
}
|
||||||
if (isPasswordIncorrect) {
|
if (isPasswordIncorrect) {
|
||||||
await progressDialog.hide();
|
await progressDialog.hide();
|
||||||
|
|
||||||
Future.delayed(const Duration(seconds: 0), () {
|
Future.delayed(const Duration(seconds: 0), () {
|
||||||
_decryptExportData(context, enteAuthExport, password: password);
|
_decryptExportData(context, enteAuthExport, password: password);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String content = utf8.decode(decryptedContent!);
|
String content = utf8.decode(decryptedContent!);
|
||||||
List<String> splitCodes = content.split("\n");
|
List<String> splitCodes = content.split("\n");
|
||||||
final parsedCodes = [];
|
|
||||||
for (final code in splitCodes) {
|
final List<Code> parsedCodes = [];
|
||||||
|
for (final line in splitCodes) {
|
||||||
|
if (line.trim().isEmpty) continue;
|
||||||
try {
|
try {
|
||||||
parsedCodes.add(Code.fromOTPAuthUrl(code));
|
String otpUrl = jsonDecode(line);
|
||||||
|
parsedCodes.add(Code.fromOTPAuthUrl(otpUrl));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Logger('EncryptedText').severe("Could not parse code", e);
|
Logger('EncryptedText').severe("Could not parse code", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (final code in parsedCodes) {
|
|
||||||
await CodeStore.instance.addCode(code, shouldSync: false);
|
final List<Code> codesInApp = await CodeStore.instance.getAllCodes();
|
||||||
|
final Map<String, Code> appCodesBySecret = { for (var code in codesInApp) code.secret: code };
|
||||||
|
final List<Code> codesToImportAsNew = [];
|
||||||
|
final List<Code> codesToUpdate = [];
|
||||||
|
final Set<String> processedSecrets = {};
|
||||||
|
|
||||||
|
for (final codeFromFile in parsedCodes) {
|
||||||
|
if (processedSecrets.contains(codeFromFile.secret)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
processedSecrets.add(codeFromFile.secret);
|
||||||
|
if (appCodesBySecret.containsKey(codeFromFile.secret)) {
|
||||||
|
final originalCodeInApp = appCodesBySecret[codeFromFile.secret]!;
|
||||||
|
final updatedCode = codeFromFile.copyWith();
|
||||||
|
updatedCode.generatedID = originalCodeInApp.generatedID;
|
||||||
|
codesToUpdate.add(updatedCode);
|
||||||
|
} else {
|
||||||
|
codesToImportAsNew.add(codeFromFile);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
unawaited(AuthenticatorService.instance.onlineSync());
|
|
||||||
importedCodeCount = parsedCodes.length;
|
|
||||||
|
if (codesToUpdate.isNotEmpty) {
|
||||||
|
for (final codeToUpdate in codesToUpdate) {
|
||||||
|
final originalCode = appCodesBySecret[codeToUpdate.secret]!;
|
||||||
|
await CodeStore.instance.updateCode(originalCode, codeToUpdate, shouldSync: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (codesToImportAsNew.isNotEmpty) {
|
||||||
|
for (final newCode in codesToImportAsNew) {
|
||||||
|
await CodeStore.instance.addCode(newCode, shouldSync: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
importedCodeCount = codesToImportAsNew.length + codesToUpdate.length;
|
||||||
|
|
||||||
await progressDialog.hide();
|
await progressDialog.hide();
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
await progressDialog.hide();
|
await progressDialog.hide();
|
||||||
@@ -153,4 +195,4 @@ Future<void> _pickEnteJsonFile(BuildContext context) async {
|
|||||||
context.l10n.importFailureDescNew,
|
context.l10n.importFailureDescNew,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:ente_auth/ente_theme_data.dart';
|
||||||
|
import 'package:ente_auth/l10n/l10n.dart';
|
||||||
|
import 'package:ente_auth/services/local_backup_service.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/dialog_widget.dart';
|
||||||
|
import 'package:ente_auth/ui/components/models/button_result.dart';
|
||||||
|
import 'package:ente_auth/ui/components/models/button_type.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class LocalBackupSettingsPage extends StatefulWidget {
|
||||||
|
const LocalBackupSettingsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LocalBackupSettingsPage> createState() =>
|
||||||
|
_LocalBackupSettingsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LocalBackupSettingsPageState extends State<LocalBackupSettingsPage> {
|
||||||
|
bool _isBackupEnabled = false;
|
||||||
|
String? _backupPath;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
// to load the saved settings from SharedPreferences when the page opens.
|
||||||
|
Future<void> _loadSettings() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
setState(() {
|
||||||
|
_isBackupEnabled = prefs.getBool('isAutoBackupEnabled') ?? false;
|
||||||
|
_backupPath = prefs.getString('autoBackupPath');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _showCustomPasswordDialog() async {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final textController = TextEditingController();
|
||||||
|
// state variable to track password visibility
|
||||||
|
bool isPasswordHidden = true;
|
||||||
|
|
||||||
|
return showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setState) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(l10n.setPasswordTitle, style: getEnteTextTheme(context).largeBold),
|
||||||
|
content: TextField(
|
||||||
|
controller: textController,
|
||||||
|
autofocus: true,
|
||||||
|
obscureText: isPasswordHidden,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: l10n.enterPassword,
|
||||||
|
hintStyle: getEnteTextTheme(context).mini,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
isPasswordHidden ? Icons.visibility_off : Icons.visibility,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
isPasswordHidden = !isPasswordHidden;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (text) => setState(() {}),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
|
||||||
|
Expanded(
|
||||||
|
child: ButtonWidget(
|
||||||
|
buttonType: ButtonType.secondary,
|
||||||
|
labelText: l10n.cancel,
|
||||||
|
onTap: () async => Navigator.of(context).pop(null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
|
Expanded(
|
||||||
|
child: ButtonWidget(
|
||||||
|
buttonType: ButtonType.primary,
|
||||||
|
labelText: l10n.saveAction,
|
||||||
|
isDisabled: textController.text.isEmpty,
|
||||||
|
onTap: () async => Navigator.of(context).pop(textController.text),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ButtonResult?> _showLocationChoiceDialog({required String displayPath}) async {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
|
||||||
|
final dialogBody =
|
||||||
|
'${l10n.backupLocationChoiceDescription}\n\nSelected: ${_simplifyPath(displayPath)}';
|
||||||
|
|
||||||
|
final result = await showDialogWidget(
|
||||||
|
title: l10n.chooseBackupLocation,
|
||||||
|
context: context,
|
||||||
|
body: dialogBody,
|
||||||
|
buttons: [
|
||||||
|
ButtonWidget(
|
||||||
|
buttonType: ButtonType.primary,
|
||||||
|
labelText: l10n.saveBackup,
|
||||||
|
isInAlert: true,
|
||||||
|
buttonSize: ButtonSize.large,
|
||||||
|
buttonAction: ButtonAction.first,
|
||||||
|
),
|
||||||
|
ButtonWidget(
|
||||||
|
buttonType: ButtonType.secondary,
|
||||||
|
labelText: l10n.changeLocation,
|
||||||
|
isInAlert: true,
|
||||||
|
buttonSize: ButtonSize.large,
|
||||||
|
buttonAction: ButtonAction.second,
|
||||||
|
),
|
||||||
|
ButtonWidget(
|
||||||
|
buttonType: ButtonType.secondary,
|
||||||
|
labelText: l10n.cancel,
|
||||||
|
isInAlert: true,
|
||||||
|
buttonSize: ButtonSize.large,
|
||||||
|
buttonAction: ButtonAction.cancel,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _handleLocationSetup() async {
|
||||||
|
|
||||||
|
String currentPath = _backupPath ?? await _getDefaultBackupPath();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
final result = await _showLocationChoiceDialog(displayPath: currentPath);
|
||||||
|
|
||||||
|
if (result?.action == ButtonAction.first) {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
try {
|
||||||
|
await Directory(currentPath).create(recursive: true);
|
||||||
|
await prefs.setString('autoBackupPath', currentPath);
|
||||||
|
setState(() {
|
||||||
|
_backupPath = currentPath;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(context.l10n.initialBackupCreated)),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(context.l10n.noDefaultBackupFolder)),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else if (result?.action == ButtonAction.second) {
|
||||||
|
final newPath = await FilePicker.platform.getDirectoryPath();
|
||||||
|
if (newPath != null) {
|
||||||
|
currentPath = newPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _getDefaultBackupPath() async {
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
Directory? externalDir = await getExternalStorageDirectory();
|
||||||
|
if (externalDir != null) {
|
||||||
|
String storagePath = externalDir.path.split('/Android')[0];
|
||||||
|
return '$storagePath/Download/EnteAuthBackups';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory? dir = await getDownloadsDirectory();
|
||||||
|
dir ??= await getApplicationDocumentsDirectory();
|
||||||
|
return '${dir.path}/EnteAuthBackups';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _simplifyPath(String fullPath) { //takes a file path string and shortens it if it matches the common Android root path.
|
||||||
|
const rootToRemove = '/storage/emulated/0/';
|
||||||
|
if (fullPath.startsWith(rootToRemove)) {
|
||||||
|
return fullPath.substring(rootToRemove.length);
|
||||||
|
}
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// opens directory picker
|
||||||
|
Future<bool> _pickAndSaveBackupLocation({String? successMessage}) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final l10n = context.l10n;
|
||||||
|
|
||||||
|
String? directoryPath = await FilePicker.platform.getDirectoryPath();
|
||||||
|
|
||||||
|
if (directoryPath != null) {
|
||||||
|
|
||||||
|
await prefs.setString('autoBackupPath', directoryPath);
|
||||||
|
|
||||||
|
// we only set the state and create the backup if a path was chosen
|
||||||
|
setState(() {
|
||||||
|
_backupPath = directoryPath;
|
||||||
|
});
|
||||||
|
await LocalBackupService.instance.triggerAutomaticBackup();
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(successMessage ?? l10n.locationUpdatedAndBackupCreated),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false; //user cancelled the file picker
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showSetPasswordDialog() async {
|
||||||
|
final String? password = await _showCustomPasswordDialog();
|
||||||
|
if (password == null) {
|
||||||
|
setState(() {
|
||||||
|
_isBackupEnabled = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(context.l10n.passwordTooShort),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_isBackupEnabled = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = FlutterSecureStorage();
|
||||||
|
await storage.write(key: 'autoBackupPassword', value: password);
|
||||||
|
|
||||||
|
SchedulerBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
final bool setupCompleted = await _handleLocationSetup();
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (setupCompleted) {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool('isAutoBackupEnabled', true);
|
||||||
|
setState(() {
|
||||||
|
_isBackupEnabled = true;
|
||||||
|
});
|
||||||
|
await LocalBackupService.instance.triggerAutomaticBackup();
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_isBackupEnabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(l10n.localBackupSettingsTitle), //text shown on appbar
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.enableAutomaticBackups, //toggle text
|
||||||
|
style: getEnteTextTheme(context).largeBold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Switch.adaptive(
|
||||||
|
value: _isBackupEnabled,
|
||||||
|
activeColor: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.enteTheme
|
||||||
|
.colorScheme
|
||||||
|
.primary400,
|
||||||
|
activeTrackColor: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.enteTheme
|
||||||
|
.colorScheme
|
||||||
|
.primary300,
|
||||||
|
inactiveTrackColor: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.enteTheme
|
||||||
|
.colorScheme
|
||||||
|
.fillMuted,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
onChanged: (value) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
if (value == true) {
|
||||||
|
//when toggle is ON, show password dialog
|
||||||
|
await _showSetPasswordDialog();
|
||||||
|
} else {
|
||||||
|
await prefs.setBool('isAutoBackupEnabled', false);
|
||||||
|
setState(() {
|
||||||
|
_isBackupEnabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 10.0),
|
||||||
|
child: Text(
|
||||||
|
l10n.backupDescription, //text below toggle
|
||||||
|
style: getEnteTextTheme(context).mini,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Opacity(
|
||||||
|
opacity: _isBackupEnabled ? 1.0 : 0.4,
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: !_isBackupEnabled,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.currentLocation, //shows current backup location
|
||||||
|
style: getEnteTextTheme(context).body,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
if (_backupPath != null)
|
||||||
|
Text(
|
||||||
|
_simplifyPath(_backupPath!),
|
||||||
|
style: getEnteTextTheme(context).small,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
FutureBuilder<String>(
|
||||||
|
future: _getDefaultBackupPath(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState ==
|
||||||
|
ConnectionState.waiting) {
|
||||||
|
return Text(
|
||||||
|
l10n.loadDefaultLocation,
|
||||||
|
style: getEnteTextTheme(context)
|
||||||
|
.small
|
||||||
|
.copyWith(color: Colors.grey),
|
||||||
|
);
|
||||||
|
} else if (snapshot.hasError) {
|
||||||
|
return Text(
|
||||||
|
l10n.couldNotDetermineLocation,
|
||||||
|
style: getEnteTextTheme(context)
|
||||||
|
.small
|
||||||
|
.copyWith(color: Colors.red),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Text(
|
||||||
|
_simplifyPath(snapshot.data ?? ''),
|
||||||
|
style: getEnteTextTheme(context)
|
||||||
|
.small
|
||||||
|
.copyWith(color: Colors.grey),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 30),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () => _pickAndSaveBackupLocation(),
|
||||||
|
child: Text(l10n.changeCurrentLocation),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.withAlpha(26),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.orange.withAlpha(77),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.security_outlined,
|
||||||
|
color: Colors.orange,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
l10n.securityNotice, //security notice title
|
||||||
|
style: getEnteTextTheme(context)
|
||||||
|
.smallBold
|
||||||
|
.copyWith(
|
||||||
|
color: Colors.orange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.backupSecurityNotice, //security notice description
|
||||||
|
style: getEnteTextTheme(context).mini.copyWith(
|
||||||
|
color: Colors.orange.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -414,6 +414,7 @@ Future<dynamic> showTextInputDialog(
|
|||||||
bool alwaysShowSuccessState = false,
|
bool alwaysShowSuccessState = false,
|
||||||
bool isPasswordInput = false,
|
bool isPasswordInput = false,
|
||||||
bool useRootNavigator = false,
|
bool useRootNavigator = false,
|
||||||
|
VoidCallback? onCancel,
|
||||||
}) {
|
}) {
|
||||||
return showDialog(
|
return showDialog(
|
||||||
barrierColor: backdropFaintDark,
|
barrierColor: backdropFaintDark,
|
||||||
|
|||||||
@@ -1153,9 +1153,9 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: "91e4d1a9c55b28bf93425d1f12faf410efc1e48d"
|
ref: v2-only
|
||||||
resolved-ref: "91e4d1a9c55b28bf93425d1f12faf410efc1e48d"
|
resolved-ref: "0cdfeed654d79636eff0c57110f3f6ad5801ba2f"
|
||||||
url: "https://github.com/Sayegh7/move_to_background"
|
url: "https://github.com/ente-io/move_to_background.git"
|
||||||
source: git
|
source: git
|
||||||
version: "1.0.2"
|
version: "1.0.2"
|
||||||
native_dio_adapter:
|
native_dio_adapter:
|
||||||
|
|||||||
@@ -95,10 +95,10 @@ dependencies:
|
|||||||
local_auth_darwin: ^1.2.2
|
local_auth_darwin: ^1.2.2
|
||||||
logging: ^1.0.1
|
logging: ^1.0.1
|
||||||
modal_bottom_sheet: ^3.0.0
|
modal_bottom_sheet: ^3.0.0
|
||||||
move_to_background: # no package updates on pub.dev
|
move_to_background: # no updates in git, replace package
|
||||||
git:
|
git:
|
||||||
url: https://github.com/Sayegh7/move_to_background
|
url: https://github.com/ente-io/move_to_background.git
|
||||||
ref: 91e4d1a9c55b28bf93425d1f12faf410efc1e48d
|
ref: v2-only
|
||||||
native_dio_adapter: ^1.4.0
|
native_dio_adapter: ^1.4.0
|
||||||
otp: ^3.1.1
|
otp: ^3.1.1
|
||||||
package_info_plus: ^8.0.2
|
package_info_plus: ^8.0.2
|
||||||
|
|||||||
Reference in New Issue
Block a user