Compare commits

...

40 Commits

Author SHA1 Message Date
Prateek Sunal
cdd14d8e6c feat: add new feed feature
Co-authored-by: Copilot <noreply@anthropic.com>
2025-08-28 02:27:08 +00:00
Prateek Sunal
67d7f586b2 [mob][photos] bypass interaction check for manual stream requests (#6993)
## Summary
- Manual Create/Recreate Stream button presses now bypass user
interaction timer for immediate processing
- Fixed multiple concurrent streaming processes bug in ComputeController
- Fixed video streaming description text display spacing in advanced
settings
- Maintains device health and ML priority checks for all streaming
requests

## Tests
- [x] Manual Create/Recreate Stream button bypasses interaction timer  
- [x] Automatic streaming still respects interaction timer
- [x] Only one streaming process allowed at a time
2025-08-27 21:15:55 +05:30
Prateek Sunal
7c22a8bb25 chore: lint fix 2025-08-27 21:10:18 +05:30
Prateek Sunal
ff3864a09a fix: check only if permission granted before chunking 2025-08-27 21:09:10 +05:30
Prateek Sunal
4484b9e4ad update: add video streaming improvements to change logs
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-27 13:21:59 +00:00
Prateek Sunal
e9554ffbcb fix: prevent multiple concurrent streaming processes
Remove condition allowing additional stream requests when already streaming to ensure only one stream process runs at a time.

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-27 13:15:10 +00:00
Prateek Sunal
ad3901d484 fix: remove conditional clearQueue for manual processing
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-27 13:08:12 +00:00
Prateek Sunal
ecca4c3dc8 feat: bypass interaction check for manual stream requests
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-27 13:00:21 +00:00
Prateek Sunal
d05521f884 [mob][photos] video streaming description spacing and alignment (#6992)
## Summary
- Split videoStreamingDescription into separate line1/line2 localization
keys
- Remove TextAlign.justify from enabled state to fix awkward word
spacing
- Standardize text rendering between enabled and disabled states
- Both states now display description consistently without spacing
issues

## Test plan
- [x] Verify enabled state displays as single line without spacing
issues
- [x] Verify disabled state shows proper line breaks in onboarding
- [x] Confirm localization keys generate correctly
- [x] Run dart format and dart analyze (no issues)

Fixes video streaming settings page text display inconsistencies.
2025-08-27 18:08:28 +05:30
Prateek Sunal
ff37c4bf81 fix: video streaming description spacing and alignment
- Split videoStreamingDescription into separate line1/line2 localization keys
- Remove TextAlign.justify from enabled state to fix awkward word spacing
- Standardize text rendering between enabled and disabled states
- Both states now display description consistently without spacing issues

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-27 12:03:31 +00:00
Neeraj
84a5ad0b86 [mob][photos] More minor design changes for similar images (#6986)
## Description

- Change tab order
- Make tabs distinct
- Change default ordering to size

## Tests

Tested in debug mode on my pixel phone.
2025-08-27 13:25:40 +05:30
laurenspriem
44ad11343a Better empty state prompt for emtpy tab 2025-08-27 13:17:41 +05:30
laurenspriem
07e50e3cfe Change default sort to size 2025-08-27 13:03:30 +05:30
laurenspriem
df8bbdb788 Make identical and similar distinct 2025-08-27 13:01:56 +05:30
laurenspriem
1ed381fe52 Change order of tabs 2025-08-27 12:52:45 +05:30
Laurens Priem
ddd1d5ac86 [mob][photos] Similar images UX changes (#6981)
## Description

Similar images UX changes

## Tests

Tested in debug mode on my pixel phone.
2025-08-26 23:50:33 +05:30
Laurens Priem
26845a502e [mob][photos] Use load instead of view on index (#6980)
## Description

Use load instead of index
2025-08-26 23:48:59 +05:30
laurenspriem
21aac29020 format count properly 2025-08-26 23:48:31 +05:30
laurenspriem
c1ff02df14 Always select all on tab change 2025-08-26 23:39:21 +05:30
laurenspriem
e4927c4022 Merge branch 'main' into similar_ux_changes 2025-08-26 23:30:32 +05:30
laurenspriem
4fd797338b Empty state 2025-08-26 23:26:51 +05:30
laurenspriem
eca0e5943d tab button look 2025-08-26 23:24:37 +05:30
laurenspriem
56cc7309a5 Show progress only for multiple albums symlinking 2025-08-26 23:05:04 +05:30
laurenspriem
b740d1af05 Show modal on 100+ deleted files only 2025-08-26 23:01:30 +05:30
laurenspriem
6d21b73367 faster select 2025-08-26 22:59:30 +05:30
laurenspriem
a5704eef25 Use load instead of view on index 2025-08-26 22:46:33 +05:30
laurenspriem
7e83682686 tiny margin in threshold 2025-08-26 22:42:04 +05:30
laurenspriem
18d5aa61b0 Extract string 2025-08-26 22:34:59 +05:30
laurenspriem
7c2a719ba8 (un)select all 2025-08-26 22:32:11 +05:30
laurenspriem
47313a74ff Tab bar filter 2025-08-26 21:28:21 +05:30
Prateek Sunal
65a7a16298 [mob][photos] fixes (#6979)
## Description

- [x] Fix Spacing in Video streaming settings
- [x] Update copy in Video Streaming settings
- [x] Disable debug notifications for work manager in iOS

## Tests
2025-08-26 20:57:02 +05:30
Prateek Sunal
9251e4f5b6 fix: update spacing and remove cross icon from top right 2025-08-26 19:07:44 +05:30
Prateek Sunal
c4bc6abf83 fix: remove debug mode notification flag 2025-08-26 19:07:27 +05:30
laurenspriem
3165289483 Remove header 2025-08-26 17:48:39 +05:30
laurenspriem
01aab41c25 Select all by default 2025-08-26 17:46:53 +05:30
laurenspriem
1826258161 Copy 2025-08-26 17:43:58 +05:30
laurenspriem
df5917060b Copy change 2025-08-26 17:40:20 +05:30
Prateek Sunal
b5aa05cc1b [mob][photos] merge migration scripts (#6974)
## Description

Fixes #6923

## Tests
2025-08-26 17:16:10 +05:30
Prateek Sunal
cd865992f2 chore: directly use database 2025-08-26 17:02:21 +05:30
Prateek Sunal
370c0ab54a fix: merge migration scripts 2025-08-26 16:02:15 +05:30
22 changed files with 2184 additions and 314 deletions

View File

@@ -66,7 +66,6 @@ class UploadLocksDB {
static final migrationScripts = [
..._createTrackUploadsTable(),
..._createStreamQueueTable(),
];
final dbConfig = MigrationConfig(
@@ -142,11 +141,6 @@ class UploadLocksDB {
${_streamUploadErrorTable.columnCreatedAt} INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL
)
''',
];
}
static List<String> _createStreamQueueTable() {
return [
'''
CREATE TABLE IF NOT EXISTS ${_streamQueueTable.table} (
${_streamQueueTable.columnUploadedFileID} INTEGER PRIMARY KEY,
@@ -158,7 +152,7 @@ class UploadLocksDB {
}
Future<void> clearTable() async {
final db = await instance.database;
final db = await database;
await db.delete(_uploadLocksTable.table);
await db.delete(_trackUploadTable.table);
await db.delete(_partsTable.table);
@@ -166,7 +160,7 @@ class UploadLocksDB {
}
Future<void> acquireLock(String id, String owner, int time) async {
final db = await instance.database;
final db = await database;
final row = <String, dynamic>{};
row[_uploadLocksTable.columnID] = id;
row[_uploadLocksTable.columnOwner] = owner;
@@ -179,7 +173,7 @@ class UploadLocksDB {
}
Future<String> getLockData(String id) async {
final db = await instance.database;
final db = await database;
final rows = await db.query(
_uploadLocksTable.table,
where: '${_uploadLocksTable.columnID} = ?',
@@ -196,7 +190,7 @@ class UploadLocksDB {
}
Future<bool> isLocked(String id, String owner) async {
final db = await instance.database;
final db = await database;
final rows = await db.query(
_uploadLocksTable.table,
where:
@@ -207,7 +201,7 @@ class UploadLocksDB {
}
Future<int> releaseLock(String id, String owner) async {
final db = await instance.database;
final db = await database;
return db.delete(
_uploadLocksTable.table,
where:
@@ -217,7 +211,7 @@ class UploadLocksDB {
}
Future<int> releaseLocksAcquiredByOwnerBefore(String owner, int time) async {
final db = await instance.database;
final db = await database;
return db.delete(
_uploadLocksTable.table,
where:
@@ -227,7 +221,7 @@ class UploadLocksDB {
}
Future<int> releaseAllLocksAcquiredBefore(int time) async {
final db = await instance.database;
final db = await database;
return db.delete(
_uploadLocksTable.table,
where: '${_uploadLocksTable.columnTime} < ?',
@@ -241,7 +235,7 @@ class UploadLocksDB {
String fileHash,
int collectionID,
) async {
final db = await instance.database;
final db = await database;
final rows = await db.query(
_trackUploadTable.table,
@@ -268,7 +262,7 @@ class UploadLocksDB {
String fileHash,
int collectionID,
) async {
final db = await instance.database;
final db = await database;
await db.update(
_trackUploadTable.table,
{
@@ -291,7 +285,7 @@ class UploadLocksDB {
String fileHash,
int collectionID,
) async {
final db = await instance.database;
final db = await database;
final rows = await db.query(
_trackUploadTable.table,
where: '${_trackUploadTable.columnLocalID} = ?'
@@ -349,7 +343,7 @@ class UploadLocksDB {
int uploadedFileID,
String errorMessage,
) async {
final db = await UploadLocksDB.instance.database;
final db = await database;
await db.insert(
_streamUploadErrorTable.table,
@@ -367,7 +361,7 @@ class UploadLocksDB {
int uploadedFileID,
String errorMessage,
) async {
final db = await instance.database;
final db = await database;
await db.update(
_streamUploadErrorTable.table,
{
@@ -381,7 +375,7 @@ class UploadLocksDB {
}
Future<int> deleteStreamUploadErrorEntry(int uploadedFileID) async {
final db = await instance.database;
final db = await database;
return await db.delete(
_streamUploadErrorTable.table,
where: '${_streamUploadErrorTable.columnUploadedFileID} = ?',
@@ -390,7 +384,7 @@ class UploadLocksDB {
}
Future<Map<int, String>> getStreamUploadError() {
return instance.database.then((db) async {
return database.then((db) async {
final rows = await db.query(
_streamUploadErrorTable.table,
columns: [
@@ -419,7 +413,7 @@ class UploadLocksDB {
String keyNonce, {
required int partSize,
}) async {
final db = await UploadLocksDB.instance.database;
final db = await database;
final objectKey = urls.objectKey;
await db.insert(
@@ -462,7 +456,7 @@ class UploadLocksDB {
int partNumber,
String etag,
) async {
final db = await instance.database;
final db = await database;
await db.update(
_partsTable.table,
{
@@ -479,7 +473,7 @@ class UploadLocksDB {
String objectKey,
MultipartStatus status,
) async {
final db = await instance.database;
final db = await database;
await db.update(
_trackUploadTable.table,
{
@@ -493,7 +487,7 @@ class UploadLocksDB {
Future<int> deleteMultipartTrack(
String localId,
) async {
final db = await instance.database;
final db = await database;
return await db.delete(
_trackUploadTable.table,
where: '${_trackUploadTable.columnLocalID} = ?',
@@ -503,7 +497,7 @@ class UploadLocksDB {
// getFileNameToLastAttemptedAtMap returns a map of encrypted file name to last attempted at time
Future<Map<String, int>> getFileNameToLastAttemptedAtMap() {
return instance.database.then((db) async {
return database.then((db) async {
final rows = await db.query(
_trackUploadTable.table,
columns: [
@@ -525,7 +519,7 @@ class UploadLocksDB {
String fileHash,
int collectionID,
) {
return instance.database.then((db) async {
return database.then((db) async {
final rows = await db.query(
_trackUploadTable.table,
where: '${_trackUploadTable.columnLocalID} = ?'
@@ -546,7 +540,7 @@ class UploadLocksDB {
int uploadedFileID,
String queueType, // 'create' or 'recreate'
) async {
final db = await instance.database;
final db = await database;
await db.insert(
_streamQueueTable.table,
{
@@ -558,7 +552,7 @@ class UploadLocksDB {
}
Future<void> removeFromStreamQueue(int uploadedFileID) async {
final db = await instance.database;
final db = await database;
await db.delete(
_streamQueueTable.table,
where: '${_streamQueueTable.columnUploadedFileID} = ?',
@@ -567,7 +561,7 @@ class UploadLocksDB {
}
Future<Map<int, String>> getStreamQueue() async {
final db = await instance.database;
final db = await database;
final rows = await db.query(
_streamQueueTable.table,
columns: [
@@ -584,7 +578,7 @@ class UploadLocksDB {
}
Future<bool> isInStreamQueue(int uploadedFileID) async {
final db = await instance.database;
final db = await database;
final rows = await db.query(
_streamQueueTable.table,
where: '${_streamQueueTable.columnUploadedFileID} = ?',

View File

@@ -1831,7 +1831,8 @@
"videosProcessed": "Videos processed",
"totalVideos": "Total videos",
"skippedVideos": "Skipped videos",
"videoStreamingDescription": "Play videos instantly on any device. Enable to process video streams on this device.",
"videoStreamingDescriptionLine1": "Play videos instantly on any device.",
"videoStreamingDescriptionLine2": "Enable to process video streams on this device.",
"videoStreamingNote": "Only videos from last 60 days and under 1 minute are processed on this device. For older/longer videos, enable streaming in the desktop app.",
"createStream": "Create stream",
"recreateStream": "Recreate stream",
@@ -1866,7 +1867,7 @@
"@deletePhotosWithSize": {
"placeholders": {
"count": {
"type": "int"
"type": "String"
},
"size": {
"type": "String"
@@ -1941,5 +1942,9 @@
"findingSimilarImages": "Finding similar images",
"almostDone": "Almost done",
"processingLocally": "Processing locally",
"useMLToFindSimilarImages": "Use ML to find images that look similar to each other."
"useMLToFindSimilarImages": "Review and remove images that look similar to each other.",
"all": "All",
"similar": "Similar",
"identical": "Identical",
"nothingHereTryAnotherFilter": "Nothing here, try another filter! 👀"
}

View File

@@ -0,0 +1,103 @@
class FeedUser {
final String id;
final String name;
final String avatarUrl;
const FeedUser({
required this.id,
required this.name,
required this.avatarUrl,
});
}
class FeedPhoto {
final String id;
final String url;
final String? description;
const FeedPhoto({
required this.id,
required this.url,
this.description,
});
}
enum FeedItemType {
memory,
album,
photos,
video,
}
class FeedItem {
final String id;
final FeedUser user;
final FeedItemType type;
final String title;
final String subtitle;
final List<FeedPhoto> photos;
final bool isLiked;
final int likeCount;
final String timeAgo;
final bool isVideo;
const FeedItem({
required this.id,
required this.user,
required this.type,
required this.title,
required this.subtitle,
required this.photos,
this.isLiked = false,
this.likeCount = 0,
required this.timeAgo,
this.isVideo = false,
});
FeedItem copyWith({
bool? isLiked,
int? likeCount,
}) {
return FeedItem(
id: id,
user: user,
type: type,
title: title,
subtitle: subtitle,
photos: photos,
isLiked: isLiked ?? this.isLiked,
likeCount: likeCount ?? this.likeCount,
timeAgo: timeAgo,
isVideo: isVideo,
);
}
}
class NotificationItem {
final String id;
final FeedUser user;
final String action;
final String timeAgo;
final FeedPhoto? photo;
final bool isRead;
const NotificationItem({
required this.id,
required this.user,
required this.action,
required this.timeAgo,
this.photo,
this.isRead = false,
});
NotificationItem copyWith({bool? isRead}) {
return NotificationItem(
id: id,
user: user,
action: action,
timeAgo: timeAgo,
photo: photo,
isRead: isRead ?? this.isRead,
);
}
}

View File

@@ -0,0 +1,289 @@
import 'package:photos/models/feed/feed_models.dart';
class FeedDataService {
static const List<FeedUser> _mockUsers = [
FeedUser(
id: "1",
name: "Bob",
avatarUrl: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face",
),
FeedUser(
id: "2",
name: "Alice",
avatarUrl: "https://images.unsplash.com/photo-1494790108755-2616b09c7bec?w=150&h=150&fit=crop&crop=face",
),
FeedUser(
id: "3",
name: "Charlie",
avatarUrl: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face",
),
FeedUser(
id: "4",
name: "Diana",
avatarUrl: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face",
),
];
static List<FeedItem> getMockFeedItems() {
return [
FeedItem(
id: "1",
user: _mockUsers[0],
type: FeedItemType.memory,
title: "Trip to paris",
subtitle: "shared a memory",
timeAgo: "2h ago",
photos: [
const FeedPhoto(
id: "1",
url: "https://images.unsplash.com/photo-1499856871958-5b9627545d1a?w=400&h=600&fit=crop&crop=center",
description: "Beautiful archway in Paris with a silhouette of a person",
),
const FeedPhoto(
id: "1a",
url: "https://images.unsplash.com/photo-1502602898536-47ad22581b52?w=400&h=600&fit=crop",
description: "Eiffel Tower at sunset",
),
const FeedPhoto(
id: "1b",
url: "https://images.unsplash.com/photo-1471623643120-e6ccc3452a4e?w=400&h=600&fit=crop",
description: "Paris street with classic architecture",
),
const FeedPhoto(
id: "1c",
url: "https://images.unsplash.com/photo-1549144511-f099e773c147?w=400&h=600&fit=crop",
description: "Seine river with Notre Dame",
),
],
),
FeedItem(
id: "2",
user: _mockUsers[0],
type: FeedItemType.photos,
title: "Maldives",
subtitle: "shared 3 photos",
timeAgo: "4h ago",
likeCount: 12,
photos: [
const FeedPhoto(
id: "2",
url: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=600&fit=crop",
description: "Woman sitting on wooden walkway overlooking tropical landscape",
),
const FeedPhoto(
id: "3",
url: "https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=400&h=600&fit=crop",
description: "Tropical beach with crystal clear water",
),
const FeedPhoto(
id: "4",
url: "https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=400&h=600&fit=crop",
description: "Overwater bungalows in Maldives",
),
],
),
FeedItem(
id: "3",
user: _mockUsers[0],
type: FeedItemType.photos,
title: "Maldives",
subtitle: "shared 3 photos",
timeAgo: "6h ago",
isLiked: true,
likeCount: 8,
photos: [
const FeedPhoto(
id: "5",
url: "https://images.unsplash.com/photo-1544198365-f5d60b6d8190?w=400&h=300&fit=crop",
description: "Woman with drink on beach",
),
const FeedPhoto(
id: "6",
url: "https://images.unsplash.com/photo-1544551763-77ef2d0cfc6c?w=400&h=300&fit=crop",
description: "Couple enjoying tropical vacation",
),
const FeedPhoto(
id: "7",
url: "https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=400&h=300&fit=crop",
description: "Beautiful lagoon view",
),
],
),
FeedItem(
id: "4",
user: _mockUsers[0],
type: FeedItemType.video,
title: "Maldives",
subtitle: "shared a video",
timeAgo: "1d ago",
likeCount: 25,
isVideo: true,
photos: [
const FeedPhoto(
id: "8",
url: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=600&fit=crop",
description: "Video thumbnail of tropical scenery",
),
],
),
FeedItem(
id: "5",
user: _mockUsers[0],
type: FeedItemType.album,
title: "Pets",
subtitle: "liked an Album",
timeAgo: "2d ago",
likeCount: 15,
photos: [
const FeedPhoto(
id: "9",
url: "https://images.unsplash.com/photo-1552053831-71594a27632d?w=400&h=600&fit=crop",
description: "Happy dog in a grassy field",
),
],
),
FeedItem(
id: "6",
user: _mockUsers[1],
type: FeedItemType.memory,
title: "Mountain Adventure",
subtitle: "shared a memory",
timeAgo: "3d ago",
likeCount: 32,
photos: [
const FeedPhoto(
id: "10",
url: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=600&fit=crop",
description: "Breathtaking mountain landscape view",
),
],
),
FeedItem(
id: "7",
user: _mockUsers[2],
type: FeedItemType.photos,
title: "City Lights",
subtitle: "shared 5 photos",
timeAgo: "1w ago",
isLiked: true,
likeCount: 45,
photos: [
const FeedPhoto(
id: "11",
url: "https://images.unsplash.com/photo-1519501025264-65ba15a82390?w=400&h=600&fit=crop",
description: "Urban cityscape at night",
),
const FeedPhoto(
id: "12",
url: "https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?w=400&h=600&fit=crop",
description: "City lights reflecting on water",
),
const FeedPhoto(
id: "13",
url: "https://images.unsplash.com/photo-1514565131-fce0801e5785?w=400&h=600&fit=crop",
description: "Busy street with neon lights",
),
const FeedPhoto(
id: "14",
url: "https://images.unsplash.com/photo-1519501025264-65ba15a82390?w=400&h=600&fit=crop",
description: "Skyscraper view from below",
),
const FeedPhoto(
id: "15",
url: "https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?w=400&h=600&fit=crop",
description: "City panorama at sunset",
),
],
),
];
}
static List<NotificationItem> getMockNotifications() {
return [
NotificationItem(
id: "1",
user: _mockUsers[0],
action: "Liked your photo",
timeAgo: "40m ago",
photo: const FeedPhoto(
id: "n1",
url: "https://images.unsplash.com/photo-1552053831-71594a27632d?w=100&h=100&fit=crop",
),
),
NotificationItem(
id: "2",
user: _mockUsers[1],
action: "Liked your photo",
timeAgo: "2hrs ago",
photo: const FeedPhoto(
id: "n2",
url: "https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=100&h=100&fit=crop",
),
),
NotificationItem(
id: "3",
user: _mockUsers[2],
action: "Liked your photo",
timeAgo: "1 day ago",
photo: const FeedPhoto(
id: "n3",
url: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=100&h=100&fit=crop",
),
),
NotificationItem(
id: "4",
user: _mockUsers[0],
action: "Liked your album",
timeAgo: "14 days ago",
photo: const FeedPhoto(
id: "n4",
url: "https://images.unsplash.com/photo-1519501025264-65ba15a82390?w=100&h=100&fit=crop",
),
),
NotificationItem(
id: "5",
user: _mockUsers[3],
action: "Liked your photo",
timeAgo: "2 mnths ago",
photo: const FeedPhoto(
id: "n5",
url: "https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?w=100&h=100&fit=crop",
),
),
// Read notifications
NotificationItem(
id: "6",
user: _mockUsers[0],
action: "Liked your photo",
timeAgo: "40m ago",
isRead: true,
photo: const FeedPhoto(
id: "n6",
url: "https://images.unsplash.com/photo-1552053831-71594a27632d?w=100&h=100&fit=crop",
),
),
NotificationItem(
id: "7",
user: _mockUsers[1],
action: "Liked your photo",
timeAgo: "2hrs ago",
isRead: true,
photo: const FeedPhoto(
id: "n7",
url: "https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=100&h=100&fit=crop",
),
),
NotificationItem(
id: "8",
user: _mockUsers[2],
action: "Liked your photo",
timeAgo: "1 day ago",
isRead: true,
photo: const FeedPhoto(
id: "n8",
url: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=100&h=100&fit=crop",
),
),
];
}
}

View File

@@ -10,7 +10,7 @@ import "package:photos/core/event_bus.dart";
import "package:photos/events/compute_control_event.dart";
import "package:thermal/thermal.dart";
enum _ComputeRunState {
enum ComputeRunState {
idle,
runningML,
generatingStream,
@@ -35,7 +35,7 @@ class ComputeController {
bool interactionOverride = false;
late Timer _userInteractionTimer;
_ComputeRunState _currentRunState = _ComputeRunState.idle;
ComputeRunState _currentRunState = ComputeRunState.idle;
bool _waitingToRunML = false;
bool get isDeviceHealthy => _isDeviceHealthy;
@@ -70,10 +70,20 @@ class ComputeController {
_logger.info('init done ');
}
bool requestCompute({bool ml = false, bool stream = false}) {
_logger.info("Requesting compute: ml: $ml, stream: $stream");
if (!_isDeviceHealthy || !_canRunGivenUserInteraction()) {
_logger.info("Device not healthy or user interacting, denying request.");
bool requestCompute({
bool ml = false,
bool stream = false,
bool bypassInteractionCheck = false,
}) {
_logger.info(
"Requesting compute: ml: $ml, stream: $stream, bypassInteraction: $bypassInteractionCheck",
);
if (!_isDeviceHealthy) {
_logger.info("Device not healthy, denying request.");
return false;
}
if (!bypassInteractionCheck && !_canRunGivenUserInteraction()) {
_logger.info("User interacting, denying request.");
return false;
}
bool result = false;
@@ -87,13 +97,17 @@ class ComputeController {
return result;
}
ComputeRunState get computeState {
return _currentRunState;
}
bool _requestML() {
if (_currentRunState == _ComputeRunState.idle) {
_currentRunState = _ComputeRunState.runningML;
if (_currentRunState == ComputeRunState.idle) {
_currentRunState = ComputeRunState.runningML;
_waitingToRunML = false;
_logger.info("ML request granted");
return true;
} else if (_currentRunState == _ComputeRunState.runningML) {
} else if (_currentRunState == ComputeRunState.runningML) {
return true;
}
_logger.info(
@@ -104,12 +118,9 @@ class ComputeController {
}
bool _requestStream() {
if (_currentRunState == _ComputeRunState.idle && !_waitingToRunML) {
if (_currentRunState == ComputeRunState.idle && !_waitingToRunML) {
_logger.info("Stream request granted");
_currentRunState = _ComputeRunState.generatingStream;
return true;
} else if (_currentRunState == _ComputeRunState.generatingStream &&
!_waitingToRunML) {
_currentRunState = ComputeRunState.generatingStream;
return true;
}
_logger.info(
@@ -124,13 +135,13 @@ class ComputeController {
);
if (ml) {
if (_currentRunState == _ComputeRunState.runningML) {
_currentRunState = _ComputeRunState.idle;
if (_currentRunState == ComputeRunState.runningML) {
_currentRunState = ComputeRunState.idle;
}
_waitingToRunML = false;
} else if (stream) {
if (_currentRunState == _ComputeRunState.generatingStream) {
_currentRunState = _ComputeRunState.idle;
if (_currentRunState == ComputeRunState.generatingStream) {
_currentRunState = ComputeRunState.idle;
}
}
}

View File

@@ -32,6 +32,7 @@ import "package:photos/service_locator.dart";
import "package:photos/services/file_magic_service.dart";
import "package:photos/services/filedata/model/file_data.dart";
import "package:photos/services/isolated_ffmpeg_service.dart";
import "package:photos/services/machine_learning/compute_controller.dart";
import "package:photos/ui/notification/toast.dart";
import "package:photos/utils/exif_util.dart";
import "package:photos/utils/file_key.dart";
@@ -120,8 +121,9 @@ class VideoPreviewService {
if (file.uploadedFileID == null) return false;
// Check if already in queue
final bool alreadyInQueue =
await uploadLocksDB.isInStreamQueue(file.uploadedFileID!);
final bool alreadyInQueue = await uploadLocksDB.isInStreamQueue(
file.uploadedFileID!,
);
if (alreadyInQueue) {
return false; // Indicates file was already in queue
}
@@ -131,7 +133,7 @@ class VideoPreviewService {
// Start processing if not already processing
if (uploadingFileId < 0) {
queueFiles(duration: Duration.zero);
queueFiles(duration: Duration.zero, isManual: true);
} else {
_items[file.uploadedFileID!] = PreviewItem(
status: PreviewItemStatus.inQueue,
@@ -252,10 +254,12 @@ class VideoPreviewService {
BuildContext? ctx,
EnteFile enteFile, [
bool forceUpload = false,
bool isManual = false,
]) async {
if (!_allowStream()) {
final canStream = _isPermissionGranted();
if (!canStream) {
_logger.info(
"Pause preview due to disabledSteaming($isVideoStreamingEnabled) or computeController permission)",
"Pause preview due to disabledSteaming($isVideoStreamingEnabled) or computeController permission) - isManual: $isManual",
);
if (isVideoStreamingEnabled) _logger.info("No permission to run compute");
clearQueue();
@@ -575,8 +579,9 @@ class VideoPreviewService {
Future<void> _removeFromLocks(EnteFile enteFile) async {
final bool isFailurePresent =
_failureFiles?.contains(enteFile.uploadedFileID!) ?? false;
final bool isInManualQueue =
await uploadLocksDB.isInStreamQueue(enteFile.uploadedFileID!);
final bool isInManualQueue = await uploadLocksDB.isInStreamQueue(
enteFile.uploadedFileID!,
);
if (isFailurePresent) {
await uploadLocksDB.deleteStreamUploadErrorEntry(
@@ -1025,8 +1030,9 @@ class VideoPreviewService {
}
// First try to find the file in the 60-day list
var queueFile =
files.firstWhereOrNull((f) => f.uploadedFileID == queueFileId);
var queueFile = files.firstWhereOrNull(
(f) => f.uploadedFileID == queueFileId,
);
// If not found in 60-day list, fetch it individually
queueFile ??=
@@ -1124,11 +1130,27 @@ class VideoPreviewService {
computeController.requestCompute(stream: true);
}
void queueFiles({Duration duration = const Duration(seconds: 5)}) {
bool _allowManualStream() {
return isVideoStreamingEnabled &&
computeController.requestCompute(
stream: true,
bypassInteractionCheck: true,
);
}
bool _isPermissionGranted() {
return isVideoStreamingEnabled &&
computeController.computeState == ComputeRunState.generatingStream;
}
void queueFiles({
Duration duration = const Duration(seconds: 5),
bool isManual = false,
}) {
Future.delayed(duration, () async {
if (_hasQueuedFile) return;
final isStreamAllowed = _allowStream();
final isStreamAllowed = isManual ? _allowManualStream() : _allowStream();
if (!isStreamAllowed) return;
await _ensurePreviewIdsInitialized();

View File

@@ -276,12 +276,12 @@ class _CollectionActionSheetState extends State<CollectionActionSheet> {
isDisabled: _selectedCollections.isEmpty,
onTap: () async {
if (widget.selectedPeople != null) {
final ProgressDialog? dialog = createProgressDialog(
final ProgressDialog dialog = createProgressDialog(
context,
AppLocalizations.of(context).uploadingFilesToAlbum,
isDismissible: true,
);
await dialog?.show();
await dialog.show();
for (final collection in _selectedCollections) {
try {
await smartAlbumsService.addPeopleToSmartAlbum(
@@ -297,7 +297,7 @@ class _CollectionActionSheetState extends State<CollectionActionSheet> {
}
}
unawaited(smartAlbumsService.syncSmartAlbums());
await dialog?.hide();
await dialog.hide();
return;
}
final CollectionActions collectionActions =

View File

@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:photos/models/feed/feed_models.dart';
import 'package:photos/services/feed/feed_data_service.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/feed/feed_viewer_page.dart';
import 'package:photos/ui/feed/notifications_page.dart';
import 'package:photos/ui/feed/widgets/feed_item_widget.dart';
import 'package:photos/utils/navigation_util.dart';
class FeedTab extends StatefulWidget {
const FeedTab({super.key});
@override
State<FeedTab> createState() => _FeedTabState();
}
class _FeedTabState extends State<FeedTab> {
List<FeedItem> _feedItems = [];
@override
void initState() {
super.initState();
_loadFeedItems();
}
void _loadFeedItems() {
setState(() {
_feedItems = FeedDataService.getMockFeedItems();
});
}
void _onLike(int index) {
setState(() {
final item = _feedItems[index];
_feedItems[index] = item.copyWith(
isLiked: !item.isLiked,
likeCount: item.isLiked ? item.likeCount - 1 : item.likeCount + 1,
);
});
}
void _onFeedItemTap(FeedItem item) {
routeToPage(
context,
FeedViewerPage(
feedItem: item,
onLike: () {
final index = _feedItems.indexWhere((f) => f.id == item.id);
if (index != -1) {
_onLike(index);
}
},
),
);
}
void _onNotificationsTap() {
routeToPage(context, const NotificationsPage());
}
@override
Widget build(BuildContext context) {
final colorScheme = getEnteColorScheme(context);
return Scaffold(
backgroundColor: colorScheme.backgroundBase,
body: SafeArea(
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 32),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Feed',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
),
),
GestureDetector(
onTap: _onNotificationsTap,
child: Container(
padding: const EdgeInsets.all(8),
child: Icon(
Icons.notifications_outlined,
size: 28,
color: colorScheme.textBase,
),
),
),
],
),
),
// Feed content
Expanded(
child: _feedItems.isEmpty
? const Center(
child: CircularProgressIndicator(),
)
: ListView.builder(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
itemCount: _feedItems.length,
itemBuilder: (context, index) {
final item = _feedItems[index];
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: FeedItemWidget(
item: item,
onTap: () => _onFeedItemTap(item),
onLike: () => _onLike(index),
),
);
},
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,322 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:photos/models/feed/feed_models.dart';
import 'package:photos/ui/feed/notifications_page.dart';
import 'package:photos/ui/feed/widgets/feed_user_avatar.dart';
import 'package:photos/utils/navigation_util.dart';
class FeedViewerPage extends StatefulWidget {
final FeedItem feedItem;
final VoidCallback? onLike;
const FeedViewerPage({
required this.feedItem,
this.onLike,
super.key,
});
@override
State<FeedViewerPage> createState() => _FeedViewerPageState();
}
class _FeedViewerPageState extends State<FeedViewerPage> {
late PageController _pageController;
int _currentPhotoIndex = 0;
late bool _isLiked;
@override
void initState() {
super.initState();
_pageController = PageController();
_isLiked = widget.feedItem.isLiked;
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _onLike() {
setState(() {
_isLiked = !_isLiked;
});
widget.onLike?.call();
}
void _onNotificationsTap() {
routeToPage(context, const NotificationsPage());
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
// Main content
_buildMainContent(),
// Top bar
_buildTopBar(),
// Bottom overlay
_buildBottomOverlay(),
],
),
);
}
Widget _buildMainContent() {
if (widget.feedItem.photos.length == 1) {
return _buildSinglePhoto();
} else {
return _buildMultiplePhotos();
}
}
Widget _buildSinglePhoto() {
final photo = widget.feedItem.photos.first;
return Center(
child: CachedNetworkImage(
imageUrl: photo.url,
fit: BoxFit.contain,
width: double.infinity,
height: double.infinity,
placeholder: (context, url) => const Center(
child: CircularProgressIndicator(
color: Colors.white,
),
),
errorWidget: (context, url, error) => const Center(
child: Icon(
Icons.error,
color: Colors.white,
size: 50,
),
),
),
);
}
Widget _buildMultiplePhotos() {
return PageView.builder(
controller: _pageController,
onPageChanged: (index) {
setState(() {
_currentPhotoIndex = index;
});
},
itemCount: widget.feedItem.photos.length,
itemBuilder: (context, index) {
final photo = widget.feedItem.photos[index];
return Center(
child: CachedNetworkImage(
imageUrl: photo.url,
fit: BoxFit.contain,
width: double.infinity,
height: double.infinity,
placeholder: (context, url) => const Center(
child: CircularProgressIndicator(
color: Colors.white,
),
),
errorWidget: (context, url, error) => const Center(
child: Icon(
Icons.error,
color: Colors.white,
size: 50,
),
),
),
);
},
);
}
Widget _buildTopBar() {
return SafeArea(
child: Container(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
padding: const EdgeInsets.all(8),
child: const Icon(
Icons.arrow_back,
color: Colors.white,
size: 28,
),
),
),
const SizedBox(width: 8),
const Text(
'Feed',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
],
),
GestureDetector(
onTap: _onNotificationsTap,
child: Container(
padding: const EdgeInsets.all(8),
child: const Icon(
Icons.notifications_outlined,
color: Colors.white,
size: 28,
),
),
),
],
),
),
);
}
Widget _buildBottomOverlay() {
return Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black54,
Colors.black87,
],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Photo indicators for multiple photos
if (widget.feedItem.photos.length > 1) ...[
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
widget.feedItem.photos.length,
(index) => Container(
margin: const EdgeInsets.symmetric(horizontal: 3),
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: index == _currentPhotoIndex
? Colors.white
: Colors.white.withOpacity(0.4),
),
),
),
),
const SizedBox(height: 20),
],
// Action buttons
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// User info and description
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
FeedUserAvatar(
avatarUrl: widget.feedItem.user.avatarUrl,
name: widget.feedItem.user.name,
size: 40,
),
const SizedBox(width: 12),
Text(
widget.feedItem.user.name,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 8),
if (widget.feedItem.photos.first.description != null)
Text(
widget.feedItem.photos.first.description!,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 16),
// Action buttons
Column(
children: [
GestureDetector(
onTap: _onLike,
child: Container(
padding: const EdgeInsets.all(12),
child: Icon(
_isLiked ? Icons.favorite : Icons.favorite_border,
color: _isLiked ? Colors.red : Colors.white,
size: 32,
),
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
child: const Icon(
Icons.chat_bubble_outline,
color: Colors.white,
size: 32,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
child: const Icon(
Icons.share_outlined,
color: Colors.white,
size: 32,
),
),
],
),
],
),
const SizedBox(height: 8),
// Progress bar (placeholder)
Container(
height: 4,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(2),
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,199 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:photos/models/feed/feed_models.dart';
import 'package:photos/services/feed/feed_data_service.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/feed/widgets/feed_user_avatar.dart';
class NotificationsPage extends StatefulWidget {
const NotificationsPage({super.key});
@override
State<NotificationsPage> createState() => _NotificationsPageState();
}
class _NotificationsPageState extends State<NotificationsPage> {
List<NotificationItem> _unreadNotifications = [];
List<NotificationItem> _readNotifications = [];
@override
void initState() {
super.initState();
_loadNotifications();
}
void _loadNotifications() {
final notifications = FeedDataService.getMockNotifications();
setState(() {
_unreadNotifications = notifications.where((n) => !n.isRead).toList();
_readNotifications = notifications.where((n) => n.isRead).toList();
});
}
@override
Widget build(BuildContext context) {
final colorScheme = getEnteColorScheme(context);
return Scaffold(
backgroundColor: colorScheme.backgroundBase,
body: SafeArea(
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
child: Row(
children: [
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
padding: const EdgeInsets.all(8),
child: Icon(
Icons.arrow_back,
color: colorScheme.textBase,
size: 24,
),
),
),
],
),
),
// Content
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Unread section
if (_unreadNotifications.isNotEmpty) ...[
const Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Text(
'Unread',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
),
),
),
..._unreadNotifications.map((notification) =>
_buildNotificationItem(notification, colorScheme),),
const SizedBox(height: 24),
],
// Read section
if (_readNotifications.isNotEmpty) ...[
const Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Text(
'Read',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
),
),
),
..._readNotifications.map((notification) =>
_buildNotificationItem(notification, colorScheme),),
const SizedBox(height: 24),
],
],
),
),
),
],
),
),
);
}
Widget _buildNotificationItem(NotificationItem notification, colorScheme) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Row(
children: [
// User avatar
FeedUserAvatar(
avatarUrl: notification.user.avatarUrl,
name: notification.user.name,
size: 50,
),
const SizedBox(width: 16),
// Notification details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
style: TextStyle(
fontSize: 16,
color: colorScheme.textBase,
),
children: [
TextSpan(
text: notification.user.name,
style: const TextStyle(
fontWeight: FontWeight.w600,
),
),
TextSpan(
text: ' ${notification.action}',
style: TextStyle(
color: colorScheme.textMuted,
),
),
],
),
),
const SizedBox(height: 4),
Text(
notification.timeAgo,
style: TextStyle(
fontSize: 14,
color: colorScheme.textMuted,
),
),
],
),
),
// Photo thumbnail if available
if (notification.photo != null) ...[
const SizedBox(width: 12),
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Colors.grey[300],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: notification.photo!.url,
width: 60,
height: 60,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey[300],
child: const Center(
child: CircularProgressIndicator(),
),
),
errorWidget: (context, url, error) => Container(
color: Colors.grey[300],
child: const Center(
child: Icon(Icons.error),
),
),
),
),
),
],
],
),
);
}
}

View File

@@ -0,0 +1,455 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:photos/models/feed/feed_models.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/feed/widgets/feed_user_avatar.dart';
class FeedItemWidget extends StatelessWidget {
final FeedItem item;
final VoidCallback? onTap;
final VoidCallback? onLike;
const FeedItemWidget({
required this.item,
this.onTap,
this.onLike,
super.key,
});
@override
Widget build(BuildContext context) {
final colorScheme = getEnteColorScheme(context);
return GestureDetector(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 12,
offset: const Offset(0, 3),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// User header
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Row(
children: [
FeedUserAvatar(
avatarUrl: item.user.avatarUrl,
name: item.user.name,
size: 40,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.user.name,
style: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 16,
height: 1.2,
),
),
const SizedBox(height: 2),
Row(
children: [
Text(
item.subtitle,
style: TextStyle(
color: Colors.grey[600],
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.1,
),
),
const SizedBox(width: 8),
Flexible(
child: Text(
item.title,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: Colors.black87,
height: 1.1,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
GestureDetector(
onTap: onLike,
child: Container(
padding: const EdgeInsets.all(8),
child: Icon(
item.isLiked ? Icons.favorite : Icons.favorite_border,
color: item.isLiked ? Colors.red : colorScheme.textMuted,
size: 24,
),
),
),
],
),
),
// Content
_buildContent(context),
],
),
),
);
}
Widget _buildContent(BuildContext context) {
if (item.photos.length == 1) {
return _buildSinglePhoto(context);
} else if (item.type == FeedItemType.memory) {
return _buildMemoryCarousel(context);
} else {
return _buildMultiplePhotos(context);
}
}
Widget _buildSinglePhoto(BuildContext context) {
final photo = item.photos.first;
return Container(
width: double.infinity,
height: 400,
margin: const EdgeInsets.fromLTRB(16, 0, 16, 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.grey[300],
),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: CachedNetworkImage(
imageUrl: photo.url,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey[100],
child: Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.grey[400],
),
),
),
errorWidget: (context, url, error) => Container(
color: Colors.grey[100],
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.image_outlined,
size: 48,
color: Colors.grey[400],
),
const SizedBox(height: 8),
Text(
'Image unavailable',
style: TextStyle(
color: Colors.grey[600],
fontSize: 12,
),
),
],
),
),
),
),
// Title overlay for memories and albums
if (item.type == FeedItemType.memory || item.type == FeedItemType.album)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
decoration: const BoxDecoration(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(16),
),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black38,
Colors.black54,
],
),
),
padding: const EdgeInsets.all(16),
child: Text(
item.title,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
shadows: [
Shadow(
offset: Offset(0, 1),
blurRadius: 3,
color: Colors.black26,
),
],
),
),
),
),
// Video play button
if (item.isVideo)
Positioned.fill(
child: Center(
child: Container(
width: 60,
height: 60,
decoration: const BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: const Icon(
Icons.play_arrow,
color: Colors.white,
size: 30,
),
),
),
),
],
),
);
}
Widget _buildMultiplePhotos(BuildContext context) {
final photosToShow = item.photos.take(3).toList();
final remainingCount = item.photos.length > 3 ? item.photos.length - 3 : 0;
return Container(
height: 300,
margin: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Column(
children: [
// Top row - 2 images side by side
if (photosToShow.length >= 2)
Expanded(
child: Row(
children: [
Expanded(
child: _buildPhotoItem(photosToShow[0], false, 0),
),
const SizedBox(width: 2),
Expanded(
child: _buildPhotoItem(photosToShow[1], false, 0),
),
],
),
),
// Bottom row - 1 image full width (if 3+ photos)
if (photosToShow.length >= 3) ...[
const SizedBox(height: 2),
Expanded(
child: _buildPhotoItem(
photosToShow[2],
remainingCount > 0,
remainingCount,
),
),
],
],
),
),
);
}
Widget _buildMemoryCarousel(BuildContext context) {
final photo = item.photos.first;
return Container(
width: double.infinity,
height: 400,
margin: const EdgeInsets.fromLTRB(16, 0, 16, 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: Colors.grey[300],
),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: CachedNetworkImage(
imageUrl: photo.url,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey[100],
child: Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.grey[400],
),
),
),
errorWidget: (context, url, error) => Container(
color: Colors.grey[100],
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.image_outlined,
size: 48,
color: Colors.grey[400],
),
const SizedBox(height: 8),
Text(
'Image unavailable',
style: TextStyle(
color: Colors.grey[600],
fontSize: 12,
),
),
],
),
),
),
),
// Title overlay for memories
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
decoration: const BoxDecoration(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(16),
),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black38,
Colors.black54,
],
),
),
padding: const EdgeInsets.all(16),
child: Text(
item.title,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
shadows: [
Shadow(
offset: Offset(0, 1),
blurRadius: 3,
color: Colors.black26,
),
],
),
),
),
),
// Carousel indicator
Positioned(
top: 16,
right: 16,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.6),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'1/${item.photos.length}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
);
}
Widget _buildPhotoItem(FeedPhoto photo, bool showOverlay, int remainingCount) {
return Stack(
children: [
CachedNetworkImage(
imageUrl: photo.url,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey[100],
child: Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.grey[400],
),
),
),
errorWidget: (context, url, error) => Container(
color: Colors.grey[100],
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.image_outlined,
size: 24,
color: Colors.grey[400],
),
const SizedBox(height: 2),
Text(
'Image unavailable',
style: TextStyle(
color: Colors.grey[600],
fontSize: 8,
),
),
],
),
),
),
// Overlay for remaining photos count
if (showOverlay)
Container(
color: Colors.black.withOpacity(0.6),
child: Center(
child: Text(
'+$remainingCount',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,56 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
class FeedUserAvatar extends StatelessWidget {
final String avatarUrl;
final double size;
final String name;
const FeedUserAvatar({
required this.avatarUrl,
this.size = 40.0,
required this.name,
super.key,
});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.grey[300],
),
child: ClipOval(
child: CachedNetworkImage(
imageUrl: avatarUrl,
width: size,
height: size,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey[300],
child: Icon(
Icons.person,
color: Colors.grey[600],
size: size * 0.6,
),
),
errorWidget: (context, url, error) => Container(
color: Colors.grey[300],
child: Center(
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
fontSize: size * 0.4,
fontWeight: FontWeight.bold,
color: Colors.grey[700],
),
),
),
),
),
),
);
}
}

View File

@@ -151,7 +151,7 @@ class _HomeBottomNavigationBarState extends State<HomeBottomNavigationBar> {
),
GButton(
margin: const EdgeInsets.fromLTRB(10, 6, 8, 6),
icon: Icons.people_outlined,
icon: Icons.dynamic_feed_outlined,
iconColor: enteColorScheme.tabIcon,
iconActiveColor: strokeBaseLight,
text: '',

View File

@@ -12,7 +12,6 @@ import "package:photos/theme/ente_theme.dart";
import "package:photos/ui/common/loading_widget.dart";
import "package:photos/ui/common/web_page.dart";
import "package:photos/ui/components/buttons/button_widget.dart";
import "package:photos/ui/components/buttons/icon_button_widget.dart";
import "package:photos/ui/components/captioned_text_widget.dart";
import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart";
import "package:photos/ui/components/models/button_type.dart";
@@ -49,7 +48,8 @@ class _VideoStreamingSettingsPageState
bottomNavigationBar: !hasEnabled
? SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 16)
.copyWith(bottom: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -75,15 +75,7 @@ class _VideoStreamingSettingsPageState
flexibleSpaceTitle: TitleBarTitleWidget(
title: AppLocalizations.of(context).videoStreaming,
),
actionIcons: [
IconButtonWidget(
icon: Icons.close_outlined,
iconButtonType: IconButtonType.secondary,
onTap: () {
Navigator.popUntil(context, (route) => route.isFirst);
},
),
],
actionIcons: const [],
isSliver: false,
),
),
@@ -96,17 +88,7 @@ class _VideoStreamingSettingsPageState
flexibleSpaceTitle: TitleBarTitleWidget(
title: AppLocalizations.of(context).videoStreaming,
),
actionIcons: [
IconButtonWidget(
icon: Icons.close_outlined,
iconButtonType: IconButtonType.secondary,
onTap: () {
Navigator.pop(context);
if (Navigator.canPop(context)) Navigator.pop(context);
if (Navigator.canPop(context)) Navigator.pop(context);
},
),
],
actionIcons: const [],
),
SliverToBoxAdapter(
child: Container(
@@ -118,7 +100,12 @@ class _VideoStreamingSettingsPageState
children: [
TextSpan(
text: AppLocalizations.of(context)
.videoStreamingDescription,
.videoStreamingDescriptionLine1,
),
const TextSpan(text: " "),
TextSpan(
text: AppLocalizations.of(context)
.videoStreamingDescriptionLine2,
),
const TextSpan(text: " "),
TextSpan(
@@ -131,7 +118,6 @@ class _VideoStreamingSettingsPageState
),
],
),
textAlign: TextAlign.justify,
style: getEnteTextTheme(context).mini.copyWith(
color: getEnteColorScheme(context).textMuted,
),
@@ -159,24 +145,34 @@ class _VideoStreamingSettingsPageState
height: 160,
),
const SizedBox(height: 16),
Text.rich(
TextSpan(
text: AppLocalizations.of(context)
.videoStreamingDescription +
" ",
children: [
TextSpan(
text: AppLocalizations.of(context).moreDetails,
style: TextStyle(
color: getEnteColorScheme(context).primary500,
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: AppLocalizations.of(context)
.videoStreamingDescriptionLine1,
),
recognizer: TapGestureRecognizer()
..onTap = openHelp,
),
],
const TextSpan(text: "\n"),
TextSpan(
text: AppLocalizations.of(context)
.videoStreamingDescriptionLine2,
),
const TextSpan(text: "\n"),
TextSpan(
text: AppLocalizations.of(context).moreDetails,
style: TextStyle(
color: getEnteColorScheme(context).primary500,
),
recognizer: TapGestureRecognizer()
..onTap = openHelp,
),
],
),
style: getEnteTextTheme(context).smallMuted,
textAlign: TextAlign.center,
),
style: getEnteTextTheme(context).smallMuted,
textAlign: TextAlign.center,
),
const SizedBox(height: 140),
],

View File

@@ -54,6 +54,7 @@ import 'package:photos/ui/collections/collection_action_sheet.dart';
import "package:photos/ui/components/buttons/button_widget.dart";
import "package:photos/ui/components/models/button_type.dart";
import 'package:photos/ui/extents_page_view.dart';
import "package:photos/ui/feed/feed_tab.dart";
import 'package:photos/ui/home/grant_permissions_widget.dart';
import 'package:photos/ui/home/header_widget.dart';
import 'package:photos/ui/home/home_bottom_nav_bar.dart';
@@ -64,7 +65,6 @@ import 'package:photos/ui/home/start_backup_hook_widget.dart';
import 'package:photos/ui/notification/update/change_log_page.dart';
import "package:photos/ui/settings/app_update_dialog.dart";
import "package:photos/ui/settings_page.dart";
import "package:photos/ui/tabs/shared_collections_tab.dart";
import "package:photos/ui/tabs/user_collections_tab.dart";
import "package:photos/ui/viewer/actions/file_viewer.dart";
import "package:photos/ui/viewer/file/detail_page.dart";
@@ -87,7 +87,6 @@ class HomeWidget extends StatefulWidget {
}
class _HomeWidgetState extends State<HomeWidget> {
static const _sharedCollectionTab = SharedCollectionsTab();
static const _searchTab = SearchTab();
static final _settingsPage = SettingsPage(
emailNotifier: UserService.instance.emailValueNotifier,
@@ -761,7 +760,7 @@ class _HomeWidgetState extends State<HomeWidget> {
selectedFiles: _selectedFiles,
),
UserCollectionsTab(selectedAlbums: _selectedAlbums),
_sharedCollectionTab,
const FeedTab(),
_searchTab,
],
);

View File

@@ -1,19 +1,26 @@
import 'dart:async';
import "dart:math";
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import "package:photos/core/configuration.dart";
import "package:photos/core/constants.dart";
import 'package:photos/core/event_bus.dart';
import "package:photos/events/album_sort_order_change_event.dart";
import 'package:photos/events/collection_updated_event.dart';
import "package:photos/events/favorites_service_init_complete_event.dart";
import 'package:photos/events/local_photos_updated_event.dart';
import "package:photos/events/tab_changed_event.dart";
import 'package:photos/events/user_logged_out_event.dart';
import "package:photos/generated/l10n.dart";
import 'package:photos/models/collection/collection.dart';
import 'package:photos/models/collection/collection_items.dart';
import "package:photos/models/search/generic_search_result.dart";
import "package:photos/models/selected_albums.dart";
import 'package:photos/services/collections_service.dart';
import "package:photos/services/search_service.dart";
import "package:photos/theme/ente_theme.dart";
import "package:photos/ui/collections/album/row_item.dart";
import "package:photos/ui/collections/button/archived_button.dart";
import "package:photos/ui/collections/button/hidden_button.dart";
import "package:photos/ui/collections/button/trash_button.dart";
@@ -25,9 +32,14 @@ import "package:photos/ui/collections/flex_grid_view.dart";
import 'package:photos/ui/common/loading_widget.dart';
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
import "package:photos/ui/tabs/section_title.dart";
import "package:photos/ui/tabs/shared/all_quick_links_page.dart";
import "package:photos/ui/tabs/shared/quick_link_album_item.dart";
import "package:photos/ui/viewer/actions/album_selection_overlay_bar.dart";
import "package:photos/ui/viewer/actions/delete_empty_albums.dart";
import "package:photos/ui/viewer/gallery/collect_photos_card_widget.dart";
import "package:photos/ui/viewer/gallery/collection_page.dart";
import "package:photos/ui/viewer/gallery/empty_state.dart";
import "package:photos/ui/viewer/search_tab/contacts_section.dart";
import "package:photos/utils/navigation_util.dart";
import "package:photos/utils/standalone/debouncer.dart";
@@ -50,6 +62,7 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
late StreamSubscription<FavoritesServiceInitCompleteEvent>
_favoritesServiceInitCompleteEvent;
late StreamSubscription<AlbumSortOrderChangeEvent> _albumSortOrderChangeEvent;
late StreamSubscription<TabChangedEvent> _tabChangeEvent;
String _loadReason = "init";
final _scrollController = ScrollController();
@@ -59,6 +72,16 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
leading: true,
);
// For shared collections functionality
final _canLoadDeferredWidgets = ValueNotifier<bool>(false);
final _debouncerForDeferringLoad = Debouncer(
const Duration(milliseconds: 500),
);
static const heroTagPrefix = "outgoing_collection";
static const maxThumbnailWidth = 224.0;
static const crossAxisSpacing = 8.0;
static const horizontalPadding = 16.0;
static const int _kOnEnteItemLimitCount = 12;
@override
void initState() {
@@ -97,6 +120,27 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
_loadReason = event.reason;
setState(() {});
});
_tabChangeEvent = Bus.instance.on<TabChangedEvent>().listen((event) {
if (event.selectedIndex == 1) {
_debouncerForDeferringLoad.run(() async {
_logger.info("Loading deferred widgets in collections tab");
if (mounted) {
_canLoadDeferredWidgets.value = true;
await _tabChangeEvent.cancel();
Future.delayed(
Duration.zero,
() => _debouncerForDeferringLoad.cancelDebounceTimer(),
);
}
});
} else {
_debouncerForDeferringLoad.cancelDebounceTimer();
if (mounted) {
_canLoadDeferredWidgets.value = false;
}
}
});
}
@override
@@ -198,6 +242,8 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
enableSelectionMode: true,
)
: const SliverToBoxAdapter(child: EmptyState()),
// Shared Collections Content
_buildSharedCollectionsSections(),
SliverToBoxAdapter(
child: Divider(
color: getEnteColorScheme(context).strokeFaint,
@@ -236,6 +282,251 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
);
}
Widget _buildSharedCollectionsSections() {
return FutureBuilder<SharedCollections>(
future: Future.value(CollectionsService.instance.getSharedCollections()),
builder: (context, snapshot) {
if (snapshot.hasData) {
return _getSharedCollectionsContent(snapshot.data!);
} else if (snapshot.hasError) {
_logger.severe(
"failed to load shared collections",
snapshot.error,
snapshot.stackTrace,
);
return const SliverToBoxAdapter(child: SizedBox.shrink());
} else {
return const SliverToBoxAdapter(child: EnteLoadingWidget());
}
},
);
}
Widget _getSharedCollectionsContent(SharedCollections collections) {
const maxQuickLinks = 4;
final numberOfQuickLinks = collections.quickLinks.length;
final double screenWidth = MediaQuery.sizeOf(context).width;
final int albumsCountInRow = max(screenWidth ~/ maxThumbnailWidth, 3);
final totalHorizontalPadding = (albumsCountInRow - 1) * crossAxisSpacing;
final sideOfThumbnail =
(screenWidth - totalHorizontalPadding - horizontalPadding) /
albumsCountInRow;
const quickLinkTitleHeroTag = "quick_link_title";
final SectionTitle sharedWithYou =
SectionTitle(title: AppLocalizations.of(context).sharedWithYou);
final SectionTitle sharedByYou =
SectionTitle(title: AppLocalizations.of(context).sharedByYou);
final colorTheme = getEnteColorScheme(context);
return SliverList(
delegate: SliverChildListDelegate([
// Check if we have any shared collections content to show
if (collections.incoming.isEmpty &&
collections.outgoing.isEmpty &&
numberOfQuickLinks == 0)
const SizedBox.shrink()
else ...[
const SizedBox(height: 24),
// Incoming Collections (Shared with you)
if (collections.incoming.isNotEmpty) ...[
SectionOptions(
onTap: () {
unawaited(
routeToPage(
context,
CollectionListPage(
collections.incoming,
sectionType: UISectionType.incomingCollections,
tag: "incoming",
appTitle: sharedWithYou,
),
),
);
},
Hero(tag: "incoming", child: sharedWithYou),
trailingWidget: IconButtonWidget(
icon: Icons.chevron_right,
iconButtonType: IconButtonType.secondary,
iconColor: colorTheme.blurStrokePressed,
),
),
const SizedBox(height: 2),
SizedBox(
height: sideOfThumbnail + 46,
child: ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: horizontalPadding / 2,
),
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(
right: horizontalPadding / 2,
),
child: AlbumRowItemWidget(
collections.incoming[index],
sideOfThumbnail,
tag: "incoming",
showFileCount: true,
),
);
},
itemCount: collections.incoming.length,
),
),
const SizedBox(height: 24),
],
// Outgoing Collections (Shared by you)
if (collections.outgoing.isNotEmpty) ...[
SectionOptions(
onTap: () {
unawaited(
routeToPage(
context,
CollectionListPage(
collections.outgoing,
sectionType: UISectionType.outgoingCollections,
tag: "outgoing",
appTitle: sharedByYou,
),
),
);
},
Hero(tag: "outgoing", child: sharedByYou),
trailingWidget: IconButtonWidget(
icon: Icons.chevron_right,
iconButtonType: IconButtonType.secondary,
iconColor: colorTheme.blurStrokePressed,
),
),
const SizedBox(height: 2),
SizedBox(
height: sideOfThumbnail + 46,
child: ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: 8,
),
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(
right: horizontalPadding / 2,
),
child: AlbumRowItemWidget(
collections.outgoing[index],
sideOfThumbnail,
tag: "outgoing",
showFileCount: true,
),
);
},
itemCount: collections.outgoing.length,
),
),
const SizedBox(height: 24),
],
// Quick Links
if (numberOfQuickLinks > 0) ...[
SectionOptions(
onTap: numberOfQuickLinks > maxQuickLinks
? () {
unawaited(
routeToPage(
context,
AllQuickLinksPage(
titleHeroTag: quickLinkTitleHeroTag,
quickLinks: collections.quickLinks,
),
),
);
}
: null,
Hero(
tag: quickLinkTitleHeroTag,
child: SectionTitle(
title: AppLocalizations.of(context).quickLinks,
),
),
trailingWidget: numberOfQuickLinks > maxQuickLinks
? IconButtonWidget(
icon: Icons.chevron_right,
iconButtonType: IconButtonType.secondary,
iconColor: colorTheme.blurStrokePressed,
)
: null,
),
const SizedBox(height: 2),
ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.only(
bottom: 12,
left: 12,
right: 12,
),
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
return GestureDetector(
onTap: () async {
final thumbnail = await CollectionsService
.instance
.getCover(collections.quickLinks[index]);
final page = CollectionPage(
CollectionWithThumbnail(
collections.quickLinks[index],
thumbnail,
),
tagPrefix: heroTagPrefix,
);
// ignore: unawaited_futures
routeToPage(context, page);
},
child: QuickLinkAlbumItem(
c: collections.quickLinks[index],
),
);
},
separatorBuilder: (context, index) {
return const SizedBox(height: 4);
},
itemCount: min(numberOfQuickLinks, maxQuickLinks),
),
const SizedBox(height: 24),
],
// Contacts Section (deferred loading)
ValueListenableBuilder(
valueListenable: _canLoadDeferredWidgets,
builder: (context, value, _) {
return value
? FutureBuilder(
future: SearchService.instance
.getAllContactsSearchResults(kSearchSectionLimit),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ContactsSection(
snapshot.data as List<GenericSearchResult>,
);
} else if (snapshot.hasError) {
_logger.severe(
"failed to load contacts section",
snapshot.error,
snapshot.stackTrace,
);
return const SizedBox.shrink();
} else {
return const EnteLoadingWidget();
}
},
)
: const SizedBox.shrink();
},
),
const CollectPhotosCardWidget(),
const SizedBox(height: 12),
],
]),
);
}
@override
void dispose() {
_localFilesSubscription.cancel();
@@ -245,6 +536,9 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
_scrollController.dispose();
_debouncer.cancelDebounceTimer();
_albumSortOrderChangeEvent.cancel();
_tabChangeEvent.cancel();
_debouncerForDeferringLoad.cancelDebounceTimer();
_canLoadDeferredWidgets.dispose();
super.dispose();
}

View File

@@ -2,6 +2,7 @@ import "dart:async";
import "package:flutter/foundation.dart" show kDebugMode;
import 'package:flutter/material.dart';
import "package:intl/intl.dart";
import 'package:logging/logging.dart';
import "package:photos/core/configuration.dart";
import 'package:photos/core/constants.dart';
@@ -12,13 +13,15 @@ import "package:photos/models/similar_files.dart";
import "package:photos/service_locator.dart";
import "package:photos/services/collections_service.dart";
import "package:photos/services/machine_learning/similar_images_service.dart";
import "package:photos/theme/colors.dart";
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/components/action_sheet_widget.dart';
import "package:photos/theme/text_style.dart";
import 'package:photos/ui/components/buttons/button_widget.dart';
import "package:photos/ui/components/models/button_type.dart";
import "package:photos/ui/components/toggle_switch_widget.dart";
import "package:photos/ui/viewer/file/detail_page.dart";
import "package:photos/ui/viewer/file/thumbnail_widget.dart";
import "package:photos/ui/viewer/gallery/empty_state.dart";
import "package:photos/utils/delete_file_util.dart";
import "package:photos/utils/dialog_util.dart";
import "package:photos/utils/navigation_util.dart";
@@ -37,6 +40,12 @@ enum SortKey {
count,
}
enum TabFilter {
all,
similar,
identical,
}
class SimilarImagesPage extends StatefulWidget {
final bool debugScreen;
@@ -49,6 +58,8 @@ class SimilarImagesPage extends StatefulWidget {
class _SimilarImagesPageState extends State<SimilarImagesPage> {
static const crossAxisCount = 3;
static const crossAxisSpacing = 12.0;
static const double _similarThreshold = 0.02;
static const double _identicalThreshold = 0.0001;
final _logger = Logger("SimilarImagesPage");
bool _isDisposed = false;
@@ -56,14 +67,40 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
SimilarImagesPageState _pageState = SimilarImagesPageState.setup;
double _distanceThreshold = 0.04; // Default value
List<SimilarFiles> _similarFilesList = [];
SortKey _sortKey = SortKey.distanceAsc;
SortKey _sortKey = SortKey.size;
bool _exactSearch = false;
bool _fullRefresh = false;
bool _isSelectionSheetOpen = false;
TabFilter _selectedTab = TabFilter.identical;
late SelectedFiles _selectedFiles;
late ValueNotifier<String> _deleteProgress;
List<SimilarFiles> get _filteredGroups {
switch (_selectedTab) {
case TabFilter.all:
return _similarFilesList;
case TabFilter.similar:
final filteredGroups = <SimilarFiles>[];
for (final group in _similarFilesList) {
final distance = group.furthestDistance;
if (distance > _identicalThreshold && distance <= _similarThreshold) {
filteredGroups.add(group);
}
}
return filteredGroups;
case TabFilter.identical:
final filteredGroups = <SimilarFiles>[];
for (final group in _similarFilesList) {
final distance = group.furthestDistance;
if (distance <= _identicalThreshold) {
filteredGroups.add(group);
}
}
return filteredGroups;
}
}
@override
void initState() {
super.initState();
@@ -318,78 +355,125 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
return Column(
children: [
_buildTabBar(),
Expanded(
child: ListView.builder(
cacheExtent: 400,
itemCount: _similarFilesList.length + 1, // +1 for header
itemBuilder: (context, index) {
if (index == 0) {
return RepaintBoundary(
child: Container(
margin: const EdgeInsets.symmetric(
horizontal: crossAxisSpacing,
vertical: 12,
),
padding: const EdgeInsets.all(crossAxisSpacing),
decoration: BoxDecoration(
color: colorScheme.fillFaint,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.photo_library_outlined,
size: 20,
color: colorScheme.textMuted,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context).similarGroupsFound(
count: _similarFilesList.length,
),
style: textTheme.bodyBold,
),
const SizedBox(height: 4),
Text(
AppLocalizations.of(context)
.reviewAndRemoveSimilarImages,
style: textTheme.miniMuted,
),
],
),
),
],
),
),
);
}
// Similar files groups (index - 1 because first item is header)
final similarFiles = _similarFilesList[index - 1];
return RepaintBoundary(
child: _buildSimilarFilesGroup(similarFiles),
);
},
),
child: _filteredGroups.isEmpty
? EmptyState(
text:
AppLocalizations.of(context).nothingHereTryAnotherFilter,
)
: ListView.builder(
cacheExtent: 400,
itemCount: _filteredGroups.length,
itemBuilder: (context, index) {
final similarFiles = _filteredGroups[index];
return RepaintBoundary(
child: _buildSimilarFilesGroup(similarFiles),
);
},
),
),
_getBottomActionButtons(),
if (_filteredGroups.isNotEmpty) _getBottomActionButtons(),
],
);
}
Widget _buildTabBar() {
final colorScheme = getEnteColorScheme(context);
final textTheme = getEnteTextTheme(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
_buildTabButton(
TabFilter.identical,
AppLocalizations.of(context).identical,
colorScheme,
textTheme,
),
const SizedBox(width: crossAxisSpacing),
_buildTabButton(
TabFilter.similar,
AppLocalizations.of(context).similar,
colorScheme,
textTheme,
),
const SizedBox(width: crossAxisSpacing),
_buildTabButton(
TabFilter.all,
AppLocalizations.of(context).all,
colorScheme,
textTheme,
),
],
),
);
}
Widget _buildTabButton(
TabFilter tab,
String label,
EnteColorScheme colorScheme,
EnteTextTheme textTheme,
) {
final isSelected = _selectedTab == tab;
return GestureDetector(
onTap: () => _onTabChanged(tab),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration(
color: isSelected ? colorScheme.primary700 : colorScheme.fillFaint,
borderRadius: BorderRadius.circular(20),
),
child: Text(
label,
style: isSelected
? textTheme.smallBold.copyWith(color: Colors.white)
: textTheme.smallBold,
),
),
);
}
void _onTabChanged(TabFilter newTab) {
setState(() {
_selectedTab = newTab;
final newSelection = <EnteFile>{};
for (final group in _filteredGroups) {
for (int i = 1; i < group.files.length; i++) {
newSelection.add(group.files[i]);
}
}
_selectedFiles.clearAll();
_selectedFiles.selectAll(newSelection);
});
}
Widget _getBottomActionButtons() {
return ListenableBuilder(
listenable: _selectedFiles,
builder: (context, _) {
final selectedCount = _selectedFiles.files.length;
final selectedFiles = _selectedFiles.files;
final selectedCount = selectedFiles.length;
final hasSelectedFiles = selectedCount > 0;
final eligibleFilteredFiles = <EnteFile>{};
for (final group in _filteredGroups) {
for (int i = 1; i < group.files.length; i++) {
eligibleFilteredFiles.add(group.files[i]);
}
}
final selectedFilteredFiles =
selectedFiles.intersection(eligibleFilteredFiles);
final allFilteredSelected = eligibleFilteredFiles.isNotEmpty &&
selectedFilteredFiles.length == eligibleFilteredFiles.length;
int totalSize = 0;
for (final file in _selectedFiles.files) {
for (final file in selectedFilteredFiles) {
totalSize += file.fileSize ?? 0;
}
@@ -424,7 +508,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
),
);
},
child: hasSelectedFiles && !_isSelectionSheetOpen
child: hasSelectedFiles
? Column(
key: const ValueKey('delete_section'),
children: [
@@ -433,7 +517,8 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
child: ButtonWidget(
labelText: AppLocalizations.of(context)
.deletePhotosWithSize(
count: selectedCount,
count: NumberFormat()
.format(selectedFilteredFiles.length),
size: formatBytes(totalSize),
),
buttonType: ButtonType.critical,
@@ -441,7 +526,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
shouldShowSuccessConfirmation: false,
onTap: () async {
await _deleteFiles(
_selectedFiles.files,
selectedFilteredFiles,
showDialog: true,
showUIFeedback: true,
);
@@ -453,27 +538,20 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
)
: const SizedBox.shrink(key: ValueKey('no_delete')),
),
if (!_isSelectionSheetOpen)
SizedBox(
width: double.infinity,
child: ButtonWidget(
labelText: AppLocalizations.of(context).selectionOptions,
buttonType: ButtonType.secondary,
shouldSurfaceExecutionStates: false,
shouldShowSuccessConfirmation: false,
onTap: () async {
setState(() {
_isSelectionSheetOpen = true;
});
await _showSelectionOptionsSheet();
if (mounted) {
setState(() {
_isSelectionSheetOpen = false;
});
}
},
),
SizedBox(
width: double.infinity,
child: ButtonWidget(
labelText: allFilteredSelected
? AppLocalizations.of(context).unselectAll
: AppLocalizations.of(context).selectAll,
buttonType: ButtonType.secondary,
shouldSurfaceExecutionStates: false,
shouldShowSuccessConfirmation: false,
onTap: () async {
_toggleSelectAll();
},
),
),
],
),
),
@@ -482,6 +560,25 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
);
}
void _toggleSelectAll() {
final eligibleFiles = <EnteFile>{};
for (final group in _filteredGroups) {
for (int i = 1; i < group.files.length; i++) {
eligibleFiles.add(group.files[i]);
}
}
final currentSelected = _selectedFiles.files.intersection(eligibleFiles);
final allSelected = eligibleFiles.isNotEmpty &&
currentSelected.length == eligibleFiles.length;
if (allSelected) {
_selectedFiles.unSelectAll(eligibleFiles);
} else {
_selectedFiles.selectAll(eligibleFiles);
}
}
Future<void> _findSimilarImages() async {
if (_isDisposed) return;
setState(() {
@@ -489,7 +586,6 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
});
try {
// You can use _toggleValue here for advanced mode features
_logger.info("exact mode: $_exactSearch");
final similarFiles = await SimilarImagesService.instance.getSimilarFiles(
@@ -505,6 +601,14 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
_pageState = SimilarImagesPageState.results;
_sortSimilarFiles();
for (final group in _similarFilesList) {
if (group.files.length > 1) {
for (int i = 1; i < group.files.length; i++) {
_selectedFiles.toggleSelection(group.files[i]);
}
}
}
if (_isDisposed) return;
setState(() {});
@@ -557,114 +661,6 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
setState(() {});
}
void _selectFilesByThreshold(double threshold) {
final filesToSelect = <EnteFile>{};
for (final similarFilesGroup in _similarFilesList) {
if (similarFilesGroup.furthestDistance <= threshold) {
for (int i = 1; i < similarFilesGroup.files.length; i++) {
filesToSelect.add(similarFilesGroup.files[i]);
}
}
}
if (filesToSelect.isNotEmpty) {
_selectedFiles.clearAll(fireEvent: false);
_selectedFiles.selectAll(filesToSelect);
} else {
_selectedFiles.clearAll(fireEvent: false);
}
}
Future<void> _showSelectionOptionsSheet() async {
// Calculate how many files fall into each category
int exactFiles = 0;
int similarFiles = 0;
int allFiles = 0;
for (final group in _similarFilesList) {
final duplicateCount = group.files.length - 1; // Exclude the first file
allFiles += duplicateCount;
if (group.furthestDistance <= 0.0) {
exactFiles += duplicateCount;
similarFiles += duplicateCount;
} else if (group.furthestDistance <= 0.02) {
similarFiles += duplicateCount;
}
}
// Always show counts, even when 0
final String exactLabel =
AppLocalizations.of(context).selectExactWithCount(count: exactFiles);
final String similarLabel = AppLocalizations.of(context)
.selectSimilarWithCount(count: similarFiles);
final String allLabel =
AppLocalizations.of(context).selectAllWithCount(count: allFiles);
await showActionSheet(
context: context,
title: AppLocalizations.of(context).selectSimilarImagesTitle,
body: AppLocalizations.of(context).chooseSimilarImagesToSelect,
buttons: [
ButtonWidget(
labelText: exactLabel,
buttonType: ButtonType.neutral,
buttonSize: ButtonSize.large,
shouldStickToDarkTheme: true,
isInAlert: true,
buttonAction: ButtonAction.first,
shouldSurfaceExecutionStates: false,
isDisabled: exactFiles == 0,
onTap: () async {
_selectFilesByThreshold(0.0);
},
),
ButtonWidget(
labelText: similarLabel,
buttonType: ButtonType.neutral,
buttonSize: ButtonSize.large,
shouldStickToDarkTheme: true,
isInAlert: true,
buttonAction: ButtonAction.second,
shouldSurfaceExecutionStates: false,
isDisabled: similarFiles == 0,
onTap: () async {
_selectFilesByThreshold(0.02);
},
),
ButtonWidget(
labelText: allLabel,
buttonType: ButtonType.neutral,
buttonSize: ButtonSize.large,
shouldStickToDarkTheme: true,
isInAlert: true,
buttonAction: ButtonAction.third,
shouldSurfaceExecutionStates: false,
isDisabled: allFiles == 0,
onTap: () async {
_selectFilesByThreshold(0.05);
},
),
ButtonWidget(
labelText: AppLocalizations.of(context).clearSelection,
buttonType: ButtonType.secondary,
buttonSize: ButtonSize.large,
shouldStickToDarkTheme: true,
isInAlert: true,
buttonAction: ButtonAction.cancel,
shouldSurfaceExecutionStates: false,
onTap: () async {
_selectedFiles.clearAll(fireEvent: false);
},
),
],
actionSheetType: ActionSheetType.defaultActionSheet,
);
}
Widget _buildSimilarFilesGroup(SimilarFiles similarFiles) {
final textTheme = getEnteTextTheme(context);
return Padding(
@@ -975,15 +971,15 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
_similarFilesList.remove(group);
}
final int collectionCnt = collectionToFilesToAddMap.keys.length;
if (createSymlink) {
final userID = Configuration.instance.getUserID();
final int collectionCnt = collectionToFilesToAddMap.keys.length;
int progress = 0;
for (final collectionID in collectionToFilesToAddMap.keys) {
if (!mounted) {
return;
}
if (collectionCnt > 0 && showUIFeedback) {
if (collectionCnt > 2 && showUIFeedback) {
progress++;
// calculate progress percentage upto 2 decimal places
final double percentage = (progress / collectionCnt) * 100;
@@ -1004,7 +1000,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
}
}
}
if (showUIFeedback) {
if (collectionCnt > 2 && showUIFeedback) {
_deleteProgress.value = "";
}
@@ -1013,7 +1009,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
await deleteFilesFromRemoteOnly(context, allDeleteFiles.toList());
// Show congratulations popup
if (allDeleteFiles.isNotEmpty && mounted && showUIFeedback) {
if (allDeleteFiles.length > 100 && mounted && showUIFeedback) {
final int totalSize = allDeleteFiles.fold<int>(
0,
(sum, file) => sum + (file.fileSize ?? 0),
@@ -1080,7 +1076,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
String text;
switch (key) {
case SortKey.size:
text = AppLocalizations.of(context).size;
text = AppLocalizations.of(context).totalSize;
break;
case SortKey.distanceAsc:
text = AppLocalizations.of(context).similarity;

View File

@@ -53,11 +53,11 @@ const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600);
/// {@end-tool}
///
/// A scrollbar track can be added using [trackVisibility]. This can also be
/// drawn when triggered by a hover event, or based on any [MaterialState] by
/// drawn when triggered by a hover event, or based on any [WidgetState] by
/// using [ScrollbarThemeData.trackVisibility].
///
/// The [thickness] of the track and scrollbar thumb can be changed dynamically
/// in response to [MaterialState]s using [ScrollbarThemeData.thickness].
/// in response to [WidgetState]s using [ScrollbarThemeData.thickness].
///
/// See also:
///

View File

@@ -6,7 +6,6 @@ import "package:permission_handler/permission_handler.dart";
import "package:photos/db/upload_locks_db.dart";
import "package:photos/extensions/stop_watch.dart";
import "package:photos/main.dart";
import "package:photos/service_locator.dart";
import "package:photos/utils/file_uploader.dart";
import "package:shared_preferences/shared_preferences.dart";
import "package:workmanager/workmanager.dart" as workmanager;
@@ -81,7 +80,7 @@ class BgTaskUtils {
try {
await workmanager.Workmanager().initialize(
callbackDispatcher,
isInDebugMode: Platform.isIOS && flagService.internalUser,
isInDebugMode: false,
);
await workmanager.Workmanager().registerPeriodicTask(
backgroundTaskIdentifier,

View File

@@ -32,8 +32,8 @@ impl VectorDB {
if file_exists {
println!("Loading index from disk.");
// Creates a view of the index from a file without loading it into memory. https://docs.rs/usearch/latest/usearch/struct.Index.html#method.view
db.index.view(file_path).expect("Failed to load index");
// Loads the index into memory. https://docs.rs/usearch/latest/usearch/struct.Index.html#method.load
db.index.load(file_path).expect("Failed to load index");
} else {
println!("Creating new index.");
db.save_index();

View File

@@ -1,3 +1,6 @@
- Prateek: Enable immediate manual video stream processing by bypassing user interaction timer
- Prateek: Fix multiple concurrent streaming processes bug in ComputeController
- Prateek: Fix video streaming description text display spacing in advanced settings
- Ashil: Render cached thumbnails faster (noticeable in gallery scrolling)
- Similar images UI changes
- Neeraj: Fix for double enteries for local file

View File

@@ -1,3 +1,4 @@
- Video streaming improvements
- Added support for custom domain links
- Image editor fixes:
- Fixed bottom navigation bar color in light theme