Compare commits
6 Commits
info
...
faces_grow
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d1845fbd6 | ||
|
|
9abce0883b | ||
|
|
f54e08bd62 | ||
|
|
aee62b6e64 | ||
|
|
cabb770958 | ||
|
|
943c6ab585 |
65
CLAUDE.md
Normal file
65
CLAUDE.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Repository Overview
|
||||
|
||||
Ente is a monorepo containing end-to-end encrypted cloud storage applications (Photos and Auth), with clients for multiple platforms and a self-hostable backend server. The codebase uses end-to-end encryption for all user data, ensuring privacy and security.
|
||||
|
||||
## Common Development Commands
|
||||
|
||||
### Web Development
|
||||
- **Run Photos app**: `cd web && yarn dev:photos` (port 3000)
|
||||
- **Run Auth app**: `cd web && yarn dev:auth` (port 3003)
|
||||
- **Build Photos**: `cd web && yarn build:photos`
|
||||
- **Lint and typecheck**: `cd web && yarn lint`
|
||||
- **Fix linting issues**: `cd web && yarn lint-fix`
|
||||
|
||||
### Mobile Development (Flutter)
|
||||
- **Bootstrap monorepo**: `cd mobile && melos bootstrap`
|
||||
- **Run Photos app**: `cd mobile && melos run:photos:apk`
|
||||
- **Run Auth app**: `cd mobile && melos run:auth:apk`
|
||||
- **Build Photos APK**: `cd mobile && melos build:photos:apk`
|
||||
- **Clean all projects**: `cd mobile && melos clean:all`
|
||||
|
||||
### Desktop Development (Electron)
|
||||
- **Run development**: `cd desktop && yarn dev`
|
||||
- **Build quickly**: `cd desktop && yarn build:quick`
|
||||
- **Full build**: `cd desktop && yarn build`
|
||||
- **Lint**: `cd desktop && yarn lint`
|
||||
|
||||
### Server Development (Go)
|
||||
- **Run locally**: `cd server && docker compose up --build`
|
||||
- **API endpoint**: `http://localhost:8080`
|
||||
- **Health check**: `curl http://localhost:8080/ping`
|
||||
|
||||
## Architecture
|
||||
|
||||
### Encryption Architecture
|
||||
The system implements end-to-end encryption using:
|
||||
- **Master Key**: Generated client-side, never leaves device unencrypted
|
||||
- **Key Encryption Key**: Derived from user password using Argon2
|
||||
- **Collection Keys**: Per-folder/album encryption keys
|
||||
- **File Keys**: Individual encryption for each file
|
||||
- Uses libsodium for all cryptographic operations
|
||||
|
||||
### Project Structure
|
||||
- `web/apps/` - Next.js web applications (photos, auth, accounts, etc.)
|
||||
- `mobile/apps/` - Flutter applications for iOS/Android
|
||||
- `desktop/` - Electron desktop application
|
||||
- `server/` - Go backend API (Museum)
|
||||
- `cli/` - Command-line interface
|
||||
- `docs/` - Documentation
|
||||
- `infra/` - Infrastructure and deployment configs
|
||||
|
||||
### Key Technologies
|
||||
- **Frontend**: Next.js, React, TypeScript
|
||||
- **Mobile**: Flutter, Dart
|
||||
- **Desktop**: Electron, TypeScript
|
||||
- **Backend**: Go, PostgreSQL, Docker
|
||||
- **Cryptography**: libsodium, end-to-end encryption
|
||||
|
||||
## Testing Approach
|
||||
- Check for test scripts in package.json files
|
||||
- Mobile tests can be run with Flutter's test command
|
||||
- Server tests use Go's built-in testing framework
|
||||
@@ -48,11 +48,7 @@ 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. 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.
|
||||
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.
|
||||
|
||||
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,52 +349,5 @@
|
||||
"mastodon": "Mastodon",
|
||||
"matrix": "Matrix",
|
||||
"discord": "Discord",
|
||||
"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"
|
||||
"reddit": "Reddit"
|
||||
}
|
||||
|
||||
@@ -1017,288 +1017,6 @@ 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,155 +534,4 @@ 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';
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
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,7 +1,6 @@
|
||||
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';
|
||||
@@ -24,7 +23,6 @@ class EnteFile {
|
||||
String? thumbnailDecryptionHeader;
|
||||
String? metadataDecryptionHeader;
|
||||
int? fileSize;
|
||||
FileType? fileType;
|
||||
|
||||
String? mMdEncodedJson;
|
||||
int mMdVersion = 0;
|
||||
|
||||
@@ -17,7 +17,6 @@ const motionVideoIndexKey = "mvi";
|
||||
const noThumbKey = "noThumb";
|
||||
const dateTimeKey = 'dateTime';
|
||||
const offsetTimeKey = 'offsetTime';
|
||||
const infoKey = 'info';
|
||||
|
||||
class MagicMetadata {
|
||||
// 0 -> visible
|
||||
@@ -75,11 +74,6 @@ 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,
|
||||
@@ -95,7 +89,6 @@ class PubMagicMetadata {
|
||||
this.dateTime,
|
||||
this.offsetTime,
|
||||
this.sv,
|
||||
this.info,
|
||||
});
|
||||
|
||||
factory PubMagicMetadata.fromEncodedJson(String encodedJson) =>
|
||||
@@ -121,7 +114,6 @@ class PubMagicMetadata {
|
||||
dateTime: map[dateTimeKey],
|
||||
offsetTime: map[offsetTimeKey],
|
||||
sv: safeParseInt(map[streamVersionKey], streamVersionKey),
|
||||
info: map[infoKey],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -97,66 +97,6 @@ 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;
|
||||
}
|
||||
@@ -720,43 +660,6 @@ 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 {
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
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();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
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,7 +20,6 @@ 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';
|
||||
@@ -687,41 +686,34 @@ class _HomePageState extends UploaderPageState<HomePage>
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
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,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FloatingActionButton(
|
||||
heroTag: "information",
|
||||
heroTag: "createCollection",
|
||||
mini: true,
|
||||
onPressed: () {
|
||||
_toggleFab();
|
||||
_showInformationDialog();
|
||||
_createCollection();
|
||||
},
|
||||
backgroundColor:
|
||||
getEnteColorScheme(context).fillBase,
|
||||
child: const Icon(Icons.edit_document),
|
||||
child: const Icon(Icons.create_new_folder),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -735,29 +727,22 @@ class _HomePageState extends UploaderPageState<HomePage>
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
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,
|
||||
),
|
||||
),
|
||||
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),
|
||||
@@ -823,12 +808,4 @@ class _HomePageState extends UploaderPageState<HomePage>
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _showInformationDialog() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const InformationPage(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,203 +2,66 @@
|
||||
|
||||
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
|
||||
### Prerequisites
|
||||
- Flutter v3.32.8
|
||||
- Rust (for Flutter Rust Bridge)
|
||||
- Flutter Rust Bridge: `cargo install flutter_rust_bridge_codegen`
|
||||
|
||||
# Run Photos app specifically
|
||||
melos run:photos:apk
|
||||
### Development
|
||||
- **Run development build**: `flutter run -t lib/main.dart --flavor independent`
|
||||
- **Alternative with env file**: `./run.sh` (uses .env file for configuration)
|
||||
- **Generate Rust bindings**: `flutter_rust_bridge_codegen generate`
|
||||
|
||||
# Build Photos APK
|
||||
melos build:photos:apk
|
||||
### Build Commands
|
||||
- **Build APK**: `flutter build apk --release --flavor independent`
|
||||
- **Build iOS**: `flutter build ios`
|
||||
- **iOS setup**: `cd ios && pod install && cd ..`
|
||||
|
||||
# Clean Photos app
|
||||
melos clean:photos
|
||||
```
|
||||
### Testing
|
||||
- **Run all tests**: `flutter test`
|
||||
- **Run specific test**: `flutter test test/path/to/test_file.dart`
|
||||
- **Integration tests**: `flutter test integration_test/`
|
||||
- **Performance tests**: Use scripts in `scripts/` directory
|
||||
|
||||
### Direct Flutter Commands
|
||||
```bash
|
||||
# Development run with environment variables
|
||||
./run.sh # Uses .env file with --flavor dev
|
||||
### Code Generation
|
||||
- **Generate localization**: Automatically runs with flutter (see l10n.yaml)
|
||||
- **Generate launcher icons**: `dart run flutter_launcher_icons`
|
||||
- **Generate splash screen**: `dart run flutter_native_splash:create`
|
||||
|
||||
# Development run without env file
|
||||
flutter run -t lib/main.dart --flavor independent
|
||||
## Architecture
|
||||
|
||||
# Build release APK
|
||||
flutter build apk --release --flavor independent
|
||||
### Core Services Structure
|
||||
The app follows a service-oriented architecture with dependency injection via `service_locator.dart`:
|
||||
|
||||
# iOS build
|
||||
cd ios && pod install && cd ..
|
||||
flutter build ios
|
||||
```
|
||||
- **Authentication**: `services/account/` - handles user authentication, billing, passkeys
|
||||
- **Sync Services**: `services/sync/` - local and remote file synchronization
|
||||
- **Machine Learning**: `services/machine_learning/` - face detection, semantic search, ML models
|
||||
- **Collections**: `services/collections_service.dart` - manages photo albums and folders
|
||||
- **File Management**: `services/files_service.dart`, `utils/file_uploader.dart`
|
||||
|
||||
### Code Quality
|
||||
```bash
|
||||
# Static analysis and linting
|
||||
flutter analyze .
|
||||
### Data Layer
|
||||
- **Databases**: SQLite with migrations via `sqflite_migration`
|
||||
- `db/files_db.dart` - main file storage
|
||||
- `db/collections_db.dart` - collections and albums
|
||||
- `db/ml/` - ML-related data (embeddings, face data)
|
||||
- **Models**: `models/` directory contains data models with freezed for immutables
|
||||
|
||||
# Run tests
|
||||
flutter test
|
||||
```
|
||||
### UI Architecture
|
||||
- **State Management**: Event-based architecture using `event_bus`
|
||||
- **Navigation**: Standard Flutter navigation with named routes
|
||||
- **Theming**: Custom theme system in `theme/` and `ente_theme_data.dart`
|
||||
- **Main screens**: Located in `ui/` with feature-specific subdirectories
|
||||
|
||||
## Architecture Overview
|
||||
### Key Features Implementation
|
||||
- **End-to-end encryption**: Uses `ente_crypto` plugin with libsodium
|
||||
- **Photo upload**: Background upload via `workmanager` and custom uploader
|
||||
- **Video playback**: Multiple players (media_kit, video_player, chewie)
|
||||
- **Image editing**: `pro_image_editor` and custom video editor
|
||||
- **Home widgets**: iOS and Android widgets in `ios/EnteWidget/` and via `home_widget` package
|
||||
|
||||
### 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
|
||||
### Platform-Specific Code
|
||||
- **Android**: Flavors configured in `android/app/build.gradle`
|
||||
- **iOS**: Widget extensions in `ios/EnteWidget/`
|
||||
- **Rust integration**: FFI bridge in `rust/` directory
|
||||
430
mobile/apps/photos/docs/FACES_THROUGH_TIME_DESIGN_V2.md
Normal file
430
mobile/apps/photos/docs/FACES_THROUGH_TIME_DESIGN_V2.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# Faces Through Time - Feature Design Document (Final MVP)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
"Faces Through Time" is a delightful slideshow feature that automatically displays a person's face photos chronologically across all the years, creating a visual journey of how they've grown and changed. The feature includes simple sharing capabilities to help spread the joy and potentially grow the product organically.
|
||||
|
||||
## Core Requirements
|
||||
|
||||
### Eligibility Criteria
|
||||
|
||||
- **Minimum time span**: 7 consecutive years of photos
|
||||
- **Photos per year**: At least 4 faces per year
|
||||
- **Face quality**: Minimum face score of 0.85
|
||||
- **Age requirement**: All photos must be from after the person turned 5 years old
|
||||
- **Total faces**: Minimum 28 faces meeting above criteria
|
||||
|
||||
### Display Requirements
|
||||
|
||||
- **Faces per year**: Exactly 4 (using quantile selection)
|
||||
- **Display duration**: 2 seconds per face
|
||||
- **Format**: Single face at a time, full screen
|
||||
- **Padding**: Standard 40% padding (use existing face crop logic)
|
||||
|
||||
## User Experience Flow
|
||||
|
||||
### 1. Progressive Discovery
|
||||
|
||||
When user navigates to `PeoplePage`:
|
||||
|
||||
```
|
||||
1. Background eligibility check (instant, non-blocking)
|
||||
2. If eligible → Check if already viewed
|
||||
3. If not viewed → Start face selection & thumbnail generation
|
||||
4. Generate thumbnails (max 4 concurrent)
|
||||
5. When ready → Show banner at top of page
|
||||
6. User taps banner → Opens slideshow, mark as viewed
|
||||
```
|
||||
|
||||
### 2. Banner & Menu Logic
|
||||
|
||||
**First Time (Not Viewed)**:
|
||||
|
||||
- Show eye-catching banner at top of `PeoplePage`
|
||||
- Text: "How [Name] grew over the years"
|
||||
- Appears only when all thumbnails are ready
|
||||
|
||||
**After First View**:
|
||||
|
||||
- No banner shown
|
||||
- Add menu option in top-right overflow menu
|
||||
- Menu text: "Show face timeline"
|
||||
- Clicking menu item opens slideshow directly
|
||||
|
||||
### 3. Slideshow Page
|
||||
|
||||
**Layout**:
|
||||
|
||||
- Full-screen face thumbnail display
|
||||
- Age display OR relative time below face:
|
||||
- With DOB (age > 5): "Age 7 years 2 months"
|
||||
- With DOB (current year): "6 months ago"
|
||||
- Without DOB: "8 years ago"
|
||||
- Minimal UI overlay
|
||||
- Auto-advance every 2 seconds
|
||||
|
||||
**Interaction Controls**:
|
||||
|
||||
- **Tap center**: Pause/Resume
|
||||
- **Tap and hold**: Pause (release to resume)
|
||||
- **Tap left side**: Previous face
|
||||
- **Tap right side**: Next face
|
||||
- **Close button**: Top-left corner
|
||||
- **Share button**: Top-right corner
|
||||
|
||||
## Face Selection Algorithm
|
||||
|
||||
### Simple Quantile Selection
|
||||
|
||||
For each eligible year:
|
||||
|
||||
1. Get all faces with score ≥ 0.85
|
||||
2. Filter out faces where person age ≤ 4 years (if DOB available)
|
||||
3. Sort faces by timestamp
|
||||
4. Select faces at positions:
|
||||
- 1st percentile (earliest)
|
||||
- 25th percentile
|
||||
- 50th percentile (median)
|
||||
- 75th percentile
|
||||
|
||||
This ensures even distribution across the year without complex logic.
|
||||
|
||||
## Sharing Feature (MVP)
|
||||
|
||||
### Share Flow
|
||||
|
||||
1. User taps share button in slideshow
|
||||
2. Generate temporary video file:
|
||||
- 1 second per face (faster than slideshow)
|
||||
- Include age/year text overlay
|
||||
- Add subtle Ente watermark
|
||||
- Resolution: 720p (balance quality/size)
|
||||
3. Open system share sheet
|
||||
4. Clean up temp file after sharing
|
||||
|
||||
### Video Generation
|
||||
|
||||
```dart
|
||||
// Pseudocode for video generation
|
||||
final frames = timeline.entries.map((entry) => {
|
||||
'image': faceThumbnail,
|
||||
'text': entry.ageText ?? entry.relativeTimeText,
|
||||
'duration': 1000, // 1 second
|
||||
});
|
||||
final videoPath = await generateVideo(frames, watermark: true);
|
||||
Share.shareFiles([videoPath]);
|
||||
```
|
||||
|
||||
### Privacy Considerations
|
||||
|
||||
- Strip all metadata from video
|
||||
- Don't include person's name in video
|
||||
- Watermark: "Created with Ente Photos"
|
||||
- Temporary file deleted after share
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Caching Strategy
|
||||
|
||||
**Cache Structure** (JSON file):
|
||||
|
||||
```json
|
||||
{
|
||||
"personId": "person_123",
|
||||
"generatedAt": "2024-01-15T10:30:00Z",
|
||||
"faceIds": ["face_1", "face_2", ..., "face_28"],
|
||||
"hasBeenViewed": true,
|
||||
"version": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Cache Implementation** (Similar to `similar_images_service.dart`):
|
||||
|
||||
```dart
|
||||
Future<String> _getCachePath(String personId) async {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
return "${dir.path}/cache/faces_timeline_${personId}.json";
|
||||
}
|
||||
|
||||
Future<void> _cacheTimeline(FaceTimeline timeline) async {
|
||||
final cachePath = await _getCachePath(timeline.personId);
|
||||
await writeToJsonFile(cachePath, timeline.toJson());
|
||||
}
|
||||
```
|
||||
|
||||
**Cache Rules**:
|
||||
|
||||
- Cache persists for 1 year
|
||||
- Only invalidate if older than 1 year
|
||||
- One cache file per person
|
||||
- No limit on number of cached persons
|
||||
|
||||
### Thumbnail Generation
|
||||
|
||||
**Batch Processing**:
|
||||
|
||||
```dart
|
||||
// Generate thumbnails in batches of 4
|
||||
for (int i = 0; i < faceIds.length; i += 4) {
|
||||
final batch = faceIds.skip(i).take(4).toList();
|
||||
final thumbnails = await Future.wait(
|
||||
batch.map((faceId) => generateFaceThumbnail(faceId))
|
||||
);
|
||||
// Store thumbnails
|
||||
}
|
||||
```
|
||||
|
||||
**Use Existing Methods**:
|
||||
|
||||
```dart
|
||||
// Use standard face cropping from face_thumbnail_cache.dart
|
||||
final cropMap = await getCachedFaceCrops(
|
||||
file,
|
||||
faces,
|
||||
useFullFile: true, // Always use full file for quality
|
||||
useTempCache: false, // Use persistent cache
|
||||
);
|
||||
```
|
||||
|
||||
### View State Tracking
|
||||
|
||||
**Storage**:
|
||||
|
||||
```dart
|
||||
// Simple key-value storage for viewed state
|
||||
final viewedKey = "faces_timeline_viewed_${personId}";
|
||||
final hasViewed = prefs.getBool(viewedKey) ?? false;
|
||||
if (!hasViewed) {
|
||||
// Show banner
|
||||
}
|
||||
// After viewing:
|
||||
await prefs.setBool(viewedKey, true);
|
||||
```
|
||||
|
||||
## Age Filtering Logic
|
||||
|
||||
### When DOB is Available
|
||||
|
||||
```dart
|
||||
bool isEligibleFace(Face face, DateTime? dob, DateTime photoTime) {
|
||||
if (dob == null) return true;
|
||||
|
||||
final ageAtPhoto = photoTime.difference(dob);
|
||||
final yearsOld = ageAtPhoto.inDays / 365.25;
|
||||
|
||||
// Exclude photos where person was 4 or younger
|
||||
return yearsOld > 4.0;
|
||||
}
|
||||
```
|
||||
|
||||
### Eligibility Check Update
|
||||
|
||||
Must have 7 consecutive years where ALL photos are:
|
||||
|
||||
- After person turned 5 (if DOB known)
|
||||
- Meeting quality threshold (score ≥ 0.85)
|
||||
|
||||
## Data Flow Summary
|
||||
|
||||
```
|
||||
PeoplePage Load
|
||||
↓
|
||||
Check Eligibility (with age filter)
|
||||
↓
|
||||
Check if Viewed
|
||||
├─→ Not Viewed: Show banner when ready
|
||||
└─→ Viewed: Add menu option
|
||||
↓
|
||||
User Interaction
|
||||
↓
|
||||
Load/Generate Timeline
|
||||
↓
|
||||
Show Slideshow
|
||||
↓
|
||||
Optional: Share as Video
|
||||
```
|
||||
|
||||
## Implementation Components
|
||||
|
||||
### New Files Required
|
||||
|
||||
1. **Service**: `faces_through_time_service.dart`
|
||||
|
||||
- Eligibility checking
|
||||
- Face selection logic
|
||||
- Cache management
|
||||
|
||||
2. **UI**: `faces_through_time_page.dart`
|
||||
|
||||
- Slideshow display
|
||||
- Auto-advance logic
|
||||
- Age/time display
|
||||
|
||||
3. **Widget**: `faces_timeline_banner.dart`
|
||||
- Banner component for PeoplePage
|
||||
- Loading state management
|
||||
|
||||
### Database Queries Needed
|
||||
|
||||
```dart
|
||||
// Get person's photo time span
|
||||
Future<int> getPersonPhotoYearSpan(String personId);
|
||||
|
||||
// Get high-quality faces with timestamps
|
||||
Future<List<FaceWithTimestamp>> getPersonHighQualityFaces(
|
||||
String personId,
|
||||
double minScore,
|
||||
);
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
|
||||
1. **PeoplePage** (`people_page.dart`):
|
||||
|
||||
- Add `FacesThroughTimeService` initialization
|
||||
- Add banner widget in header section
|
||||
- Trigger background processing on page load
|
||||
|
||||
2. **Face Quality Check**:
|
||||
|
||||
- Use existing `face.score` field
|
||||
- Filter with score >= 0.85
|
||||
|
||||
3. **Thumbnail Generation**:
|
||||
- Use existing `getCachedFaceCrops` with `useFullFile: true`
|
||||
- Leverage existing cache system
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### Concurrent Limits
|
||||
|
||||
- Max 4 thumbnail generations at once
|
||||
- Sequential batch processing
|
||||
- Total generation time: ~7-10 seconds for 28 faces
|
||||
|
||||
### Memory Management
|
||||
|
||||
- Load 5 thumbnails ahead (current + 4)
|
||||
- Release thumbnails >5 positions behind
|
||||
- Peak memory: ~15MB (5 thumbnails × 3MB)
|
||||
|
||||
### Background Processing
|
||||
|
||||
- All computation done in background
|
||||
- No UI blocking
|
||||
- Silent failure (just log errors)
|
||||
|
||||
## Edge Cases Handled
|
||||
|
||||
### Age-Related
|
||||
|
||||
- Person with DOB but some photos before age 5: Filter them out
|
||||
- Person without DOB: Use all photos
|
||||
- Calculating age: Use precise date math
|
||||
|
||||
### UI States
|
||||
|
||||
- Banner dismissed accidentally: Access via menu
|
||||
- Slideshow interrupted: Resume from beginning
|
||||
- Share cancelled: Clean up temp files
|
||||
|
||||
### Data Issues
|
||||
|
||||
- Missing thumbnails: Skip that face
|
||||
- Corrupted cache: Regenerate
|
||||
- Face selection fails: Don't show feature
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Primary Goals
|
||||
|
||||
- Users express delight and share with others
|
||||
- Organic growth through shared timelines
|
||||
- High completion rate (>80%)
|
||||
|
||||
### Tracking (Anonymous)
|
||||
|
||||
- Feature discovery rate
|
||||
- View completion percentage
|
||||
- Share button usage
|
||||
- Video shares completed
|
||||
|
||||
## Final Specifications
|
||||
|
||||
### Constants
|
||||
|
||||
```dart
|
||||
const kMinYearSpan = 7;
|
||||
const kPhotosPerYear = 4;
|
||||
const kMinFaceScore = 0.85;
|
||||
const kMinAge = 5.0; // years
|
||||
const kSlideshowInterval = 2000; // ms
|
||||
const kVideoFrameDuration = 1000; // ms
|
||||
const kMaxConcurrentThumbnails = 4;
|
||||
const kCacheValidityDays = 365;
|
||||
const kThumbnailPadding = 0.4; // 40% standard
|
||||
```
|
||||
|
||||
### Text Strings
|
||||
|
||||
```dart
|
||||
// Banner
|
||||
"How ${person.name} grew over the years"
|
||||
|
||||
// Menu option
|
||||
"Show face timeline"
|
||||
|
||||
// Age display (with DOB)
|
||||
"Age ${years} years${months > 0 ? ' ${months} months' : ''}"
|
||||
|
||||
// Relative time (without DOB)
|
||||
"${years} years ago"
|
||||
"${months} months ago"
|
||||
"Recently"
|
||||
|
||||
// Share watermark
|
||||
"Created with Ente Photos"
|
||||
```
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Core Features
|
||||
|
||||
- [ ] Eligibility check with age filtering
|
||||
- [ ] Quantile-based face selection
|
||||
- [ ] JSON caching system
|
||||
- [ ] Batch thumbnail generation
|
||||
- [ ] View state tracking
|
||||
- [ ] Banner display logic
|
||||
- [ ] Menu option for viewed timelines
|
||||
|
||||
### Slideshow UI
|
||||
|
||||
- [ ] Auto-advance timer (2 seconds)
|
||||
- [ ] Tap to pause/resume
|
||||
- [ ] Tap sides for navigation
|
||||
- [ ] Age/time display
|
||||
- [ ] Close button
|
||||
|
||||
### Sharing Feature
|
||||
|
||||
- [ ] Video generation from thumbnails
|
||||
- [ ] Text overlay on frames
|
||||
- [ ] Watermark addition
|
||||
- [ ] System share sheet integration
|
||||
- [ ] Temp file cleanup
|
||||
|
||||
## Questions Resolved
|
||||
|
||||
1. **Face selection**: Quantile approach (1st, 25th, 50th, 75th) ✓
|
||||
2. **Banner behavior**: Show once until viewed ✓
|
||||
3. **Controls**: Tap to pause, sides to navigate ✓
|
||||
4. **Age filtering**: Exclude ≤4 years old ✓
|
||||
5. **Face cropping**: Use standard padding ✓
|
||||
6. **Cache duration**: 1 year ✓
|
||||
7. **Loading**: No indicators, silent generation ✓
|
||||
8. **Sharing**: Simple video export ✓
|
||||
|
||||
## Ready for Implementation
|
||||
|
||||
This design is now complete and ready for implementation. The MVP balances simplicity with user delight, includes viral sharing potential, and leverages existing infrastructure efficiently.
|
||||
File diff suppressed because it is too large
Load Diff
58
mobile/apps/photos/docs/FACES_THROUGH_TIME_PROGRESS.md
Normal file
58
mobile/apps/photos/docs/FACES_THROUGH_TIME_PROGRESS.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Faces Through Time - Implementation Progress
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Phase 1: Database & Models
|
||||
- [x] Create FaceTimeline model class
|
||||
- [x] Create FaceTimelineEntry model class
|
||||
- [x] Add getPersonFileIds query to MLDataDB
|
||||
- [x] Add getPersonFacesWithScores query to MLDataDB
|
||||
- [ ] Test database queries
|
||||
|
||||
### Phase 2: Core Service
|
||||
- [x] Create FacesThroughTimeService class
|
||||
- [x] Implement eligibility checking logic
|
||||
- [x] Implement quantile-based face selection
|
||||
- [x] Implement JSON caching mechanism
|
||||
- [x] Add view state tracking with SharedPreferences
|
||||
|
||||
### Phase 3: Thumbnail Generation
|
||||
- [ ] Integrate with existing face thumbnail cache
|
||||
- [ ] Implement batch processing (4 at a time)
|
||||
- [ ] Add memory management for thumbnails
|
||||
- [ ] Test thumbnail generation
|
||||
|
||||
### Phase 4: UI Components
|
||||
- [x] Create FacesTimelineBanner widget
|
||||
- [x] Create FacesThroughTimePage (slideshow)
|
||||
- [x] Implement auto-advance timer (2 seconds)
|
||||
- [x] Add tap controls (pause/resume/navigate)
|
||||
- [x] Add age/time display logic
|
||||
|
||||
### Phase 5: Integration
|
||||
- [x] Create FacesTimelineReadyEvent
|
||||
- [x] Integrate with PeoplePage
|
||||
- [x] Add banner display logic
|
||||
- [ ] Add menu option for viewed timelines
|
||||
- [x] Test end-to-end flow
|
||||
|
||||
### Phase 6: Video Generation
|
||||
- [ ] Create FacesThroughTimeVideoService
|
||||
- [ ] Implement FFmpeg video generation
|
||||
- [ ] Add text overlays for age/time
|
||||
- [ ] Add Ente watermark
|
||||
- [ ] Integrate with system share sheet
|
||||
|
||||
### Phase 7: Testing & Polish
|
||||
- [ ] Test eligibility with various photo counts
|
||||
- [ ] Verify age filtering (exclude ≤4 years)
|
||||
- [ ] Test video generation and sharing
|
||||
- [ ] Performance optimization
|
||||
- [ ] Error handling for edge cases
|
||||
|
||||
## Current Status
|
||||
Starting implementation...
|
||||
|
||||
## Notes
|
||||
- Following design doc: FACES_THROUGH_TIME_DESIGN_V2.md
|
||||
- Following implementation guide: FACES_THROUGH_TIME_IMPLEMENTATION_COMPLETE.md
|
||||
@@ -312,7 +312,7 @@ DEPENDENCIES:
|
||||
- workmanager (from `.symlinks/plugins/workmanager/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
https://github.com/ente-io/ffmpeg-kit-custom-repo-ios.git:
|
||||
https://github.com/ente-io/ffmpeg-kit-custom-repo-ios:
|
||||
- ffmpeg_kit_custom
|
||||
trunk:
|
||||
- Firebase
|
||||
@@ -457,85 +457,85 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/workmanager/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
app_links: f3e17e4ee5e357b39d8b95290a9b2c299fca71c6
|
||||
battery_info: b6c551049266af31556b93c9d9b9452cfec0219f
|
||||
connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d
|
||||
cupertino_http: 947a233f40cfea55167a49f2facc18434ea117ba
|
||||
dart_ui_isolate: d5bcda83ca4b04f129d70eb90110b7a567aece14
|
||||
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
||||
emoji_picker_flutter: fe2e6151c5b548e975d546e6eeb567daf0962a58
|
||||
app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7
|
||||
battery_info: 83f3aae7be2fccefab1d2bf06b8aa96f11c8bcdd
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
|
||||
dart_ui_isolate: 46f6714abe6891313267153ef6f9748d8ecfcab1
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
emoji_picker_flutter: ed468d9746c21711e66b2788880519a9de5de211
|
||||
ffmpeg_kit_custom: 682b4f2f1ff1f8abae5a92f6c3540f2441d5be99
|
||||
ffmpeg_kit_flutter: 9dce4803991478c78c6fb9f972703301101095fe
|
||||
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
|
||||
ffmpeg_kit_flutter: 915b345acc97d4142e8a9a8549d177ff10f043f5
|
||||
file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
|
||||
Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e
|
||||
firebase_core: cf4d42a8ac915e51c0c2dc103442f3036d941a2d
|
||||
firebase_messaging: fee490327c1aae28a0da1e65fca856547deca493
|
||||
firebase_core: ece862f94b2bc72ee0edbeec7ab5c7cb09fe1ab5
|
||||
firebase_messaging: e1a5fae495603115be1d0183bc849da748734e2b
|
||||
FirebaseCore: efb3893e5b94f32b86e331e3bd6dadf18b66568e
|
||||
FirebaseCoreInternal: 9afa45b1159304c963da48addb78275ef701c6b4
|
||||
FirebaseInstallations: 317270fec08a5d418fdbc8429282238cab3ac843
|
||||
FirebaseMessaging: 3b26e2cee503815e01c3701236b020aa9b576f09
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
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
|
||||
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
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
|
||||
in_app_purchase_storekit: a1ce04056e23eecc666b086040239da7619cd783
|
||||
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||
launcher_icon_switcher: 8e0ad2131a20c51c1dd939896ee32e70cd845b37
|
||||
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
|
||||
in_app_purchase_storekit: d1a48cb0f8b29dbf5f85f782f5dd79b21b90a5e6
|
||||
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
|
||||
launcher_icon_switcher: 84c218d233505aa7d8655d8fa61a3ba802c022da
|
||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||
local_auth_ios: 5046a18c018dd973247a0564496c8898dbb5adf9
|
||||
local_auth_ios: f7a1841beef3151d140a967c2e46f30637cdf451
|
||||
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
|
||||
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
|
||||
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
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
native_video_player: 29ab24a926804ac8c4a57eb6d744c7d927c2bc3e
|
||||
objective_c: 77e887b5ba1827970907e10e832eec1683f3431d
|
||||
onnxruntime: e7c2ae44385191eaad5ae64c935a72debaddc997
|
||||
native_video_player: 6809dec117e8997161dbfb42a6f90d6df71a504d
|
||||
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
|
||||
onnxruntime: f9b296392c96c42882be020a59dbeac6310d81b2
|
||||
onnxruntime-c: a909204639a1f035f575127ac406f781ac797c9c
|
||||
onnxruntime-objc: b6fab0f1787aa6f7190c2013f03037df4718bd8b
|
||||
open_mail_app: 70273c53f768beefdafbe310c3d9086e4da3cb02
|
||||
open_mail_app: 7314a609e88eed22d53671279e189af7a0ab0f11
|
||||
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
photo_manager: 81954a1bf804b6e882d0453b3b6bc7fad7b47d3d
|
||||
privacy_screen: 1a131c052ceb3c3659934b003b0d397c2381a24e
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62
|
||||
privacy_screen: 3159a541f5d3a31bea916cfd4e58f9dc722b3fd4
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
receive_sharing_intent: 79c848f5b045674ad60b9fea3bafea59962ad2c1
|
||||
rive_common: 4743dbfd2911c99066547a3c6454681e0fa907df
|
||||
rust_lib_photos: 8813b31af48ff02ca75520cbc81a363a13d51a84
|
||||
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
|
||||
rive_common: dd421daaf9ae69f0125aa761dd96abd278399952
|
||||
rust_lib_photos: 04d3901908d2972192944083310b65abf410774c
|
||||
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
||||
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
|
||||
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
|
||||
sentry_flutter: 2df8b0aab7e4aba81261c230cbea31c82a62dd1b
|
||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
sqlite3: 1d85290c3321153511f6e900ede7a1608718bbd5
|
||||
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
|
||||
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
|
||||
|
||||
PODFILE CHECKSUM: cce2cd3351d3488dca65b151118552b680e23635
|
||||
|
||||
|
||||
@@ -39,26 +39,10 @@ class ClipVectorDB {
|
||||
final documentsDirectory = await getApplicationDocumentsDirectory();
|
||||
final String dbPath = join(documentsDirectory.path, _databaseName);
|
||||
_logger.info("Opening vectorDB access: DB path " + dbPath);
|
||||
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 vectorDB = VectorDb(
|
||||
filePath: dbPath,
|
||||
dimensions: _embeddingDimension,
|
||||
);
|
||||
final stats = await getIndexStats(vectorDB);
|
||||
_logger.info("VectorDB connection opened with stats: ${stats.toString()}");
|
||||
|
||||
|
||||
@@ -1505,4 +1505,52 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
final List<Object?> params = [personOrClusterID];
|
||||
await db.execute(sql, params);
|
||||
}
|
||||
|
||||
// Faces Through Time feature queries
|
||||
// Note: These methods need to be used in conjunction with FilesDB
|
||||
// to get creation_time information, as that's stored in a different database
|
||||
Future<List<int>> getPersonFileIds(String personId) async {
|
||||
final db = await instance.asyncDB;
|
||||
final result = await db.getAll(
|
||||
'''
|
||||
SELECT DISTINCT fc.$fileIDColumn
|
||||
FROM $facesTable fc
|
||||
JOIN $faceClustersTable fcluster ON fc.$faceIDColumn = fcluster.$faceIDColumn
|
||||
JOIN $clusterPersonTable cp ON fcluster.$clusterIDColumn = cp.$clusterIDColumn
|
||||
WHERE cp.$personIdColumn = ?
|
||||
''',
|
||||
[personId],
|
||||
);
|
||||
return result.map((row) => row[fileIDColumn] as int).toList();
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getPersonFacesWithScores(
|
||||
String personId,
|
||||
double minScore,
|
||||
) async {
|
||||
final db = await instance.asyncDB;
|
||||
final results = await db.getAll(
|
||||
'''
|
||||
SELECT
|
||||
fc.$faceIDColumn as faceId,
|
||||
fc.$fileIDColumn as fileId,
|
||||
fc.$faceScore as score,
|
||||
fc.$faceBlur as blur
|
||||
FROM $facesTable fc
|
||||
JOIN $faceClustersTable fcluster ON fc.$faceIDColumn = fcluster.$faceIDColumn
|
||||
JOIN $clusterPersonTable cp ON fcluster.$clusterIDColumn = cp.$clusterIDColumn
|
||||
WHERE cp.$personIdColumn = ? AND fc.$faceScore >= ?
|
||||
''',
|
||||
[personId, minScore],
|
||||
);
|
||||
|
||||
return results.map((row) {
|
||||
return {
|
||||
'faceId': row['faceId'],
|
||||
'fileId': row['fileId'],
|
||||
'score': row['score'],
|
||||
'blur': row['blur'],
|
||||
};
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class FacesTimelineReadyEvent {
|
||||
final String personId;
|
||||
|
||||
FacesTimelineReadyEvent(this.personId);
|
||||
}
|
||||
@@ -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": "Video-Streaming-Verbesserungen",
|
||||
"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",
|
||||
"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": "Video streaming enhancements",
|
||||
"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",
|
||||
"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": "Mejoras 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": "Generación manual 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": "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",
|
||||
"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",
|
||||
"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": "Miglioramenti streaming 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": "Generazione manuale stream 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": "Video streaming verbeteringen",
|
||||
"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",
|
||||
"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": "Prestatieverbeteringen",
|
||||
"cLTitle3": "Prestatie Verbeteringen",
|
||||
"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": "Video streaming forbedringer",
|
||||
"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",
|
||||
"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": "Ytelsesforbedringer",
|
||||
"cLTitle3": "Ytelsesforbedringar",
|
||||
"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": "Ulepszenia streamingu wideo",
|
||||
"cLTitle2": "Ręczne generowanie strumienia 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": "Melhorias do streaming 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": "Geração manual de stream 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 desempenho",
|
||||
"cLTitle3": "Melhorias de Performance",
|
||||
"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": "Melhorias do streaming 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": "Geração manual de stream 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 desempenho",
|
||||
"cLTitle3": "Melhorias de Performance",
|
||||
"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": "Îmbunătățiri streaming video",
|
||||
"cLTitle2": "Generarea manuală a fluxului 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": "Video akış geliştirmeleri",
|
||||
"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",
|
||||
"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": "Cải thiện streaming video",
|
||||
"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",
|
||||
"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": "多个底层改进,包括更好的缓存使用和更流畅的滚动体验"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:photos/models/faces_through_time/face_timeline_entry.dart';
|
||||
|
||||
class FaceTimeline {
|
||||
final String personId;
|
||||
final List<FaceTimelineEntry> entries;
|
||||
final DateTime generatedAt;
|
||||
final bool hasBeenViewed;
|
||||
final int version;
|
||||
|
||||
FaceTimeline({
|
||||
required this.personId,
|
||||
required this.entries,
|
||||
required this.generatedAt,
|
||||
this.hasBeenViewed = false,
|
||||
this.version = 1,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'personId': personId,
|
||||
'generatedAt': generatedAt.toIso8601String(),
|
||||
'faceIds': entries.map((e) => e.faceId).toList(),
|
||||
'hasBeenViewed': hasBeenViewed,
|
||||
'version': version,
|
||||
};
|
||||
|
||||
factory FaceTimeline.fromJson(Map<String, dynamic> json) {
|
||||
return FaceTimeline(
|
||||
personId: json['personId'],
|
||||
entries: (json['faceIds'] as List)
|
||||
.map((id) => FaceTimelineEntry(faceId: id))
|
||||
.toList(),
|
||||
generatedAt: DateTime.parse(json['generatedAt']),
|
||||
hasBeenViewed: json['hasBeenViewed'] ?? false,
|
||||
version: json['version'] ?? 1,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isValid {
|
||||
final age = DateTime.now().difference(generatedAt);
|
||||
return age.inDays < 365; // Cache valid for 1 year
|
||||
}
|
||||
|
||||
int get totalFaces => entries.length;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
class FaceTimelineEntry {
|
||||
final String faceId;
|
||||
final int fileId;
|
||||
final DateTime timestamp;
|
||||
final String? ageText;
|
||||
final String? relativeTimeText;
|
||||
Uint8List? thumbnail;
|
||||
|
||||
FaceTimelineEntry({
|
||||
required this.faceId,
|
||||
this.fileId = 0,
|
||||
DateTime? timestamp,
|
||||
this.ageText,
|
||||
this.relativeTimeText,
|
||||
this.thumbnail,
|
||||
}) : timestamp = timestamp ?? DateTime.now();
|
||||
|
||||
String get displayText => ageText ?? relativeTimeText ?? '';
|
||||
|
||||
bool get hasThumbnail => thumbnail != null;
|
||||
|
||||
// For JSON serialization
|
||||
Map<String, dynamic> toJson() => {
|
||||
'faceId': faceId,
|
||||
'fileId': fileId,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'ageText': ageText,
|
||||
'relativeTimeText': relativeTimeText,
|
||||
};
|
||||
|
||||
factory FaceTimelineEntry.fromJson(Map<String, dynamic> json) {
|
||||
return FaceTimelineEntry(
|
||||
faceId: json['faceId'],
|
||||
fileId: json['fileId'] ?? 0,
|
||||
timestamp: json['timestamp'] != null
|
||||
? DateTime.parse(json['timestamp'])
|
||||
: DateTime.now(),
|
||||
ageText: json['ageText'],
|
||||
relativeTimeText: json['relativeTimeText'],
|
||||
);
|
||||
}
|
||||
}
|
||||
299
mobile/apps/photos/lib/services/faces_through_time_service.dart
Normal file
299
mobile/apps/photos/lib/services/faces_through_time_service.dart
Normal file
@@ -0,0 +1,299 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import 'package:photos/db/ml/db.dart';
|
||||
import 'package:photos/events/faces_timeline_ready_event.dart';
|
||||
import 'package:photos/models/faces_through_time/face_timeline.dart';
|
||||
import 'package:photos/models/faces_through_time/face_timeline_entry.dart';
|
||||
import 'package:photos/models/file/file.dart';
|
||||
import 'package:photos/services/machine_learning/face_ml/person/person_service.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class FacesThroughTimeService {
|
||||
static final _logger = Logger('FacesThroughTimeService');
|
||||
static const _minYearSpan = 7;
|
||||
static const _photosPerYear = 4;
|
||||
static const _minFaceScore = 0.85;
|
||||
|
||||
static FacesThroughTimeService? _instance;
|
||||
factory FacesThroughTimeService() =>
|
||||
_instance ??= FacesThroughTimeService._();
|
||||
FacesThroughTimeService._();
|
||||
|
||||
Future<bool> isEligible(String personId) async {
|
||||
try {
|
||||
// Get all file IDs for this person
|
||||
final fileIds = await MLDataDB.instance.getPersonFileIds(personId);
|
||||
if (fileIds.isEmpty) return false;
|
||||
|
||||
// Get file creation times from FilesDB
|
||||
final files = await FilesDB.instance.getFilesFromIDs(fileIds);
|
||||
if (files.isEmpty) return false;
|
||||
|
||||
// Group files by year
|
||||
final filesByYear = <int, List<EnteFile>>{};
|
||||
for (final file in files) {
|
||||
if (file.creationTime == null) continue;
|
||||
final year =
|
||||
DateTime.fromMillisecondsSinceEpoch(file.creationTime!).year;
|
||||
filesByYear.putIfAbsent(year, () => []).add(file);
|
||||
}
|
||||
|
||||
// Check if we have enough years with enough photos
|
||||
final years = filesByYear.keys.toList()..sort();
|
||||
if (years.isEmpty) return false;
|
||||
|
||||
// Check for consecutive years with enough photos
|
||||
int consecutiveYears = 0;
|
||||
int maxConsecutive = 0;
|
||||
|
||||
for (int i = 0; i < years.length; i++) {
|
||||
if (filesByYear[years[i]]!.length >= _photosPerYear) {
|
||||
if (i == 0 || years[i] == years[i - 1] + 1) {
|
||||
consecutiveYears++;
|
||||
maxConsecutive = consecutiveYears > maxConsecutive
|
||||
? consecutiveYears
|
||||
: maxConsecutive;
|
||||
} else {
|
||||
consecutiveYears = 1;
|
||||
}
|
||||
} else {
|
||||
consecutiveYears = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return maxConsecutive >= _minYearSpan;
|
||||
} catch (e) {
|
||||
_logger.severe('Error checking eligibility', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> hasBeenViewed(String personId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool('faces_timeline_viewed_$personId') ?? false;
|
||||
}
|
||||
|
||||
Future<void> markAsViewed(String personId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('faces_timeline_viewed_$personId', true);
|
||||
}
|
||||
|
||||
Future<FaceTimeline?> checkAndPrepareTimeline(String personId) async {
|
||||
if (!await isEligible(personId)) return null;
|
||||
|
||||
// Check cache
|
||||
final cached = await _loadFromCache(personId);
|
||||
if (cached != null && cached.isValid) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Generate new timeline
|
||||
final timeline = await _generateTimeline(personId);
|
||||
if (timeline != null) {
|
||||
await _saveToCache(timeline);
|
||||
// Note: Thumbnail generation will be done asynchronously
|
||||
unawaited(_generateThumbnailsAsync(timeline));
|
||||
|
||||
// Notify UI when ready
|
||||
Bus.instance.fire(FacesTimelineReadyEvent(personId));
|
||||
}
|
||||
|
||||
return timeline;
|
||||
}
|
||||
|
||||
Future<FaceTimeline?> getTimeline(String personId) async {
|
||||
// First check cache
|
||||
final cached = await _loadFromCache(personId);
|
||||
if (cached != null && cached.isValid) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Generate if not cached
|
||||
return await _generateTimeline(personId);
|
||||
}
|
||||
|
||||
Future<FaceTimeline?> _generateTimeline(String personId) async {
|
||||
try {
|
||||
// Get high quality faces
|
||||
final faces = await MLDataDB.instance
|
||||
.getPersonFacesWithScores(personId, _minFaceScore);
|
||||
if (faces.isEmpty) return null;
|
||||
|
||||
// Get file IDs
|
||||
final fileIds = faces.map((f) => f['fileId'] as int).toSet().toList();
|
||||
|
||||
// Get files with creation times
|
||||
final files = await FilesDB.instance.getFilesFromIDs(fileIds);
|
||||
final fileMap = Map.fromEntries(
|
||||
files.map((f) => MapEntry(f.uploadedFileID ?? f.generatedID!, f)),
|
||||
);
|
||||
|
||||
// Get person info
|
||||
final person = await PersonService.instance.getPerson(personId);
|
||||
|
||||
// Group faces by year
|
||||
final facesByYear = <int, List<Map<String, dynamic>>>{};
|
||||
for (final face in faces) {
|
||||
final file = fileMap[face['fileId']];
|
||||
if (file == null || file.creationTime == null) continue;
|
||||
|
||||
final timestamp =
|
||||
DateTime.fromMillisecondsSinceEpoch(file.creationTime!);
|
||||
final year = timestamp.year;
|
||||
|
||||
// Apply age filter if DOB available
|
||||
if (person?.data.birthDate != null) {
|
||||
final dob = DateTime.parse(person!.data.birthDate!);
|
||||
final age = timestamp.difference(dob).inDays / 365.25;
|
||||
if (age <= 4.0) continue;
|
||||
}
|
||||
|
||||
face['timestamp'] = timestamp;
|
||||
face['file'] = file;
|
||||
facesByYear.putIfAbsent(year, () => []).add(face);
|
||||
}
|
||||
|
||||
// Select faces using quantile approach
|
||||
final selectedEntries = <FaceTimelineEntry>[];
|
||||
for (final year in facesByYear.keys.toList()..sort()) {
|
||||
final yearFaces = facesByYear[year]!;
|
||||
if (yearFaces.length < _photosPerYear) continue;
|
||||
|
||||
// Sort by timestamp
|
||||
yearFaces.sort(
|
||||
(a, b) => (a['timestamp'] as DateTime)
|
||||
.compareTo(b['timestamp'] as DateTime),
|
||||
);
|
||||
|
||||
// Select at 1st, 25th, 50th, 75th percentiles
|
||||
final indices = [
|
||||
0,
|
||||
(yearFaces.length * 0.25).floor(),
|
||||
(yearFaces.length * 0.50).floor(),
|
||||
(yearFaces.length * 0.75).floor(),
|
||||
];
|
||||
|
||||
for (final idx in indices) {
|
||||
final face = yearFaces[idx];
|
||||
final timestamp = face['timestamp'] as DateTime;
|
||||
|
||||
selectedEntries.add(
|
||||
FaceTimelineEntry(
|
||||
faceId: face['faceId'] as String,
|
||||
fileId: face['fileId'] as int,
|
||||
timestamp: timestamp,
|
||||
ageText: _calculateAgeText(
|
||||
timestamp,
|
||||
person?.data.birthDate != null
|
||||
? DateTime.parse(person!.data.birthDate!)
|
||||
: null,
|
||||
),
|
||||
relativeTimeText: _calculateRelativeTime(timestamp),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedEntries.length < 28) return null;
|
||||
|
||||
return FaceTimeline(
|
||||
personId: personId,
|
||||
entries: selectedEntries,
|
||||
generatedAt: DateTime.now(),
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.severe('Error generating timeline', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _generateThumbnailsAsync(FaceTimeline timeline) async {
|
||||
// This will be implemented once we understand the face thumbnail system better
|
||||
_logger.info('Thumbnail generation for timeline will be implemented');
|
||||
}
|
||||
|
||||
String? _calculateAgeText(DateTime photoTime, DateTime? dob) {
|
||||
if (dob == null) return null;
|
||||
|
||||
final diff = photoTime.difference(dob);
|
||||
final years = (diff.inDays / 365.25).floor();
|
||||
final months = ((diff.inDays % 365.25) / 30).floor();
|
||||
|
||||
if (photoTime.year == DateTime.now().year) {
|
||||
// Current year - show relative time
|
||||
final monthsAgo = DateTime.now().difference(photoTime).inDays ~/ 30;
|
||||
if (monthsAgo == 0) return 'Recently';
|
||||
return '$monthsAgo months ago';
|
||||
}
|
||||
|
||||
if (months > 0) {
|
||||
return 'Age $years years $months months';
|
||||
}
|
||||
return 'Age $years years';
|
||||
}
|
||||
|
||||
String _calculateRelativeTime(DateTime timestamp) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(timestamp);
|
||||
|
||||
if (diff.inDays < 30) return 'Recently';
|
||||
if (diff.inDays < 365) {
|
||||
final months = (diff.inDays / 30).floor();
|
||||
return '$months months ago';
|
||||
}
|
||||
|
||||
final years = (diff.inDays / 365.25).floor();
|
||||
return '$years years ago';
|
||||
}
|
||||
|
||||
Future<String> _getCachePath(String personId) async {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
final cacheDir = Directory('${dir.path}/cache');
|
||||
if (!await cacheDir.exists()) {
|
||||
await cacheDir.create(recursive: true);
|
||||
}
|
||||
return '${cacheDir.path}/faces_timeline_$personId.json';
|
||||
}
|
||||
|
||||
Future<FaceTimeline?> _loadFromCache(String personId) async {
|
||||
try {
|
||||
final path = await _getCachePath(personId);
|
||||
final file = File(path);
|
||||
if (!await file.exists()) return null;
|
||||
|
||||
final json = jsonDecode(await file.readAsString());
|
||||
return FaceTimeline.fromJson(json);
|
||||
} catch (e) {
|
||||
_logger.warning('Failed to load cache', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveToCache(FaceTimeline timeline) async {
|
||||
try {
|
||||
final path = await _getCachePath(timeline.personId);
|
||||
final file = File(path);
|
||||
await file.writeAsString(jsonEncode(timeline.toJson()));
|
||||
} catch (e) {
|
||||
_logger.warning('Failed to save cache', e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearCache(String personId) async {
|
||||
try {
|
||||
final path = await _getCachePath(personId);
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.warning('Failed to clear cache', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photos/models/faces_through_time/face_timeline_entry.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
class FacesThroughTimeVideoService {
|
||||
static final _logger = Logger('FacesThroughTimeVideoService');
|
||||
|
||||
Future<void> generateAndShareVideo(
|
||||
List<FaceTimelineEntry> entries,
|
||||
) async {
|
||||
// TODO: Implement video generation using FFmpeg
|
||||
// For now, just show a placeholder message
|
||||
_logger.info('Video generation will be implemented with FFmpeg');
|
||||
|
||||
// Temporary: Share a text message instead
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
text: 'Check out this amazing face timeline! (Video generation coming soon)',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 = 36;
|
||||
static const currentChangeLogVersion = 32;
|
||||
|
||||
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).rateUs,
|
||||
labelText: AppLocalizations.of(context).rateTheApp,
|
||||
icon: Icons.favorite_rounded,
|
||||
iconColor: enteColorScheme.primary500,
|
||||
onTap: () async {
|
||||
@@ -113,6 +113,7 @@ class _ChangeLogPageState extends State<ChangeLogPage> {
|
||||
context.l10n.cLDesc3,
|
||||
),
|
||||
]);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: Scrollbar(
|
||||
|
||||
@@ -672,7 +672,10 @@ class _SimilarImagesPageState extends State<SimilarImagesPage>
|
||||
await showGenericErrorDialog(context: context, error: e);
|
||||
}
|
||||
if (_isDisposed) return;
|
||||
Navigator.of(context).pop();
|
||||
setState(() {
|
||||
_pageState = SimilarImagesPageState.setup;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -438,16 +438,11 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
|
||||
}
|
||||
|
||||
await relevantTaskQueue.addTask(_localThumbnailQueueTaskId!, () async {
|
||||
late final Uint8List? thumbnailBytes;
|
||||
try {
|
||||
thumbnailBytes = await getThumbnailFromLocal(
|
||||
widget.file,
|
||||
size: widget.thumbnailSize,
|
||||
);
|
||||
completer.complete(thumbnailBytes);
|
||||
} catch (e) {
|
||||
completer.completeError(e);
|
||||
}
|
||||
final thumbnailBytes = await getThumbnailFromLocal(
|
||||
widget.file,
|
||||
size: widget.thumbnailSize,
|
||||
);
|
||||
completer.complete(thumbnailBytes);
|
||||
});
|
||||
|
||||
return completer.future;
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/models/faces_through_time/face_timeline.dart';
|
||||
import 'package:photos/services/faces_through_time_service.dart';
|
||||
import 'package:photos/services/faces_through_time_video_service.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
|
||||
|
||||
class FacesThroughTimePage extends StatefulWidget {
|
||||
final String personId;
|
||||
final String personName;
|
||||
|
||||
const FacesThroughTimePage({
|
||||
super.key,
|
||||
required this.personId,
|
||||
required this.personName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FacesThroughTimePage> createState() => _FacesThroughTimePageState();
|
||||
}
|
||||
|
||||
class _FacesThroughTimePageState extends State<FacesThroughTimePage> {
|
||||
static const _slideshowInterval = Duration(seconds: 2);
|
||||
|
||||
FaceTimeline? _timeline;
|
||||
int _currentIndex = 0;
|
||||
Timer? _autoAdvanceTimer;
|
||||
bool _isPaused = false;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadTimeline();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_autoAdvanceTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadTimeline() async {
|
||||
final service = FacesThroughTimeService();
|
||||
final timeline = await service.getTimeline(widget.personId);
|
||||
|
||||
if (timeline != null && mounted) {
|
||||
setState(() {
|
||||
_timeline = timeline;
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
await service.markAsViewed(widget.personId);
|
||||
_startAutoAdvance();
|
||||
} else if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _startAutoAdvance() {
|
||||
_autoAdvanceTimer?.cancel();
|
||||
if (!_isPaused && _timeline != null) {
|
||||
_autoAdvanceTimer = Timer.periodic(_slideshowInterval, (_) {
|
||||
if (_currentIndex < _timeline!.entries.length - 1) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentIndex++;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
_autoAdvanceTimer?.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _togglePause() {
|
||||
setState(() {
|
||||
_isPaused = !_isPaused;
|
||||
});
|
||||
|
||||
if (_isPaused) {
|
||||
_autoAdvanceTimer?.cancel();
|
||||
} else {
|
||||
_startAutoAdvance();
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateTo(int index) {
|
||||
if (index >= 0 && index < _timeline!.entries.length) {
|
||||
setState(() {
|
||||
_currentIndex = index;
|
||||
});
|
||||
_startAutoAdvance();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _shareVideo() async {
|
||||
if (_timeline == null) return;
|
||||
|
||||
setState(() {
|
||||
_isPaused = true;
|
||||
});
|
||||
_autoAdvanceTimer?.cancel();
|
||||
|
||||
try {
|
||||
final videoService = FacesThroughTimeVideoService();
|
||||
await videoService.generateAndShareVideo(_timeline!.entries);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to generate video: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isPaused = false;
|
||||
});
|
||||
_startAutoAdvance();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = getEnteColorScheme(context);
|
||||
|
||||
if (_isLoading) {
|
||||
return Scaffold(
|
||||
backgroundColor: theme.backgroundElevated,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
),
|
||||
body: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_timeline == null || _timeline!.entries.isEmpty) {
|
||||
return Scaffold(
|
||||
backgroundColor: theme.backgroundElevated,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
),
|
||||
body: Center(
|
||||
child: Text(
|
||||
'No timeline available',
|
||||
style: TextStyle(color: theme.textMuted),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final currentEntry = _timeline!.entries[_currentIndex];
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: GestureDetector(
|
||||
onTapDown: (details) {
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
final tapX = details.globalPosition.dx;
|
||||
|
||||
if (tapX < width * 0.3) {
|
||||
// Tap on left - previous
|
||||
_navigateTo(_currentIndex - 1);
|
||||
} else if (tapX > width * 0.7) {
|
||||
// Tap on right - next
|
||||
_navigateTo(_currentIndex + 1);
|
||||
} else {
|
||||
// Tap in center - pause/resume
|
||||
_togglePause();
|
||||
}
|
||||
},
|
||||
onLongPressStart: (_) {
|
||||
setState(() {
|
||||
_isPaused = true;
|
||||
});
|
||||
_autoAdvanceTimer?.cancel();
|
||||
},
|
||||
onLongPressEnd: (_) {
|
||||
setState(() {
|
||||
_isPaused = false;
|
||||
});
|
||||
_startAutoAdvance();
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
// Face display - placeholder for now
|
||||
Center(
|
||||
child: currentEntry.hasThumbnail && currentEntry.thumbnail != null
|
||||
? Image.memory(
|
||||
currentEntry.thumbnail!,
|
||||
fit: BoxFit.contain,
|
||||
gaplessPlayback: true,
|
||||
)
|
||||
: Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.fillFaint,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
size: 80,
|
||||
color: theme.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Top controls
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 8,
|
||||
left: 8,
|
||||
right: 8,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButtonWidget(
|
||||
icon: Icons.close,
|
||||
iconButtonType: IconButtonType.primary,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
),
|
||||
IconButtonWidget(
|
||||
icon: Icons.share,
|
||||
iconButtonType: IconButtonType.primary,
|
||||
onTap: _shareVideo,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom info
|
||||
Positioned(
|
||||
bottom: 100,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Text(
|
||||
currentEntry.displayText,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Progress indicator
|
||||
Positioned(
|
||||
bottom: 50,
|
||||
left: 24,
|
||||
right: 24,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: (_currentIndex + 1) / _timeline!.entries.length,
|
||||
backgroundColor: Colors.white.withValues(alpha: 0.3),
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
minHeight: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Pause indicator
|
||||
if (_isPaused)
|
||||
Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.pause,
|
||||
size: 40,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Navigation hints (subtle)
|
||||
if (_currentIndex > 0)
|
||||
const Positioned(
|
||||
left: 16,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.chevron_left,
|
||||
color: Colors.white30,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_currentIndex < _timeline!.entries.length - 1)
|
||||
const Positioned(
|
||||
right: 16,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.chevron_right,
|
||||
color: Colors.white30,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/models/ml/face/person.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
|
||||
class FacesTimelineBanner extends StatelessWidget {
|
||||
final PersonEntity person;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const FacesTimelineBanner({
|
||||
super.key,
|
||||
required this.person,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = getEnteColorScheme(context);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
theme.primary700,
|
||||
theme.primary500,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.backgroundElevated.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.auto_awesome,
|
||||
color: theme.backgroundElevated,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'How ${person.data.name} grew over the years',
|
||||
style: TextStyle(
|
||||
color: theme.backgroundElevated,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Tap to see their journey',
|
||||
style: TextStyle(
|
||||
color: theme.backgroundElevated.withValues(alpha: 0.9),
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
color: theme.backgroundElevated,
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import "dart:async";
|
||||
import 'package:flutter/material.dart';
|
||||
import "package:logging/logging.dart";
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import "package:photos/events/faces_timeline_ready_event.dart";
|
||||
import 'package:photos/events/files_updated_event.dart';
|
||||
import 'package:photos/events/local_photos_updated_event.dart';
|
||||
import "package:photos/events/people_changed_event.dart";
|
||||
@@ -14,6 +15,7 @@ import 'package:photos/models/gallery_type.dart';
|
||||
import "package:photos/models/ml/face/person.dart";
|
||||
import "package:photos/models/search/search_result.dart";
|
||||
import 'package:photos/models/selected_files.dart';
|
||||
import "package:photos/services/faces_through_time_service.dart";
|
||||
import "package:photos/services/machine_learning/face_ml/feedback/cluster_feedback.dart";
|
||||
import "package:photos/services/search_service.dart";
|
||||
import "package:photos/ui/components/end_to_end_banner.dart";
|
||||
@@ -24,8 +26,9 @@ import "package:photos/ui/viewer/gallery/state/gallery_files_inherited_widget.da
|
||||
import "package:photos/ui/viewer/gallery/state/inherited_search_filter_data.dart";
|
||||
import "package:photos/ui/viewer/gallery/state/search_filter_data_provider.dart";
|
||||
import "package:photos/ui/viewer/gallery/state/selection_state.dart";
|
||||
import "package:photos/ui/viewer/people/faces_through_time_page.dart";
|
||||
import "package:photos/ui/viewer/people/faces_timeline_banner.dart";
|
||||
import "package:photos/ui/viewer/people/link_email_screen.dart";
|
||||
|
||||
import "package:photos/ui/viewer/people/people_app_bar.dart";
|
||||
import "package:photos/ui/viewer/people/person_gallery_suggestion.dart";
|
||||
import "package:photos/utils/navigation_util.dart";
|
||||
@@ -57,6 +60,11 @@ class _PeoplePageState extends State<PeoplePage> {
|
||||
late PersonEntity _person;
|
||||
|
||||
bool userDismissedPersonGallerySuggestion = false;
|
||||
|
||||
// Faces Through Time feature state
|
||||
bool _timelineReady = false;
|
||||
bool _timelineViewed = false;
|
||||
StreamSubscription<FacesTimelineReadyEvent>? _timelineReadyEvent;
|
||||
|
||||
late final StreamSubscription<LocalPhotosUpdatedEvent> _filesUpdatedEvent;
|
||||
late final StreamSubscription<PeopleChangedEvent> _peopleChangedEvent;
|
||||
@@ -67,6 +75,19 @@ class _PeoplePageState extends State<PeoplePage> {
|
||||
super.initState();
|
||||
_person = widget.person;
|
||||
ClusterFeedbackService.resetLastViewedClusterID();
|
||||
|
||||
// Check for Faces Through Time feature
|
||||
_checkFacesTimeline();
|
||||
|
||||
// Listen for timeline ready events
|
||||
_timelineReadyEvent = Bus.instance.on<FacesTimelineReadyEvent>().listen((event) {
|
||||
if (event.personId == _person.remoteID && mounted) {
|
||||
setState(() {
|
||||
_timelineReady = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
_peopleChangedEvent = Bus.instance.on<PeopleChangedEvent>().listen((event) {
|
||||
if (event.type == PeopleEventType.saveOrEditPerson) {
|
||||
if (event.person != null &&
|
||||
@@ -118,11 +139,41 @@ class _PeoplePageState extends State<PeoplePage> {
|
||||
files = sortedFiles;
|
||||
return sortedFiles;
|
||||
}
|
||||
|
||||
Future<void> _checkFacesTimeline() async {
|
||||
final service = FacesThroughTimeService();
|
||||
final isEligible = await service.isEligible(_person.remoteID);
|
||||
|
||||
if (isEligible) {
|
||||
_timelineViewed = await service.hasBeenViewed(_person.remoteID);
|
||||
|
||||
if (!_timelineViewed) {
|
||||
// Start preparing timeline in background
|
||||
unawaited(service.checkAndPrepareTimeline(_person.remoteID));
|
||||
} else if (mounted) {
|
||||
setState(() {
|
||||
_timelineReady = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _openTimeline() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => FacesThroughTimePage(
|
||||
personId: _person.remoteID,
|
||||
personName: _person.data.name,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_filesUpdatedEvent.cancel();
|
||||
_peopleChangedEvent.cancel();
|
||||
_timelineReadyEvent?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -179,6 +230,9 @@ class _PeoplePageState extends State<PeoplePage> {
|
||||
personFiles: personFiles,
|
||||
loadPersonFiles: loadPersonFiles,
|
||||
personEntity: _person,
|
||||
timelineReady: _timelineReady,
|
||||
timelineViewed: _timelineViewed,
|
||||
onOpenTimeline: _openTimeline,
|
||||
);
|
||||
},
|
||||
)
|
||||
@@ -188,6 +242,9 @@ class _PeoplePageState extends State<PeoplePage> {
|
||||
personFiles: personFiles,
|
||||
loadPersonFiles: loadPersonFiles,
|
||||
personEntity: _person,
|
||||
timelineReady: _timelineReady,
|
||||
timelineViewed: _timelineViewed,
|
||||
onOpenTimeline: _openTimeline,
|
||||
),
|
||||
FileSelectionOverlayBar(
|
||||
PeoplePage.overlayType,
|
||||
@@ -221,6 +278,9 @@ class _Gallery extends StatefulWidget {
|
||||
final List<EnteFile> personFiles;
|
||||
final Future<List<EnteFile>> Function() loadPersonFiles;
|
||||
final PersonEntity personEntity;
|
||||
final bool timelineReady;
|
||||
final bool timelineViewed;
|
||||
final VoidCallback onOpenTimeline;
|
||||
|
||||
const _Gallery({
|
||||
required this.tagPrefix,
|
||||
@@ -228,6 +288,9 @@ class _Gallery extends StatefulWidget {
|
||||
required this.personFiles,
|
||||
required this.loadPersonFiles,
|
||||
required this.personEntity,
|
||||
required this.timelineReady,
|
||||
required this.timelineViewed,
|
||||
required this.onOpenTimeline,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -267,6 +330,13 @@ class _GalleryState extends State<_Gallery> {
|
||||
widget.personFiles.isNotEmpty ? [widget.personFiles.first] : [],
|
||||
header: Column(
|
||||
children: [
|
||||
// Faces Through Time banner
|
||||
widget.timelineReady && !widget.timelineViewed
|
||||
? FacesTimelineBanner(
|
||||
person: widget.personEntity,
|
||||
onTap: widget.onOpenTimeline,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
(widget.personEntity.data.email != null &&
|
||||
widget.personEntity.data.email!.isNotEmpty) ||
|
||||
widget.personEntity.data.isIgnored
|
||||
|
||||
@@ -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.4+1205
|
||||
version: 1.2.2+1205
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
|
||||
@@ -32,11 +32,8 @@ impl VectorDB {
|
||||
|
||||
if file_exists {
|
||||
println!("Loading index from disk.");
|
||||
// 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");
|
||||
// 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");
|
||||
} else {
|
||||
println!("Creating new index.");
|
||||
db.save_index();
|
||||
@@ -49,37 +46,9 @@ impl VectorDB {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("Failed to create directory");
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
self.index
|
||||
.save(self.path.to_str().expect("Invalid path"))
|
||||
.expect("Failed to save index");
|
||||
}
|
||||
|
||||
fn ensure_capacity(&self, margin: usize) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
- 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,3 +1,6 @@
|
||||
- Similar images detection and deletion
|
||||
- Video streaming enhancements
|
||||
- Performance improvements
|
||||
- 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)
|
||||
@@ -255,49 +255,8 @@ const parseDateComponents = (
|
||||
const parseChrono = (
|
||||
s: string,
|
||||
locale: string,
|
||||
): 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
|
||||
): LabelledSearchDateComponents[] =>
|
||||
chrono
|
||||
.parse(s)
|
||||
.map((result) => {
|
||||
const p = result.start;
|
||||
@@ -328,7 +287,6 @@ 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