Compare commits
31 Commits
faces_grow
...
info
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
834bee3f46 | ||
|
|
49c90a802a | ||
|
|
8b2db5e576 | ||
|
|
b5d4839e04 | ||
|
|
ac57097eb4 | ||
|
|
4e08e38bf6 | ||
|
|
a7d3cf4178 | ||
|
|
c63dfc36e9 | ||
|
|
2985503254 | ||
|
|
9be023d68a | ||
|
|
6a6e1b3c47 | ||
|
|
7516363715 | ||
|
|
2b76b71db8 | ||
|
|
c32a70fb25 | ||
|
|
4098c1a072 | ||
|
|
972be1f41e | ||
|
|
3acb2136d0 | ||
|
|
eba729625f | ||
|
|
a477742cd0 | ||
|
|
c974bde11c | ||
|
|
ecc654bae0 | ||
|
|
201ef88305 | ||
|
|
742035d7cc | ||
|
|
8f29d5aa19 | ||
|
|
8a4e76fb6f | ||
|
|
c03eaf83aa | ||
|
|
378878538d | ||
|
|
01c3d6b105 | ||
|
|
c6f5c68f1e | ||
|
|
d0c8925ff3 | ||
|
|
d6c84421ce |
@@ -48,7 +48,11 @@ See [docs/](docs/README.md) for how to edit these documents.
|
||||
|
||||
## Code contributions
|
||||
|
||||
If you'd like to contribute code, it is best to start small. Consider some well-scoped changes, say like adding more [custom icons to auth](mobile/apps/auth/docs/adding-icons.md), or fixing a specific bug.
|
||||
If you'd like to contribute code, it is best to start small. Consider some well-scoped changes, say like adding more [custom icons to auth](mobile/apps/auth/docs/adding-icons.md), or fixing a specific bug. There is a (possibly outdated) list of tasks with the ["help wanted" or "good first issue"](<https://github.com/ente-io/ente/issues?q=state%3Aopen%20(label%3A%22good%20first%20issue%22%20OR%20label%3A%22help%20wanted%22%20)>) label too.
|
||||
|
||||
If you use any form of AI assistance, please include a co-author attribution in the commit for transparency.
|
||||
|
||||
In your PR, please include before / after screenshots, and clearly indicate the tests that you performed.
|
||||
|
||||
Code that changes the behaviour of the product might not get merged, at least not initially. The PR can serve as a discussion bed, but you might find it easier to just start a discussion instead, or post your perspective in the (likely) existing thread about the behaviour change or new feature you wish for.
|
||||
|
||||
|
||||
@@ -349,5 +349,52 @@
|
||||
"mastodon": "Mastodon",
|
||||
"matrix": "Matrix",
|
||||
"discord": "Discord",
|
||||
"reddit": "Reddit"
|
||||
"reddit": "Reddit",
|
||||
"information": "Information",
|
||||
"saveInformation": "Save information",
|
||||
"informationDescription": "Save important information that can be shared and passed down to loved ones.",
|
||||
"personalNote": "Personal note",
|
||||
"personalNoteDescription": "Save important notes or thoughts",
|
||||
"physicalRecords": "Physical records",
|
||||
"physicalRecordsDescription": "Save the real-world locations of important items",
|
||||
"accountCredentials": "Account credentials",
|
||||
"accountCredentialsDescription": "Securely store login details for important accounts",
|
||||
"emergencyContact": "Emergency contact",
|
||||
"emergencyContactDescription": "Save details of people to contact in emergencies",
|
||||
"noteName": "Title",
|
||||
"noteNameHint": "Give your note a meaningful title",
|
||||
"noteContent": "Content",
|
||||
"noteContentHint": "Write down important thoughts, instructions, or memories you want to preserve",
|
||||
"recordName": "Record name",
|
||||
"recordNameHint": "Name of the real-world item",
|
||||
"recordLocation": "Location",
|
||||
"recordLocationHint": "Where can this item be found? (e.g., 'Safety deposit box at First Bank, Box #123')",
|
||||
"recordNotes": "Notes",
|
||||
"recordNotesHint": "Any additional details about accessing or understanding this record",
|
||||
"credentialName": "Account name",
|
||||
"credentialNameHint": "Name of the service or account",
|
||||
"username": "Username",
|
||||
"usernameHint": "Login username or email address",
|
||||
"password": "Password",
|
||||
"passwordHint": "Account password",
|
||||
"credentialNotes": "Additional notes",
|
||||
"credentialNotesHint": "Recovery methods, security questions, or other important details",
|
||||
"contactName": "Contact name",
|
||||
"contactNameHint": "Full name of the emergency contact",
|
||||
"contactDetails": "Contact details",
|
||||
"contactDetailsHint": "Phone number, email, or other contact information",
|
||||
"contactNotes": "Message for contact",
|
||||
"contactNotesHint": "Important information to share with this person when they are contacted",
|
||||
"saveRecord": "Save",
|
||||
"recordSavedSuccessfully": "Record saved successfully",
|
||||
"failedToSaveRecord": "Failed to save record",
|
||||
"pleaseEnterNoteName": "Please enter a title",
|
||||
"pleaseEnterNoteContent": "Please enter content",
|
||||
"pleaseEnterRecordName": "Please enter a record name",
|
||||
"pleaseEnterLocation": "Please enter a location",
|
||||
"pleaseEnterAccountName": "Please enter an account name",
|
||||
"pleaseEnterUsername": "Please enter a username",
|
||||
"pleaseEnterPassword": "Please enter a password",
|
||||
"pleaseEnterContactName": "Please enter a contact name",
|
||||
"pleaseEnterContactDetails": "Please enter contact details"
|
||||
}
|
||||
|
||||
@@ -1017,6 +1017,288 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Reddit'**
|
||||
String get reddit;
|
||||
|
||||
/// No description provided for @information.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Information'**
|
||||
String get information;
|
||||
|
||||
/// No description provided for @saveInformation.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save information'**
|
||||
String get saveInformation;
|
||||
|
||||
/// No description provided for @informationDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save important information that can be shared and passed down to loved ones.'**
|
||||
String get informationDescription;
|
||||
|
||||
/// No description provided for @personalNote.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Personal note'**
|
||||
String get personalNote;
|
||||
|
||||
/// No description provided for @personalNoteDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save important notes or thoughts'**
|
||||
String get personalNoteDescription;
|
||||
|
||||
/// No description provided for @physicalRecords.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Physical records'**
|
||||
String get physicalRecords;
|
||||
|
||||
/// No description provided for @physicalRecordsDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save the real-world locations of important items'**
|
||||
String get physicalRecordsDescription;
|
||||
|
||||
/// No description provided for @accountCredentials.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Account credentials'**
|
||||
String get accountCredentials;
|
||||
|
||||
/// No description provided for @accountCredentialsDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Securely store login details for important accounts'**
|
||||
String get accountCredentialsDescription;
|
||||
|
||||
/// No description provided for @emergencyContact.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Emergency contact'**
|
||||
String get emergencyContact;
|
||||
|
||||
/// No description provided for @emergencyContactDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save details of people to contact in emergencies'**
|
||||
String get emergencyContactDescription;
|
||||
|
||||
/// No description provided for @noteName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Title'**
|
||||
String get noteName;
|
||||
|
||||
/// No description provided for @noteNameHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Give your note a meaningful title'**
|
||||
String get noteNameHint;
|
||||
|
||||
/// No description provided for @noteContent.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Content'**
|
||||
String get noteContent;
|
||||
|
||||
/// No description provided for @noteContentHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Write down important thoughts, instructions, or memories you want to preserve'**
|
||||
String get noteContentHint;
|
||||
|
||||
/// No description provided for @recordName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Record name'**
|
||||
String get recordName;
|
||||
|
||||
/// No description provided for @recordNameHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Name of the real-world item'**
|
||||
String get recordNameHint;
|
||||
|
||||
/// No description provided for @recordLocation.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Location'**
|
||||
String get recordLocation;
|
||||
|
||||
/// No description provided for @recordLocationHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Where can this item be found? (e.g., \'Safety deposit box at First Bank, Box #123\')'**
|
||||
String get recordLocationHint;
|
||||
|
||||
/// No description provided for @recordNotes.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Notes'**
|
||||
String get recordNotes;
|
||||
|
||||
/// No description provided for @recordNotesHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Any additional details about accessing or understanding this record'**
|
||||
String get recordNotesHint;
|
||||
|
||||
/// No description provided for @credentialName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Account name'**
|
||||
String get credentialName;
|
||||
|
||||
/// No description provided for @credentialNameHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Name of the service or account'**
|
||||
String get credentialNameHint;
|
||||
|
||||
/// No description provided for @username.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Username'**
|
||||
String get username;
|
||||
|
||||
/// No description provided for @usernameHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Login username or email address'**
|
||||
String get usernameHint;
|
||||
|
||||
/// No description provided for @password.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Password'**
|
||||
String get password;
|
||||
|
||||
/// No description provided for @passwordHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Account password'**
|
||||
String get passwordHint;
|
||||
|
||||
/// No description provided for @credentialNotes.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Additional notes'**
|
||||
String get credentialNotes;
|
||||
|
||||
/// No description provided for @credentialNotesHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Recovery methods, security questions, or other important details'**
|
||||
String get credentialNotesHint;
|
||||
|
||||
/// No description provided for @contactName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Contact name'**
|
||||
String get contactName;
|
||||
|
||||
/// No description provided for @contactNameHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Full name of the emergency contact'**
|
||||
String get contactNameHint;
|
||||
|
||||
/// No description provided for @contactDetails.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Contact details'**
|
||||
String get contactDetails;
|
||||
|
||||
/// No description provided for @contactDetailsHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Phone number, email, or other contact information'**
|
||||
String get contactDetailsHint;
|
||||
|
||||
/// No description provided for @contactNotes.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Message for contact'**
|
||||
String get contactNotes;
|
||||
|
||||
/// No description provided for @contactNotesHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Important information to share with this person when they are contacted'**
|
||||
String get contactNotesHint;
|
||||
|
||||
/// No description provided for @saveRecord.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save'**
|
||||
String get saveRecord;
|
||||
|
||||
/// No description provided for @recordSavedSuccessfully.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Record saved successfully'**
|
||||
String get recordSavedSuccessfully;
|
||||
|
||||
/// No description provided for @failedToSaveRecord.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to save record'**
|
||||
String get failedToSaveRecord;
|
||||
|
||||
/// No description provided for @pleaseEnterNoteName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Please enter a title'**
|
||||
String get pleaseEnterNoteName;
|
||||
|
||||
/// No description provided for @pleaseEnterNoteContent.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Please enter content'**
|
||||
String get pleaseEnterNoteContent;
|
||||
|
||||
/// No description provided for @pleaseEnterRecordName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Please enter a record name'**
|
||||
String get pleaseEnterRecordName;
|
||||
|
||||
/// No description provided for @pleaseEnterLocation.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Please enter a location'**
|
||||
String get pleaseEnterLocation;
|
||||
|
||||
/// No description provided for @pleaseEnterAccountName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Please enter an account name'**
|
||||
String get pleaseEnterAccountName;
|
||||
|
||||
/// No description provided for @pleaseEnterUsername.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Please enter a username'**
|
||||
String get pleaseEnterUsername;
|
||||
|
||||
/// No description provided for @pleaseEnterPassword.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Please enter a password'**
|
||||
String get pleaseEnterPassword;
|
||||
|
||||
/// No description provided for @pleaseEnterContactName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Please enter a contact name'**
|
||||
String get pleaseEnterContactName;
|
||||
|
||||
/// No description provided for @pleaseEnterContactDetails.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Please enter contact details'**
|
||||
String get pleaseEnterContactDetails;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -534,4 +534,155 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get reddit => 'Reddit';
|
||||
|
||||
@override
|
||||
String get information => 'Information';
|
||||
|
||||
@override
|
||||
String get saveInformation => 'Save information';
|
||||
|
||||
@override
|
||||
String get informationDescription =>
|
||||
'Save important information that can be shared and passed down to loved ones.';
|
||||
|
||||
@override
|
||||
String get personalNote => 'Personal note';
|
||||
|
||||
@override
|
||||
String get personalNoteDescription => 'Save important notes or thoughts';
|
||||
|
||||
@override
|
||||
String get physicalRecords => 'Physical records';
|
||||
|
||||
@override
|
||||
String get physicalRecordsDescription =>
|
||||
'Save the real-world locations of important items';
|
||||
|
||||
@override
|
||||
String get accountCredentials => 'Account credentials';
|
||||
|
||||
@override
|
||||
String get accountCredentialsDescription =>
|
||||
'Securely store login details for important accounts';
|
||||
|
||||
@override
|
||||
String get emergencyContact => 'Emergency contact';
|
||||
|
||||
@override
|
||||
String get emergencyContactDescription =>
|
||||
'Save details of people to contact in emergencies';
|
||||
|
||||
@override
|
||||
String get noteName => 'Title';
|
||||
|
||||
@override
|
||||
String get noteNameHint => 'Give your note a meaningful title';
|
||||
|
||||
@override
|
||||
String get noteContent => 'Content';
|
||||
|
||||
@override
|
||||
String get noteContentHint =>
|
||||
'Write down important thoughts, instructions, or memories you want to preserve';
|
||||
|
||||
@override
|
||||
String get recordName => 'Record name';
|
||||
|
||||
@override
|
||||
String get recordNameHint => 'Name of the real-world item';
|
||||
|
||||
@override
|
||||
String get recordLocation => 'Location';
|
||||
|
||||
@override
|
||||
String get recordLocationHint =>
|
||||
'Where can this item be found? (e.g., \'Safety deposit box at First Bank, Box #123\')';
|
||||
|
||||
@override
|
||||
String get recordNotes => 'Notes';
|
||||
|
||||
@override
|
||||
String get recordNotesHint =>
|
||||
'Any additional details about accessing or understanding this record';
|
||||
|
||||
@override
|
||||
String get credentialName => 'Account name';
|
||||
|
||||
@override
|
||||
String get credentialNameHint => 'Name of the service or account';
|
||||
|
||||
@override
|
||||
String get username => 'Username';
|
||||
|
||||
@override
|
||||
String get usernameHint => 'Login username or email address';
|
||||
|
||||
@override
|
||||
String get password => 'Password';
|
||||
|
||||
@override
|
||||
String get passwordHint => 'Account password';
|
||||
|
||||
@override
|
||||
String get credentialNotes => 'Additional notes';
|
||||
|
||||
@override
|
||||
String get credentialNotesHint =>
|
||||
'Recovery methods, security questions, or other important details';
|
||||
|
||||
@override
|
||||
String get contactName => 'Contact name';
|
||||
|
||||
@override
|
||||
String get contactNameHint => 'Full name of the emergency contact';
|
||||
|
||||
@override
|
||||
String get contactDetails => 'Contact details';
|
||||
|
||||
@override
|
||||
String get contactDetailsHint =>
|
||||
'Phone number, email, or other contact information';
|
||||
|
||||
@override
|
||||
String get contactNotes => 'Message for contact';
|
||||
|
||||
@override
|
||||
String get contactNotesHint =>
|
||||
'Important information to share with this person when they are contacted';
|
||||
|
||||
@override
|
||||
String get saveRecord => 'Save';
|
||||
|
||||
@override
|
||||
String get recordSavedSuccessfully => 'Record saved successfully';
|
||||
|
||||
@override
|
||||
String get failedToSaveRecord => 'Failed to save record';
|
||||
|
||||
@override
|
||||
String get pleaseEnterNoteName => 'Please enter a title';
|
||||
|
||||
@override
|
||||
String get pleaseEnterNoteContent => 'Please enter content';
|
||||
|
||||
@override
|
||||
String get pleaseEnterRecordName => 'Please enter a record name';
|
||||
|
||||
@override
|
||||
String get pleaseEnterLocation => 'Please enter a location';
|
||||
|
||||
@override
|
||||
String get pleaseEnterAccountName => 'Please enter an account name';
|
||||
|
||||
@override
|
||||
String get pleaseEnterUsername => 'Please enter a username';
|
||||
|
||||
@override
|
||||
String get pleaseEnterPassword => 'Please enter a password';
|
||||
|
||||
@override
|
||||
String get pleaseEnterContactName => 'Please enter a contact name';
|
||||
|
||||
@override
|
||||
String get pleaseEnterContactDetails => 'Please enter contact details';
|
||||
}
|
||||
|
||||
54
mobile/apps/locker/lib/models/file_type.dart
Normal file
54
mobile/apps/locker/lib/models/file_type.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
enum FileType {
|
||||
image,
|
||||
video,
|
||||
livePhoto,
|
||||
other,
|
||||
info, // New type for information files
|
||||
}
|
||||
|
||||
int getInt(FileType fileType) {
|
||||
switch (fileType) {
|
||||
case FileType.image:
|
||||
return 0;
|
||||
case FileType.video:
|
||||
return 1;
|
||||
case FileType.livePhoto:
|
||||
return 2;
|
||||
case FileType.other:
|
||||
return 3;
|
||||
case FileType.info:
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
|
||||
FileType getFileType(int fileType) {
|
||||
switch (fileType) {
|
||||
case 0:
|
||||
return FileType.image;
|
||||
case 1:
|
||||
return FileType.video;
|
||||
case 2:
|
||||
return FileType.livePhoto;
|
||||
case 3:
|
||||
return FileType.other;
|
||||
case 4:
|
||||
return FileType.info;
|
||||
default:
|
||||
return FileType.other;
|
||||
}
|
||||
}
|
||||
|
||||
String getHumanReadableString(FileType fileType) {
|
||||
switch (fileType) {
|
||||
case FileType.image:
|
||||
return 'Images';
|
||||
case FileType.video:
|
||||
return 'Videos';
|
||||
case FileType.livePhoto:
|
||||
return 'Live Photos';
|
||||
case FileType.other:
|
||||
return 'Other Files';
|
||||
case FileType.info:
|
||||
return 'Information';
|
||||
}
|
||||
}
|
||||
244
mobile/apps/locker/lib/models/info/info_item.dart
Normal file
244
mobile/apps/locker/lib/models/info/info_item.dart
Normal file
@@ -0,0 +1,244 @@
|
||||
import 'dart:convert';
|
||||
|
||||
// Enum for different information types
|
||||
enum InfoType {
|
||||
note,
|
||||
physicalRecord,
|
||||
accountCredential,
|
||||
emergencyContact,
|
||||
}
|
||||
|
||||
// Extension to convert enum to string and vice versa
|
||||
extension InfoTypeExtension on InfoType {
|
||||
String get value {
|
||||
switch (this) {
|
||||
case InfoType.note:
|
||||
return 'note';
|
||||
case InfoType.physicalRecord:
|
||||
return 'physical-record';
|
||||
case InfoType.accountCredential:
|
||||
return 'account-credential';
|
||||
case InfoType.emergencyContact:
|
||||
return 'emergency-contact';
|
||||
}
|
||||
}
|
||||
|
||||
static InfoType fromString(String value) {
|
||||
switch (value) {
|
||||
case 'note':
|
||||
return InfoType.note;
|
||||
case 'physical-record':
|
||||
return InfoType.physicalRecord;
|
||||
case 'account-credential':
|
||||
return InfoType.accountCredential;
|
||||
case 'emergency-contact':
|
||||
return InfoType.emergencyContact;
|
||||
default:
|
||||
throw ArgumentError('Unknown InfoType: $value');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Base class for all information data
|
||||
abstract class InfoData {
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
static InfoData fromJson(InfoType type, Map<String, dynamic> json) {
|
||||
switch (type) {
|
||||
case InfoType.note:
|
||||
return PersonalNoteData.fromJson(json);
|
||||
case InfoType.physicalRecord:
|
||||
return PhysicalRecordData.fromJson(json);
|
||||
case InfoType.accountCredential:
|
||||
return AccountCredentialData.fromJson(json);
|
||||
case InfoType.emergencyContact:
|
||||
return EmergencyContactData.fromJson(json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Personal Note Data Model
|
||||
class PersonalNoteData extends InfoData {
|
||||
final String title;
|
||||
final String content;
|
||||
|
||||
PersonalNoteData({
|
||||
required this.title,
|
||||
required this.content,
|
||||
});
|
||||
|
||||
factory PersonalNoteData.fromJson(Map<String, dynamic> json) {
|
||||
return PersonalNoteData(
|
||||
title: json['title'] ?? '',
|
||||
content: json['content'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'title': title,
|
||||
'content': content,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Physical Record Data Model
|
||||
class PhysicalRecordData extends InfoData {
|
||||
final String name;
|
||||
final String location;
|
||||
final String? notes;
|
||||
|
||||
PhysicalRecordData({
|
||||
required this.name,
|
||||
required this.location,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
factory PhysicalRecordData.fromJson(Map<String, dynamic> json) {
|
||||
return PhysicalRecordData(
|
||||
name: json['name'] ?? '',
|
||||
location: json['location'] ?? '',
|
||||
notes: json['notes'],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'name': name,
|
||||
'location': location,
|
||||
if (notes != null && notes!.isNotEmpty) 'notes': notes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Account Credential Data Model
|
||||
class AccountCredentialData extends InfoData {
|
||||
final String name;
|
||||
final String username;
|
||||
final String password;
|
||||
final String? notes;
|
||||
|
||||
AccountCredentialData({
|
||||
required this.name,
|
||||
required this.username,
|
||||
required this.password,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
factory AccountCredentialData.fromJson(Map<String, dynamic> json) {
|
||||
return AccountCredentialData(
|
||||
name: json['name'] ?? '',
|
||||
username: json['username'] ?? '',
|
||||
password: json['password'] ?? '',
|
||||
notes: json['notes'],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'name': name,
|
||||
'username': username,
|
||||
'password': password,
|
||||
if (notes != null && notes!.isNotEmpty) 'notes': notes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Emergency Contact Data Model
|
||||
class EmergencyContactData extends InfoData {
|
||||
final String name;
|
||||
final String contactDetails;
|
||||
final String? notes;
|
||||
|
||||
EmergencyContactData({
|
||||
required this.name,
|
||||
required this.contactDetails,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
factory EmergencyContactData.fromJson(Map<String, dynamic> json) {
|
||||
return EmergencyContactData(
|
||||
name: json['name'] ?? '',
|
||||
contactDetails: json['contactDetails'] ?? '',
|
||||
notes: json['notes'],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'name': name,
|
||||
'contactDetails': contactDetails,
|
||||
if (notes != null && notes!.isNotEmpty) 'notes': notes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Main Information Item wrapper
|
||||
class InfoItem {
|
||||
final InfoType type;
|
||||
final InfoData data;
|
||||
final DateTime createdAt;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
InfoItem({
|
||||
required this.type,
|
||||
required this.data,
|
||||
required this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
factory InfoItem.fromJson(Map<String, dynamic> json) {
|
||||
final type = InfoTypeExtension.fromString(json['type']);
|
||||
final data = InfoData.fromJson(type, json['data']);
|
||||
|
||||
return InfoItem(
|
||||
type: type,
|
||||
data: data,
|
||||
createdAt: DateTime.parse(json['createdAt']),
|
||||
updatedAt:
|
||||
json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.value,
|
||||
'data': data.toJson(),
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
String toJsonString() => jsonEncode(toJson());
|
||||
|
||||
static InfoItem fromJsonString(String jsonString) {
|
||||
return InfoItem.fromJson(jsonDecode(jsonString));
|
||||
}
|
||||
|
||||
// Create a copy with updated data
|
||||
InfoItem copyWith({
|
||||
InfoType? type,
|
||||
InfoData? data,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return InfoItem(
|
||||
type: type ?? this.type,
|
||||
data: data ?? this.data,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
// Update with new data and timestamp
|
||||
InfoItem update(InfoData newData) {
|
||||
return copyWith(
|
||||
data: newData,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:locker/models/file_type.dart';
|
||||
import 'package:locker/services/files/download/file_url.dart';
|
||||
import 'package:locker/services/files/sync/models/file_magic.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -23,6 +24,7 @@ class EnteFile {
|
||||
String? thumbnailDecryptionHeader;
|
||||
String? metadataDecryptionHeader;
|
||||
int? fileSize;
|
||||
FileType? fileType;
|
||||
|
||||
String? mMdEncodedJson;
|
||||
int mMdVersion = 0;
|
||||
|
||||
@@ -17,6 +17,7 @@ const motionVideoIndexKey = "mvi";
|
||||
const noThumbKey = "noThumb";
|
||||
const dateTimeKey = 'dateTime';
|
||||
const offsetTimeKey = 'offsetTime';
|
||||
const infoKey = 'info';
|
||||
|
||||
class MagicMetadata {
|
||||
// 0 -> visible
|
||||
@@ -74,6 +75,11 @@ class PubMagicMetadata {
|
||||
// 1 -> panorama
|
||||
int? mediaType;
|
||||
|
||||
// JSON containing information data for info files
|
||||
// Contains type (note, physical-record, account-credential, emergency-contact)
|
||||
// and data (the actual information content)
|
||||
Map<String, dynamic>? info;
|
||||
|
||||
PubMagicMetadata({
|
||||
this.editedTime,
|
||||
this.editedName,
|
||||
@@ -89,6 +95,7 @@ class PubMagicMetadata {
|
||||
this.dateTime,
|
||||
this.offsetTime,
|
||||
this.sv,
|
||||
this.info,
|
||||
});
|
||||
|
||||
factory PubMagicMetadata.fromEncodedJson(String encodedJson) =>
|
||||
@@ -114,6 +121,7 @@ class PubMagicMetadata {
|
||||
dateTime: map[dateTimeKey],
|
||||
offsetTime: map[offsetTimeKey],
|
||||
sv: safeParseInt(map[streamVersionKey], streamVersionKey),
|
||||
info: map[infoKey],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,66 @@ class FileUploader {
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
/// Special upload method for info files that contain only metadata
|
||||
Future<EnteFile> uploadInfoFile(
|
||||
EnteFile infoFile,
|
||||
Collection collection,
|
||||
) async {
|
||||
try {
|
||||
_logger.info('Starting upload of info file: ${infoFile.title}');
|
||||
|
||||
// Generate a file key for encryption
|
||||
final fileKey = CryptoUtil.generateKey();
|
||||
|
||||
// Create metadata for the info file
|
||||
final Map<String, dynamic> metadata = infoFile.metadata;
|
||||
final encryptedMetadataResult = await CryptoUtil.encryptData(
|
||||
utf8.encode(jsonEncode(metadata)),
|
||||
fileKey,
|
||||
);
|
||||
|
||||
final encryptedMetadata = CryptoUtil.bin2base64(
|
||||
encryptedMetadataResult.encryptedData!,
|
||||
);
|
||||
final metadataDecryptionHeader = CryptoUtil.bin2base64(
|
||||
encryptedMetadataResult.header!,
|
||||
);
|
||||
|
||||
// Encrypt the file key with collection key
|
||||
final encryptedFileKeyData = CryptoUtil.encryptSync(
|
||||
fileKey,
|
||||
CryptoHelper.instance.getCollectionKey(collection),
|
||||
);
|
||||
final encryptedKey =
|
||||
CryptoUtil.bin2base64(encryptedFileKeyData.encryptedData!);
|
||||
final keyDecryptionNonce =
|
||||
CryptoUtil.bin2base64(encryptedFileKeyData.nonce!);
|
||||
|
||||
final pubMetadataRequest = await getPubMetadataRequest(
|
||||
infoFile,
|
||||
{'info': infoFile.pubMagicMetadata.info},
|
||||
fileKey,
|
||||
);
|
||||
|
||||
// Upload as metadata-only file (no file content or thumbnail)
|
||||
final uploadedFile = await _uploadInfoFileMetadata(
|
||||
infoFile,
|
||||
collection.id,
|
||||
encryptedKey,
|
||||
keyDecryptionNonce,
|
||||
encryptedMetadata,
|
||||
metadataDecryptionHeader,
|
||||
pubMetadataRequest,
|
||||
);
|
||||
|
||||
_logger.info('Successfully uploaded info file: ${uploadedFile.title}');
|
||||
return uploadedFile;
|
||||
} catch (e, s) {
|
||||
_logger.severe('Failed to upload info file: ${infoFile.title}', e, s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
int getCurrentSessionUploadCount() {
|
||||
return _totalCountInUploadSession;
|
||||
}
|
||||
@@ -660,6 +720,43 @@ class FileUploader {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload method specifically for info files that don't require file content or thumbnails
|
||||
Future<EnteFile> _uploadInfoFileMetadata(
|
||||
EnteFile file,
|
||||
int collectionID,
|
||||
String encryptedKey,
|
||||
String keyDecryptionNonce,
|
||||
String encryptedMetadata,
|
||||
String metadataDecryptionHeader,
|
||||
MetadataRequest pubMetadata,
|
||||
) async {
|
||||
final request = {
|
||||
"collectionID": collectionID,
|
||||
"encryptedKey": encryptedKey,
|
||||
"keyDecryptionNonce": keyDecryptionNonce,
|
||||
"metadata": {
|
||||
"encryptedData": encryptedMetadata,
|
||||
"decryptionHeader": metadataDecryptionHeader,
|
||||
},
|
||||
"pubMagicMetadata": pubMetadata,
|
||||
};
|
||||
try {
|
||||
final response = await _enteDio.post("/files/meta", data: request);
|
||||
final data = response.data;
|
||||
file.uploadedFileID = data["id"];
|
||||
file.collectionID = collectionID;
|
||||
file.updationTime = data["updationTime"];
|
||||
file.ownerID = data["ownerID"];
|
||||
file.encryptedKey = encryptedKey;
|
||||
file.keyDecryptionNonce = keyDecryptionNonce;
|
||||
file.metadataDecryptionHeader = metadataDecryptionHeader;
|
||||
return file;
|
||||
} catch (e, s) {
|
||||
_logger.severe("Info file upload failed", e, s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FileUploadItem {
|
||||
|
||||
170
mobile/apps/locker/lib/services/info_file_service.dart
Normal file
170
mobile/apps/locker/lib/services/info_file_service.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
import 'package:locker/models/file_type.dart';
|
||||
import 'package:locker/models/info/info_item.dart';
|
||||
import 'package:locker/services/collections/models/collection.dart';
|
||||
import 'package:locker/services/files/sync/models/file.dart';
|
||||
import 'package:locker/services/files/sync/models/file_magic.dart';
|
||||
import 'package:locker/services/files/upload/file_upload_service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class InfoFileService {
|
||||
static final InfoFileService instance = InfoFileService._privateConstructor();
|
||||
InfoFileService._privateConstructor();
|
||||
|
||||
final _logger = Logger('InfoFileService');
|
||||
|
||||
/// Creates and uploads an info file
|
||||
Future<EnteFile> createAndUploadInfoFile({
|
||||
required InfoItem infoItem,
|
||||
required Collection collection,
|
||||
}) async {
|
||||
try {
|
||||
// Create EnteFile object directly without a physical file
|
||||
final enteFile = EnteFile();
|
||||
enteFile.fileType = FileType.info;
|
||||
enteFile.collectionID = collection.id;
|
||||
|
||||
// Set the title based on info type and data
|
||||
enteFile.title = _getInfoFileTitle(infoItem);
|
||||
|
||||
// Set creation and modification times
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
enteFile.creationTime = now;
|
||||
enteFile.modificationTime = now;
|
||||
|
||||
// Create public magic metadata with info data
|
||||
final pubMagicMetadata = PubMagicMetadata(
|
||||
info: {
|
||||
'type': infoItem.type.name,
|
||||
'data': infoItem.data.toJson(),
|
||||
},
|
||||
noThumb: true, // No thumbnail for info files
|
||||
);
|
||||
enteFile.pubMagicMetadata = pubMagicMetadata;
|
||||
|
||||
// Upload the file using the special info file upload method
|
||||
final uploadedFile = await _uploadInfoFile(enteFile, collection);
|
||||
|
||||
_logger.info('Successfully uploaded info file: ${uploadedFile.title}');
|
||||
return uploadedFile;
|
||||
} catch (e, s) {
|
||||
_logger.severe('Failed to create and upload info file', e, s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates an existing info file with new data
|
||||
Future<EnteFile> updateInfoFile({
|
||||
required EnteFile existingFile,
|
||||
required InfoItem updatedInfoItem,
|
||||
}) async {
|
||||
try {
|
||||
// Update the public magic metadata
|
||||
final updatedPubMagicMetadata = existingFile.pubMagicMetadata;
|
||||
updatedPubMagicMetadata.info = {
|
||||
'type': updatedInfoItem.type.name,
|
||||
'data': updatedInfoItem.data.toJson(),
|
||||
};
|
||||
|
||||
// Update the title
|
||||
final updatedTitle = _getInfoFileTitle(updatedInfoItem);
|
||||
updatedPubMagicMetadata.editedName = updatedTitle;
|
||||
updatedPubMagicMetadata.editedTime =
|
||||
DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
existingFile.pubMagicMetadata = updatedPubMagicMetadata;
|
||||
|
||||
// Update metadata on server
|
||||
// This would call the metadata update service
|
||||
// TODO: Implement metadata update and sync
|
||||
|
||||
_logger.info('Successfully updated info file: $updatedTitle');
|
||||
return existingFile;
|
||||
} catch (e, s) {
|
||||
_logger.severe('Failed to update info file', e, s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts info data from a file
|
||||
InfoItem? extractInfoFromFile(EnteFile file) {
|
||||
try {
|
||||
if (file.fileType != FileType.info ||
|
||||
file.pubMagicMetadata.info == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final infoData = file.pubMagicMetadata.info!;
|
||||
final typeString = infoData['type'] as String?;
|
||||
final data = infoData['data'] as Map<String, dynamic>?;
|
||||
|
||||
if (typeString == null || data == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final infoType = InfoType.values.firstWhere(
|
||||
(type) => type.name == typeString,
|
||||
orElse: () => InfoType.note,
|
||||
);
|
||||
|
||||
InfoData infoDataObj;
|
||||
switch (infoType) {
|
||||
case InfoType.note:
|
||||
infoDataObj = PersonalNoteData.fromJson(data);
|
||||
break;
|
||||
case InfoType.physicalRecord:
|
||||
infoDataObj = PhysicalRecordData.fromJson(data);
|
||||
break;
|
||||
case InfoType.accountCredential:
|
||||
infoDataObj = AccountCredentialData.fromJson(data);
|
||||
break;
|
||||
case InfoType.emergencyContact:
|
||||
infoDataObj = EmergencyContactData.fromJson(data);
|
||||
break;
|
||||
}
|
||||
|
||||
return InfoItem(
|
||||
type: infoType,
|
||||
data: infoDataObj,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
} catch (e, s) {
|
||||
_logger.severe('Failed to extract info from file', e, s);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if a file is an info file
|
||||
bool isInfoFile(EnteFile file) {
|
||||
return file.fileType == FileType.info && file.pubMagicMetadata.info != null;
|
||||
}
|
||||
|
||||
String _getInfoFileTitle(InfoItem infoItem) {
|
||||
switch (infoItem.type) {
|
||||
case InfoType.note:
|
||||
final noteData = infoItem.data as PersonalNoteData;
|
||||
return noteData.title.isNotEmpty ? noteData.title : 'Personal Note';
|
||||
case InfoType.physicalRecord:
|
||||
final recordData = infoItem.data as PhysicalRecordData;
|
||||
return recordData.name.isNotEmpty ? recordData.name : 'Physical Record';
|
||||
case InfoType.accountCredential:
|
||||
final credData = infoItem.data as AccountCredentialData;
|
||||
return credData.name.isNotEmpty
|
||||
? '${credData.name} Account'
|
||||
: 'Account Credential';
|
||||
case InfoType.emergencyContact:
|
||||
final contactData = infoItem.data as EmergencyContactData;
|
||||
return contactData.name.isNotEmpty
|
||||
? '${contactData.name} (Emergency Contact)'
|
||||
: 'Emergency Contact';
|
||||
}
|
||||
}
|
||||
|
||||
/// Special upload method for info files that don't require physical file content
|
||||
Future<EnteFile> _uploadInfoFile(
|
||||
EnteFile enteFile,
|
||||
Collection collection,
|
||||
) async {
|
||||
// Use the FileUploader's special method for info files
|
||||
return await FileUploader.instance.uploadInfoFile(enteFile, collection);
|
||||
}
|
||||
}
|
||||
224
mobile/apps/locker/lib/ui/components/form_text_input_widget.dart
Normal file
224
mobile/apps/locker/lib/ui/components/form_text_input_widget.dart
Normal file
@@ -0,0 +1,224 @@
|
||||
import 'package:ente_ui/components/text_input_widget.dart';
|
||||
import 'package:ente_ui/theme/ente_theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// A form-compatible wrapper that uses Ente UI TextInputWidget when possible,
|
||||
/// or falls back to custom implementation for advanced features
|
||||
class FormTextInputWidget extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final String labelText;
|
||||
final String? hintText;
|
||||
final String? Function(String?)? validator;
|
||||
final bool obscureText;
|
||||
final Widget? suffixIcon;
|
||||
final int? maxLines;
|
||||
final TextCapitalization textCapitalization;
|
||||
final TextInputType? keyboardType;
|
||||
final bool enabled;
|
||||
final bool autofocus;
|
||||
final int? maxLength;
|
||||
final bool showValidationErrors;
|
||||
|
||||
const FormTextInputWidget({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.labelText,
|
||||
this.hintText,
|
||||
this.validator,
|
||||
this.obscureText = false,
|
||||
this.suffixIcon,
|
||||
this.maxLines = 1,
|
||||
this.textCapitalization = TextCapitalization.none,
|
||||
this.keyboardType,
|
||||
this.enabled = true,
|
||||
this.autofocus = false,
|
||||
this.maxLength,
|
||||
this.showValidationErrors = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FormTextInputWidget> createState() => _FormTextInputWidgetState();
|
||||
}
|
||||
|
||||
class _FormTextInputWidgetState extends State<FormTextInputWidget> {
|
||||
final GlobalKey<FormFieldState> _formFieldKey = GlobalKey<FormFieldState>();
|
||||
String? _errorText;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_onTextChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onTextChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTextChanged() {
|
||||
if (_errorText != null) {
|
||||
setState(() {
|
||||
_errorText = null;
|
||||
});
|
||||
}
|
||||
// Only validate if we should show validation errors
|
||||
if (widget.showValidationErrors) {
|
||||
_formFieldKey.currentState?.validate();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we can use the UI package's TextInputWidget
|
||||
bool get _canUseTextInputWidget {
|
||||
return widget.suffixIcon == null &&
|
||||
(widget.maxLines ?? 1) == 1 &&
|
||||
widget.enabled;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
final textTheme = getEnteTextTheme(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_canUseTextInputWidget) ...[
|
||||
// Use the UI package's TextInputWidget for simple cases
|
||||
TextInputWidget(
|
||||
label: widget.labelText,
|
||||
hintText: widget.hintText,
|
||||
initialValue: widget.controller.text,
|
||||
isPasswordInput: widget.obscureText,
|
||||
textCapitalization: widget.textCapitalization,
|
||||
autoFocus: widget.autofocus,
|
||||
maxLength: widget.maxLength,
|
||||
shouldSurfaceExecutionStates: false,
|
||||
onChange: (value) {
|
||||
if (widget.controller.text != value) {
|
||||
widget.controller.text = value;
|
||||
}
|
||||
},
|
||||
),
|
||||
] else ...[
|
||||
// Custom implementation for advanced features
|
||||
if (widget.labelText.isNotEmpty) ...[
|
||||
Text(widget.labelText),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: TextFormField(
|
||||
controller: widget.controller,
|
||||
validator: (value) => null, // Handled separately
|
||||
obscureText: widget.obscureText,
|
||||
maxLines: widget.obscureText ? 1 : widget.maxLines,
|
||||
textCapitalization: widget.textCapitalization,
|
||||
keyboardType: widget.keyboardType,
|
||||
enabled: widget.enabled,
|
||||
autofocus: widget.autofocus,
|
||||
inputFormatters: widget.maxLength != null
|
||||
? [LengthLimitingTextInputFormatter(widget.maxLength!)]
|
||||
: null,
|
||||
style: textTheme.body,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hintText,
|
||||
hintStyle:
|
||||
textTheme.body.copyWith(color: colorScheme.textMuted),
|
||||
filled: true,
|
||||
fillColor: colorScheme.fillFaint,
|
||||
contentPadding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
border: const UnderlineInputBorder(
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.strokeFaint,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.primary500,
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.warning500,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.warning500,
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
suffixIcon: widget.suffixIcon != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: widget.suffixIcon,
|
||||
)
|
||||
: null,
|
||||
suffixIconConstraints: const BoxConstraints(
|
||||
maxHeight: 24,
|
||||
maxWidth: 48,
|
||||
minHeight: 24,
|
||||
minWidth: 48,
|
||||
),
|
||||
errorStyle: const TextStyle(fontSize: 0, height: 0),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Custom validation error display (for both cases)
|
||||
if (_errorText != null && widget.showValidationErrors) ...[
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
_errorText!,
|
||||
style: textTheme.mini.copyWith(
|
||||
color: colorScheme.warning500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Invisible FormField for validation integration
|
||||
SizedBox(
|
||||
height: 0,
|
||||
child: FormField<String>(
|
||||
key: _formFieldKey,
|
||||
validator: (value) {
|
||||
final error = widget.validator?.call(widget.controller.text);
|
||||
if (mounted && widget.showValidationErrors) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorText = error;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return error;
|
||||
},
|
||||
builder: (FormFieldState<String> field) {
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
357
mobile/apps/locker/lib/ui/pages/account_credentials_page.dart
Normal file
357
mobile/apps/locker/lib/ui/pages/account_credentials_page.dart
Normal file
@@ -0,0 +1,357 @@
|
||||
import 'package:ente_ui/components/buttons/gradient_button.dart';
|
||||
import 'package:ente_ui/theme/ente_theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:locker/l10n/l10n.dart';
|
||||
import 'package:locker/models/info/info_item.dart';
|
||||
import 'package:locker/services/collections/collections_service.dart';
|
||||
import 'package:locker/services/collections/models/collection.dart';
|
||||
import 'package:locker/services/info_file_service.dart';
|
||||
import 'package:locker/ui/components/collection_selection_widget.dart';
|
||||
import 'package:locker/ui/components/form_text_input_widget.dart';
|
||||
|
||||
class AccountCredentialsPage extends StatefulWidget {
|
||||
const AccountCredentialsPage({super.key});
|
||||
|
||||
@override
|
||||
State<AccountCredentialsPage> createState() => _AccountCredentialsPageState();
|
||||
}
|
||||
|
||||
class _AccountCredentialsPageState extends State<AccountCredentialsPage> {
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
final TextEditingController _usernameController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
final TextEditingController _notesController = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isLoading = false;
|
||||
bool _passwordVisible = false;
|
||||
bool _showValidationErrors = false;
|
||||
final _passwordFocusNode = FocusNode();
|
||||
bool _passwordInFocus = false;
|
||||
|
||||
// Collection selection state
|
||||
List<Collection> _availableCollections = [];
|
||||
Set<int> _selectedCollectionIds = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_passwordFocusNode.addListener(() {
|
||||
setState(() {
|
||||
_passwordInFocus = _passwordFocusNode.hasFocus;
|
||||
});
|
||||
});
|
||||
_loadCollections();
|
||||
}
|
||||
|
||||
Future<void> _loadCollections() async {
|
||||
try {
|
||||
final collections = await CollectionService.instance.getCollections();
|
||||
setState(() {
|
||||
_availableCollections = collections;
|
||||
// Pre-select a default collection if available
|
||||
if (collections.isNotEmpty) {
|
||||
final defaultCollection = collections.firstWhere(
|
||||
(c) => c.name == 'Information',
|
||||
orElse: () => collections.first,
|
||||
);
|
||||
_selectedCollectionIds = {defaultCollection.id};
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Handle error silently or show a message
|
||||
}
|
||||
}
|
||||
|
||||
void _onToggleCollection(int collectionId) {
|
||||
setState(() {
|
||||
if (_selectedCollectionIds.contains(collectionId)) {
|
||||
_selectedCollectionIds.remove(collectionId);
|
||||
} else {
|
||||
// Allow multiple selections
|
||||
_selectedCollectionIds.add(collectionId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onCollectionsUpdated(List<Collection> updatedCollections) {
|
||||
setState(() {
|
||||
_availableCollections = updatedCollections;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
_notesController.dispose();
|
||||
_passwordFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
context.l10n.accountCredentials,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
elevation: 0,
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.accountCredentialsDescription,
|
||||
style: getEnteTextTheme(context).body.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
FormTextInputWidget(
|
||||
controller: _nameController,
|
||||
labelText: context.l10n.credentialName,
|
||||
hintText: context.l10n.credentialNameHint,
|
||||
showValidationErrors: _showValidationErrors,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return context.l10n.pleaseEnterAccountName;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FormTextInputWidget(
|
||||
controller: _usernameController,
|
||||
labelText: context.l10n.username,
|
||||
hintText: context.l10n.usernameHint,
|
||||
showValidationErrors: _showValidationErrors,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return context.l10n.pleaseEnterUsername;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(context.l10n.password),
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 0, 0, 0),
|
||||
child: TextFormField(
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocusNode,
|
||||
obscureText: !_passwordVisible,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
style: getEnteTextTheme(context).body,
|
||||
decoration: InputDecoration(
|
||||
fillColor: getEnteColorScheme(context).fillFaint,
|
||||
filled: true,
|
||||
hintText: context.l10n.passwordHint,
|
||||
hintStyle: getEnteTextTheme(context).body.copyWith(
|
||||
color: getEnteColorScheme(context).textMuted,
|
||||
),
|
||||
contentPadding:
|
||||
const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
border: const UnderlineInputBorder(
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: getEnteColorScheme(context).strokeFaint,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: getEnteColorScheme(context).primary500,
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: getEnteColorScheme(context).warning500,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: getEnteColorScheme(context).warning500,
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
suffixIcon: _passwordInFocus
|
||||
? IconButton(
|
||||
icon: Icon(
|
||||
_passwordVisible
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_passwordVisible = !_passwordVisible;
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
suffixIconConstraints: const BoxConstraints(
|
||||
maxHeight: 24,
|
||||
maxWidth: 48,
|
||||
minHeight: 24,
|
||||
minWidth: 48,
|
||||
),
|
||||
),
|
||||
validator: _showValidationErrors
|
||||
? (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return context.l10n.pleaseEnterPassword;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
: null,
|
||||
onChanged: (value) {
|
||||
if (_showValidationErrors) {
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FormTextInputWidget(
|
||||
controller: _notesController,
|
||||
labelText: context.l10n.credentialNotes,
|
||||
hintText: context.l10n.credentialNotesHint,
|
||||
maxLines: 5,
|
||||
showValidationErrors: _showValidationErrors,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
CollectionSelectionWidget(
|
||||
collections: _availableCollections,
|
||||
selectedCollectionIds: _selectedCollectionIds,
|
||||
onToggleCollection: _onToggleCollection,
|
||||
onCollectionsUpdated: _onCollectionsUpdated,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: GradientButton(
|
||||
onTap: _isLoading ? null : _saveRecord,
|
||||
text: context.l10n.saveRecord,
|
||||
paddingValue: 16.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveRecord() async {
|
||||
setState(() {
|
||||
_showValidationErrors = true;
|
||||
});
|
||||
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selectedCollectionIds.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Please select at least one collection'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// Create InfoItem for account credentials
|
||||
final credentialData = AccountCredentialData(
|
||||
name: _nameController.text.trim(),
|
||||
username: _usernameController.text.trim(),
|
||||
password: _passwordController.text.trim(),
|
||||
notes: _notesController.text.trim().isNotEmpty
|
||||
? _notesController.text.trim()
|
||||
: null,
|
||||
);
|
||||
|
||||
final infoItem = InfoItem(
|
||||
type: InfoType.accountCredential,
|
||||
data: credentialData,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
// Upload to all selected collections
|
||||
final selectedCollections = _availableCollections
|
||||
.where((c) => _selectedCollectionIds.contains(c.id))
|
||||
.toList();
|
||||
|
||||
// Create and upload the info file to each selected collection
|
||||
for (final collection in selectedCollections) {
|
||||
await InfoFileService.instance.createAndUploadInfoFile(
|
||||
infoItem: infoItem,
|
||||
collection: collection,
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(); // Go back to information page
|
||||
|
||||
// Show success message
|
||||
final collectionCount = selectedCollections.length;
|
||||
final message = collectionCount == 1
|
||||
? context.l10n.recordSavedSuccessfully
|
||||
: 'Record saved to $collectionCount collections successfully';
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${context.l10n.failedToSaveRecord}: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
258
mobile/apps/locker/lib/ui/pages/emergency_contact_page.dart
Normal file
258
mobile/apps/locker/lib/ui/pages/emergency_contact_page.dart
Normal file
@@ -0,0 +1,258 @@
|
||||
import 'package:ente_ui/components/buttons/gradient_button.dart';
|
||||
import 'package:ente_ui/theme/ente_theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:locker/l10n/l10n.dart';
|
||||
import 'package:locker/models/info/info_item.dart';
|
||||
import 'package:locker/services/collections/collections_service.dart';
|
||||
import 'package:locker/services/collections/models/collection.dart';
|
||||
import 'package:locker/services/info_file_service.dart';
|
||||
import 'package:locker/ui/components/collection_selection_widget.dart';
|
||||
import 'package:locker/ui/components/form_text_input_widget.dart';
|
||||
|
||||
class EmergencyContactPage extends StatefulWidget {
|
||||
const EmergencyContactPage({super.key});
|
||||
|
||||
@override
|
||||
State<EmergencyContactPage> createState() => _EmergencyContactPageState();
|
||||
}
|
||||
|
||||
class _EmergencyContactPageState extends State<EmergencyContactPage> {
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
final TextEditingController _contactDetailsController =
|
||||
TextEditingController();
|
||||
final TextEditingController _notesController = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isLoading = false;
|
||||
bool _showValidationErrors = false;
|
||||
|
||||
// Collection selection state
|
||||
List<Collection> _availableCollections = [];
|
||||
Set<int> _selectedCollectionIds = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCollections();
|
||||
}
|
||||
|
||||
Future<void> _loadCollections() async {
|
||||
try {
|
||||
final collections = await CollectionService.instance.getCollections();
|
||||
setState(() {
|
||||
_availableCollections = collections;
|
||||
// Pre-select a default collection if available
|
||||
if (collections.isNotEmpty) {
|
||||
final defaultCollection = collections.firstWhere(
|
||||
(c) => c.name == 'Information',
|
||||
orElse: () => collections.first,
|
||||
);
|
||||
_selectedCollectionIds = {defaultCollection.id};
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Handle error silently or show a message
|
||||
}
|
||||
}
|
||||
|
||||
void _onToggleCollection(int collectionId) {
|
||||
setState(() {
|
||||
if (_selectedCollectionIds.contains(collectionId)) {
|
||||
_selectedCollectionIds.remove(collectionId);
|
||||
} else {
|
||||
// Allow multiple selections
|
||||
_selectedCollectionIds.add(collectionId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onCollectionsUpdated(List<Collection> updatedCollections) {
|
||||
setState(() {
|
||||
_availableCollections = updatedCollections;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_contactDetailsController.dispose();
|
||||
_notesController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
context.l10n.emergencyContact,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
elevation: 0,
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.emergencyContactDescription,
|
||||
style: getEnteTextTheme(context).body.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
FormTextInputWidget(
|
||||
controller: _nameController,
|
||||
labelText: context.l10n.contactName,
|
||||
hintText: context.l10n.contactNameHint,
|
||||
showValidationErrors: _showValidationErrors,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return context.l10n.pleaseEnterContactName;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FormTextInputWidget(
|
||||
controller: _contactDetailsController,
|
||||
labelText: context.l10n.contactDetails,
|
||||
hintText: context.l10n.contactDetailsHint,
|
||||
maxLines: 3,
|
||||
showValidationErrors: _showValidationErrors,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return context.l10n.pleaseEnterContactDetails;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FormTextInputWidget(
|
||||
controller: _notesController,
|
||||
labelText: context.l10n.contactNotes,
|
||||
hintText: context.l10n.contactNotesHint,
|
||||
maxLines: 4,
|
||||
showValidationErrors: _showValidationErrors,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
CollectionSelectionWidget(
|
||||
collections: _availableCollections,
|
||||
selectedCollectionIds: _selectedCollectionIds,
|
||||
onToggleCollection: _onToggleCollection,
|
||||
onCollectionsUpdated: _onCollectionsUpdated,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: GradientButton(
|
||||
onTap: _isLoading ? null : _saveRecord,
|
||||
text: context.l10n.saveRecord,
|
||||
paddingValue: 16.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveRecord() async {
|
||||
setState(() {
|
||||
_showValidationErrors = true;
|
||||
});
|
||||
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selectedCollectionIds.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Please select at least one collection'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// Create InfoItem for emergency contact
|
||||
final contactData = EmergencyContactData(
|
||||
name: _nameController.text.trim(),
|
||||
contactDetails: _contactDetailsController.text.trim(),
|
||||
notes: _notesController.text.trim().isNotEmpty
|
||||
? _notesController.text.trim()
|
||||
: null,
|
||||
);
|
||||
|
||||
final infoItem = InfoItem(
|
||||
type: InfoType.emergencyContact,
|
||||
data: contactData,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
// Upload to all selected collections
|
||||
final selectedCollections = _availableCollections
|
||||
.where((c) => _selectedCollectionIds.contains(c.id))
|
||||
.toList();
|
||||
|
||||
// Create and upload the info file to each selected collection
|
||||
for (final collection in selectedCollections) {
|
||||
await InfoFileService.instance.createAndUploadInfoFile(
|
||||
infoItem: infoItem,
|
||||
collection: collection,
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(); // Go back to information page
|
||||
|
||||
// Show success message
|
||||
final collectionCount = selectedCollections.length;
|
||||
final message = collectionCount == 1
|
||||
? context.l10n.recordSavedSuccessfully
|
||||
: 'Record saved to $collectionCount collections successfully';
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${context.l10n.failedToSaveRecord}: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import 'package:locker/ui/components/search_result_view.dart';
|
||||
import 'package:locker/ui/mixins/search_mixin.dart';
|
||||
import 'package:locker/ui/pages/all_collections_page.dart';
|
||||
import 'package:locker/ui/pages/collection_page.dart';
|
||||
import 'package:locker/ui/pages/information_page.dart';
|
||||
import "package:locker/ui/pages/settings_page.dart";
|
||||
import 'package:locker/ui/pages/uploader_page.dart';
|
||||
import 'package:locker/utils/collection_actions.dart';
|
||||
@@ -686,34 +687,41 @@ class _HomePageState extends UploaderPageState<HomePage>
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: getEnteColorScheme(context).fillBase,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.createCollectionTooltip,
|
||||
style: getEnteTextTheme(context).small.copyWith(
|
||||
color: getEnteColorScheme(context)
|
||||
.backgroundBase,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
_toggleFab();
|
||||
_showInformationDialog();
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: getEnteColorScheme(context).fillBase,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.saveInformation,
|
||||
style:
|
||||
getEnteTextTheme(context).small.copyWith(
|
||||
color: getEnteColorScheme(context)
|
||||
.backgroundBase,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FloatingActionButton(
|
||||
heroTag: "createCollection",
|
||||
heroTag: "information",
|
||||
mini: true,
|
||||
onPressed: () {
|
||||
_toggleFab();
|
||||
_createCollection();
|
||||
_showInformationDialog();
|
||||
},
|
||||
backgroundColor:
|
||||
getEnteColorScheme(context).fillBase,
|
||||
child: const Icon(Icons.create_new_folder),
|
||||
child: const Icon(Icons.edit_document),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -727,22 +735,29 @@ class _HomePageState extends UploaderPageState<HomePage>
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: getEnteColorScheme(context).fillBase,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.uploadDocumentTooltip,
|
||||
style:
|
||||
getEnteTextTheme(context).small.copyWith(
|
||||
color: getEnteColorScheme(context)
|
||||
.backgroundBase,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
_toggleFab();
|
||||
addFile();
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: getEnteColorScheme(context).fillBase,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.uploadDocumentTooltip,
|
||||
style: getEnteTextTheme(context)
|
||||
.small
|
||||
.copyWith(
|
||||
color: getEnteColorScheme(context)
|
||||
.backgroundBase,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -808,4 +823,12 @@ class _HomePageState extends UploaderPageState<HomePage>
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _showInformationDialog() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const InformationPage(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
157
mobile/apps/locker/lib/ui/pages/information_page.dart
Normal file
157
mobile/apps/locker/lib/ui/pages/information_page.dart
Normal file
@@ -0,0 +1,157 @@
|
||||
import 'package:ente_ui/theme/ente_theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:locker/l10n/l10n.dart';
|
||||
import 'package:locker/ui/pages/account_credentials_page.dart';
|
||||
import 'package:locker/ui/pages/emergency_contact_page.dart';
|
||||
import 'package:locker/ui/pages/personal_note_page.dart';
|
||||
import 'package:locker/ui/pages/physical_records_page.dart';
|
||||
|
||||
enum InformationType {
|
||||
note,
|
||||
physicalRecord,
|
||||
credentials,
|
||||
emergencyContact,
|
||||
}
|
||||
|
||||
class InformationPage extends StatelessWidget {
|
||||
const InformationPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
context.l10n.saveInformation,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
elevation: 0,
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.informationDescription,
|
||||
style: getEnteTextTheme(context).body.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
_buildInformationOption(
|
||||
context,
|
||||
icon: Icons.notes,
|
||||
title: context.l10n.personalNote,
|
||||
description: context.l10n.personalNoteDescription,
|
||||
type: InformationType.note,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildInformationOption(
|
||||
context,
|
||||
icon: Icons.folder_outlined,
|
||||
title: context.l10n.physicalRecords,
|
||||
description: context.l10n.physicalRecordsDescription,
|
||||
type: InformationType.physicalRecord,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildInformationOption(
|
||||
context,
|
||||
icon: Icons.lock,
|
||||
title: context.l10n.accountCredentials,
|
||||
description: context.l10n.accountCredentialsDescription,
|
||||
type: InformationType.credentials,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildInformationOption(
|
||||
context,
|
||||
icon: Icons.contact_phone,
|
||||
title: context.l10n.emergencyContact,
|
||||
description: context.l10n.emergencyContactDescription,
|
||||
type: InformationType.emergencyContact,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInformationOption(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String description,
|
||||
required InformationType type,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
_showInformationForm(context, type);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: getEnteColorScheme(context).fillFaint,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 24,
|
||||
color: getEnteColorScheme(context).primary500,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: getEnteTextTheme(context).body.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
description,
|
||||
style: getEnteTextTheme(context).small.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showInformationForm(BuildContext context, InformationType type) {
|
||||
Widget page;
|
||||
switch (type) {
|
||||
case InformationType.note:
|
||||
page = const PersonalNotePage();
|
||||
break;
|
||||
case InformationType.physicalRecord:
|
||||
page = const PhysicalRecordsPage();
|
||||
break;
|
||||
case InformationType.credentials:
|
||||
page = const AccountCredentialsPage();
|
||||
break;
|
||||
case InformationType.emergencyContact:
|
||||
page = const EmergencyContactPage();
|
||||
break;
|
||||
}
|
||||
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => page),
|
||||
);
|
||||
}
|
||||
}
|
||||
245
mobile/apps/locker/lib/ui/pages/personal_note_page.dart
Normal file
245
mobile/apps/locker/lib/ui/pages/personal_note_page.dart
Normal file
@@ -0,0 +1,245 @@
|
||||
import 'package:ente_ui/components/buttons/gradient_button.dart';
|
||||
import 'package:ente_ui/theme/ente_theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:locker/l10n/l10n.dart';
|
||||
import 'package:locker/models/info/info_item.dart';
|
||||
import 'package:locker/services/collections/collections_service.dart';
|
||||
import 'package:locker/services/collections/models/collection.dart';
|
||||
import 'package:locker/services/info_file_service.dart';
|
||||
import 'package:locker/ui/components/collection_selection_widget.dart';
|
||||
import 'package:locker/ui/components/form_text_input_widget.dart';
|
||||
|
||||
class PersonalNotePage extends StatefulWidget {
|
||||
const PersonalNotePage({super.key});
|
||||
|
||||
@override
|
||||
State<PersonalNotePage> createState() => _PersonalNotePageState();
|
||||
}
|
||||
|
||||
class _PersonalNotePageState extends State<PersonalNotePage> {
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
final TextEditingController _contentController = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isLoading = false;
|
||||
bool _showValidationErrors = false;
|
||||
|
||||
// Collection selection state
|
||||
List<Collection> _availableCollections = [];
|
||||
Set<int> _selectedCollectionIds = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCollections();
|
||||
}
|
||||
|
||||
Future<void> _loadCollections() async {
|
||||
try {
|
||||
final collections = await CollectionService.instance.getCollections();
|
||||
setState(() {
|
||||
_availableCollections = collections;
|
||||
// Pre-select a default collection if available
|
||||
if (collections.isNotEmpty) {
|
||||
final defaultCollection = collections.firstWhere(
|
||||
(c) => c.name == 'Information',
|
||||
orElse: () => collections.first,
|
||||
);
|
||||
_selectedCollectionIds = {defaultCollection.id};
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Handle error silently or show a message
|
||||
}
|
||||
}
|
||||
|
||||
void _onToggleCollection(int collectionId) {
|
||||
setState(() {
|
||||
if (_selectedCollectionIds.contains(collectionId)) {
|
||||
_selectedCollectionIds.remove(collectionId);
|
||||
} else {
|
||||
// Allow multiple selections
|
||||
_selectedCollectionIds.add(collectionId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onCollectionsUpdated(List<Collection> updatedCollections) {
|
||||
setState(() {
|
||||
_availableCollections = updatedCollections;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_contentController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
context.l10n.personalNote,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
elevation: 0,
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.personalNoteDescription,
|
||||
style: getEnteTextTheme(context).body.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
FormTextInputWidget(
|
||||
controller: _nameController,
|
||||
labelText: context.l10n.noteName,
|
||||
hintText: context.l10n.noteNameHint,
|
||||
showValidationErrors: _showValidationErrors,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return context.l10n.pleaseEnterNoteName;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FormTextInputWidget(
|
||||
controller: _contentController,
|
||||
labelText: context.l10n.noteContent,
|
||||
hintText: context.l10n.noteContentHint,
|
||||
maxLines: 6,
|
||||
showValidationErrors: _showValidationErrors,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return context.l10n.pleaseEnterNoteContent;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
CollectionSelectionWidget(
|
||||
collections: _availableCollections,
|
||||
selectedCollectionIds: _selectedCollectionIds,
|
||||
onToggleCollection: _onToggleCollection,
|
||||
onCollectionsUpdated: _onCollectionsUpdated,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: GradientButton(
|
||||
onTap: _isLoading ? null : _saveRecord,
|
||||
text: context.l10n.saveRecord,
|
||||
paddingValue: 16.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveRecord() async {
|
||||
setState(() {
|
||||
_showValidationErrors = true;
|
||||
});
|
||||
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selectedCollectionIds.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Please select at least one collection'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// Create InfoItem for personal note
|
||||
final noteData = PersonalNoteData(
|
||||
title: _nameController.text.trim(),
|
||||
content: _contentController.text.trim(),
|
||||
);
|
||||
|
||||
final infoItem = InfoItem(
|
||||
type: InfoType.note,
|
||||
data: noteData,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
// Upload to all selected collections
|
||||
final selectedCollections = _availableCollections
|
||||
.where((c) => _selectedCollectionIds.contains(c.id))
|
||||
.toList();
|
||||
|
||||
// Create and upload the info file to each selected collection
|
||||
for (final collection in selectedCollections) {
|
||||
await InfoFileService.instance.createAndUploadInfoFile(
|
||||
infoItem: infoItem,
|
||||
collection: collection,
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context)
|
||||
.pop(); // Close this page and return to information page
|
||||
|
||||
// Show success message
|
||||
final collectionCount = selectedCollections.length;
|
||||
final message = collectionCount == 1
|
||||
? context.l10n.recordSavedSuccessfully
|
||||
: 'Record saved to $collectionCount collections successfully';
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${context.l10n.failedToSaveRecord}: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
257
mobile/apps/locker/lib/ui/pages/physical_records_page.dart
Normal file
257
mobile/apps/locker/lib/ui/pages/physical_records_page.dart
Normal file
@@ -0,0 +1,257 @@
|
||||
import 'package:ente_ui/components/buttons/gradient_button.dart';
|
||||
import 'package:ente_ui/theme/ente_theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:locker/l10n/l10n.dart';
|
||||
import 'package:locker/models/info/info_item.dart';
|
||||
import 'package:locker/services/collections/collections_service.dart';
|
||||
import 'package:locker/services/collections/models/collection.dart';
|
||||
import 'package:locker/services/info_file_service.dart';
|
||||
import 'package:locker/ui/components/collection_selection_widget.dart';
|
||||
import 'package:locker/ui/components/form_text_input_widget.dart';
|
||||
|
||||
class PhysicalRecordsPage extends StatefulWidget {
|
||||
const PhysicalRecordsPage({super.key});
|
||||
|
||||
@override
|
||||
State<PhysicalRecordsPage> createState() => _PhysicalRecordsPageState();
|
||||
}
|
||||
|
||||
class _PhysicalRecordsPageState extends State<PhysicalRecordsPage> {
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
final TextEditingController _locationController = TextEditingController();
|
||||
final TextEditingController _notesController = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isLoading = false;
|
||||
bool _showValidationErrors = false;
|
||||
|
||||
// Collection selection state
|
||||
List<Collection> _availableCollections = [];
|
||||
Set<int> _selectedCollectionIds = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCollections();
|
||||
}
|
||||
|
||||
Future<void> _loadCollections() async {
|
||||
try {
|
||||
final collections = await CollectionService.instance.getCollections();
|
||||
setState(() {
|
||||
_availableCollections = collections;
|
||||
// Pre-select a default collection if available
|
||||
if (collections.isNotEmpty) {
|
||||
final defaultCollection = collections.firstWhere(
|
||||
(c) => c.name == 'Information',
|
||||
orElse: () => collections.first,
|
||||
);
|
||||
_selectedCollectionIds = {defaultCollection.id};
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Handle error silently or show a message
|
||||
}
|
||||
}
|
||||
|
||||
void _onToggleCollection(int collectionId) {
|
||||
setState(() {
|
||||
if (_selectedCollectionIds.contains(collectionId)) {
|
||||
_selectedCollectionIds.remove(collectionId);
|
||||
} else {
|
||||
// Allow multiple selections
|
||||
_selectedCollectionIds.add(collectionId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onCollectionsUpdated(List<Collection> updatedCollections) {
|
||||
setState(() {
|
||||
_availableCollections = updatedCollections;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_locationController.dispose();
|
||||
_notesController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
context.l10n.physicalRecords,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
elevation: 0,
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.physicalRecordsDescription,
|
||||
style: getEnteTextTheme(context).body.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
FormTextInputWidget(
|
||||
controller: _nameController,
|
||||
labelText: context.l10n.recordName,
|
||||
hintText: context.l10n.recordNameHint,
|
||||
showValidationErrors: _showValidationErrors,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return context.l10n.pleaseEnterRecordName;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FormTextInputWidget(
|
||||
controller: _locationController,
|
||||
labelText: context.l10n.recordLocation,
|
||||
hintText: context.l10n.recordLocationHint,
|
||||
maxLines: 3,
|
||||
showValidationErrors: _showValidationErrors,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return context.l10n.pleaseEnterLocation;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FormTextInputWidget(
|
||||
controller: _notesController,
|
||||
labelText: context.l10n.recordNotes,
|
||||
hintText: context.l10n.recordNotesHint,
|
||||
maxLines: 5,
|
||||
showValidationErrors: _showValidationErrors,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
CollectionSelectionWidget(
|
||||
collections: _availableCollections,
|
||||
selectedCollectionIds: _selectedCollectionIds,
|
||||
onToggleCollection: _onToggleCollection,
|
||||
onCollectionsUpdated: _onCollectionsUpdated,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: GradientButton(
|
||||
onTap: _isLoading ? null : _saveRecord,
|
||||
text: context.l10n.saveRecord,
|
||||
paddingValue: 16.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveRecord() async {
|
||||
setState(() {
|
||||
_showValidationErrors = true;
|
||||
});
|
||||
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selectedCollectionIds.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Please select at least one collection'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// Create InfoItem for physical record
|
||||
final recordData = PhysicalRecordData(
|
||||
name: _nameController.text.trim(),
|
||||
location: _locationController.text.trim(),
|
||||
notes: _notesController.text.trim().isNotEmpty
|
||||
? _notesController.text.trim()
|
||||
: null,
|
||||
);
|
||||
|
||||
final infoItem = InfoItem(
|
||||
type: InfoType.physicalRecord,
|
||||
data: recordData,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
// Upload to all selected collections
|
||||
final selectedCollections = _availableCollections
|
||||
.where((c) => _selectedCollectionIds.contains(c.id))
|
||||
.toList();
|
||||
|
||||
// Create and upload the info file to each selected collection
|
||||
for (final collection in selectedCollections) {
|
||||
await InfoFileService.instance.createAndUploadInfoFile(
|
||||
infoItem: infoItem,
|
||||
collection: collection,
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(); // Go back to information page
|
||||
|
||||
// Show success message
|
||||
final collectionCount = selectedCollections.length;
|
||||
final message = collectionCount == 1
|
||||
? context.l10n.recordSavedSuccessfully
|
||||
: 'Record saved to $collectionCount collections successfully';
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${context.l10n.failedToSaveRecord}: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
204
mobile/apps/photos/CLAUDE.md
Normal file
204
mobile/apps/photos/CLAUDE.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Philosophy
|
||||
|
||||
Ente is focused on privacy, transparency and trust. It's a fully open-source, end-to-end encrypted platform for storing data in the cloud. When contributing, always prioritize:
|
||||
- User privacy and data security
|
||||
- End-to-end encryption integrity
|
||||
- Transparent, auditable code
|
||||
- Zero-knowledge architecture principles
|
||||
|
||||
## Monorepo Context
|
||||
|
||||
This is the Ente Photos mobile app within the Ente monorepo. The monorepo contains:
|
||||
- Mobile apps (Photos, Auth, Locker) at `mobile/apps/`
|
||||
- Shared packages at `mobile/packages/`
|
||||
- Web, desktop, CLI, and server components in parent directories
|
||||
|
||||
### Package Architecture
|
||||
The Photos app uses two types of packages:
|
||||
- **Shared packages** (`../../packages/`): Common code shared across multiple Ente apps (Photos, Auth, Locker)
|
||||
- **Photos-specific plugins** (`./plugins/`): Custom Flutter plugins specific to Photos app for separation and testability
|
||||
|
||||
## Commit & PR Guidelines
|
||||
|
||||
⚠️ **CRITICAL: From the default template, use ONLY: Co-Authored-By: Claude <noreply@anthropic.com>** ⚠️
|
||||
|
||||
### Pre-commit/PR Checklist (RUN BEFORE EVERY COMMIT OR PR!)
|
||||
|
||||
**CRITICAL: CI will fail if ANY of these checks fail. Run ALL commands and ensure they ALL pass.**
|
||||
|
||||
```bash
|
||||
# 1. Analyze flutter code for errors and warnings
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
**Why CI might fail even after running these:**
|
||||
|
||||
- Skipping any command above
|
||||
- Assuming auto-fix tools handle everything (they don't)
|
||||
- Not fixing warnings that flutter reports
|
||||
- Making changes after running the checks
|
||||
|
||||
### Commit & PR Message Rules
|
||||
|
||||
**These rules apply to BOTH commit messages AND pull request descriptions**
|
||||
|
||||
- Keep messages CONCISE (no walls of text)
|
||||
- Subject line under 72 chars (no body text unless critical)
|
||||
- NO emojis
|
||||
- NO promotional text or links (except Co-Authored-By line)
|
||||
|
||||
### Additional Guidelines
|
||||
|
||||
- Check `git status` before committing to avoid adding temporary/binary files
|
||||
- Never commit to main branch
|
||||
- All CI checks must pass - run the checklist commands above before committing or creating PR
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Using Melos (Monorepo Management)
|
||||
```bash
|
||||
# From mobile/ directory - bootstrap all packages
|
||||
melos bootstrap
|
||||
|
||||
# Run Photos app specifically
|
||||
melos run:photos:apk
|
||||
|
||||
# Build Photos APK
|
||||
melos build:photos:apk
|
||||
|
||||
# Clean Photos app
|
||||
melos clean:photos
|
||||
```
|
||||
|
||||
### Direct Flutter Commands
|
||||
```bash
|
||||
# Development run with environment variables
|
||||
./run.sh # Uses .env file with --flavor dev
|
||||
|
||||
# Development run without env file
|
||||
flutter run -t lib/main.dart --flavor independent
|
||||
|
||||
# Build release APK
|
||||
flutter build apk --release --flavor independent
|
||||
|
||||
# iOS build
|
||||
cd ios && pod install && cd ..
|
||||
flutter build ios
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
```bash
|
||||
# Static analysis and linting
|
||||
flutter analyze .
|
||||
|
||||
# Run tests
|
||||
flutter test
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Service-Oriented Architecture
|
||||
The app uses a service layer pattern with 28+ specialized services:
|
||||
- **collections_service.dart**: Album and collection management
|
||||
- **search_service.dart**: Search functionality with ML support
|
||||
- **smart_memories_service.dart**: AI-powered memory curation
|
||||
- **sync_service.dart**: Local/remote synchronization
|
||||
- **Machine Learning Services**: Face recognition, semantic search, similar images
|
||||
|
||||
### Key Patterns
|
||||
- **Service Locator**: Dependency injection via `lib/service_locator.dart`
|
||||
- **Event Bus**: Loose coupling via `lib/core/event_bus.dart`
|
||||
- **Repository Pattern**: Database abstraction in `lib/db/`
|
||||
- **Rust Integration**: Performance-critical operations via Flutter Rust Bridge
|
||||
|
||||
### Security Architecture
|
||||
- End-to-end encryption with `ente_crypto` package
|
||||
- BIP39 mnemonic-based key generation (24 words)
|
||||
- Secure storage using platform-specific implementations
|
||||
- App lock and privacy screen features
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
lib/
|
||||
├── core/ # Configuration, constants, networking
|
||||
├── services/ # Business logic (28+ services)
|
||||
├── ui/ # UI components (18 subdirectories)
|
||||
├── models/ # Data models (17 subdirectories)
|
||||
├── db/ # SQLite database layer
|
||||
├── utils/ # Utilities and helpers
|
||||
├── gateways/ # API gateway interfaces
|
||||
├── events/ # Event system
|
||||
├── l10n/ # Localization files (intl_*.arb)
|
||||
└── generated/ # Auto-generated code including localizations
|
||||
```
|
||||
|
||||
## Localization (Flutter)
|
||||
|
||||
- Add new strings to `lib/l10n/intl_en.arb` (English base file)
|
||||
- Use `AppLocalizations` to access localized strings in code
|
||||
- Example: `AppLocalizations.of(context).yourStringKey`
|
||||
- Run code generation after adding new strings: `flutter pub get`
|
||||
- Translations managed via Crowdin for other languages
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
- **Flutter 3.32.8** with Dart SDK >=3.3.0 <4.0.0
|
||||
- **Media**: `photo_manager`, `video_editor`, `ffmpeg_kit_flutter`
|
||||
- **Storage**: `sqlite_async`, `flutter_secure_storage`
|
||||
- **ML/AI**: Custom ONNX runtime, `ml_linalg`
|
||||
- **Rust**: Flutter Rust Bridge for performance
|
||||
|
||||
## Development Setup Requirements
|
||||
|
||||
1. Install Flutter v3.32.8 and Rust
|
||||
2. Install Flutter Rust Bridge: `cargo install flutter_rust_bridge_codegen`
|
||||
3. Generate Rust bindings: `flutter_rust_bridge_codegen generate`
|
||||
4. Update submodules: `git submodule update --init --recursive`
|
||||
5. Enable git hooks: `git config core.hooksPath hooks`
|
||||
|
||||
## Critical Coding Requirements
|
||||
|
||||
### 1. Code Quality - MANDATORY
|
||||
**Every code change MUST pass `flutter analyze` with zero issues**
|
||||
- Run `flutter analyze` after EVERY code modification
|
||||
- Resolve ALL issues (info, warning, error) - no exceptions
|
||||
- The codebase has zero issues by default, so any issue is from your changes
|
||||
- DO NOT commit or consider work complete until `flutter analyze` passes cleanly
|
||||
|
||||
### 2. Component Reuse - MANDATORY
|
||||
**Always try to reuse existing components**
|
||||
- Use a subagent to search for existing components before creating new ones
|
||||
- Only create new components if none exist that meet the requirements
|
||||
- Check both UI components in `lib/ui/` and shared components in `../../packages/`
|
||||
|
||||
### 3. Design System - MANDATORY
|
||||
**Never hardcode colors or text styles**
|
||||
- Always use the Ente design system for colors and typography
|
||||
- Use a subagent to find the appropriate design tokens
|
||||
- Access colors via theme: `getEnteColorScheme(context)`
|
||||
- Access text styles via theme: `getEnteTextTheme(context)`
|
||||
- Call above theme getters only at the top of (`build`) methods and re-use them throughout the component
|
||||
- If you MUST use custom colors/styles (extremely rare), explicitly inform the user with a clear warning
|
||||
|
||||
### 4. Documentation Sync - MANDATORY
|
||||
**Keep spec documents synchronized with code changes**
|
||||
- When modifying code, also update any associated spec documents
|
||||
- Check for related spec files in `docs/` or project directories
|
||||
- Ensure documentation reflects the current implementation
|
||||
- Update examples in specs if behavior changes
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Large service files (some 70k+ lines) - consider file context when editing
|
||||
- 400+ dependencies - check existing libraries before adding new ones
|
||||
- When adding functionality, check both `../../packages/` for shared code and `./plugins/` for Photos-specific plugins
|
||||
- Performance-critical paths use Rust integration
|
||||
- Always follow existing code conventions and patterns in neighboring files
|
||||
|
||||
# Individual Preferences
|
||||
- @~/.claude/my-project-instructions.md
|
||||
@@ -312,7 +312,7 @@ DEPENDENCIES:
|
||||
- workmanager (from `.symlinks/plugins/workmanager/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
https://github.com/ente-io/ffmpeg-kit-custom-repo-ios:
|
||||
https://github.com/ente-io/ffmpeg-kit-custom-repo-ios.git:
|
||||
- ffmpeg_kit_custom
|
||||
trunk:
|
||||
- Firebase
|
||||
@@ -457,85 +457,85 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/workmanager/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7
|
||||
battery_info: 83f3aae7be2fccefab1d2bf06b8aa96f11c8bcdd
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
|
||||
dart_ui_isolate: 46f6714abe6891313267153ef6f9748d8ecfcab1
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
emoji_picker_flutter: ed468d9746c21711e66b2788880519a9de5de211
|
||||
app_links: f3e17e4ee5e357b39d8b95290a9b2c299fca71c6
|
||||
battery_info: b6c551049266af31556b93c9d9b9452cfec0219f
|
||||
connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d
|
||||
cupertino_http: 947a233f40cfea55167a49f2facc18434ea117ba
|
||||
dart_ui_isolate: d5bcda83ca4b04f129d70eb90110b7a567aece14
|
||||
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
||||
emoji_picker_flutter: fe2e6151c5b548e975d546e6eeb567daf0962a58
|
||||
ffmpeg_kit_custom: 682b4f2f1ff1f8abae5a92f6c3540f2441d5be99
|
||||
ffmpeg_kit_flutter: 915b345acc97d4142e8a9a8549d177ff10f043f5
|
||||
file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
|
||||
ffmpeg_kit_flutter: 9dce4803991478c78c6fb9f972703301101095fe
|
||||
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
|
||||
Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e
|
||||
firebase_core: ece862f94b2bc72ee0edbeec7ab5c7cb09fe1ab5
|
||||
firebase_messaging: e1a5fae495603115be1d0183bc849da748734e2b
|
||||
firebase_core: cf4d42a8ac915e51c0c2dc103442f3036d941a2d
|
||||
firebase_messaging: fee490327c1aae28a0da1e65fca856547deca493
|
||||
FirebaseCore: efb3893e5b94f32b86e331e3bd6dadf18b66568e
|
||||
FirebaseCoreInternal: 9afa45b1159304c963da48addb78275ef701c6b4
|
||||
FirebaseInstallations: 317270fec08a5d418fdbc8429282238cab3ac843
|
||||
FirebaseMessaging: 3b26e2cee503815e01c3701236b020aa9b576f09
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
flutter_email_sender: aa1e9772696691d02cd91fea829856c11efb8e58
|
||||
flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1
|
||||
flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99
|
||||
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
|
||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||
flutter_sodium: 7e4621538491834eba53bd524547854bcbbd6987
|
||||
flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544
|
||||
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
|
||||
flutter_email_sender: e03bdda7637bcd3539bfe718fddd980e9508efaa
|
||||
flutter_image_compress_common: ec1d45c362c9d30a3f6a0426c297f47c52007e3e
|
||||
flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4
|
||||
flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f
|
||||
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
|
||||
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
||||
flutter_sodium: a00383520fc689c688b66fd3092984174712493e
|
||||
flutter_timezone: ac3da59ac941ff1c98a2e1f0293420e020120282
|
||||
fluttertoast: 21eecd6935e7064cc1fcb733a4c5a428f3f24f0f
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
|
||||
in_app_purchase_storekit: d1a48cb0f8b29dbf5f85f782f5dd79b21b90a5e6
|
||||
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
|
||||
launcher_icon_switcher: 84c218d233505aa7d8655d8fa61a3ba802c022da
|
||||
home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
|
||||
in_app_purchase_storekit: a1ce04056e23eecc666b086040239da7619cd783
|
||||
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||
launcher_icon_switcher: 8e0ad2131a20c51c1dd939896ee32e70cd845b37
|
||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||
local_auth_ios: f7a1841beef3151d140a967c2e46f30637cdf451
|
||||
local_auth_ios: 5046a18c018dd973247a0564496c8898dbb5adf9
|
||||
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
|
||||
maps_launcher: edf829809ba9e894d70e569bab11c16352dedb45
|
||||
media_extension: 671e2567880d96c95c65c9a82ccceed8f2e309fd
|
||||
media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
|
||||
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
|
||||
motion_sensors: 741e702c17467b9569a92165dda8d4d88c6167f1
|
||||
motionphoto: 23e2aeb5c6380112f69468d71f970fa7438e5ed1
|
||||
move_to_background: 7e3467dd2a1d1013e98c9c1cb93fd53cd7ef9d84
|
||||
maps_launcher: 2e5b6a2d664ec6c27f82ffa81b74228d770ab203
|
||||
media_extension: 6618f07abd762cdbfaadf1b0c56a287e820f0c84
|
||||
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
|
||||
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
|
||||
motion_sensors: 03f55b7c637a7e365a0b5f9697a449f9059d5d91
|
||||
motionphoto: 8b65ce50c7d7ff3c767534fc3768b2eed9ac24e4
|
||||
move_to_background: cd3091014529ec7829e342ad2d75c0a11f4378a5
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
native_video_player: 6809dec117e8997161dbfb42a6f90d6df71a504d
|
||||
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
|
||||
onnxruntime: f9b296392c96c42882be020a59dbeac6310d81b2
|
||||
native_video_player: 29ab24a926804ac8c4a57eb6d744c7d927c2bc3e
|
||||
objective_c: 77e887b5ba1827970907e10e832eec1683f3431d
|
||||
onnxruntime: e7c2ae44385191eaad5ae64c935a72debaddc997
|
||||
onnxruntime-c: a909204639a1f035f575127ac406f781ac797c9c
|
||||
onnxruntime-objc: b6fab0f1787aa6f7190c2013f03037df4718bd8b
|
||||
open_mail_app: 7314a609e88eed22d53671279e189af7a0ab0f11
|
||||
open_mail_app: 70273c53f768beefdafbe310c3d9086e4da3cb02
|
||||
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62
|
||||
privacy_screen: 3159a541f5d3a31bea916cfd4e58f9dc722b3fd4
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
photo_manager: 81954a1bf804b6e882d0453b3b6bc7fad7b47d3d
|
||||
privacy_screen: 1a131c052ceb3c3659934b003b0d397c2381a24e
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
|
||||
rive_common: dd421daaf9ae69f0125aa761dd96abd278399952
|
||||
rust_lib_photos: 04d3901908d2972192944083310b65abf410774c
|
||||
receive_sharing_intent: 79c848f5b045674ad60b9fea3bafea59962ad2c1
|
||||
rive_common: 4743dbfd2911c99066547a3c6454681e0fa907df
|
||||
rust_lib_photos: 8813b31af48ff02ca75520cbc81a363a13d51a84
|
||||
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
||||
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
|
||||
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
|
||||
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
sentry_flutter: 2df8b0aab7e4aba81261c230cbea31c82a62dd1b
|
||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
sqlite3: 1d85290c3321153511f6e900ede7a1608718bbd5
|
||||
sqlite3_flutter_libs: e7fc8c9ea2200ff3271f08f127842131746b70e2
|
||||
system_info_plus: 555ce7047fbbf29154726db942ae785c29211740
|
||||
thermal: d4c48be750d1ddbab36b0e2dcb2471531bc8df41
|
||||
ua_client_hints: 92fe0d139619b73ec9fcb46cc7e079a26178f586
|
||||
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
||||
vibration: 8e2f50fc35bb736f9eecb7dd9f7047fbb6a6e888
|
||||
video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b
|
||||
video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140
|
||||
volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
workmanager: b89e4e4445d8b57ee2fdbf1c3925696ebe5b8990
|
||||
sqlite3_flutter_libs: 2c48c4ee7217fd653251975e43412250d5bcbbe2
|
||||
system_info_plus: 5393c8da281d899950d751713575fbf91c7709aa
|
||||
thermal: a9261044101ae8f532fa29cab4e8270b51b3f55c
|
||||
ua_client_hints: aeabd123262c087f0ce151ef96fa3ab77bfc8b38
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
vibration: 7d883d141656a1c1a6d8d238616b2042a51a1241
|
||||
video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3
|
||||
video_thumbnail: c4e2a3c539e247d4de13cd545344fd2d26ffafd1
|
||||
volume_controller: 2e3de73d6e7e81a0067310d17fb70f2f86d71ac7
|
||||
wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e
|
||||
workmanager: 05afacf221f5086e18450250dce57f59bb23e6b0
|
||||
|
||||
PODFILE CHECKSUM: cce2cd3351d3488dca65b151118552b680e23635
|
||||
|
||||
|
||||
@@ -39,10 +39,26 @@ class ClipVectorDB {
|
||||
final documentsDirectory = await getApplicationDocumentsDirectory();
|
||||
final String dbPath = join(documentsDirectory.path, _databaseName);
|
||||
_logger.info("Opening vectorDB access: DB path " + dbPath);
|
||||
final vectorDB = VectorDb(
|
||||
filePath: dbPath,
|
||||
dimensions: _embeddingDimension,
|
||||
);
|
||||
late VectorDb vectorDB;
|
||||
try {
|
||||
vectorDB = VectorDb(
|
||||
filePath: dbPath,
|
||||
dimensions: _embeddingDimension,
|
||||
);
|
||||
} catch (e, s) {
|
||||
_logger.severe("Could not open VectorDB at path $dbPath", e, s);
|
||||
_logger.severe("Deleting the index file and trying again");
|
||||
await deleteIndexFile();
|
||||
try {
|
||||
vectorDB = VectorDb(
|
||||
filePath: dbPath,
|
||||
dimensions: _embeddingDimension,
|
||||
);
|
||||
} catch (e, s) {
|
||||
_logger.severe("Still can't open VectorDB at path $dbPath", e, s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
final stats = await getIndexStats(vectorDB);
|
||||
_logger.info("VectorDB connection opened with stats: ${stats.toString()}");
|
||||
|
||||
|
||||
@@ -1927,8 +1927,8 @@
|
||||
"hoorayyyy": "Hurraaaa!",
|
||||
"nothingToTidyUpHere": "Hier gibt es nichts zu bereinigen",
|
||||
"cLTitle1": "Ähnliche Bilder",
|
||||
"cLDesc1": "Wir führen ein neues ML-basiertes System ein, um ähnliche Bilder zu erkennen, mit dem du deine Bibliothek bereinigen kannst. Verfügbar unter Einstellungen->Sicherung->Speicherplatz freigeben",
|
||||
"cLTitle2": "Manuelle Video-Stream-Generierung",
|
||||
"cLDesc1": "Wir führen ein neues ML-basiertes System ein, um ähnliche Bilder zu erkennen, mit dem du deine Bibliothek bereinigen kannst. Verfügbar unter Einstellungen -> Sicherung -> Speicherplatz freigeben",
|
||||
"cLTitle2": "Video-Streaming-Verbesserungen",
|
||||
"cLDesc2": "Du kannst jetzt die Stream-Generierung für Videos direkt aus der App manuell auslösen. Wir haben auch einen neuen Video-Streaming-Einstellungsbildschirm hinzugefügt, der dir zeigt, welcher Prozentsatz deiner Videos für das Streaming verarbeitet wurde",
|
||||
"cLTitle3": "Leistungsverbesserungen",
|
||||
"cLDesc3": "Mehrere Verbesserungen im Hintergrund, einschließlich besserer Cache-Nutzung und einer flüssigeren Scroll-Erfahrung"
|
||||
|
||||
@@ -1932,10 +1932,10 @@
|
||||
"hoorayyyy": "Hoorayyyy!",
|
||||
"nothingToTidyUpHere": "Nothing to tidy up here",
|
||||
"deletingDash": "Deleting - ",
|
||||
"cLTitle1": "Similar Images",
|
||||
"cLDesc1": "We are introducing a new ML-based system to detect similar images, using which you can cleanup your library. Available in Settings->Backup->Free Up Space",
|
||||
"cLTitle2": "Manual video stream generation",
|
||||
"cLTitle1": "Similar images",
|
||||
"cLDesc1": "We are introducing a new ML-based system to detect similar images, using which you can cleanup your library. Available in Settings -> Backup -> Free up space",
|
||||
"cLTitle2": "Video streaming enhancements",
|
||||
"cLDesc2": "You can now manually trigger stream generation for videos directly from the app. We have also added a new video streaming settings screen which will show you what percentage of your videos have been processed for streaming",
|
||||
"cLTitle3": "Performance Improvements",
|
||||
"cLTitle3": "Performance improvements",
|
||||
"cLDesc3": "Multiple under the hood improvements, including better cache usage and a smoother scroll experience"
|
||||
}
|
||||
|
||||
@@ -1926,10 +1926,10 @@
|
||||
"related": "Relacionado",
|
||||
"hoorayyyy": "¡Hurraaaa!",
|
||||
"nothingToTidyUpHere": "Nada que limpiar aquí",
|
||||
"cLTitle1": "Imágenes Similares",
|
||||
"cLDesc1": "Estamos introduciendo un nuevo sistema basado en ML para detectar imágenes similares, con el cual puedes limpiar tu biblioteca. Disponible en Configuración->Copia de Seguridad->Liberar Espacio",
|
||||
"cLTitle2": "Generación manual de transmisión de video",
|
||||
"cLTitle1": "Imágenes similares",
|
||||
"cLDesc1": "Estamos introduciendo un nuevo sistema basado en ML para detectar imágenes similares, con el cual puedes limpiar tu biblioteca. Disponible en Configuración -> Copia de seguridad -> Liberar espacio",
|
||||
"cLTitle2": "Mejoras de transmisión de video",
|
||||
"cLDesc2": "Ahora puedes activar manualmente la generación de transmisión para videos directamente desde la aplicación. También hemos agregado una nueva pantalla de configuración de transmisión de video que te mostrará qué porcentaje de tus videos han sido procesados para transmisión",
|
||||
"cLTitle3": "Mejoras de Rendimiento",
|
||||
"cLTitle3": "Mejoras de rendimiento",
|
||||
"cLDesc3": "Múltiples mejoras internas, incluyendo mejor uso de caché y una experiencia de desplazamiento más fluida"
|
||||
}
|
||||
@@ -1918,10 +1918,10 @@
|
||||
"related": "Liés",
|
||||
"hoorayyyy": "Houraaa !",
|
||||
"nothingToTidyUpHere": "Rien à nettoyer ici",
|
||||
"cLTitle1": "Images Similaires",
|
||||
"cLDesc1": "Nous introduisons un nouveau système basé sur l'IA pour détecter les images similaires, avec lequel vous pouvez nettoyer votre bibliothèque. Disponible dans Paramètres->Sauvegarde->Libérer de l'espace",
|
||||
"cLTitle2": "Génération manuelle de flux vidéo",
|
||||
"cLDesc2": "Vous pouvez maintenant déclencher manuellement la génération de flux pour les vidéos directement depuis l'application. Nous avons également ajouté un nouvel écran de paramètres de streaming vidéo qui vous montrera quel pourcentage de vos vidéos ont été traitées pour le streaming",
|
||||
"cLTitle3": "Améliorations de performance",
|
||||
"cLTitle1": "Images similaires",
|
||||
"cLDesc1": "Nous introduisons un nouveau système basé sur l'IA pour détecter les images similaires, avec lequel vous pouvez nettoyer votre bibliothèque. Disponible dans Paramètres -> Sauvegarde -> Libérer de l'espace",
|
||||
"cLTitle2": "Améliorations de la diffusion vidéo",
|
||||
"cLDesc2": "Vous pouvez maintenant déclencher manuellement la génération de flux pour les vidéos directement depuis l'application. Nous avons également ajouté un nouvel écran de paramètres de diffusion vidéo qui vous montrera quel pourcentage de vos vidéos ont été traitées pour la diffusion",
|
||||
"cLTitle3": "Améliorations des performances",
|
||||
"cLDesc3": "Plusieurs améliorations internes, incluant une meilleure utilisation du cache et une expérience de défilement plus fluide"
|
||||
}
|
||||
@@ -1746,10 +1746,10 @@
|
||||
"receiveRemindersOnBirthdays": "Ricevi promemoria quando è il compleanno di qualcuno. Toccare la notifica ti porterà alle foto della persona che compie gli anni.",
|
||||
"happyBirthday": "Buon compleanno! 🥳",
|
||||
"birthdays": "Compleanni",
|
||||
"cLTitle1": "Immagini Simili",
|
||||
"cLDesc1": "Stiamo introducendo un nuovo sistema basato su ML per rilevare immagini simili, con il quale puoi pulire la tua libreria. Disponibile in Impostazioni->Backup->Libera Spazio",
|
||||
"cLTitle2": "Generazione manuale stream video",
|
||||
"cLTitle1": "Immagini simili",
|
||||
"cLDesc1": "Stiamo introducendo un nuovo sistema basato su ML per rilevare immagini simili, con il quale puoi pulire la tua libreria. Disponibile in Impostazioni -> Backup -> Libera spazio",
|
||||
"cLTitle2": "Miglioramenti streaming video",
|
||||
"cLDesc2": "Ora puoi attivare manualmente la generazione di stream per i video direttamente dall'app. Abbiamo anche aggiunto una nuova schermata delle impostazioni di streaming video che ti mostrerà quale percentuale dei tuoi video è stata elaborata per lo streaming",
|
||||
"cLTitle3": "Miglioramenti delle Prestazioni",
|
||||
"cLTitle3": "Miglioramenti delle prestazioni",
|
||||
"cLDesc3": "Multipli miglioramenti interni, incluso un miglior utilizzo della cache e un'esperienza di scorrimento più fluida"
|
||||
}
|
||||
@@ -1667,9 +1667,9 @@
|
||||
"food": "料理を楽しむ",
|
||||
"pets": "毛むくじゃらな仲間たち",
|
||||
"cLTitle1": "類似画像",
|
||||
"cLDesc1": "類似画像を検出する新しいML基盤システムを導入し、ライブラリをクリーンアップできます。設定->バックアップ->容量を空けるで利用可能",
|
||||
"cLTitle2": "手動ビデオストリーム生成",
|
||||
"cLDesc2": "アプリから直接、ビデオのストリーム生成を手動でトリガーできるようになりました。また、ビデオのうち何パーセントがストリーミング用に処理されたかを表示する新しいビデオストリーミング設定画面も追加しました",
|
||||
"cLDesc1": "類似画像を検出する新しいML基盤システムを導入し、ライブラリをクリーンアップできます。設定 -> バックアップ -> 容量を空ける で利用可能",
|
||||
"cLTitle2": "動画ストリーミングの強化",
|
||||
"cLDesc2": "アプリから直接、動画のストリーム生成を手動でトリガーできるようになりました。また、動画のうち何パーセントがストリーミング用に処理されたかを表示する新しい動画ストリーミング設定画面も追加しました",
|
||||
"cLTitle3": "パフォーマンスの改善",
|
||||
"cLDesc3": "より良いキャッシュ使用とよりスムーズなスクロール体験を含む、複数の内部改善"
|
||||
}
|
||||
@@ -1773,10 +1773,10 @@
|
||||
"areYouSureYouWantToMergeThem": "Weet je zeker dat je ze wilt samenvoegen?",
|
||||
"allUnnamedGroupsWillBeMergedIntoTheSelectedPerson": "Alle naamloze groepen worden samengevoegd met de geselecteerde persoon. Dit kan nog steeds ongedaan worden gemaakt vanuit het geschiedenisoverzicht van de persoon.",
|
||||
"yesIgnore": "Ja, negeer",
|
||||
"cLTitle1": "Vergelijkbare Afbeeldingen",
|
||||
"cLDesc1": "We introduceren een nieuw ML-gebaseerd systeem om vergelijkbare afbeeldingen te detecteren, waarmee je je bibliotheek kunt opschonen. Beschikbaar in Instellingen->Backup->Ruimte vrijmaken",
|
||||
"cLTitle2": "Handmatige video stream generatie",
|
||||
"cLTitle1": "Vergelijkbare afbeeldingen",
|
||||
"cLDesc1": "We introduceren een nieuw ML-gebaseerd systeem om vergelijkbare afbeeldingen te detecteren, waarmee je je bibliotheek kunt opschonen. Beschikbaar in Instellingen -> Backup -> Ruimte vrijmaken",
|
||||
"cLTitle2": "Video streaming verbeteringen",
|
||||
"cLDesc2": "Je kunt nu handmatig stream generatie voor video's activeren direct vanuit de app. We hebben ook een nieuw video streaming instellingenscherm toegevoegd dat toont welk percentage van je video's is verwerkt voor streaming",
|
||||
"cLTitle3": "Prestatie Verbeteringen",
|
||||
"cLTitle3": "Prestatieverbeteringen",
|
||||
"cLDesc3": "Meerdere verbeteringen onder de motorkap, inclusief beter cache gebruik en een vloeiendere scroll ervaring"
|
||||
}
|
||||
@@ -1737,10 +1737,10 @@
|
||||
"memoriesWidgetDesc": "Velg typen minner du ønsker å se på din hjemskjerm.",
|
||||
"smartMemories": "Smarte minner",
|
||||
"pastYearsMemories": "Tidligere års minner",
|
||||
"cLTitle1": "Lignende Bilder",
|
||||
"cLDesc1": "Vi introduserer et nytt ML-basert system for å oppdage lignende bilder, som du kan bruke til å rydde opp i biblioteket ditt. Tilgjengelig i Innstillinger->Sikkerhetskopi->Frigjør plass",
|
||||
"cLTitle2": "Manuell video stream generering",
|
||||
"cLTitle1": "Lignende bilder",
|
||||
"cLDesc1": "Vi introduserer et nytt ML-basert system for å oppdage lignende bilder, som du kan bruke til å rydde opp i biblioteket ditt. Tilgjengelig i Innstillinger -> Sikkerhetskopi -> Frigjør plass",
|
||||
"cLTitle2": "Video streaming forbedringer",
|
||||
"cLDesc2": "Du kan nå manuelt utløse stream generering for videoer direkte fra appen. Vi har også lagt til en ny video streaming innstillinger skjerm som viser deg hvor mange prosent av videoene dine som er behandlet for streaming",
|
||||
"cLTitle3": "Ytelsesforbedringar",
|
||||
"cLTitle3": "Ytelsesforbedringer",
|
||||
"cLDesc3": "Flere forbedringer under panseret, inkludert bedre cache bruk og en jevnere rullingsopplevelse"
|
||||
}
|
||||
@@ -1820,10 +1820,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"cLTitle1": "Podobne Obrazy",
|
||||
"cLTitle1": "Podobne obrazy",
|
||||
"cLDesc1": "Wprowadzamy nowy system oparty na ML do wykrywania podobnych obrazów, za pomocą którego możesz posprzątać swoją bibliotekę. Dostępne w Ustawienia->Kopia zapasowa->Zwolnij miejsce",
|
||||
"cLTitle2": "Ręczne generowanie strumienia wideo",
|
||||
"cLTitle2": "Ulepszenia streamingu wideo",
|
||||
"cLDesc2": "Możesz teraz ręcznie wyzwolić generowanie strumienia dla filmów bezpośrednio z aplikacji. Dodaliśmy również nowy ekran ustawień streamingu wideo, który pokaże ci, jaki procent twoich filmów zostało przetworzonych do streamingu",
|
||||
"cLTitle3": "Ulepszenia Wydajności",
|
||||
"cLTitle3": "Ulepszenia wydajności",
|
||||
"cLDesc3": "Liczne ulepszenia pod maską, w tym lepsze wykorzystanie pamięci podręcznej i płynniejsze przewijanie"
|
||||
}
|
||||
@@ -1820,10 +1820,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"cLTitle1": "Imagens Similares",
|
||||
"cLDesc1": "Estamos introduzindo um novo sistema baseado em ML para detectar imagens similares, com o qual você pode limpar sua biblioteca. Disponível em Configurações->Backup->Liberar Espaço",
|
||||
"cLTitle2": "Geração manual de stream de vídeo",
|
||||
"cLTitle1": "Imagens similares",
|
||||
"cLDesc1": "Estamos introduzindo um novo sistema baseado em ML para detectar imagens similares, com o qual você pode limpar sua biblioteca. Disponível em Configurações -> Backup -> Liberar espaço",
|
||||
"cLTitle2": "Melhorias do streaming de vídeo",
|
||||
"cLDesc2": "Agora você pode acionar manualmente a geração de stream para vídeos diretamente do aplicativo. Também adicionamos uma nova tela de configurações de streaming de vídeo que mostrará qual porcentagem dos seus vídeos foram processados para streaming",
|
||||
"cLTitle3": "Melhorias de Performance",
|
||||
"cLTitle3": "Melhorias de desempenho",
|
||||
"cLDesc3": "Múltiplas melhorias internas, incluindo melhor uso de cache e uma experiência de rolagem mais suave"
|
||||
}
|
||||
|
||||
@@ -1820,10 +1820,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"cLTitle1": "Imagens Similares",
|
||||
"cLDesc1": "Estamos a introduzir um novo sistema baseado em ML para detectar imagens similares, com o qual pode limpar a sua biblioteca. Disponível em Definições->Cópia de Segurança->Libertar Espaço",
|
||||
"cLTitle2": "Geração manual de stream de vídeo",
|
||||
"cLTitle1": "Imagens similares",
|
||||
"cLDesc1": "Estamos a introduzir um novo sistema baseado em ML para detectar imagens similares, com o qual pode limpar a sua biblioteca. Disponível em Definições -> Cópia de segurança -> Libertar espaço",
|
||||
"cLTitle2": "Melhorias do streaming de vídeo",
|
||||
"cLDesc2": "Agora pode accionar manualmente a geração de stream para vídeos directamente da aplicação. Também adicionámos um novo ecrã de definições de streaming de vídeo que mostrará que percentagem dos seus vídeos foram processados para streaming",
|
||||
"cLTitle3": "Melhorias de Performance",
|
||||
"cLTitle3": "Melhorias de desempenho",
|
||||
"cLDesc3": "Múltiplas melhorias internas, incluindo melhor uso de cache e uma experiência de deslocação mais suave"
|
||||
}
|
||||
|
||||
@@ -1522,9 +1522,9 @@
|
||||
"joinAlbumSubtext": "pentru a vedea și a adăuga fotografii",
|
||||
"joinAlbumSubtextViewer": "pentru a adăuga la albumele distribuite",
|
||||
"join": "Alăturare",
|
||||
"cLTitle1": "Imagini Similare",
|
||||
"cLTitle1": "Imagini similare",
|
||||
"cLDesc1": "Introducem un nou sistem bazat pe ML pentru detectarea imaginilor similare, cu care vă puteți curăța biblioteca. Disponibil în Setări->Backup->Eliberați Spațiu",
|
||||
"cLTitle2": "Generarea manuală a fluxului video",
|
||||
"cLTitle2": "Îmbunătățiri streaming video",
|
||||
"cLDesc2": "Acum puteți declanșa manual generarea fluxului pentru videoclipuri direct din aplicație. Am adăugat, de asemenea, un nou ecran de setări pentru streaming video care vă va arăta ce procent din videoclipurile dvs. au fost procesate pentru streaming",
|
||||
"cLTitle3": "Îmbunătățiri de Performanță",
|
||||
"cLDesc3": "Multiple îmbunătățiri în fundal, inclusiv o utilizare mai bună a cache-ului și o experiență de defilare mai fluidă"
|
||||
|
||||
@@ -1786,10 +1786,10 @@
|
||||
"day": "День",
|
||||
"filter": "Фильтр",
|
||||
"font": "Шрифт",
|
||||
"cLTitle1": "Похожие Изображения",
|
||||
"cLTitle1": "Похожие изображения",
|
||||
"cLDesc1": "Мы внедряем новую систему на основе ML для обнаружения похожих изображений, с помощью которой вы можете очистить свою библиотеку. Доступно в Настройки->Резервная копия->Освободить место",
|
||||
"cLTitle2": "Ручная генерация видео потока",
|
||||
"cLTitle2": "Улучшения видео стриминга",
|
||||
"cLDesc2": "Теперь вы можете вручную запустить генерацию потока для видео прямо из приложения. Мы также добавили новый экран настроек видео стриминга, который покажет вам, какой процент ваших видео был обработан для стриминга",
|
||||
"cLTitle3": "Улучшения Производительности",
|
||||
"cLTitle3": "Улучшения производительности",
|
||||
"cLDesc3": "Множественные улучшения под капотом, включая лучшее использование кэша и более плавную прокрутку"
|
||||
}
|
||||
@@ -1777,9 +1777,9 @@
|
||||
"different": "Farklı",
|
||||
"sameperson": "Aynı kişi mi?",
|
||||
"indexingPausedStatusDescription": "Dizin oluşturma duraklatıldı. Cihaz hazır olduğunda otomatik olarak devam edecektir. Cihaz, pil seviyesi, pil sağlığı ve termal durumu sağlıklı bir aralıkta olduğunda hazır kabul edilir.",
|
||||
"cLTitle1": "Benzer Görüntüler",
|
||||
"cLDesc1": "Benzer görüntüleri tespit etmek için yeni bir ML tabanlı sistem tanıtıyoruz, bununla kütüphanenizi temizleyebilirsiniz. Ayarlar->Yedekleme->Alan Boşalt kısmından ulaşabilirsiniz",
|
||||
"cLTitle2": "Manuel video akış oluşturma",
|
||||
"cLTitle1": "Benzer görüntüler",
|
||||
"cLDesc1": "Benzer görüntüleri tespit etmek için yeni bir ML tabanlı sistem tanıtıyoruz, bununla kütüphanenizi temizleyebilirsiniz. Ayarlar -> Yedekleme -> Alan boşalt kısmından ulaşabilirsiniz",
|
||||
"cLTitle2": "Video akış geliştirmeleri",
|
||||
"cLDesc2": "Artık doğrudan uygulamadan videolar için akış oluşturmayı manuel olarak tetikleyebilirsiniz. Ayrıca videolarınızın yüzde kaçının akış için işlendiğini gösteren yeni bir video akış ayarları ekranı da ekledik",
|
||||
"cLTitle3": "Performans İyileştirmeleri",
|
||||
"cLDesc3": "Daha iyi önbellek kullanımı ve daha pürüzsüz kaydırma deneyimi dahil olmak üzere perde arkasında birçok iyileştirme"
|
||||
|
||||
@@ -1510,10 +1510,10 @@
|
||||
"legacyInvite": "{email} запросив вас стати довіреною особою",
|
||||
"authToManageLegacy": "Авторизуйтесь, щоби керувати довіреними контактами",
|
||||
"useDifferentPlayerInfo": "Виникли проблеми з відтворенням цього відео? Натисніть і утримуйте тут, щоб спробувати інший плеєр.",
|
||||
"cLTitle1": "Схожі Зображення",
|
||||
"cLTitle1": "Схожі зображення",
|
||||
"cLDesc1": "Ми впроваджуємо нову систему на основі ML для виявлення схожих зображень, за допомогою якої ви можете очистити свою бібліотеку. Доступно в Налаштування->Резервна копія->Звільнити місце",
|
||||
"cLTitle2": "Ручна генерація відео потоку",
|
||||
"cLTitle2": "Покращення відео стрімінгу",
|
||||
"cLDesc2": "Тепер ви можете вручну запустити генерацію потоку для відео прямо з додатку. Ми також додали новий екран налаштувань відео стрімінгу, який покаже вам, який відсоток ваших відео було оброблено для стрімінгу",
|
||||
"cLTitle3": "Покращення Продуктивності",
|
||||
"cLTitle3": "Покращення продуктивності",
|
||||
"cLDesc3": "Численні покращення під капотом, включаючи краще використання кешу та більш плавну прокрутку"
|
||||
}
|
||||
@@ -1932,9 +1932,9 @@
|
||||
"hoorayyyy": "Hoorayyyy!",
|
||||
"nothingToTidyUpHere": "Ở đây đã ngon lành rồi",
|
||||
"deletingDash": "Đang xóa - ",
|
||||
"cLTitle1": "Hình Ảnh Tương Tự",
|
||||
"cLDesc1": "Chúng tôi đang giới thiệu một hệ thống dựa trên ML mới để phát hiện hình ảnh tương tự, bạn có thể dùng để dọn dẹp thư viện của mình. Có sẵn trong Cài đặt->Sao lưu->Giải phóng Dung lượng",
|
||||
"cLTitle2": "Tạo luồng video thủ công",
|
||||
"cLTitle1": "Hình ảnh tương tự",
|
||||
"cLDesc1": "Chúng tôi đang giới thiệu một hệ thống dựa trên ML mới để phát hiện hình ảnh tương tự, bạn có thể dùng để dọn dẹp thư viện của mình. Có sẵn trong Cài đặt -> Sao lưu -> Giải phóng dung lượng",
|
||||
"cLTitle2": "Cải thiện streaming video",
|
||||
"cLDesc2": "Bây giờ bạn có thể kích hoạt tạo luồng cho video trực tiếp từ ứng dụng. Chúng tôi cũng đã thêm màn hình cài đặt phát trực tuyến video mới sẽ cho bạn biết bao nhiêu phần trăm video của bạn đã được xử lý để phát trực tuyến",
|
||||
"cLTitle3": "Cải Thiện Hiệu Suất",
|
||||
"cLDesc3": "Nhiều cải thiện bên trong, bao gồm sử dụng bộ nhớ đệm tốt hơn và trải nghiệm cuộn mượt mà hơn"
|
||||
|
||||
@@ -1927,8 +1927,8 @@
|
||||
"hoorayyyy": "耶~~!",
|
||||
"nothingToTidyUpHere": "这里没什么可清理的",
|
||||
"cLTitle1": "相似图像",
|
||||
"cLDesc1": "我们正在推出一个基于机器学习的新系统来检测相似图像,您可以用它来清理您的图库。在 设置->备份->释放空间 中可用",
|
||||
"cLTitle2": "手动视频流生成",
|
||||
"cLDesc1": "我们正在推出一个基于机器学习的新系统来检测相似图像,您可以用它来清理您的图库。在 设置 -> 备份 -> 释放空间 中可用",
|
||||
"cLTitle2": "视频流媒体增强",
|
||||
"cLDesc2": "您现在可以直接从应用程序手动触发视频的流生成。我们还添加了一个新的视频流设置屏幕,它将显示您的视频中有百分之几已被处理用于流媒体播放",
|
||||
"cLTitle3": "性能改进",
|
||||
"cLDesc3": "多个底层改进,包括更好的缓存使用和更流畅的滚动体验"
|
||||
|
||||
@@ -14,7 +14,7 @@ import 'package:url_launcher/url_launcher_string.dart';
|
||||
class UpdateService {
|
||||
static const kUpdateAvailableShownTimeKey = "update_available_shown_time_key";
|
||||
static const changeLogVersionKey = "update_change_log_key";
|
||||
static const currentChangeLogVersion = 32;
|
||||
static const currentChangeLogVersion = 36;
|
||||
|
||||
LatestVersionInfo? _latestVersion;
|
||||
final _logger = Logger("UpdateService");
|
||||
|
||||
@@ -77,7 +77,7 @@ class _ChangeLogPageState extends State<ChangeLogPage> {
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.trailingIconSecondary,
|
||||
buttonSize: ButtonSize.large,
|
||||
labelText: AppLocalizations.of(context).rateTheApp,
|
||||
labelText: AppLocalizations.of(context).rateUs,
|
||||
icon: Icons.favorite_rounded,
|
||||
iconColor: enteColorScheme.primary500,
|
||||
onTap: () async {
|
||||
@@ -113,7 +113,6 @@ class _ChangeLogPageState extends State<ChangeLogPage> {
|
||||
context.l10n.cLDesc3,
|
||||
),
|
||||
]);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: Scrollbar(
|
||||
|
||||
@@ -672,10 +672,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage>
|
||||
await showGenericErrorDialog(context: context, error: e);
|
||||
}
|
||||
if (_isDisposed) return;
|
||||
setState(() {
|
||||
_pageState = SimilarImagesPageState.setup;
|
||||
});
|
||||
return;
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -438,11 +438,16 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
|
||||
}
|
||||
|
||||
await relevantTaskQueue.addTask(_localThumbnailQueueTaskId!, () async {
|
||||
final thumbnailBytes = await getThumbnailFromLocal(
|
||||
widget.file,
|
||||
size: widget.thumbnailSize,
|
||||
);
|
||||
completer.complete(thumbnailBytes);
|
||||
late final Uint8List? thumbnailBytes;
|
||||
try {
|
||||
thumbnailBytes = await getThumbnailFromLocal(
|
||||
widget.file,
|
||||
size: widget.thumbnailSize,
|
||||
);
|
||||
completer.complete(thumbnailBytes);
|
||||
} catch (e) {
|
||||
completer.completeError(e);
|
||||
}
|
||||
});
|
||||
|
||||
return completer.future;
|
||||
|
||||
@@ -12,7 +12,7 @@ description: ente photos application
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
|
||||
version: 1.2.2+1205
|
||||
version: 1.2.4+1205
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
|
||||
@@ -32,8 +32,11 @@ impl VectorDB {
|
||||
|
||||
if file_exists {
|
||||
println!("Loading index from disk.");
|
||||
// Use view to not load the index into memory. https://docs.rs/usearch/latest/usearch/struct.Index.html#method.view
|
||||
db.index.view(file_path).expect("Failed to load index");
|
||||
// Must use load() instead of view() because:
|
||||
// - view() creates a read-only memory-mapped view (immutable)
|
||||
// - load() loads the index into RAM for read/write operations (mutable)
|
||||
// Using view() causes "Can't add to an immutable index" error
|
||||
db.index.load(file_path).expect("Failed to load index");
|
||||
} else {
|
||||
println!("Creating new index.");
|
||||
db.save_index();
|
||||
@@ -46,9 +49,37 @@ impl VectorDB {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("Failed to create directory");
|
||||
}
|
||||
self.index
|
||||
.save(self.path.to_str().expect("Invalid path"))
|
||||
.expect("Failed to save index");
|
||||
|
||||
// Use atomic write: save to temp file first, then rename
|
||||
let temp_path = self.path.with_extension("tmp");
|
||||
let temp_path_str = temp_path.to_str().expect("Invalid temp path");
|
||||
|
||||
// Save to temporary file
|
||||
match self.index.save(temp_path_str) {
|
||||
Ok(_) => {
|
||||
// Atomic rename - guaranteed atomic on iOS/Android
|
||||
// This will atomically replace the existing file
|
||||
// The rename ensures we never have a partially written file,
|
||||
// even if the app is suspended or crashes
|
||||
match std::fs::rename(&temp_path, &self.path) {
|
||||
Ok(_) => {
|
||||
println!("Successfully saved index atomically");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Failed to rename temp index file: {:?}", e);
|
||||
// Try to clean up temp file
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
panic!("Failed to atomically save index: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Failed to save index to temp file: {:?}", e);
|
||||
// Try to clean up temp file if it exists
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
panic!("Failed to save index: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_capacity(&self, margin: usize) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
- Ashil: Changes meant for figuring out thumbnail not loading issue (this change has no scope for regressions or bugs)
|
||||
- Ashil: Add new section (show local ID & config local thumb queue) in debug section in settings to help in debugging thumbnail not loading issue.
|
||||
- Ashil: Revert diskLoadDeferDuration to 80ms (Fixes local thumbnails taking ~1 sec to load on scrolling gallery)
|
||||
- Ashil: Revert diskLoadDeferDuration to 500ms (Was 80ms before but fixes local thumbnail taking very long to load or never loading)
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
- Video streaming improvements
|
||||
- Added support for custom domain links
|
||||
- Image editor fixes:
|
||||
- Fixed bottom navigation bar color in light theme
|
||||
- Resolved initial color issue in paint editor
|
||||
- Added tap-to-reset with haptics for tune adjustments (brightness/exposure)
|
||||
- Similar images detection and deletion
|
||||
- Video streaming enhancements
|
||||
- Performance improvements
|
||||
@@ -255,8 +255,49 @@ const parseDateComponents = (
|
||||
const parseChrono = (
|
||||
s: string,
|
||||
locale: string,
|
||||
): LabelledSearchDateComponents[] =>
|
||||
chrono
|
||||
): LabelledSearchDateComponents[] => {
|
||||
// Use the appropriate chrono parser based on locale
|
||||
// For US locales, use the default parser (MM/DD/YYYY)
|
||||
// For other locales, use the GB parser (DD/MM/YYYY)
|
||||
const isUSLocale =
|
||||
locale.toLowerCase().includes("en-us") || locale.toLowerCase() === "en";
|
||||
|
||||
// Select the appropriate chrono instance based on locale
|
||||
let chronoInstance;
|
||||
if (isUSLocale) {
|
||||
// For US locale, use the default chrono parser (MM/DD/YYYY)
|
||||
chronoInstance = chrono;
|
||||
} else {
|
||||
// For non-US locales, use GB parser (DD/MM/YYYY) and add DD.MM.YYYY support
|
||||
chronoInstance = new chrono.Chrono(chrono.en.GB);
|
||||
|
||||
// Add parser for DD.MM.YYYY format (common in Germany, Switzerland, etc.)
|
||||
// This format uses dots as separators instead of slashes
|
||||
chronoInstance.parsers.push({
|
||||
pattern: () => /\b(\d{1,2})\.(\d{1,2})\.(\d{2,4})\b/,
|
||||
extract: (_context, match) => {
|
||||
if (!match[1] || !match[2] || !match[3]) return null;
|
||||
|
||||
const day = parseInt(match[1]);
|
||||
const month = parseInt(match[2]);
|
||||
let year = parseInt(match[3]);
|
||||
|
||||
// Handle 2-digit years
|
||||
if (year < 100) {
|
||||
year = year > 50 ? 1900 + year : 2000 + year;
|
||||
}
|
||||
|
||||
// Validate the date
|
||||
if (day < 1 || day > 31 || month < 1 || month > 12) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { day, month, year };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return chronoInstance
|
||||
.parse(s)
|
||||
.map((result) => {
|
||||
const p = result.start;
|
||||
@@ -287,6 +328,7 @@ const parseChrono = (
|
||||
return { components, label };
|
||||
})
|
||||
.filter((x) => x !== undefined);
|
||||
};
|
||||
|
||||
/** chrono does not parse years like "2024", so do it manually. */
|
||||
const parseYearComponents = (s: string): LabelledSearchDateComponents[] => {
|
||||
|
||||
Reference in New Issue
Block a user