Compare commits

...

1 Commits
main ... info

Author SHA1 Message Date
vishnukvmd
834bee3f46 Add Info
Co-authored-by: GitHub Copilot <noreply@github.com>
2025-09-11 18:33:45 +05:30
17 changed files with 2611 additions and 35 deletions

View File

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

View File

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

View File

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

View 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';
}
}

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

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

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

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