Compare commits

..

6 Commits

Author SHA1 Message Date
laurenspriem
7d1845fbd6 Fix deprecated Share usage by using SharePlus.instance.share() 2025-09-02 14:19:19 +05:30
laurenspriem
9abce0883b Merge branch 'main' into faces_growup 2025-09-02 14:05:36 +05:30
laurenspriem
f54e08bd62 Fix unused import in face_timeline.dart 2025-09-02 10:06:46 +05:30
laurenspriem
aee62b6e64 Add UI components and integrate Faces Through Time with PeoplePage 2025-09-02 09:56:21 +05:30
laurenspriem
cabb770958 Implement core data models and service for Faces Through Time feature 2025-09-02 09:41:17 +05:30
laurenspriem
943c6ab585 Add Faces Through Time feature design and implementation docs 2025-09-01 17:34:22 +05:30
60 changed files with 2832 additions and 3039 deletions

65
CLAUDE.md Normal file
View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
class FacesTimelineReadyEvent {
final String personId;
FacesTimelineReadyEvent(this.personId);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1667,9 +1667,9 @@
"food": "料理を楽しむ",
"pets": "毛むくじゃらな仲間たち",
"cLTitle1": "類似画像",
"cLDesc1": "類似画像を検出する新しいML基盤システムを導入し、ライブラリをクリーンアップできます。設定 -> バックアップ -> 容量を空ける で利用可能",
"cLTitle2": "動画ストリーミングの強化",
"cLDesc2": "アプリから直接、動画のストリーム生成を手動でトリガーできるようになりました。また、動画のうち何パーセントがストリーミング用に処理されたかを表示する新しい動画ストリーミング設定画面も追加しました",
"cLDesc1": "類似画像を検出する新しいML基盤システムを導入し、ライブラリをクリーンアップできます。設定->バックアップ->容量を空けるで利用可能",
"cLTitle2": "手動ビデオストリーム生成",
"cLDesc2": "アプリから直接、ビデオのストリーム生成を手動でトリガーできるようになりました。また、ビデオのうち何パーセントがストリーミング用に処理されたかを表示する新しいビデオストリーミング設定画面も追加しました",
"cLTitle3": "パフォーマンスの改善",
"cLDesc3": "より良いキャッシュ使用とよりスムーズなスクロール体験を含む、複数の内部改善"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1786,10 +1786,10 @@
"day": "День",
"filter": "Фильтр",
"font": "Шрифт",
"cLTitle1": "Похожие изображения",
"cLTitle1": "Похожие Изображения",
"cLDesc1": "Мы внедряем новую систему на основе ML для обнаружения похожих изображений, с помощью которой вы можете очистить свою библиотеку. Доступно в Настройки->Резервная копия->Освободить место",
"cLTitle2": "Улучшения видео стриминга",
"cLTitle2": "Ручная генерация видео потока",
"cLDesc2": "Теперь вы можете вручную запустить генерацию потока для видео прямо из приложения. Мы также добавили новый экран настроек видео стриминга, который покажет вам, какой процент ваших видео был обработан для стриминга",
"cLTitle3": "Улучшения производительности",
"cLTitle3": "Улучшения Производительности",
"cLDesc3": "Множественные улучшения под капотом, включая лучшее использование кэша и более плавную прокрутку"
}

View File

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

View File

@@ -1510,10 +1510,10 @@
"legacyInvite": "{email} запросив вас стати довіреною особою",
"authToManageLegacy": "Авторизуйтесь, щоби керувати довіреними контактами",
"useDifferentPlayerInfo": "Виникли проблеми з відтворенням цього відео? Натисніть і утримуйте тут, щоб спробувати інший плеєр.",
"cLTitle1": "Схожі зображення",
"cLTitle1": "Схожі Зображення",
"cLDesc1": "Ми впроваджуємо нову систему на основі ML для виявлення схожих зображень, за допомогою якої ви можете очистити свою бібліотеку. Доступно в Налаштування->Резервна копія->Звільнити місце",
"cLTitle2": "Покращення відео стрімінгу",
"cLTitle2": "Ручна генерація відео потоку",
"cLDesc2": "Тепер ви можете вручну запустити генерацію потоку для відео прямо з додатку. Ми також додали новий екран налаштувань відео стрімінгу, який покаже вам, який відсоток ваших відео було оброблено для стрімінгу",
"cLTitle3": "Покращення продуктивності",
"cLTitle3": "Покращення Продуктивності",
"cLDesc3": "Численні покращення під капотом, включаючи краще використання кешу та більш плавну прокрутку"
}

View File

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

View File

@@ -1927,8 +1927,8 @@
"hoorayyyy": "耶~~!",
"nothingToTidyUpHere": "这里没什么可清理的",
"cLTitle1": "相似图像",
"cLDesc1": "我们正在推出一个基于机器学习的新系统来检测相似图像,您可以用它来清理您的图库。在 设置 -> 备份 -> 释放空间 中可用",
"cLTitle2": "视频流媒体增强",
"cLDesc1": "我们正在推出一个基于机器学习的新系统来检测相似图像,您可以用它来清理您的图库。在 设置->备份->释放空间 中可用",
"cLTitle2": "手动视频流生成",
"cLDesc2": "您现在可以直接从应用程序手动触发视频的流生成。我们还添加了一个新的视频流设置屏幕,它将显示您的视频中有百分之几已被处理用于流媒体播放",
"cLTitle3": "性能改进",
"cLDesc3": "多个底层改进,包括更好的缓存使用和更流畅的滚动体验"

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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[] => {