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/
|
||||
.history
|
||||
.svn/
|
||||
android/app/build/
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
|
||||
Submodule mobile/apps/auth/flutter updated: edada7c56e...2663184aa7
@@ -11,6 +11,7 @@
|
||||
"setupFirstAccount": "Setup your first account",
|
||||
"importScanQrCode": "Scan a QR Code",
|
||||
"qrCode": "QR Code",
|
||||
"qr": "QR",
|
||||
"importEnterSetupKey": "Enter a setup key",
|
||||
"importAccountPageTitle": "Enter account details",
|
||||
"secretCanNotBeEmpty": "Secret can not be empty",
|
||||
@@ -139,6 +140,7 @@
|
||||
"existingUser": "Existing User",
|
||||
"newUser": "New to Ente",
|
||||
"delete": "Delete",
|
||||
"addTag": "Add tag",
|
||||
"enterYourPasswordHint": "Enter your password",
|
||||
"forgotPassword": "Forgot password",
|
||||
"oops": "Oops",
|
||||
@@ -527,5 +529,28 @@
|
||||
"errorInvalidQRCodeBody": "The scanned QR code is not a valid 2FA account.",
|
||||
"errorNoQRCode": "No QR code found",
|
||||
"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(
|
||||
width: 10,
|
||||
),
|
||||
Text(
|
||||
code?.account ?? '',
|
||||
style: enteTextTheme.largeBold,
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Text(
|
||||
code?.account ?? '',
|
||||
style: enteTextTheme.largeBold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -73,9 +78,14 @@ class ViewQrPage extends StatelessWidget {
|
||||
const SizedBox(
|
||||
width: 10,
|
||||
),
|
||||
Text(
|
||||
code?.issuer ?? '',
|
||||
style: enteTextTheme.largeBold,
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
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;
|
||||
|
||||
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 {
|
||||
_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/code.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_events/event_bus.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -64,6 +65,27 @@ class CodeStore {
|
||||
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({
|
||||
AccountMode? accountMode,
|
||||
bool sortCodes = true,
|
||||
@@ -95,7 +117,6 @@ class CodeStore {
|
||||
}
|
||||
|
||||
if (sortCodes) {
|
||||
// sort codes by issuer,account
|
||||
codes.sort((firstCode, secondCode) {
|
||||
if (secondCode.isPinned && !firstCode.isPinned) return 1;
|
||||
if (!secondCode.isPinned && firstCode.isPinned) return -1;
|
||||
@@ -121,12 +142,15 @@ class CodeStore {
|
||||
AccountMode? accountMode,
|
||||
List<Code>? existingAllCodes,
|
||||
}) async {
|
||||
|
||||
final mode = accountMode ?? _authenticatorService.getAccountMode();
|
||||
final allCodes = existingAllCodes ?? (await getAllCodes(accountMode: mode));
|
||||
bool isExistingCode = false;
|
||||
bool hasSameCode = false;
|
||||
|
||||
for (final existingCode in allCodes) {
|
||||
if (existingCode.hasError) continue;
|
||||
|
||||
if (code.generatedID != null &&
|
||||
existingCode.generatedID == code.generatedID) {
|
||||
isExistingCode = true;
|
||||
@@ -155,6 +179,7 @@ class CodeStore {
|
||||
shouldSync,
|
||||
mode,
|
||||
);
|
||||
LocalBackupService.instance.triggerAutomaticBackup().ignore();
|
||||
}
|
||||
Bus.instance.fire(CodesUpdatedEvent());
|
||||
return result;
|
||||
@@ -164,6 +189,7 @@ class CodeStore {
|
||||
final mode = accountMode ?? _authenticatorService.getAccountMode();
|
||||
await _authenticatorService.deleteEntry(code.generatedID!, mode);
|
||||
Bus.instance.fire(CodesUpdatedEvent());
|
||||
LocalBackupService.instance.triggerAutomaticBackup().ignore();
|
||||
}
|
||||
|
||||
bool _isOfflineImportRunning = false;
|
||||
@@ -214,7 +240,6 @@ class CodeStore {
|
||||
'importingCode: genID ${eachCode.generatedID} & isAlreadyPresent $alreadyPresent',
|
||||
);
|
||||
if (!alreadyPresent) {
|
||||
// Avoid conflict with generatedID of online codes
|
||||
eachCode.generatedID = null;
|
||||
final AddResult result = await CodeStore.instance.addCode(
|
||||
eachCode,
|
||||
@@ -236,10 +261,21 @@ class CodeStore {
|
||||
_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 {
|
||||
newCode,
|
||||
duplicate,
|
||||
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/view_qr_page.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/theme/ente_theme.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/share/code_share.dart';
|
||||
import 'package:ente_auth/ui/utils/icon_utils.dart';
|
||||
@@ -103,7 +103,6 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ignorePin = widget.sortKey != null && widget.sortKey == CodeSortKey.manual;
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
if (isMaskingEnabled != PreferenceService.instance.shouldHideCodes()) {
|
||||
isMaskingEnabled = PreferenceService.instance.shouldHideCodes();
|
||||
_hideCode = isMaskingEnabled;
|
||||
@@ -118,96 +117,110 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
}
|
||||
final l10n = context.l10n;
|
||||
|
||||
Widget getCardContents(AppLocalizations l10n) {
|
||||
return Stack(
|
||||
Widget getCardContents(AppLocalizations l10n, {required bool isSelected}) {
|
||||
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: [
|
||||
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.type.isTOTPCompatible)
|
||||
CodeTimerProgress(
|
||||
key: ValueKey('period_${widget.code.period}'),
|
||||
period: widget.code.period,
|
||||
isCompactMode: widget.isCompactMode,
|
||||
timeOffsetInMilliseconds:
|
||||
PreferenceService.instance.timeOffsetInMilliSeconds(),
|
||||
),
|
||||
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,
|
||||
widget.isCompactMode
|
||||
? const SizedBox(height: 4)
|
||||
: const SizedBox(height: 28),
|
||||
Row(
|
||||
children: [
|
||||
if (widget.code.type.isTOTPCompatible)
|
||||
CodeTimerProgress(
|
||||
key: ValueKey('period_${widget.code.period}'),
|
||||
period: widget.code.period,
|
||||
isCompactMode: widget.isCompactMode,
|
||||
timeOffsetInMilliseconds:
|
||||
PreferenceService.instance.timeOffsetInMilliSeconds(),
|
||||
_shouldShowLargeIcon ? _getIcon() : const SizedBox.shrink(),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
_getTopRow(isSelected: isSelected),
|
||||
widget.isCompactMode
|
||||
? 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) ...[
|
||||
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.isCompactMode
|
||||
? const SizedBox(height: 4)
|
||||
: const SizedBox(height: 32),
|
||||
],
|
||||
);
|
||||
}
|
||||
),
|
||||
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(
|
||||
decoration: BoxDecoration(
|
||||
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:
|
||||
widget.code.isPinned ? colorScheme.pinnedCardBoxShadow : [],
|
||||
(widget.code.isPinned && !isSelected) ? colorScheme.pinnedCardBoxShadow : [],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
@@ -215,7 +228,12 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
onTap: () {
|
||||
_copyCurrentOTPToClipboard();
|
||||
final store = CodeDisplayStore.instance;
|
||||
if (store.isSelectionModeActive.value) {
|
||||
store.toggleSelection(widget.code.secret);
|
||||
} else {
|
||||
_copyCurrentOTPToClipboard();
|
||||
}
|
||||
},
|
||||
onDoubleTap: isMaskingEnabled
|
||||
? () {
|
||||
@@ -229,30 +247,16 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
onLongPress: widget.isReordering
|
||||
? null
|
||||
: () {
|
||||
showModalBottomSheet(
|
||||
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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
CodeDisplayStore.instance.toggleSelection(widget.code.secret);
|
||||
},
|
||||
child: getCardContents(l10n),
|
||||
child: getCardContents(l10n, isSelected: isSelected),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: widget.isCompactMode
|
||||
@@ -273,7 +277,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
),
|
||||
if (!widget.code.isTrashed)
|
||||
MenuItem(
|
||||
label: 'QR',
|
||||
label: context.l10n.qr,
|
||||
icon: Icons.qr_code_2_outlined,
|
||||
onSelected: () => _onShowQrPressed(null),
|
||||
),
|
||||
@@ -307,7 +311,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
const MenuDivider(),
|
||||
MenuItem(
|
||||
label: widget.code.isTrashed ? l10n.delete : l10n.trash,
|
||||
value: "Delete",
|
||||
value: l10n.delete,
|
||||
icon: widget.code.isTrashed
|
||||
? Icons.delete_forever
|
||||
: Icons.delete,
|
||||
@@ -403,54 +407,64 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getTopRow() {
|
||||
bool isCompactMode = widget.isCompactMode;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
safeDecode(widget.code.issuer).trim(),
|
||||
style: isCompactMode
|
||||
? Theme.of(context).textTheme.bodyMedium
|
||||
: 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
Widget _getTopRow({required bool isSelected}) {
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
bool isCompactMode = widget.isCompactMode;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (isSelected)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: Icon(
|
||||
Icons.check_circle,
|
||||
color: colorScheme.primary400,
|
||||
size: isCompactMode ? 20 : 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
(widget.code.hasSynced != null && widget.code.hasSynced!) ||
|
||||
!hasConfiguredAccount
|
||||
? const SizedBox.shrink()
|
||||
: const Icon(
|
||||
Icons.sync_disabled,
|
||||
size: 20,
|
||||
color: Colors.amber,
|
||||
Text(
|
||||
safeDecode(widget.code.issuer).trim(),
|
||||
style: isCompactMode
|
||||
? Theme.of(context).textTheme.bodyMedium
|
||||
: 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: 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() {
|
||||
final String iconData;
|
||||
@@ -478,7 +492,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
_getCurrentOTP(),
|
||||
confirmationMessage: context.l10n.copiedToClipboard,
|
||||
);
|
||||
_udateCodeMetadata().ignore();
|
||||
_updateCodeMetadata().ignore();
|
||||
}
|
||||
|
||||
void _copyNextToClipboard() {
|
||||
@@ -486,10 +500,10 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
_getNextTotp(),
|
||||
confirmationMessage: context.l10n.copiedNextToClipboard,
|
||||
);
|
||||
_udateCodeMetadata().ignore();
|
||||
_updateCodeMetadata().ignore();
|
||||
}
|
||||
|
||||
Future<void> _udateCodeMetadata() async {
|
||||
Future<void> _updateCodeMetadata() async {
|
||||
if (widget.sortKey == null) return;
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (mounted) {
|
||||
@@ -502,7 +516,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
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) {
|
||||
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),
|
||||
);
|
||||
unawaited(
|
||||
CodeStore.instance.addCode(code).then(
|
||||
CodeStore.instance.updateCode(widget.code,code).then(
|
||||
(value) => showToast(
|
||||
context,
|
||||
!currentlyPinned
|
||||
@@ -694,7 +708,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
final Code code = widget.code.copyWith(
|
||||
display: display.copyWith(trashed: true),
|
||||
);
|
||||
await CodeStore.instance.addCode(code);
|
||||
await CodeStore.instance.updateCode(widget.code, code);
|
||||
} catch (e) {
|
||||
logger.severe('Failed to trash code: ${e.toString()}');
|
||||
showGenericErrorDialog(context: context, error: e).ignore();
|
||||
@@ -718,7 +732,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
final Code code = widget.code.copyWith(
|
||||
display: display.copyWith(trashed: false),
|
||||
);
|
||||
await CodeStore.instance.addCode(code);
|
||||
await CodeStore.instance.updateCode(widget.code, code);
|
||||
} catch (e) {
|
||||
logger.severe('Failed to restore code: ${e.toString()}');
|
||||
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/view/common/tag_chip.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/store/code_display_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/dialog_widget.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/home_empty_state.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/scanner_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/utils/icon_utils.dart';
|
||||
import 'package:ente_auth/utils/dialog_util.dart';
|
||||
import 'package:ente_auth/utils/platform_util.dart';
|
||||
import 'package:ente_auth/utils/toast_util.dart';
|
||||
import 'package:ente_auth/utils/totp_util.dart';
|
||||
import 'package: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/ui/app_lock.dart';
|
||||
import 'package:ente_qr/ente_qr.dart';
|
||||
@@ -58,6 +64,7 @@ class HomePage extends BaseHomePage {
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
final _codeDisplayStore = CodeDisplayStore.instance;
|
||||
late final _settingsPage = SettingsPage(
|
||||
emailNotifier: UserService.instance.emailValueNotifier,
|
||||
scaffoldKey: scaffoldKey,
|
||||
@@ -94,6 +101,7 @@ class _HomePageState extends State<HomePage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_codeSortKey = PreferenceService.instance.codeSortKey();
|
||||
_textController.addListener(_applyFilteringAndRefresh);
|
||||
_loadCodes();
|
||||
@@ -119,6 +127,631 @@ class _HomePageState extends State<HomePage> {
|
||||
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) {
|
||||
if (event is KeyDownEvent) {
|
||||
_pressedKeys.add(event.logicalKey);
|
||||
@@ -153,6 +786,7 @@ class _HomePageState extends State<HomePage> {
|
||||
}
|
||||
|
||||
void _loadCodes() {
|
||||
debugPrint("[HOME_DEBUG] _loadCodes triggered!");
|
||||
CodeStore.instance.getAllCodes().then((codes) {
|
||||
_allCodes = codes;
|
||||
hasTrashedCodes = false;
|
||||
@@ -419,120 +1053,135 @@ class _HomePageState extends State<HomePage> {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
LockScreenSettings.instance
|
||||
.setLightMode(getEnteColorScheme(context).isLightTheme);
|
||||
final l10n = context.l10n;
|
||||
isCompactMode = PreferenceService.instance.isCompactMode();
|
||||
Widget build(BuildContext context) {
|
||||
LockScreenSettings.instance
|
||||
.setLightMode(getEnteColorScheme(context).isLightTheme);
|
||||
final l10n = context.l10n;
|
||||
isCompactMode = PreferenceService.instance.isCompactMode();
|
||||
|
||||
return PopScope(
|
||||
onPopInvokedWithResult: (_, result) async {
|
||||
if (_isSettingsOpen) {
|
||||
scaffoldKey.currentState!.closeDrawer();
|
||||
return;
|
||||
} else if (!Platform.isAndroid) {
|
||||
Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
await MoveToBackground.moveTaskToBack();
|
||||
},
|
||||
canPop: false,
|
||||
child: Scaffold(
|
||||
key: scaffoldKey,
|
||||
drawerEnableOpenDragGesture: !Platform.isAndroid,
|
||||
drawer: Drawer(
|
||||
width: 428,
|
||||
child: _settingsPage,
|
||||
),
|
||||
onDrawerChanged: (isOpened) => _isSettingsOpen = isOpened,
|
||||
body: SafeArea(
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: _codeDisplayStore.isSelectionModeActive,
|
||||
builder: (context, isSelecting, child) {
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (_, result) async {
|
||||
if (isSelecting) {
|
||||
_codeDisplayStore.clearSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isSettingsOpen) {
|
||||
scaffoldKey.currentState!.closeDrawer();
|
||||
return;
|
||||
} else if (!Platform.isAndroid) {
|
||||
Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
await MoveToBackground.moveTaskToBack();
|
||||
},
|
||||
child: Scaffold(
|
||||
key: scaffoldKey,
|
||||
drawerEnableOpenDragGesture: !Platform.isAndroid,
|
||||
drawer: Drawer(
|
||||
width: 428,
|
||||
child: _settingsPage,
|
||||
),
|
||||
onDrawerChanged: (isOpened) => _isSettingsOpen = isOpened,
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
return _getBody();
|
||||
},
|
||||
),
|
||||
),
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: AppBar(
|
||||
title: !_showSearchBox
|
||||
? const Text('Ente Auth', style: brandStyleMedium)
|
||||
: TextField(
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
autofocus: _autoFocusSearch,
|
||||
controller: _textController,
|
||||
onChanged: (val) {
|
||||
_searchText = val;
|
||||
_applyFilteringAndRefresh();
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.searchHint,
|
||||
border: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
),
|
||||
bottomNavigationBar: isSelecting ? _buildSelectionActionBar() : null,
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: !_showSearchBox
|
||||
? const Text('Ente Auth', style: brandStyleMedium)
|
||||
: TextField(
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
autofocus: _autoFocusSearch,
|
||||
controller: _textController,
|
||||
onChanged: (val) {
|
||||
_searchText = val;
|
||||
_applyFilteringAndRefresh();
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.searchHint,
|
||||
border: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
),
|
||||
focusNode: searchBoxFocusNode,
|
||||
),
|
||||
focusNode: searchBoxFocusNode,
|
||||
),
|
||||
centerTitle: PlatformUtil.isDesktop() ? false : true,
|
||||
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,
|
||||
centerTitle: PlatformUtil.isDesktop() ? false : true,
|
||||
actions: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
onPressed: () async {
|
||||
await navigateToLockScreen();
|
||||
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),
|
||||
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)
|
||||
: 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();
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
floatingActionButton: isSelecting
|
||||
? null
|
||||
: (!_hasLoaded ||
|
||||
(_allCodes?.isEmpty ?? true) ||
|
||||
!PreferenceService.instance.hasShownCoachMark()
|
||||
? null
|
||||
: _getFab()),
|
||||
),
|
||||
floatingActionButton: !_hasLoaded ||
|
||||
(_allCodes?.isEmpty ?? true) ||
|
||||
!PreferenceService.instance.hasShownCoachMark()
|
||||
? null
|
||||
: _getFab(),
|
||||
),
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getBody() {
|
||||
final l10n = context.l10n;
|
||||
@@ -570,6 +1219,7 @@ class _HomePageState extends State<HomePage> {
|
||||
? TagChipState.selected
|
||||
: TagChipState.unselected,
|
||||
onTap: () {
|
||||
_codeDisplayStore.clearSelection();
|
||||
selectedTag = "";
|
||||
_isTrashOpen = false;
|
||||
|
||||
@@ -586,6 +1236,7 @@ class _HomePageState extends State<HomePage> {
|
||||
? TagChipState.selected
|
||||
: TagChipState.unselected,
|
||||
onTap: () {
|
||||
_codeDisplayStore.clearSelection();
|
||||
selectedTag = "";
|
||||
_isTrashOpen = !_isTrashOpen;
|
||||
setState(() {});
|
||||
@@ -603,6 +1254,7 @@ class _HomePageState extends State<HomePage> {
|
||||
? TagChipState.selected
|
||||
: TagChipState.unselected,
|
||||
onTap: () {
|
||||
_codeDisplayStore.clearSelection();
|
||||
_isTrashOpen = false;
|
||||
if (selectedTag == tags[customTagIndex]) {
|
||||
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/export_widget.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/navigation_util.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) {
|
||||
final l10n = context.l10n;
|
||||
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,
|
||||
]);
|
||||
return Column(
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import 'dart:io';
|
||||
import 'package:ente_auth/l10n/l10n.dart';
|
||||
import 'package:ente_auth/models/code.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/ui/components/buttons/button_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 == ButtonAction.first) {
|
||||
await _pickEnteJsonFile(context);
|
||||
} else {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +57,9 @@ Future<void> _decryptExportData(
|
||||
final l10n = context.l10n;
|
||||
bool isPasswordIncorrect = false;
|
||||
int? importedCodeCount;
|
||||
|
||||
bool importHasRun = false;
|
||||
|
||||
await showTextInputDialog(
|
||||
context,
|
||||
title: l10n.passwordForDecryptingExport,
|
||||
@@ -67,6 +69,11 @@ Future<void> _decryptExportData(
|
||||
alwaysShowSuccessState: false,
|
||||
showOnlyLoadingState: true,
|
||||
onSubmit: (String password) async {
|
||||
if (importHasRun) {
|
||||
return;
|
||||
}
|
||||
importHasRun = true;
|
||||
|
||||
if (password.isEmpty) {
|
||||
showToast(context, l10n.passwordEmptyError);
|
||||
Future.delayed(const Duration(seconds: 0), () {
|
||||
@@ -78,6 +85,7 @@ Future<void> _decryptExportData(
|
||||
final progressDialog = createProgressDialog(context, l10n.pleaseWait);
|
||||
try {
|
||||
await progressDialog.show();
|
||||
|
||||
final derivedKey = await CryptoUtil.deriveKey(
|
||||
utf8.encode(password),
|
||||
CryptoUtil.base642bin(enteAuthExport.kdfParams.salt),
|
||||
@@ -85,7 +93,6 @@ Future<void> _decryptExportData(
|
||||
enteAuthExport.kdfParams.opsLimit,
|
||||
);
|
||||
Uint8List? decryptedContent;
|
||||
// Encrypt the key with this derived key
|
||||
try {
|
||||
decryptedContent = await CryptoUtil.decryptData(
|
||||
CryptoUtil.base642bin(enteAuthExport.encryptedData),
|
||||
@@ -99,27 +106,62 @@ Future<void> _decryptExportData(
|
||||
}
|
||||
if (isPasswordIncorrect) {
|
||||
await progressDialog.hide();
|
||||
|
||||
Future.delayed(const Duration(seconds: 0), () {
|
||||
_decryptExportData(context, enteAuthExport, password: password);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
String content = utf8.decode(decryptedContent!);
|
||||
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 {
|
||||
parsedCodes.add(Code.fromOTPAuthUrl(code));
|
||||
String otpUrl = jsonDecode(line);
|
||||
parsedCodes.add(Code.fromOTPAuthUrl(otpUrl));
|
||||
} catch (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();
|
||||
} catch (e, s) {
|
||||
await progressDialog.hide();
|
||||
@@ -153,4 +195,4 @@ Future<void> _pickEnteJsonFile(BuildContext context) async {
|
||||
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 isPasswordInput = false,
|
||||
bool useRootNavigator = false,
|
||||
VoidCallback? onCancel,
|
||||
}) {
|
||||
return showDialog(
|
||||
barrierColor: backdropFaintDark,
|
||||
|
||||
@@ -1153,9 +1153,9 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "91e4d1a9c55b28bf93425d1f12faf410efc1e48d"
|
||||
resolved-ref: "91e4d1a9c55b28bf93425d1f12faf410efc1e48d"
|
||||
url: "https://github.com/Sayegh7/move_to_background"
|
||||
ref: v2-only
|
||||
resolved-ref: "0cdfeed654d79636eff0c57110f3f6ad5801ba2f"
|
||||
url: "https://github.com/ente-io/move_to_background.git"
|
||||
source: git
|
||||
version: "1.0.2"
|
||||
native_dio_adapter:
|
||||
|
||||
@@ -95,10 +95,10 @@ dependencies:
|
||||
local_auth_darwin: ^1.2.2
|
||||
logging: ^1.0.1
|
||||
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:
|
||||
url: https://github.com/Sayegh7/move_to_background
|
||||
ref: 91e4d1a9c55b28bf93425d1f12faf410efc1e48d
|
||||
url: https://github.com/ente-io/move_to_background.git
|
||||
ref: v2-only
|
||||
native_dio_adapter: ^1.4.0
|
||||
otp: ^3.1.1
|
||||
package_info_plus: ^8.0.2
|
||||
|
||||
Reference in New Issue
Block a user