Compare commits
40 Commits
fix-upload
...
testing-fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdd14d8e6c | ||
|
|
67d7f586b2 | ||
|
|
7c22a8bb25 | ||
|
|
ff3864a09a | ||
|
|
4484b9e4ad | ||
|
|
e9554ffbcb | ||
|
|
ad3901d484 | ||
|
|
ecca4c3dc8 | ||
|
|
d05521f884 | ||
|
|
ff37c4bf81 | ||
|
|
84a5ad0b86 | ||
|
|
44ad11343a | ||
|
|
07e50e3cfe | ||
|
|
df8bbdb788 | ||
|
|
1ed381fe52 | ||
|
|
ddd1d5ac86 | ||
|
|
26845a502e | ||
|
|
21aac29020 | ||
|
|
c1ff02df14 | ||
|
|
e4927c4022 | ||
|
|
4fd797338b | ||
|
|
eca0e5943d | ||
|
|
56cc7309a5 | ||
|
|
b740d1af05 | ||
|
|
6d21b73367 | ||
|
|
a5704eef25 | ||
|
|
7e83682686 | ||
|
|
18d5aa61b0 | ||
|
|
7c2a719ba8 | ||
|
|
47313a74ff | ||
|
|
65a7a16298 | ||
|
|
9251e4f5b6 | ||
|
|
c4bc6abf83 | ||
|
|
3165289483 | ||
|
|
01aab41c25 | ||
|
|
1826258161 | ||
|
|
df5917060b | ||
|
|
b5aa05cc1b | ||
|
|
cd865992f2 | ||
|
|
370c0ab54a |
@@ -66,7 +66,6 @@ class UploadLocksDB {
|
|||||||
|
|
||||||
static final migrationScripts = [
|
static final migrationScripts = [
|
||||||
..._createTrackUploadsTable(),
|
..._createTrackUploadsTable(),
|
||||||
..._createStreamQueueTable(),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
final dbConfig = MigrationConfig(
|
final dbConfig = MigrationConfig(
|
||||||
@@ -142,11 +141,6 @@ class UploadLocksDB {
|
|||||||
${_streamUploadErrorTable.columnCreatedAt} INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL
|
${_streamUploadErrorTable.columnCreatedAt} INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
)
|
)
|
||||||
''',
|
''',
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<String> _createStreamQueueTable() {
|
|
||||||
return [
|
|
||||||
'''
|
'''
|
||||||
CREATE TABLE IF NOT EXISTS ${_streamQueueTable.table} (
|
CREATE TABLE IF NOT EXISTS ${_streamQueueTable.table} (
|
||||||
${_streamQueueTable.columnUploadedFileID} INTEGER PRIMARY KEY,
|
${_streamQueueTable.columnUploadedFileID} INTEGER PRIMARY KEY,
|
||||||
@@ -158,7 +152,7 @@ class UploadLocksDB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clearTable() async {
|
Future<void> clearTable() async {
|
||||||
final db = await instance.database;
|
final db = await database;
|
||||||
await db.delete(_uploadLocksTable.table);
|
await db.delete(_uploadLocksTable.table);
|
||||||
await db.delete(_trackUploadTable.table);
|
await db.delete(_trackUploadTable.table);
|
||||||
await db.delete(_partsTable.table);
|
await db.delete(_partsTable.table);
|
||||||
@@ -166,7 +160,7 @@ class UploadLocksDB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> acquireLock(String id, String owner, int time) async {
|
Future<void> acquireLock(String id, String owner, int time) async {
|
||||||
final db = await instance.database;
|
final db = await database;
|
||||||
final row = <String, dynamic>{};
|
final row = <String, dynamic>{};
|
||||||
row[_uploadLocksTable.columnID] = id;
|
row[_uploadLocksTable.columnID] = id;
|
||||||
row[_uploadLocksTable.columnOwner] = owner;
|
row[_uploadLocksTable.columnOwner] = owner;
|
||||||
@@ -179,7 +173,7 @@ class UploadLocksDB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<String> getLockData(String id) async {
|
Future<String> getLockData(String id) async {
|
||||||
final db = await instance.database;
|
final db = await database;
|
||||||
final rows = await db.query(
|
final rows = await db.query(
|
||||||
_uploadLocksTable.table,
|
_uploadLocksTable.table,
|
||||||
where: '${_uploadLocksTable.columnID} = ?',
|
where: '${_uploadLocksTable.columnID} = ?',
|
||||||
@@ -196,7 +190,7 @@ class UploadLocksDB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> isLocked(String id, String owner) async {
|
Future<bool> isLocked(String id, String owner) async {
|
||||||
final db = await instance.database;
|
final db = await database;
|
||||||
final rows = await db.query(
|
final rows = await db.query(
|
||||||
_uploadLocksTable.table,
|
_uploadLocksTable.table,
|
||||||
where:
|
where:
|
||||||
@@ -207,7 +201,7 @@ class UploadLocksDB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<int> releaseLock(String id, String owner) async {
|
Future<int> releaseLock(String id, String owner) async {
|
||||||
final db = await instance.database;
|
final db = await database;
|
||||||
return db.delete(
|
return db.delete(
|
||||||
_uploadLocksTable.table,
|
_uploadLocksTable.table,
|
||||||
where:
|
where:
|
||||||
@@ -217,7 +211,7 @@ class UploadLocksDB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<int> releaseLocksAcquiredByOwnerBefore(String owner, int time) async {
|
Future<int> releaseLocksAcquiredByOwnerBefore(String owner, int time) async {
|
||||||
final db = await instance.database;
|
final db = await database;
|
||||||
return db.delete(
|
return db.delete(
|
||||||
_uploadLocksTable.table,
|
_uploadLocksTable.table,
|
||||||
where:
|
where:
|
||||||
@@ -227,7 +221,7 @@ class UploadLocksDB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<int> releaseAllLocksAcquiredBefore(int time) async {
|
Future<int> releaseAllLocksAcquiredBefore(int time) async {
|
||||||
final db = await instance.database;
|
final db = await database;
|
||||||
return db.delete(
|
return db.delete(
|
||||||
_uploadLocksTable.table,
|
_uploadLocksTable.table,
|
||||||
where: '${_uploadLocksTable.columnTime} < ?',
|
where: '${_uploadLocksTable.columnTime} < ?',
|
||||||
@@ -241,7 +235,7 @@ class UploadLocksDB {
|
|||||||
String fileHash,
|
String fileHash,
|
||||||
int collectionID,
|
int collectionID,
|
||||||
) async {
|
) async {
|
||||||
final db = await instance.database;
|
final db = await database;
|
||||||
|
|
||||||
final rows = await db.query(
|
final rows = await db.query(
|
||||||
_trackUploadTable.table,
|
_trackUploadTable.table,
|
||||||
@@ -268,7 +262,7 @@ class UploadLocksDB {
|
|||||||
String fileHash,
|
String fileHash,
|
||||||
int collectionID,
|
int collectionID,
|
||||||
) async {
|
) async {
|
||||||
final db = await instance.database;
|
final db = await database;
|
||||||
await db.update(
|
await db.update(
|
||||||
_trackUploadTable.table,
|
_trackUploadTable.table,
|
||||||
{
|
{
|
||||||
@@ -291,7 +285,7 @@ class UploadLocksDB {
|
|||||||
String fileHash,
|
String fileHash,
|
||||||
int collectionID,
|
int collectionID,
|
||||||
) async {
|
) async {
|
||||||
final db = await instance.database;
|
final db = await database;
|
||||||
final rows = await db.query(
|
final rows = await db.query(
|
||||||
_trackUploadTable.table,
|
_trackUploadTable.table,
|
||||||
where: '${_trackUploadTable.columnLocalID} = ?'
|
where: '${_trackUploadTable.columnLocalID} = ?'
|
||||||
@@ -349,7 +343,7 @@ class UploadLocksDB {
|
|||||||
int uploadedFileID,
|
int uploadedFileID,
|
||||||
String errorMessage,
|
String errorMessage,
|
||||||
) async {
|
) async {
|
||||||
final db = await UploadLocksDB.instance.database;
|
final db = await database;
|
||||||
|
|
||||||
await db.insert(
|
await db.insert(
|
||||||
_streamUploadErrorTable.table,
|
_streamUploadErrorTable.table,
|
||||||
@@ -367,7 +361,7 @@ class UploadLocksDB {
|
|||||||
int uploadedFileID,
|
int uploadedFileID,
|
||||||
String errorMessage,
|
String errorMessage,
|
||||||
) async {
|
) async {
|
||||||
final db = await instance.database;
|
final db = await database;
|
||||||
await db.update(
|
await db.update(
|
||||||
_streamUploadErrorTable.table,
|
_streamUploadErrorTable.table,
|
||||||
{
|
{
|
||||||
@@ -381,7 +375,7 @@ class UploadLocksDB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<int> deleteStreamUploadErrorEntry(int uploadedFileID) async {
|
Future<int> deleteStreamUploadErrorEntry(int uploadedFileID) async {
|
||||||
final db = await instance.database;
|
final db = await database;
|
||||||
return await db.delete(
|
return await db.delete(
|
||||||
_streamUploadErrorTable.table,
|
_streamUploadErrorTable.table,
|
||||||
where: '${_streamUploadErrorTable.columnUploadedFileID} = ?',
|
where: '${_streamUploadErrorTable.columnUploadedFileID} = ?',
|
||||||
@@ -390,7 +384,7 @@ class UploadLocksDB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<int, String>> getStreamUploadError() {
|
Future<Map<int, String>> getStreamUploadError() {
|
||||||
return instance.database.then((db) async {
|
return database.then((db) async {
|
||||||
final rows = await db.query(
|
final rows = await db.query(
|
||||||
_streamUploadErrorTable.table,
|
_streamUploadErrorTable.table,
|
||||||
columns: [
|
columns: [
|
||||||
@@ -419,7 +413,7 @@ class UploadLocksDB {
|
|||||||
String keyNonce, {
|
String keyNonce, {
|
||||||
required int partSize,
|
required int partSize,
|
||||||
}) async {
|
}) async {
|
||||||
final db = await UploadLocksDB.instance.database;
|
final db = await database;
|
||||||
final objectKey = urls.objectKey;
|
final objectKey = urls.objectKey;
|
||||||
|
|
||||||
await db.insert(
|
await db.insert(
|
||||||
@@ -462,7 +456,7 @@ class UploadLocksDB {
|
|||||||
int partNumber,
|
int partNumber,
|
||||||
String etag,
|
String etag,
|
||||||
) async {
|
) async {
|
||||||
final db = await instance.database;
|
final db = await database;
|
||||||
await db.update(
|
await db.update(
|
||||||
_partsTable.table,
|
_partsTable.table,
|
||||||
{
|
{
|
||||||
@@ -479,7 +473,7 @@ class UploadLocksDB {
|
|||||||
String objectKey,
|
String objectKey,
|
||||||
MultipartStatus status,
|
MultipartStatus status,
|
||||||
) async {
|
) async {
|
||||||
final db = await instance.database;
|
final db = await database;
|
||||||
await db.update(
|
await db.update(
|
||||||
_trackUploadTable.table,
|
_trackUploadTable.table,
|
||||||
{
|
{
|
||||||
@@ -493,7 +487,7 @@ class UploadLocksDB {
|
|||||||
Future<int> deleteMultipartTrack(
|
Future<int> deleteMultipartTrack(
|
||||||
String localId,
|
String localId,
|
||||||
) async {
|
) async {
|
||||||
final db = await instance.database;
|
final db = await database;
|
||||||
return await db.delete(
|
return await db.delete(
|
||||||
_trackUploadTable.table,
|
_trackUploadTable.table,
|
||||||
where: '${_trackUploadTable.columnLocalID} = ?',
|
where: '${_trackUploadTable.columnLocalID} = ?',
|
||||||
@@ -503,7 +497,7 @@ class UploadLocksDB {
|
|||||||
|
|
||||||
// getFileNameToLastAttemptedAtMap returns a map of encrypted file name to last attempted at time
|
// getFileNameToLastAttemptedAtMap returns a map of encrypted file name to last attempted at time
|
||||||
Future<Map<String, int>> getFileNameToLastAttemptedAtMap() {
|
Future<Map<String, int>> getFileNameToLastAttemptedAtMap() {
|
||||||
return instance.database.then((db) async {
|
return database.then((db) async {
|
||||||
final rows = await db.query(
|
final rows = await db.query(
|
||||||
_trackUploadTable.table,
|
_trackUploadTable.table,
|
||||||
columns: [
|
columns: [
|
||||||
@@ -525,7 +519,7 @@ class UploadLocksDB {
|
|||||||
String fileHash,
|
String fileHash,
|
||||||
int collectionID,
|
int collectionID,
|
||||||
) {
|
) {
|
||||||
return instance.database.then((db) async {
|
return database.then((db) async {
|
||||||
final rows = await db.query(
|
final rows = await db.query(
|
||||||
_trackUploadTable.table,
|
_trackUploadTable.table,
|
||||||
where: '${_trackUploadTable.columnLocalID} = ?'
|
where: '${_trackUploadTable.columnLocalID} = ?'
|
||||||
@@ -546,7 +540,7 @@ class UploadLocksDB {
|
|||||||
int uploadedFileID,
|
int uploadedFileID,
|
||||||
String queueType, // 'create' or 'recreate'
|
String queueType, // 'create' or 'recreate'
|
||||||
) async {
|
) async {
|
||||||
final db = await instance.database;
|
final db = await database;
|
||||||
await db.insert(
|
await db.insert(
|
||||||
_streamQueueTable.table,
|
_streamQueueTable.table,
|
||||||
{
|
{
|
||||||
@@ -558,7 +552,7 @@ class UploadLocksDB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> removeFromStreamQueue(int uploadedFileID) async {
|
Future<void> removeFromStreamQueue(int uploadedFileID) async {
|
||||||
final db = await instance.database;
|
final db = await database;
|
||||||
await db.delete(
|
await db.delete(
|
||||||
_streamQueueTable.table,
|
_streamQueueTable.table,
|
||||||
where: '${_streamQueueTable.columnUploadedFileID} = ?',
|
where: '${_streamQueueTable.columnUploadedFileID} = ?',
|
||||||
@@ -567,7 +561,7 @@ class UploadLocksDB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<int, String>> getStreamQueue() async {
|
Future<Map<int, String>> getStreamQueue() async {
|
||||||
final db = await instance.database;
|
final db = await database;
|
||||||
final rows = await db.query(
|
final rows = await db.query(
|
||||||
_streamQueueTable.table,
|
_streamQueueTable.table,
|
||||||
columns: [
|
columns: [
|
||||||
@@ -584,7 +578,7 @@ class UploadLocksDB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> isInStreamQueue(int uploadedFileID) async {
|
Future<bool> isInStreamQueue(int uploadedFileID) async {
|
||||||
final db = await instance.database;
|
final db = await database;
|
||||||
final rows = await db.query(
|
final rows = await db.query(
|
||||||
_streamQueueTable.table,
|
_streamQueueTable.table,
|
||||||
where: '${_streamQueueTable.columnUploadedFileID} = ?',
|
where: '${_streamQueueTable.columnUploadedFileID} = ?',
|
||||||
|
|||||||
@@ -1831,7 +1831,8 @@
|
|||||||
"videosProcessed": "Videos processed",
|
"videosProcessed": "Videos processed",
|
||||||
"totalVideos": "Total videos",
|
"totalVideos": "Total videos",
|
||||||
"skippedVideos": "Skipped 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.",
|
"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",
|
"createStream": "Create stream",
|
||||||
"recreateStream": "Recreate stream",
|
"recreateStream": "Recreate stream",
|
||||||
@@ -1866,7 +1867,7 @@
|
|||||||
"@deletePhotosWithSize": {
|
"@deletePhotosWithSize": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"count": {
|
"count": {
|
||||||
"type": "int"
|
"type": "String"
|
||||||
},
|
},
|
||||||
"size": {
|
"size": {
|
||||||
"type": "String"
|
"type": "String"
|
||||||
@@ -1941,5 +1942,9 @@
|
|||||||
"findingSimilarImages": "Finding similar images",
|
"findingSimilarImages": "Finding similar images",
|
||||||
"almostDone": "Almost done",
|
"almostDone": "Almost done",
|
||||||
"processingLocally": "Processing locally",
|
"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! 👀"
|
||||||
}
|
}
|
||||||
|
|||||||
103
mobile/apps/photos/lib/models/feed/feed_models.dart
Normal file
103
mobile/apps/photos/lib/models/feed/feed_models.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
289
mobile/apps/photos/lib/services/feed/feed_data_service.dart
Normal file
289
mobile/apps/photos/lib/services/feed/feed_data_service.dart
Normal 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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import "package:photos/core/event_bus.dart";
|
|||||||
import "package:photos/events/compute_control_event.dart";
|
import "package:photos/events/compute_control_event.dart";
|
||||||
import "package:thermal/thermal.dart";
|
import "package:thermal/thermal.dart";
|
||||||
|
|
||||||
enum _ComputeRunState {
|
enum ComputeRunState {
|
||||||
idle,
|
idle,
|
||||||
runningML,
|
runningML,
|
||||||
generatingStream,
|
generatingStream,
|
||||||
@@ -35,7 +35,7 @@ class ComputeController {
|
|||||||
bool interactionOverride = false;
|
bool interactionOverride = false;
|
||||||
late Timer _userInteractionTimer;
|
late Timer _userInteractionTimer;
|
||||||
|
|
||||||
_ComputeRunState _currentRunState = _ComputeRunState.idle;
|
ComputeRunState _currentRunState = ComputeRunState.idle;
|
||||||
bool _waitingToRunML = false;
|
bool _waitingToRunML = false;
|
||||||
|
|
||||||
bool get isDeviceHealthy => _isDeviceHealthy;
|
bool get isDeviceHealthy => _isDeviceHealthy;
|
||||||
@@ -70,10 +70,20 @@ class ComputeController {
|
|||||||
_logger.info('init done ');
|
_logger.info('init done ');
|
||||||
}
|
}
|
||||||
|
|
||||||
bool requestCompute({bool ml = false, bool stream = false}) {
|
bool requestCompute({
|
||||||
_logger.info("Requesting compute: ml: $ml, stream: $stream");
|
bool ml = false,
|
||||||
if (!_isDeviceHealthy || !_canRunGivenUserInteraction()) {
|
bool stream = false,
|
||||||
_logger.info("Device not healthy or user interacting, denying request.");
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
bool result = false;
|
bool result = false;
|
||||||
@@ -87,13 +97,17 @@ class ComputeController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ComputeRunState get computeState {
|
||||||
|
return _currentRunState;
|
||||||
|
}
|
||||||
|
|
||||||
bool _requestML() {
|
bool _requestML() {
|
||||||
if (_currentRunState == _ComputeRunState.idle) {
|
if (_currentRunState == ComputeRunState.idle) {
|
||||||
_currentRunState = _ComputeRunState.runningML;
|
_currentRunState = ComputeRunState.runningML;
|
||||||
_waitingToRunML = false;
|
_waitingToRunML = false;
|
||||||
_logger.info("ML request granted");
|
_logger.info("ML request granted");
|
||||||
return true;
|
return true;
|
||||||
} else if (_currentRunState == _ComputeRunState.runningML) {
|
} else if (_currentRunState == ComputeRunState.runningML) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
_logger.info(
|
_logger.info(
|
||||||
@@ -104,12 +118,9 @@ class ComputeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _requestStream() {
|
bool _requestStream() {
|
||||||
if (_currentRunState == _ComputeRunState.idle && !_waitingToRunML) {
|
if (_currentRunState == ComputeRunState.idle && !_waitingToRunML) {
|
||||||
_logger.info("Stream request granted");
|
_logger.info("Stream request granted");
|
||||||
_currentRunState = _ComputeRunState.generatingStream;
|
_currentRunState = ComputeRunState.generatingStream;
|
||||||
return true;
|
|
||||||
} else if (_currentRunState == _ComputeRunState.generatingStream &&
|
|
||||||
!_waitingToRunML) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
_logger.info(
|
_logger.info(
|
||||||
@@ -124,13 +135,13 @@ class ComputeController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (ml) {
|
if (ml) {
|
||||||
if (_currentRunState == _ComputeRunState.runningML) {
|
if (_currentRunState == ComputeRunState.runningML) {
|
||||||
_currentRunState = _ComputeRunState.idle;
|
_currentRunState = ComputeRunState.idle;
|
||||||
}
|
}
|
||||||
_waitingToRunML = false;
|
_waitingToRunML = false;
|
||||||
} else if (stream) {
|
} else if (stream) {
|
||||||
if (_currentRunState == _ComputeRunState.generatingStream) {
|
if (_currentRunState == ComputeRunState.generatingStream) {
|
||||||
_currentRunState = _ComputeRunState.idle;
|
_currentRunState = ComputeRunState.idle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import "package:photos/service_locator.dart";
|
|||||||
import "package:photos/services/file_magic_service.dart";
|
import "package:photos/services/file_magic_service.dart";
|
||||||
import "package:photos/services/filedata/model/file_data.dart";
|
import "package:photos/services/filedata/model/file_data.dart";
|
||||||
import "package:photos/services/isolated_ffmpeg_service.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/ui/notification/toast.dart";
|
||||||
import "package:photos/utils/exif_util.dart";
|
import "package:photos/utils/exif_util.dart";
|
||||||
import "package:photos/utils/file_key.dart";
|
import "package:photos/utils/file_key.dart";
|
||||||
@@ -120,8 +121,9 @@ class VideoPreviewService {
|
|||||||
if (file.uploadedFileID == null) return false;
|
if (file.uploadedFileID == null) return false;
|
||||||
|
|
||||||
// Check if already in queue
|
// Check if already in queue
|
||||||
final bool alreadyInQueue =
|
final bool alreadyInQueue = await uploadLocksDB.isInStreamQueue(
|
||||||
await uploadLocksDB.isInStreamQueue(file.uploadedFileID!);
|
file.uploadedFileID!,
|
||||||
|
);
|
||||||
if (alreadyInQueue) {
|
if (alreadyInQueue) {
|
||||||
return false; // Indicates file was already in queue
|
return false; // Indicates file was already in queue
|
||||||
}
|
}
|
||||||
@@ -131,7 +133,7 @@ class VideoPreviewService {
|
|||||||
|
|
||||||
// Start processing if not already processing
|
// Start processing if not already processing
|
||||||
if (uploadingFileId < 0) {
|
if (uploadingFileId < 0) {
|
||||||
queueFiles(duration: Duration.zero);
|
queueFiles(duration: Duration.zero, isManual: true);
|
||||||
} else {
|
} else {
|
||||||
_items[file.uploadedFileID!] = PreviewItem(
|
_items[file.uploadedFileID!] = PreviewItem(
|
||||||
status: PreviewItemStatus.inQueue,
|
status: PreviewItemStatus.inQueue,
|
||||||
@@ -252,10 +254,12 @@ class VideoPreviewService {
|
|||||||
BuildContext? ctx,
|
BuildContext? ctx,
|
||||||
EnteFile enteFile, [
|
EnteFile enteFile, [
|
||||||
bool forceUpload = false,
|
bool forceUpload = false,
|
||||||
|
bool isManual = false,
|
||||||
]) async {
|
]) async {
|
||||||
if (!_allowStream()) {
|
final canStream = _isPermissionGranted();
|
||||||
|
if (!canStream) {
|
||||||
_logger.info(
|
_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");
|
if (isVideoStreamingEnabled) _logger.info("No permission to run compute");
|
||||||
clearQueue();
|
clearQueue();
|
||||||
@@ -575,8 +579,9 @@ class VideoPreviewService {
|
|||||||
Future<void> _removeFromLocks(EnteFile enteFile) async {
|
Future<void> _removeFromLocks(EnteFile enteFile) async {
|
||||||
final bool isFailurePresent =
|
final bool isFailurePresent =
|
||||||
_failureFiles?.contains(enteFile.uploadedFileID!) ?? false;
|
_failureFiles?.contains(enteFile.uploadedFileID!) ?? false;
|
||||||
final bool isInManualQueue =
|
final bool isInManualQueue = await uploadLocksDB.isInStreamQueue(
|
||||||
await uploadLocksDB.isInStreamQueue(enteFile.uploadedFileID!);
|
enteFile.uploadedFileID!,
|
||||||
|
);
|
||||||
|
|
||||||
if (isFailurePresent) {
|
if (isFailurePresent) {
|
||||||
await uploadLocksDB.deleteStreamUploadErrorEntry(
|
await uploadLocksDB.deleteStreamUploadErrorEntry(
|
||||||
@@ -1025,8 +1030,9 @@ class VideoPreviewService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// First try to find the file in the 60-day list
|
// First try to find the file in the 60-day list
|
||||||
var queueFile =
|
var queueFile = files.firstWhereOrNull(
|
||||||
files.firstWhereOrNull((f) => f.uploadedFileID == queueFileId);
|
(f) => f.uploadedFileID == queueFileId,
|
||||||
|
);
|
||||||
|
|
||||||
// If not found in 60-day list, fetch it individually
|
// If not found in 60-day list, fetch it individually
|
||||||
queueFile ??=
|
queueFile ??=
|
||||||
@@ -1124,11 +1130,27 @@ class VideoPreviewService {
|
|||||||
computeController.requestCompute(stream: true);
|
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 {
|
Future.delayed(duration, () async {
|
||||||
if (_hasQueuedFile) return;
|
if (_hasQueuedFile) return;
|
||||||
|
|
||||||
final isStreamAllowed = _allowStream();
|
final isStreamAllowed = isManual ? _allowManualStream() : _allowStream();
|
||||||
if (!isStreamAllowed) return;
|
if (!isStreamAllowed) return;
|
||||||
|
|
||||||
await _ensurePreviewIdsInitialized();
|
await _ensurePreviewIdsInitialized();
|
||||||
|
|||||||
@@ -276,12 +276,12 @@ class _CollectionActionSheetState extends State<CollectionActionSheet> {
|
|||||||
isDisabled: _selectedCollections.isEmpty,
|
isDisabled: _selectedCollections.isEmpty,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
if (widget.selectedPeople != null) {
|
if (widget.selectedPeople != null) {
|
||||||
final ProgressDialog? dialog = createProgressDialog(
|
final ProgressDialog dialog = createProgressDialog(
|
||||||
context,
|
context,
|
||||||
AppLocalizations.of(context).uploadingFilesToAlbum,
|
AppLocalizations.of(context).uploadingFilesToAlbum,
|
||||||
isDismissible: true,
|
isDismissible: true,
|
||||||
);
|
);
|
||||||
await dialog?.show();
|
await dialog.show();
|
||||||
for (final collection in _selectedCollections) {
|
for (final collection in _selectedCollections) {
|
||||||
try {
|
try {
|
||||||
await smartAlbumsService.addPeopleToSmartAlbum(
|
await smartAlbumsService.addPeopleToSmartAlbum(
|
||||||
@@ -297,7 +297,7 @@ class _CollectionActionSheetState extends State<CollectionActionSheet> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
unawaited(smartAlbumsService.syncSmartAlbums());
|
unawaited(smartAlbumsService.syncSmartAlbums());
|
||||||
await dialog?.hide();
|
await dialog.hide();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final CollectionActions collectionActions =
|
final CollectionActions collectionActions =
|
||||||
|
|||||||
126
mobile/apps/photos/lib/ui/feed/feed_tab.dart
Normal file
126
mobile/apps/photos/lib/ui/feed/feed_tab.dart
Normal 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
322
mobile/apps/photos/lib/ui/feed/feed_viewer_page.dart
Normal file
322
mobile/apps/photos/lib/ui/feed/feed_viewer_page.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
199
mobile/apps/photos/lib/ui/feed/notifications_page.dart
Normal file
199
mobile/apps/photos/lib/ui/feed/notifications_page.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
455
mobile/apps/photos/lib/ui/feed/widgets/feed_item_widget.dart
Normal file
455
mobile/apps/photos/lib/ui/feed/widgets/feed_item_widget.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
mobile/apps/photos/lib/ui/feed/widgets/feed_user_avatar.dart
Normal file
56
mobile/apps/photos/lib/ui/feed/widgets/feed_user_avatar.dart
Normal 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],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -151,7 +151,7 @@ class _HomeBottomNavigationBarState extends State<HomeBottomNavigationBar> {
|
|||||||
),
|
),
|
||||||
GButton(
|
GButton(
|
||||||
margin: const EdgeInsets.fromLTRB(10, 6, 8, 6),
|
margin: const EdgeInsets.fromLTRB(10, 6, 8, 6),
|
||||||
icon: Icons.people_outlined,
|
icon: Icons.dynamic_feed_outlined,
|
||||||
iconColor: enteColorScheme.tabIcon,
|
iconColor: enteColorScheme.tabIcon,
|
||||||
iconActiveColor: strokeBaseLight,
|
iconActiveColor: strokeBaseLight,
|
||||||
text: '',
|
text: '',
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import "package:photos/theme/ente_theme.dart";
|
|||||||
import "package:photos/ui/common/loading_widget.dart";
|
import "package:photos/ui/common/loading_widget.dart";
|
||||||
import "package:photos/ui/common/web_page.dart";
|
import "package:photos/ui/common/web_page.dart";
|
||||||
import "package:photos/ui/components/buttons/button_widget.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/captioned_text_widget.dart";
|
||||||
import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart";
|
import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart";
|
||||||
import "package:photos/ui/components/models/button_type.dart";
|
import "package:photos/ui/components/models/button_type.dart";
|
||||||
@@ -49,7 +48,8 @@ class _VideoStreamingSettingsPageState
|
|||||||
bottomNavigationBar: !hasEnabled
|
bottomNavigationBar: !hasEnabled
|
||||||
? SafeArea(
|
? SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16)
|
||||||
|
.copyWith(bottom: 20),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@@ -75,15 +75,7 @@ class _VideoStreamingSettingsPageState
|
|||||||
flexibleSpaceTitle: TitleBarTitleWidget(
|
flexibleSpaceTitle: TitleBarTitleWidget(
|
||||||
title: AppLocalizations.of(context).videoStreaming,
|
title: AppLocalizations.of(context).videoStreaming,
|
||||||
),
|
),
|
||||||
actionIcons: [
|
actionIcons: const [],
|
||||||
IconButtonWidget(
|
|
||||||
icon: Icons.close_outlined,
|
|
||||||
iconButtonType: IconButtonType.secondary,
|
|
||||||
onTap: () {
|
|
||||||
Navigator.popUntil(context, (route) => route.isFirst);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
isSliver: false,
|
isSliver: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -96,17 +88,7 @@ class _VideoStreamingSettingsPageState
|
|||||||
flexibleSpaceTitle: TitleBarTitleWidget(
|
flexibleSpaceTitle: TitleBarTitleWidget(
|
||||||
title: AppLocalizations.of(context).videoStreaming,
|
title: AppLocalizations.of(context).videoStreaming,
|
||||||
),
|
),
|
||||||
actionIcons: [
|
actionIcons: const [],
|
||||||
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);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -118,7 +100,12 @@ class _VideoStreamingSettingsPageState
|
|||||||
children: [
|
children: [
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: AppLocalizations.of(context)
|
text: AppLocalizations.of(context)
|
||||||
.videoStreamingDescription,
|
.videoStreamingDescriptionLine1,
|
||||||
|
),
|
||||||
|
const TextSpan(text: " "),
|
||||||
|
TextSpan(
|
||||||
|
text: AppLocalizations.of(context)
|
||||||
|
.videoStreamingDescriptionLine2,
|
||||||
),
|
),
|
||||||
const TextSpan(text: " "),
|
const TextSpan(text: " "),
|
||||||
TextSpan(
|
TextSpan(
|
||||||
@@ -131,7 +118,6 @@ class _VideoStreamingSettingsPageState
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.justify,
|
|
||||||
style: getEnteTextTheme(context).mini.copyWith(
|
style: getEnteTextTheme(context).mini.copyWith(
|
||||||
color: getEnteColorScheme(context).textMuted,
|
color: getEnteColorScheme(context).textMuted,
|
||||||
),
|
),
|
||||||
@@ -159,24 +145,34 @@ class _VideoStreamingSettingsPageState
|
|||||||
height: 160,
|
height: 160,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text.rich(
|
Padding(
|
||||||
TextSpan(
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
text: AppLocalizations.of(context)
|
child: Text.rich(
|
||||||
.videoStreamingDescription +
|
TextSpan(
|
||||||
" ",
|
children: [
|
||||||
children: [
|
TextSpan(
|
||||||
TextSpan(
|
text: AppLocalizations.of(context)
|
||||||
text: AppLocalizations.of(context).moreDetails,
|
.videoStreamingDescriptionLine1,
|
||||||
style: TextStyle(
|
|
||||||
color: getEnteColorScheme(context).primary500,
|
|
||||||
),
|
),
|
||||||
recognizer: TapGestureRecognizer()
|
const TextSpan(text: "\n"),
|
||||||
..onTap = openHelp,
|
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),
|
const SizedBox(height: 140),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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/buttons/button_widget.dart";
|
||||||
import "package:photos/ui/components/models/button_type.dart";
|
import "package:photos/ui/components/models/button_type.dart";
|
||||||
import 'package:photos/ui/extents_page_view.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/grant_permissions_widget.dart';
|
||||||
import 'package:photos/ui/home/header_widget.dart';
|
import 'package:photos/ui/home/header_widget.dart';
|
||||||
import 'package:photos/ui/home/home_bottom_nav_bar.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/notification/update/change_log_page.dart';
|
||||||
import "package:photos/ui/settings/app_update_dialog.dart";
|
import "package:photos/ui/settings/app_update_dialog.dart";
|
||||||
import "package:photos/ui/settings_page.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/tabs/user_collections_tab.dart";
|
||||||
import "package:photos/ui/viewer/actions/file_viewer.dart";
|
import "package:photos/ui/viewer/actions/file_viewer.dart";
|
||||||
import "package:photos/ui/viewer/file/detail_page.dart";
|
import "package:photos/ui/viewer/file/detail_page.dart";
|
||||||
@@ -87,7 +87,6 @@ class HomeWidget extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomeWidgetState extends State<HomeWidget> {
|
class _HomeWidgetState extends State<HomeWidget> {
|
||||||
static const _sharedCollectionTab = SharedCollectionsTab();
|
|
||||||
static const _searchTab = SearchTab();
|
static const _searchTab = SearchTab();
|
||||||
static final _settingsPage = SettingsPage(
|
static final _settingsPage = SettingsPage(
|
||||||
emailNotifier: UserService.instance.emailValueNotifier,
|
emailNotifier: UserService.instance.emailValueNotifier,
|
||||||
@@ -761,7 +760,7 @@ class _HomeWidgetState extends State<HomeWidget> {
|
|||||||
selectedFiles: _selectedFiles,
|
selectedFiles: _selectedFiles,
|
||||||
),
|
),
|
||||||
UserCollectionsTab(selectedAlbums: _selectedAlbums),
|
UserCollectionsTab(selectedAlbums: _selectedAlbums),
|
||||||
_sharedCollectionTab,
|
const FeedTab(),
|
||||||
_searchTab,
|
_searchTab,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import "dart:math";
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import "package:photos/core/configuration.dart";
|
import "package:photos/core/configuration.dart";
|
||||||
|
import "package:photos/core/constants.dart";
|
||||||
import 'package:photos/core/event_bus.dart';
|
import 'package:photos/core/event_bus.dart';
|
||||||
import "package:photos/events/album_sort_order_change_event.dart";
|
import "package:photos/events/album_sort_order_change_event.dart";
|
||||||
import 'package:photos/events/collection_updated_event.dart';
|
import 'package:photos/events/collection_updated_event.dart';
|
||||||
import "package:photos/events/favorites_service_init_complete_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/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/events/user_logged_out_event.dart';
|
||||||
import "package:photos/generated/l10n.dart";
|
import "package:photos/generated/l10n.dart";
|
||||||
import 'package:photos/models/collection/collection.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/models/selected_albums.dart";
|
||||||
import 'package:photos/services/collections_service.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/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/archived_button.dart";
|
||||||
import "package:photos/ui/collections/button/hidden_button.dart";
|
import "package:photos/ui/collections/button/hidden_button.dart";
|
||||||
import "package:photos/ui/collections/button/trash_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/common/loading_widget.dart';
|
||||||
import 'package:photos/ui/components/buttons/icon_button_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/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/album_selection_overlay_bar.dart";
|
||||||
import "package:photos/ui/viewer/actions/delete_empty_albums.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/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/navigation_util.dart";
|
||||||
import "package:photos/utils/standalone/debouncer.dart";
|
import "package:photos/utils/standalone/debouncer.dart";
|
||||||
|
|
||||||
@@ -50,6 +62,7 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
|||||||
late StreamSubscription<FavoritesServiceInitCompleteEvent>
|
late StreamSubscription<FavoritesServiceInitCompleteEvent>
|
||||||
_favoritesServiceInitCompleteEvent;
|
_favoritesServiceInitCompleteEvent;
|
||||||
late StreamSubscription<AlbumSortOrderChangeEvent> _albumSortOrderChangeEvent;
|
late StreamSubscription<AlbumSortOrderChangeEvent> _albumSortOrderChangeEvent;
|
||||||
|
late StreamSubscription<TabChangedEvent> _tabChangeEvent;
|
||||||
|
|
||||||
String _loadReason = "init";
|
String _loadReason = "init";
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
@@ -59,6 +72,16 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
|||||||
leading: true,
|
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;
|
static const int _kOnEnteItemLimitCount = 12;
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -97,6 +120,27 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
|||||||
_loadReason = event.reason;
|
_loadReason = event.reason;
|
||||||
setState(() {});
|
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
|
@override
|
||||||
@@ -198,6 +242,8 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
|||||||
enableSelectionMode: true,
|
enableSelectionMode: true,
|
||||||
)
|
)
|
||||||
: const SliverToBoxAdapter(child: EmptyState()),
|
: const SliverToBoxAdapter(child: EmptyState()),
|
||||||
|
// Shared Collections Content
|
||||||
|
_buildSharedCollectionsSections(),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Divider(
|
child: Divider(
|
||||||
color: getEnteColorScheme(context).strokeFaint,
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_localFilesSubscription.cancel();
|
_localFilesSubscription.cancel();
|
||||||
@@ -245,6 +536,9 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
|||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_debouncer.cancelDebounceTimer();
|
_debouncer.cancelDebounceTimer();
|
||||||
_albumSortOrderChangeEvent.cancel();
|
_albumSortOrderChangeEvent.cancel();
|
||||||
|
_tabChangeEvent.cancel();
|
||||||
|
_debouncerForDeferringLoad.cancelDebounceTimer();
|
||||||
|
_canLoadDeferredWidgets.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import "dart:async";
|
|||||||
|
|
||||||
import "package:flutter/foundation.dart" show kDebugMode;
|
import "package:flutter/foundation.dart" show kDebugMode;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import "package:intl/intl.dart";
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import "package:photos/core/configuration.dart";
|
import "package:photos/core/configuration.dart";
|
||||||
import 'package:photos/core/constants.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/service_locator.dart";
|
||||||
import "package:photos/services/collections_service.dart";
|
import "package:photos/services/collections_service.dart";
|
||||||
import "package:photos/services/machine_learning/similar_images_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/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/buttons/button_widget.dart';
|
||||||
import "package:photos/ui/components/models/button_type.dart";
|
import "package:photos/ui/components/models/button_type.dart";
|
||||||
import "package:photos/ui/components/toggle_switch_widget.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/detail_page.dart";
|
||||||
import "package:photos/ui/viewer/file/thumbnail_widget.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/delete_file_util.dart";
|
||||||
import "package:photos/utils/dialog_util.dart";
|
import "package:photos/utils/dialog_util.dart";
|
||||||
import "package:photos/utils/navigation_util.dart";
|
import "package:photos/utils/navigation_util.dart";
|
||||||
@@ -37,6 +40,12 @@ enum SortKey {
|
|||||||
count,
|
count,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum TabFilter {
|
||||||
|
all,
|
||||||
|
similar,
|
||||||
|
identical,
|
||||||
|
}
|
||||||
|
|
||||||
class SimilarImagesPage extends StatefulWidget {
|
class SimilarImagesPage extends StatefulWidget {
|
||||||
final bool debugScreen;
|
final bool debugScreen;
|
||||||
|
|
||||||
@@ -49,6 +58,8 @@ class SimilarImagesPage extends StatefulWidget {
|
|||||||
class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||||
static const crossAxisCount = 3;
|
static const crossAxisCount = 3;
|
||||||
static const crossAxisSpacing = 12.0;
|
static const crossAxisSpacing = 12.0;
|
||||||
|
static const double _similarThreshold = 0.02;
|
||||||
|
static const double _identicalThreshold = 0.0001;
|
||||||
|
|
||||||
final _logger = Logger("SimilarImagesPage");
|
final _logger = Logger("SimilarImagesPage");
|
||||||
bool _isDisposed = false;
|
bool _isDisposed = false;
|
||||||
@@ -56,14 +67,40 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
|||||||
SimilarImagesPageState _pageState = SimilarImagesPageState.setup;
|
SimilarImagesPageState _pageState = SimilarImagesPageState.setup;
|
||||||
double _distanceThreshold = 0.04; // Default value
|
double _distanceThreshold = 0.04; // Default value
|
||||||
List<SimilarFiles> _similarFilesList = [];
|
List<SimilarFiles> _similarFilesList = [];
|
||||||
SortKey _sortKey = SortKey.distanceAsc;
|
|
||||||
|
SortKey _sortKey = SortKey.size;
|
||||||
bool _exactSearch = false;
|
bool _exactSearch = false;
|
||||||
bool _fullRefresh = false;
|
bool _fullRefresh = false;
|
||||||
bool _isSelectionSheetOpen = false;
|
TabFilter _selectedTab = TabFilter.identical;
|
||||||
|
|
||||||
late SelectedFiles _selectedFiles;
|
late SelectedFiles _selectedFiles;
|
||||||
late ValueNotifier<String> _deleteProgress;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -318,78 +355,125 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
|||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
|
_buildTabBar(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: _filteredGroups.isEmpty
|
||||||
cacheExtent: 400,
|
? EmptyState(
|
||||||
itemCount: _similarFilesList.length + 1, // +1 for header
|
text:
|
||||||
itemBuilder: (context, index) {
|
AppLocalizations.of(context).nothingHereTryAnotherFilter,
|
||||||
if (index == 0) {
|
)
|
||||||
return RepaintBoundary(
|
: ListView.builder(
|
||||||
child: Container(
|
cacheExtent: 400,
|
||||||
margin: const EdgeInsets.symmetric(
|
itemCount: _filteredGroups.length,
|
||||||
horizontal: crossAxisSpacing,
|
itemBuilder: (context, index) {
|
||||||
vertical: 12,
|
final similarFiles = _filteredGroups[index];
|
||||||
),
|
return RepaintBoundary(
|
||||||
padding: const EdgeInsets.all(crossAxisSpacing),
|
child: _buildSimilarFilesGroup(similarFiles),
|
||||||
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),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
_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() {
|
Widget _getBottomActionButtons() {
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: _selectedFiles,
|
listenable: _selectedFiles,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final selectedCount = _selectedFiles.files.length;
|
final selectedFiles = _selectedFiles.files;
|
||||||
|
final selectedCount = selectedFiles.length;
|
||||||
final hasSelectedFiles = selectedCount > 0;
|
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;
|
int totalSize = 0;
|
||||||
for (final file in _selectedFiles.files) {
|
for (final file in selectedFilteredFiles) {
|
||||||
totalSize += file.fileSize ?? 0;
|
totalSize += file.fileSize ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +508,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: hasSelectedFiles && !_isSelectionSheetOpen
|
child: hasSelectedFiles
|
||||||
? Column(
|
? Column(
|
||||||
key: const ValueKey('delete_section'),
|
key: const ValueKey('delete_section'),
|
||||||
children: [
|
children: [
|
||||||
@@ -433,7 +517,8 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
|||||||
child: ButtonWidget(
|
child: ButtonWidget(
|
||||||
labelText: AppLocalizations.of(context)
|
labelText: AppLocalizations.of(context)
|
||||||
.deletePhotosWithSize(
|
.deletePhotosWithSize(
|
||||||
count: selectedCount,
|
count: NumberFormat()
|
||||||
|
.format(selectedFilteredFiles.length),
|
||||||
size: formatBytes(totalSize),
|
size: formatBytes(totalSize),
|
||||||
),
|
),
|
||||||
buttonType: ButtonType.critical,
|
buttonType: ButtonType.critical,
|
||||||
@@ -441,7 +526,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
|||||||
shouldShowSuccessConfirmation: false,
|
shouldShowSuccessConfirmation: false,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await _deleteFiles(
|
await _deleteFiles(
|
||||||
_selectedFiles.files,
|
selectedFilteredFiles,
|
||||||
showDialog: true,
|
showDialog: true,
|
||||||
showUIFeedback: true,
|
showUIFeedback: true,
|
||||||
);
|
);
|
||||||
@@ -453,27 +538,20 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
|||||||
)
|
)
|
||||||
: const SizedBox.shrink(key: ValueKey('no_delete')),
|
: const SizedBox.shrink(key: ValueKey('no_delete')),
|
||||||
),
|
),
|
||||||
if (!_isSelectionSheetOpen)
|
SizedBox(
|
||||||
SizedBox(
|
width: double.infinity,
|
||||||
width: double.infinity,
|
child: ButtonWidget(
|
||||||
child: ButtonWidget(
|
labelText: allFilteredSelected
|
||||||
labelText: AppLocalizations.of(context).selectionOptions,
|
? AppLocalizations.of(context).unselectAll
|
||||||
buttonType: ButtonType.secondary,
|
: AppLocalizations.of(context).selectAll,
|
||||||
shouldSurfaceExecutionStates: false,
|
buttonType: ButtonType.secondary,
|
||||||
shouldShowSuccessConfirmation: false,
|
shouldSurfaceExecutionStates: false,
|
||||||
onTap: () async {
|
shouldShowSuccessConfirmation: false,
|
||||||
setState(() {
|
onTap: () async {
|
||||||
_isSelectionSheetOpen = true;
|
_toggleSelectAll();
|
||||||
});
|
},
|
||||||
await _showSelectionOptionsSheet();
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_isSelectionSheetOpen = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -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 {
|
Future<void> _findSimilarImages() async {
|
||||||
if (_isDisposed) return;
|
if (_isDisposed) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -489,7 +586,6 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// You can use _toggleValue here for advanced mode features
|
|
||||||
_logger.info("exact mode: $_exactSearch");
|
_logger.info("exact mode: $_exactSearch");
|
||||||
|
|
||||||
final similarFiles = await SimilarImagesService.instance.getSimilarFiles(
|
final similarFiles = await SimilarImagesService.instance.getSimilarFiles(
|
||||||
@@ -505,6 +601,14 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
|||||||
_pageState = SimilarImagesPageState.results;
|
_pageState = SimilarImagesPageState.results;
|
||||||
_sortSimilarFiles();
|
_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;
|
if (_isDisposed) return;
|
||||||
setState(() {});
|
setState(() {});
|
||||||
|
|
||||||
@@ -557,114 +661,6 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
|||||||
setState(() {});
|
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) {
|
Widget _buildSimilarFilesGroup(SimilarFiles similarFiles) {
|
||||||
final textTheme = getEnteTextTheme(context);
|
final textTheme = getEnteTextTheme(context);
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -975,15 +971,15 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
|||||||
_similarFilesList.remove(group);
|
_similarFilesList.remove(group);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final int collectionCnt = collectionToFilesToAddMap.keys.length;
|
||||||
if (createSymlink) {
|
if (createSymlink) {
|
||||||
final userID = Configuration.instance.getUserID();
|
final userID = Configuration.instance.getUserID();
|
||||||
final int collectionCnt = collectionToFilesToAddMap.keys.length;
|
|
||||||
int progress = 0;
|
int progress = 0;
|
||||||
for (final collectionID in collectionToFilesToAddMap.keys) {
|
for (final collectionID in collectionToFilesToAddMap.keys) {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (collectionCnt > 0 && showUIFeedback) {
|
if (collectionCnt > 2 && showUIFeedback) {
|
||||||
progress++;
|
progress++;
|
||||||
// calculate progress percentage upto 2 decimal places
|
// calculate progress percentage upto 2 decimal places
|
||||||
final double percentage = (progress / collectionCnt) * 100;
|
final double percentage = (progress / collectionCnt) * 100;
|
||||||
@@ -1004,7 +1000,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (showUIFeedback) {
|
if (collectionCnt > 2 && showUIFeedback) {
|
||||||
_deleteProgress.value = "";
|
_deleteProgress.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1013,7 +1009,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
|||||||
await deleteFilesFromRemoteOnly(context, allDeleteFiles.toList());
|
await deleteFilesFromRemoteOnly(context, allDeleteFiles.toList());
|
||||||
|
|
||||||
// Show congratulations popup
|
// Show congratulations popup
|
||||||
if (allDeleteFiles.isNotEmpty && mounted && showUIFeedback) {
|
if (allDeleteFiles.length > 100 && mounted && showUIFeedback) {
|
||||||
final int totalSize = allDeleteFiles.fold<int>(
|
final int totalSize = allDeleteFiles.fold<int>(
|
||||||
0,
|
0,
|
||||||
(sum, file) => sum + (file.fileSize ?? 0),
|
(sum, file) => sum + (file.fileSize ?? 0),
|
||||||
@@ -1080,7 +1076,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
|||||||
String text;
|
String text;
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case SortKey.size:
|
case SortKey.size:
|
||||||
text = AppLocalizations.of(context).size;
|
text = AppLocalizations.of(context).totalSize;
|
||||||
break;
|
break;
|
||||||
case SortKey.distanceAsc:
|
case SortKey.distanceAsc:
|
||||||
text = AppLocalizations.of(context).similarity;
|
text = AppLocalizations.of(context).similarity;
|
||||||
|
|||||||
@@ -53,11 +53,11 @@ const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600);
|
|||||||
/// {@end-tool}
|
/// {@end-tool}
|
||||||
///
|
///
|
||||||
/// A scrollbar track can be added using [trackVisibility]. This can also be
|
/// 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].
|
/// using [ScrollbarThemeData.trackVisibility].
|
||||||
///
|
///
|
||||||
/// The [thickness] of the track and scrollbar thumb can be changed dynamically
|
/// 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:
|
/// See also:
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import "package:permission_handler/permission_handler.dart";
|
|||||||
import "package:photos/db/upload_locks_db.dart";
|
import "package:photos/db/upload_locks_db.dart";
|
||||||
import "package:photos/extensions/stop_watch.dart";
|
import "package:photos/extensions/stop_watch.dart";
|
||||||
import "package:photos/main.dart";
|
import "package:photos/main.dart";
|
||||||
import "package:photos/service_locator.dart";
|
|
||||||
import "package:photos/utils/file_uploader.dart";
|
import "package:photos/utils/file_uploader.dart";
|
||||||
import "package:shared_preferences/shared_preferences.dart";
|
import "package:shared_preferences/shared_preferences.dart";
|
||||||
import "package:workmanager/workmanager.dart" as workmanager;
|
import "package:workmanager/workmanager.dart" as workmanager;
|
||||||
@@ -81,7 +80,7 @@ class BgTaskUtils {
|
|||||||
try {
|
try {
|
||||||
await workmanager.Workmanager().initialize(
|
await workmanager.Workmanager().initialize(
|
||||||
callbackDispatcher,
|
callbackDispatcher,
|
||||||
isInDebugMode: Platform.isIOS && flagService.internalUser,
|
isInDebugMode: false,
|
||||||
);
|
);
|
||||||
await workmanager.Workmanager().registerPeriodicTask(
|
await workmanager.Workmanager().registerPeriodicTask(
|
||||||
backgroundTaskIdentifier,
|
backgroundTaskIdentifier,
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ impl VectorDB {
|
|||||||
|
|
||||||
if file_exists {
|
if file_exists {
|
||||||
println!("Loading index from disk.");
|
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
|
// Loads the index into memory. https://docs.rs/usearch/latest/usearch/struct.Index.html#method.load
|
||||||
db.index.view(file_path).expect("Failed to load index");
|
db.index.load(file_path).expect("Failed to load index");
|
||||||
} else {
|
} else {
|
||||||
println!("Creating new index.");
|
println!("Creating new index.");
|
||||||
db.save_index();
|
db.save_index();
|
||||||
|
|||||||
@@ -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)
|
- Ashil: Render cached thumbnails faster (noticeable in gallery scrolling)
|
||||||
- Similar images UI changes
|
- Similar images UI changes
|
||||||
- Neeraj: Fix for double enteries for local file
|
- Neeraj: Fix for double enteries for local file
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
- Video streaming improvements
|
||||||
- Added support for custom domain links
|
- Added support for custom domain links
|
||||||
- Image editor fixes:
|
- Image editor fixes:
|
||||||
- Fixed bottom navigation bar color in light theme
|
- Fixed bottom navigation bar color in light theme
|
||||||
|
|||||||
Reference in New Issue
Block a user