Compare commits
2 Commits
remote_db
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e783c5d9b | ||
|
|
d45458f6fd |
53
mobile/apps/photos/lib/models/feed/feed_item.dart
Normal file
53
mobile/apps/photos/lib/models/feed/feed_item.dart
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
enum FeedItemType { memory, photos, video, album }
|
||||||
|
|
||||||
|
class FeedItem {
|
||||||
|
final String id;
|
||||||
|
final FeedItemType type;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final String userName;
|
||||||
|
final String userAvatarUrl;
|
||||||
|
final bool isFavorite;
|
||||||
|
final List<String> mediaUrls;
|
||||||
|
final DateTime timestamp;
|
||||||
|
final Map<String, dynamic>? metadata;
|
||||||
|
|
||||||
|
const FeedItem({
|
||||||
|
required this.id,
|
||||||
|
required this.type,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.userName,
|
||||||
|
required this.userAvatarUrl,
|
||||||
|
required this.isFavorite,
|
||||||
|
required this.mediaUrls,
|
||||||
|
required this.timestamp,
|
||||||
|
this.metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
FeedItem copyWith({
|
||||||
|
String? id,
|
||||||
|
FeedItemType? type,
|
||||||
|
String? title,
|
||||||
|
String? subtitle,
|
||||||
|
String? userName,
|
||||||
|
String? userAvatarUrl,
|
||||||
|
bool? isFavorite,
|
||||||
|
List<String>? mediaUrls,
|
||||||
|
DateTime? timestamp,
|
||||||
|
Map<String, dynamic>? metadata,
|
||||||
|
}) {
|
||||||
|
return FeedItem(
|
||||||
|
id: id ?? this.id,
|
||||||
|
type: type ?? this.type,
|
||||||
|
title: title ?? this.title,
|
||||||
|
subtitle: subtitle ?? this.subtitle,
|
||||||
|
userName: userName ?? this.userName,
|
||||||
|
userAvatarUrl: userAvatarUrl ?? this.userAvatarUrl,
|
||||||
|
isFavorite: isFavorite ?? this.isFavorite,
|
||||||
|
mediaUrls: mediaUrls ?? this.mediaUrls,
|
||||||
|
timestamp: timestamp ?? this.timestamp,
|
||||||
|
metadata: metadata ?? this.metadata,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
mobile/apps/photos/lib/services/feed_service.dart
Normal file
129
mobile/apps/photos/lib/services/feed_service.dart
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:photos/models/feed/feed_item.dart';
|
||||||
|
|
||||||
|
class FeedService {
|
||||||
|
static final FeedService _instance = FeedService._internal();
|
||||||
|
static FeedService get instance => _instance;
|
||||||
|
FeedService._internal();
|
||||||
|
|
||||||
|
List<FeedItem> _feedItems = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
List<FeedItem> get feedItems => List.unmodifiable(_feedItems);
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
|
||||||
|
void init() {
|
||||||
|
_loadMockData();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadMockData() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
debugPrint('FeedService: Loading mock data...');
|
||||||
|
_feedItems = [
|
||||||
|
FeedItem(
|
||||||
|
id: "1",
|
||||||
|
type: FeedItemType.memory,
|
||||||
|
title: "Trip to paris",
|
||||||
|
subtitle: "shared a memory",
|
||||||
|
userName: "Bob",
|
||||||
|
userAvatarUrl: "",
|
||||||
|
isFavorite: false,
|
||||||
|
mediaUrls: [
|
||||||
|
"https://images.unsplash.com/photo-1502602898536-47ad22581b52?w=400&h=600&fit=crop",
|
||||||
|
"https://images.unsplash.com/photo-1499856871958-5b9627545d1a?w=400&h=600&fit=crop",
|
||||||
|
"https://images.unsplash.com/photo-1527004013197-933c4bb611b3?w=400&h=600&fit=crop",
|
||||||
|
],
|
||||||
|
timestamp: now.subtract(const Duration(hours: 2)),
|
||||||
|
metadata: {"memoryType": "trip"},
|
||||||
|
),
|
||||||
|
FeedItem(
|
||||||
|
id: "2",
|
||||||
|
type: FeedItemType.photos,
|
||||||
|
title: "Maldives",
|
||||||
|
subtitle: "shared 3 photos",
|
||||||
|
userName: "Bob",
|
||||||
|
userAvatarUrl: "",
|
||||||
|
isFavorite: false,
|
||||||
|
mediaUrls: [
|
||||||
|
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=300&fit=crop",
|
||||||
|
"https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=400&h=300&fit=crop",
|
||||||
|
"https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=400&h=300&fit=crop",
|
||||||
|
],
|
||||||
|
timestamp: now.subtract(const Duration(hours: 5)),
|
||||||
|
),
|
||||||
|
FeedItem(
|
||||||
|
id: "3",
|
||||||
|
type: FeedItemType.video,
|
||||||
|
title: "",
|
||||||
|
subtitle: "shared a video",
|
||||||
|
userName: "Bob",
|
||||||
|
userAvatarUrl: "",
|
||||||
|
isFavorite: false,
|
||||||
|
mediaUrls: ["https://images.unsplash.com/photo-1486312338219-ce68e2c6b181?w=400&h=600&fit=crop"],
|
||||||
|
timestamp: now.subtract(const Duration(hours: 8)),
|
||||||
|
metadata: {"videoDuration": 45},
|
||||||
|
),
|
||||||
|
FeedItem(
|
||||||
|
id: "4",
|
||||||
|
type: FeedItemType.album,
|
||||||
|
title: "Pets",
|
||||||
|
subtitle: "shared an Album",
|
||||||
|
userName: "Bob",
|
||||||
|
userAvatarUrl: "",
|
||||||
|
isFavorite: false,
|
||||||
|
mediaUrls: [
|
||||||
|
"https://images.unsplash.com/photo-1415369629372-26f2fe60c467?w=400&h=400&fit=crop",
|
||||||
|
"https://images.unsplash.com/photo-1425082661705-1834bfd09dca?w=400&h=400&fit=crop",
|
||||||
|
"https://images.unsplash.com/photo-1518717758536-85ae29035b6d?w=400&h=400&fit=crop",
|
||||||
|
"https://images.unsplash.com/photo-1511044568932-338cba0ad803?w=400&h=400&fit=crop",
|
||||||
|
],
|
||||||
|
timestamp: now.subtract(const Duration(days: 1)),
|
||||||
|
metadata: {"albumVariant": "pets1"},
|
||||||
|
),
|
||||||
|
FeedItem(
|
||||||
|
id: "5",
|
||||||
|
type: FeedItemType.album,
|
||||||
|
title: "Pets",
|
||||||
|
subtitle: "shared an Album",
|
||||||
|
userName: "Bob",
|
||||||
|
userAvatarUrl: "",
|
||||||
|
isFavorite: false,
|
||||||
|
mediaUrls: [
|
||||||
|
"https://images.unsplash.com/photo-1444212477490-ca407925329e?w=400&h=400&fit=crop",
|
||||||
|
"https://images.unsplash.com/photo-1543852786-1cf6624b9987?w=400&h=400&fit=crop",
|
||||||
|
"https://images.unsplash.com/photo-1548199973-03cce0bbc87b?w=400&h=400&fit=crop",
|
||||||
|
"https://images.unsplash.com/photo-1517423440428-a5a00ad493e8?w=400&h=400&fit=crop",
|
||||||
|
"https://images.unsplash.com/photo-1583337130417-3346a1be7dee?w=400&h=400&fit=crop",
|
||||||
|
],
|
||||||
|
timestamp: now.subtract(const Duration(days: 2)),
|
||||||
|
metadata: {"albumVariant": "pets2"},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
debugPrint('FeedService: Loaded ${_feedItems.length} mock items');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleFavorite(String itemId) async {
|
||||||
|
final itemIndex = _feedItems.indexWhere((item) => item.id == itemId);
|
||||||
|
if (itemIndex != -1) {
|
||||||
|
_feedItems[itemIndex] = _feedItems[itemIndex].copyWith(
|
||||||
|
isFavorite: !_feedItems[itemIndex].isFavorite,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refreshFeed() async {
|
||||||
|
_isLoading = true;
|
||||||
|
|
||||||
|
// Simulate network delay
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
|
||||||
|
// In a real implementation, this would fetch from API
|
||||||
|
_loadMockData();
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<FeedItem> getFeedItems() {
|
||||||
|
return _feedItems;
|
||||||
|
}
|
||||||
|
}
|
||||||
333
mobile/apps/photos/lib/ui/components/feed/album_post.dart
Normal file
333
mobile/apps/photos/lib/ui/components/feed/album_post.dart
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:photos/models/feed/feed_item.dart';
|
||||||
|
import 'package:photos/ui/components/feed/feed_item_card.dart';
|
||||||
|
|
||||||
|
class AlbumPost extends StatefulWidget {
|
||||||
|
final FeedItem item;
|
||||||
|
final VoidCallback onFavoriteToggle;
|
||||||
|
|
||||||
|
const AlbumPost({
|
||||||
|
super.key,
|
||||||
|
required this.item,
|
||||||
|
required this.onFavoriteToggle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AlbumPost> createState() => _AlbumPostState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AlbumPostState extends State<AlbumPost> {
|
||||||
|
int? selectedSection;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final variant = widget.item.metadata?["albumVariant"] ?? "pets2";
|
||||||
|
|
||||||
|
return FeedItemCard(
|
||||||
|
item: widget.item,
|
||||||
|
onFavoriteToggle: widget.onFavoriteToggle,
|
||||||
|
child: Container(
|
||||||
|
height: 322,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: Colors.grey[100],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: variant == "pets1" ? _buildPets1Layout() : _buildPets2Layout(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPets1Layout() {
|
||||||
|
final urls = widget.item.mediaUrls;
|
||||||
|
if (urls.length < 3) return _buildEmptyState();
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
// Back layer
|
||||||
|
Positioned(
|
||||||
|
left: 24,
|
||||||
|
top: 0,
|
||||||
|
child: _buildSelectableImage(
|
||||||
|
urls[0],
|
||||||
|
0,
|
||||||
|
width: 270,
|
||||||
|
height: 278,
|
||||||
|
hasOverlay: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Middle layer
|
||||||
|
Positioned(
|
||||||
|
left: 11,
|
||||||
|
top: 10,
|
||||||
|
child: _buildSelectableImage(
|
||||||
|
urls[1],
|
||||||
|
1,
|
||||||
|
width: 296,
|
||||||
|
height: 306,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Front layer with title
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Container(
|
||||||
|
height: 304,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
image: DecorationImage(
|
||||||
|
image: NetworkImage(urls[2]),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => _selectSection(2),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: selectedSection == 2
|
||||||
|
? Border.all(color: Colors.blue, width: 2)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.center,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.transparent,
|
||||||
|
Colors.black.withOpacity(0.7),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.bottomLeft,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Text(
|
||||||
|
widget.item.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPets2Layout() {
|
||||||
|
final urls = widget.item.mediaUrls;
|
||||||
|
if (urls.length < 4) return _buildEmptyState();
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
// Top left
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
child: _buildSelectableImage(
|
||||||
|
urls[0],
|
||||||
|
0,
|
||||||
|
width: 159,
|
||||||
|
height: 161,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Bottom left
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
top: 165,
|
||||||
|
child: _buildSelectableImage(
|
||||||
|
urls[1],
|
||||||
|
1,
|
||||||
|
width: 159,
|
||||||
|
height: 157,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Top right
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
child: _buildSelectableImage(
|
||||||
|
urls[2],
|
||||||
|
2,
|
||||||
|
width: 156,
|
||||||
|
height: 161,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Bottom right with +5 overlay
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
top: 165,
|
||||||
|
child: _buildSelectableImageWithOverlay(
|
||||||
|
urls[3],
|
||||||
|
3,
|
||||||
|
width: 156,
|
||||||
|
height: 157,
|
||||||
|
extraCount: urls.length > 4 ? urls.length - 4 : 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSelectableImage(
|
||||||
|
String imageUrl,
|
||||||
|
int index, {
|
||||||
|
required double width,
|
||||||
|
required double height,
|
||||||
|
bool hasOverlay = false,
|
||||||
|
}) {
|
||||||
|
final isSelected = selectedSection == index;
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _selectSection(index),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
transform: Matrix4.identity()..scale(isSelected ? 1.05 : 1.0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: isSelected ? Border.all(color: Colors.blue, width: 2) : null,
|
||||||
|
boxShadow: isSelected
|
||||||
|
? [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.blue.withOpacity(0.3),
|
||||||
|
blurRadius: 8,
|
||||||
|
spreadRadius: 2,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Image.network(
|
||||||
|
imageUrl,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Container(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(Icons.error, color: Colors.grey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (hasOverlay)
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Colors.black.withOpacity(0.2),
|
||||||
|
Colors.black.withOpacity(0.2),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSelectableImageWithOverlay(
|
||||||
|
String imageUrl,
|
||||||
|
int index, {
|
||||||
|
required double width,
|
||||||
|
required double height,
|
||||||
|
required int extraCount,
|
||||||
|
}) {
|
||||||
|
final isSelected = selectedSection == index;
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _selectSection(index),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
transform: Matrix4.identity()..scale(isSelected ? 1.05 : 1.0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: isSelected ? Border.all(color: Colors.blue, width: 2) : null,
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Image.network(
|
||||||
|
imageUrl,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Container(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(Icons.error, color: Colors.grey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (extraCount > 0)
|
||||||
|
Positioned(
|
||||||
|
top: 20,
|
||||||
|
right: 20,
|
||||||
|
child: Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Colors.black.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'+$extraCount',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState() {
|
||||||
|
return Container(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(Icons.photo_album, color: Colors.grey, size: 48),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectSection(int index) {
|
||||||
|
setState(() {
|
||||||
|
selectedSection = selectedSection == index ? null : index;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class FavoriteButton extends StatelessWidget {
|
||||||
|
final bool isFavorite;
|
||||||
|
final VoidCallback onToggle;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
const FavoriteButton({
|
||||||
|
super.key,
|
||||||
|
required this.isFavorite,
|
||||||
|
required this.onToggle,
|
||||||
|
this.size = 28,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onToggle,
|
||||||
|
child: Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Colors.transparent,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||||
|
color: isFavorite ? const Color(0xFF08C225) : const Color(0xFFE0E0E0),
|
||||||
|
size: size * 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
mobile/apps/photos/lib/ui/components/feed/feed_header.dart
Normal file
89
mobile/apps/photos/lib/ui/components/feed/feed_header.dart
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class FeedHeader extends StatefulWidget {
|
||||||
|
const FeedHeader({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FeedHeader> createState() => _FeedHeaderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FeedHeaderState extends State<FeedHeader> {
|
||||||
|
int notificationCount = 3;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Feed',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black,
|
||||||
|
letterSpacing: -1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
notificationCount = 0;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Colors.white,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.1),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
const Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.notifications_outlined,
|
||||||
|
color: Colors.black,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (notificationCount > 0)
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
child: Container(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
notificationCount.toString(),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:photos/models/feed/feed_item.dart';
|
||||||
|
import 'package:photos/ui/components/feed/post_header.dart';
|
||||||
|
|
||||||
|
class FeedItemCard extends StatelessWidget {
|
||||||
|
final FeedItem item;
|
||||||
|
final Widget child;
|
||||||
|
final VoidCallback onFavoriteToggle;
|
||||||
|
final double? height;
|
||||||
|
|
||||||
|
const FeedItemCard({
|
||||||
|
super.key,
|
||||||
|
required this.item,
|
||||||
|
required this.child,
|
||||||
|
required this.onFavoriteToggle,
|
||||||
|
this.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: height,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
PostHeader(
|
||||||
|
userName: item.userName,
|
||||||
|
subtitle: item.subtitle,
|
||||||
|
title: item.title,
|
||||||
|
avatarUrl: item.userAvatarUrl,
|
||||||
|
isFavorite: item.isFavorite,
|
||||||
|
onFavoriteToggle: onFavoriteToggle,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
184
mobile/apps/photos/lib/ui/components/feed/memory_post.dart
Normal file
184
mobile/apps/photos/lib/ui/components/feed/memory_post.dart
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:photos/models/feed/feed_item.dart';
|
||||||
|
import 'package:photos/ui/components/feed/feed_item_card.dart';
|
||||||
|
|
||||||
|
class MemoryPost extends StatefulWidget {
|
||||||
|
final FeedItem item;
|
||||||
|
final VoidCallback onFavoriteToggle;
|
||||||
|
|
||||||
|
const MemoryPost({
|
||||||
|
super.key,
|
||||||
|
required this.item,
|
||||||
|
required this.onFavoriteToggle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MemoryPost> createState() => _MemoryPostState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MemoryPostState extends State<MemoryPost>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
bool isImageClicked = false;
|
||||||
|
late AnimationController _animationController;
|
||||||
|
late Animation<double> _scaleAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_animationController = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
_scaleAnimation = Tween<double>(
|
||||||
|
begin: 1.0,
|
||||||
|
end: 0.98,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
),);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_animationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onImageTapped() {
|
||||||
|
setState(() {
|
||||||
|
isImageClicked = !isImageClicked;
|
||||||
|
});
|
||||||
|
if (isImageClicked) {
|
||||||
|
_animationController.forward();
|
||||||
|
} else {
|
||||||
|
_animationController.reverse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FeedItemCard(
|
||||||
|
item: widget.item,
|
||||||
|
height: 498,
|
||||||
|
onFavoriteToggle: widget.onFavoriteToggle,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: _onImageTapped,
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _scaleAnimation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Transform.scale(
|
||||||
|
scale: _scaleAnimation.value,
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 322,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: Colors.grey[100],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: _buildMemoryLayout(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMemoryLayout() {
|
||||||
|
final urls = widget.item.mediaUrls;
|
||||||
|
if (urls.isEmpty) {
|
||||||
|
return Container(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(Icons.photo, color: Colors.grey, size: 48),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
// Background images in a layered style
|
||||||
|
if (urls.length > 2)
|
||||||
|
Positioned(
|
||||||
|
left: 10,
|
||||||
|
top: 10,
|
||||||
|
child: Container(
|
||||||
|
width: 200,
|
||||||
|
height: 280,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
image: DecorationImage(
|
||||||
|
image: NetworkImage(urls[2]),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Main center image
|
||||||
|
if (urls.isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
left: 50,
|
||||||
|
top: 0,
|
||||||
|
child: Container(
|
||||||
|
width: 240,
|
||||||
|
height: 300,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
image: DecorationImage(
|
||||||
|
image: NetworkImage(urls[0]),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.center,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.transparent,
|
||||||
|
Colors.black.withOpacity(0.7),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.bottomLeft,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Text(
|
||||||
|
widget.item.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Right side image
|
||||||
|
if (urls.length > 1)
|
||||||
|
Positioned(
|
||||||
|
right: 10,
|
||||||
|
top: 10,
|
||||||
|
child: Container(
|
||||||
|
width: 200,
|
||||||
|
height: 280,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
image: DecorationImage(
|
||||||
|
image: NetworkImage(urls[1]),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
157
mobile/apps/photos/lib/ui/components/feed/photos_post.dart
Normal file
157
mobile/apps/photos/lib/ui/components/feed/photos_post.dart
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:photos/models/feed/feed_item.dart';
|
||||||
|
import 'package:photos/ui/components/feed/feed_item_card.dart';
|
||||||
|
|
||||||
|
class PhotosPost extends StatefulWidget {
|
||||||
|
final FeedItem item;
|
||||||
|
final VoidCallback onFavoriteToggle;
|
||||||
|
|
||||||
|
const PhotosPost({
|
||||||
|
super.key,
|
||||||
|
required this.item,
|
||||||
|
required this.onFavoriteToggle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PhotosPost> createState() => _PhotosPostState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PhotosPostState extends State<PhotosPost> {
|
||||||
|
int? selectedImage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FeedItemCard(
|
||||||
|
item: widget.item,
|
||||||
|
onFavoriteToggle: widget.onFavoriteToggle,
|
||||||
|
child: Container(
|
||||||
|
height: 322,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: Colors.grey[100],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: _buildPhotosGrid(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPhotosGrid() {
|
||||||
|
final urls = widget.item.mediaUrls;
|
||||||
|
if (urls.isEmpty) {
|
||||||
|
return Container(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(Icons.photo, color: Colors.grey, size: 48),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
// Top left image
|
||||||
|
if (urls.isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
child: _buildSelectableImage(
|
||||||
|
urls[0],
|
||||||
|
0,
|
||||||
|
width: 159,
|
||||||
|
height: 161,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Top right image
|
||||||
|
if (urls.length > 1)
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
child: _buildSelectableImage(
|
||||||
|
urls[1],
|
||||||
|
1,
|
||||||
|
width: 155,
|
||||||
|
height: 161,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Bottom full-width image
|
||||||
|
if (urls.length > 2)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: _buildSelectableImage(
|
||||||
|
urls[2],
|
||||||
|
2,
|
||||||
|
width: double.infinity,
|
||||||
|
height: 157,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSelectableImage(
|
||||||
|
String imageUrl,
|
||||||
|
int index, {
|
||||||
|
required double width,
|
||||||
|
required double height,
|
||||||
|
}) {
|
||||||
|
final isSelected = selectedImage == index;
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
selectedImage = selectedImage == index ? null : index;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
transform: Matrix4.identity()
|
||||||
|
..scale(isSelected ? 1.05 : 1.0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: isSelected
|
||||||
|
? Border.all(color: Colors.blue, width: 2)
|
||||||
|
: null,
|
||||||
|
boxShadow: isSelected
|
||||||
|
? [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.blue.withOpacity(0.3),
|
||||||
|
blurRadius: 8,
|
||||||
|
spreadRadius: 2,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image.network(
|
||||||
|
imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
loadingBuilder: (context, child, loadingProgress) {
|
||||||
|
if (loadingProgress == null) return child;
|
||||||
|
return Container(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
child: const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Container(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(Icons.error, color: Colors.grey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
mobile/apps/photos/lib/ui/components/feed/post_header.dart
Normal file
84
mobile/apps/photos/lib/ui/components/feed/post_header.dart
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:photos/ui/components/feed/favorite_button.dart';
|
||||||
|
import 'package:photos/ui/components/feed/user_avatar.dart';
|
||||||
|
|
||||||
|
class PostHeader extends StatelessWidget {
|
||||||
|
final String userName;
|
||||||
|
final String subtitle;
|
||||||
|
final String title;
|
||||||
|
final String avatarUrl;
|
||||||
|
final bool isFavorite;
|
||||||
|
final VoidCallback onFavoriteToggle;
|
||||||
|
|
||||||
|
const PostHeader({
|
||||||
|
super.key,
|
||||||
|
required this.userName,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.title,
|
||||||
|
required this.avatarUrl,
|
||||||
|
required this.isFavorite,
|
||||||
|
required this.onFavoriteToggle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
UserAvatar(
|
||||||
|
avatarUrl: avatarUrl,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
userName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.black.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (title.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FavoriteButton(
|
||||||
|
isFavorite: isFavorite,
|
||||||
|
onToggle: onFavoriteToggle,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
111
mobile/apps/photos/lib/ui/components/feed/user_avatar.dart
Normal file
111
mobile/apps/photos/lib/ui/components/feed/user_avatar.dart
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class UserAvatar extends StatelessWidget {
|
||||||
|
final String avatarUrl;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
const UserAvatar({
|
||||||
|
super.key,
|
||||||
|
required this.avatarUrl,
|
||||||
|
this.size = 48,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
Color(0xFF69c3cb),
|
||||||
|
Color(0xFF5fb7bb),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
),
|
||||||
|
child: avatarUrl.isNotEmpty
|
||||||
|
? ClipOval(
|
||||||
|
child: Image.network(
|
||||||
|
avatarUrl,
|
||||||
|
width: size - 4,
|
||||||
|
height: size - 4,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (context, error, stackTrace) => _buildDefaultAvatar(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _buildDefaultAvatar(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDefaultAvatar() {
|
||||||
|
return Container(
|
||||||
|
width: size - 4,
|
||||||
|
height: size - 4,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
Color(0xFF69c3cb),
|
||||||
|
Color(0xFF5fb7bb),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// Blur effects like in React implementation
|
||||||
|
Positioned(
|
||||||
|
left: 7,
|
||||||
|
top: 1,
|
||||||
|
child: Container(
|
||||||
|
width: size * 0.7,
|
||||||
|
height: 7,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF6dc8cd).withOpacity(0.8),
|
||||||
|
borderRadius: BorderRadius.circular(3.5),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(3.5),
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2),
|
||||||
|
child: Container(color: Colors.transparent),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 7,
|
||||||
|
bottom: 1,
|
||||||
|
child: Container(
|
||||||
|
width: size * 0.7,
|
||||||
|
height: 7,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF6dc8cd).withOpacity(0.8),
|
||||||
|
borderRadius: BorderRadius.circular(3.5),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(3.5),
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2),
|
||||||
|
child: Container(color: Colors.transparent),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
139
mobile/apps/photos/lib/ui/components/feed/video_post.dart
Normal file
139
mobile/apps/photos/lib/ui/components/feed/video_post.dart
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:photos/models/feed/feed_item.dart';
|
||||||
|
import 'package:photos/ui/components/feed/feed_item_card.dart';
|
||||||
|
|
||||||
|
class VideoPost extends StatefulWidget {
|
||||||
|
final FeedItem item;
|
||||||
|
final VoidCallback onFavoriteToggle;
|
||||||
|
|
||||||
|
const VideoPost({
|
||||||
|
super.key,
|
||||||
|
required this.item,
|
||||||
|
required this.onFavoriteToggle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<VideoPost> createState() => _VideoPostState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VideoPostState extends State<VideoPost> {
|
||||||
|
bool isPlaying = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FeedItemCard(
|
||||||
|
item: widget.item,
|
||||||
|
onFavoriteToggle: widget.onFavoriteToggle,
|
||||||
|
child: Container(
|
||||||
|
height: 322,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: Colors.grey[100],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: _buildVideoContent(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildVideoContent() {
|
||||||
|
final urls = widget.item.mediaUrls;
|
||||||
|
if (urls.isEmpty) {
|
||||||
|
return Container(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(Icons.videocam, color: Colors.grey, size: 48),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
isPlaying = !isPlaying;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// Video thumbnail
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
child: Image.network(
|
||||||
|
urls[0],
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
loadingBuilder: (context, child, loadingProgress) {
|
||||||
|
if (loadingProgress == null) return child;
|
||||||
|
return Container(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
child: const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Container(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(Icons.error, color: Colors.grey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Play overlay
|
||||||
|
if (!isPlaying)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black.withOpacity(0.3),
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Colors.black.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.play_arrow,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Playing overlay
|
||||||
|
if (isPlaying)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black.withOpacity(0.5),
|
||||||
|
child: const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.pause,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Playing video...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.rss_feed_outlined,
|
||||||
iconColor: enteColorScheme.tabIcon,
|
iconColor: enteColorScheme.tabIcon,
|
||||||
iconActiveColor: strokeBaseLight,
|
iconActiveColor: strokeBaseLight,
|
||||||
text: '',
|
text: '',
|
||||||
|
|||||||
207
mobile/apps/photos/lib/ui/tabs/feed_tab.dart
Normal file
207
mobile/apps/photos/lib/ui/tabs/feed_tab.dart
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:photos/models/feed/feed_item.dart';
|
||||||
|
import 'package:photos/services/feed_service.dart';
|
||||||
|
import 'package:photos/ui/components/feed/feed_header.dart';
|
||||||
|
|
||||||
|
class FeedTab extends StatefulWidget {
|
||||||
|
const FeedTab({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FeedTab> createState() => _FeedTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FeedTabState extends State<FeedTab> {
|
||||||
|
final FeedService _feedService = FeedService.instance;
|
||||||
|
List<FeedItem> _feedItems = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_feedService.init();
|
||||||
|
_loadFeedItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadFeedItems() {
|
||||||
|
setState(() {
|
||||||
|
_feedItems = _feedService.getFeedItems();
|
||||||
|
debugPrint('_loadFeedItems: loaded ${_feedItems.length} items');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onRefresh() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await _feedService.refreshFeed();
|
||||||
|
_loadFeedItems();
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFavoriteToggle(String itemId) async {
|
||||||
|
await _feedService.toggleFavorite(itemId);
|
||||||
|
_loadFeedItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
debugPrint('FeedTab build: _feedItems length = ${_feedItems.length}');
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFFAFAFA),
|
||||||
|
body: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const FeedHeader(),
|
||||||
|
Expanded(
|
||||||
|
child: RefreshIndicator(
|
||||||
|
onRefresh: _onRefresh,
|
||||||
|
child: _feedItems.isEmpty
|
||||||
|
? _isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _buildEmptyState()
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.only(bottom: 100),
|
||||||
|
itemCount: _feedItems.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
debugPrint(
|
||||||
|
'Building item $index: ${_feedItems[index].type}',
|
||||||
|
);
|
||||||
|
return _buildFeedItem(_feedItems[index]);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFeedItem(FeedItem item) {
|
||||||
|
// Simplified version for debugging
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.all(16),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.1),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
child: Text(item.userName[0]),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.userName,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
Text('${item.subtitle} ${item.title}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
item.isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||||
|
color: item.isFavorite ? Colors.red : Colors.grey,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
height: 200,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_getIconForType(item.type),
|
||||||
|
size: 48,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
item.type.name.toUpperCase(),
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _getIconForType(FeedItemType type) {
|
||||||
|
switch (type) {
|
||||||
|
case FeedItemType.memory:
|
||||||
|
return Icons.photo_library;
|
||||||
|
case FeedItemType.photos:
|
||||||
|
return Icons.photo;
|
||||||
|
case FeedItemType.video:
|
||||||
|
return Icons.play_circle;
|
||||||
|
case FeedItemType.album:
|
||||||
|
return Icons.photo_album;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState() {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.rss_feed,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No posts in your feed yet',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
color: Colors.grey,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Check back later for updates',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,7 +64,7 @@ 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/feed_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,7 @@ class HomeWidget extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomeWidgetState extends State<HomeWidget> {
|
class _HomeWidgetState extends State<HomeWidget> {
|
||||||
static const _sharedCollectionTab = SharedCollectionsTab();
|
static const _feedTab = FeedTab();
|
||||||
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 +761,7 @@ class _HomeWidgetState extends State<HomeWidget> {
|
|||||||
selectedFiles: _selectedFiles,
|
selectedFiles: _selectedFiles,
|
||||||
),
|
),
|
||||||
UserCollectionsTab(selectedAlbums: _selectedAlbums),
|
UserCollectionsTab(selectedAlbums: _selectedAlbums),
|
||||||
_sharedCollectionTab,
|
_feedTab,
|
||||||
_searchTab,
|
_searchTab,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,395 +1,16 @@
|
|||||||
import 'dart:async';
|
|
||||||
import "dart:math";
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
import "package:photos/core/constants.dart";
|
|
||||||
import 'package:photos/core/event_bus.dart';
|
|
||||||
import 'package:photos/events/collection_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/generated/l10n.dart";
|
|
||||||
import 'package:photos/models/collection/collection_items.dart';
|
|
||||||
import "package:photos/models/search/generic_search_result.dart";
|
|
||||||
import 'package:photos/services/collections_service.dart';
|
|
||||||
import "package:photos/services/search_service.dart";
|
|
||||||
import "package:photos/theme/ente_theme.dart";
|
|
||||||
import "package:photos/ui/collections/album/row_item.dart";
|
|
||||||
import "package:photos/ui/collections/collection_list_page.dart";
|
|
||||||
import 'package:photos/ui/common/loading_widget.dart';
|
|
||||||
import "package:photos/ui/components/buttons/icon_button_widget.dart";
|
|
||||||
import 'package:photos/ui/tabs/section_title.dart';
|
|
||||||
import "package:photos/ui/tabs/shared/all_quick_links_page.dart";
|
|
||||||
import "package:photos/ui/tabs/shared/empty_state.dart";
|
|
||||||
import "package:photos/ui/tabs/shared/quick_link_album_item.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/search_tab/contacts_section.dart";
|
|
||||||
import "package:photos/utils/navigation_util.dart";
|
|
||||||
import "package:photos/utils/standalone/debouncer.dart";
|
|
||||||
|
|
||||||
class SharedCollectionsTab extends StatefulWidget {
|
class SharedCollectionsTab extends StatelessWidget {
|
||||||
const SharedCollectionsTab({super.key});
|
const SharedCollectionsTab({super.key});
|
||||||
|
|
||||||
@override
|
|
||||||
State<SharedCollectionsTab> createState() => _SharedCollectionsTabState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SharedCollectionsTabState extends State<SharedCollectionsTab>
|
|
||||||
with AutomaticKeepAliveClientMixin {
|
|
||||||
final Logger _logger = Logger("SharedCollectionGallery");
|
|
||||||
late StreamSubscription<LocalPhotosUpdatedEvent> _localFilesSubscription;
|
|
||||||
late StreamSubscription<CollectionUpdatedEvent>
|
|
||||||
_collectionUpdatesSubscription;
|
|
||||||
late StreamSubscription<UserLoggedOutEvent> _loggedOutEvent;
|
|
||||||
final _debouncer = Debouncer(
|
|
||||||
const Duration(seconds: 2),
|
|
||||||
executionInterval: const Duration(seconds: 5),
|
|
||||||
leading: true,
|
|
||||||
);
|
|
||||||
static const heroTagPrefix = "outgoing_collection";
|
|
||||||
late StreamSubscription<TabChangedEvent> _tabChangeEvent;
|
|
||||||
|
|
||||||
// This can be used to defer loading of widgets in this tab until the tab is
|
|
||||||
// selected for a certain amount of time. This will not turn true until the
|
|
||||||
// user has been in the tab for 500ms. This is to prevent loading widgets when
|
|
||||||
// the user is just switching tabs quickly.
|
|
||||||
final _canLoadDeferredWidgets = ValueNotifier<bool>(false);
|
|
||||||
final _debouncerForDeferringLoad = Debouncer(
|
|
||||||
const Duration(milliseconds: 500),
|
|
||||||
);
|
|
||||||
|
|
||||||
static const maxThumbnailWidth = 224.0;
|
|
||||||
static const crossAxisSpacing = 8.0;
|
|
||||||
static const horizontalPadding = 16.0;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_localFilesSubscription =
|
|
||||||
Bus.instance.on<LocalPhotosUpdatedEvent>().listen((event) {
|
|
||||||
_debouncer.run(() async {
|
|
||||||
if (mounted) {
|
|
||||||
debugPrint("SetState Shared Collections on ${event.reason}");
|
|
||||||
setState(() {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
_collectionUpdatesSubscription =
|
|
||||||
Bus.instance.on<CollectionUpdatedEvent>().listen((event) {
|
|
||||||
_debouncer.run(() async {
|
|
||||||
if (mounted) {
|
|
||||||
debugPrint("SetState Shared Collections on ${event.reason}");
|
|
||||||
setState(() {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
_loggedOutEvent = Bus.instance.on<UserLoggedOutEvent>().listen((event) {
|
|
||||||
setState(() {});
|
|
||||||
});
|
|
||||||
|
|
||||||
_tabChangeEvent = Bus.instance.on<TabChangedEvent>().listen((event) {
|
|
||||||
if (event.selectedIndex == 2) {
|
|
||||||
_debouncerForDeferringLoad.run(() async {
|
|
||||||
_logger.info("Loading deferred widgets in shared 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
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
return const Center(
|
||||||
return FutureBuilder<SharedCollections>(
|
child: Text(
|
||||||
future: Future.value(CollectionsService.instance.getSharedCollections()),
|
"Sharing tab is empty",
|
||||||
builder: (context, snapshot) {
|
style: TextStyle(fontSize: 16, color: Colors.grey),
|
||||||
if (snapshot.hasData) {
|
|
||||||
if ((snapshot.data?.incoming.length ?? 0) == 0 &&
|
|
||||||
(snapshot.data?.quickLinks.length ?? 0) == 0 &&
|
|
||||||
(snapshot.data?.outgoing.length ?? 0) == 0) {
|
|
||||||
return const Center(child: SharedEmptyStateWidget());
|
|
||||||
}
|
|
||||||
return SafeArea(child: _getSharedCollectionsGallery(snapshot.data!));
|
|
||||||
} else if (snapshot.hasError) {
|
|
||||||
_logger.severe(
|
|
||||||
"critical: failed to load share gallery",
|
|
||||||
snapshot.error,
|
|
||||||
snapshot.stackTrace,
|
|
||||||
);
|
|
||||||
return Center(
|
|
||||||
child: Text(AppLocalizations.of(context).somethingWentWrong),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return const EnteLoadingWidget();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _getSharedCollectionsGallery(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 SingleChildScrollView(
|
|
||||||
physics: const BouncingScrollPhysics(),
|
|
||||||
child: Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 50),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
SectionOptions(
|
|
||||||
onTap: collections.incoming.isNotEmpty
|
|
||||||
? () {
|
|
||||||
unawaited(
|
|
||||||
routeToPage(
|
|
||||||
context,
|
|
||||||
CollectionListPage(
|
|
||||||
collections.incoming,
|
|
||||||
sectionType: UISectionType.incomingCollections,
|
|
||||||
tag: "incoming",
|
|
||||||
appTitle: sharedWithYou,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
Hero(tag: "incoming", child: sharedWithYou),
|
|
||||||
trailingWidget: collections.incoming.isNotEmpty
|
|
||||||
? IconButtonWidget(
|
|
||||||
icon: Icons.chevron_right,
|
|
||||||
iconButtonType: IconButtonType.secondary,
|
|
||||||
iconColor: colorTheme.blurStrokePressed,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
collections.incoming.isNotEmpty
|
|
||||||
? 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 IncomingAlbumEmptyState(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
SectionOptions(
|
|
||||||
onTap: collections.outgoing.isNotEmpty
|
|
||||||
? () {
|
|
||||||
unawaited(
|
|
||||||
routeToPage(
|
|
||||||
context,
|
|
||||||
CollectionListPage(
|
|
||||||
collections.outgoing,
|
|
||||||
sectionType: UISectionType.outgoingCollections,
|
|
||||||
tag: "outgoing",
|
|
||||||
appTitle: sharedByYou,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
Hero(tag: "outgoing", child: sharedByYou),
|
|
||||||
trailingWidget: collections.outgoing.isNotEmpty
|
|
||||||
? IconButtonWidget(
|
|
||||||
icon: Icons.chevron_right,
|
|
||||||
iconButtonType: IconButtonType.secondary,
|
|
||||||
iconColor: colorTheme.blurStrokePressed,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
collections.outgoing.isNotEmpty
|
|
||||||
? 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 OutgoingAlbumEmptyState(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
numberOfQuickLinks > 0
|
|
||||||
? Column(
|
|
||||||
children: [
|
|
||||||
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.shrink(),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
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 EnteLoadingWidget();
|
|
||||||
} else {
|
|
||||||
return const EnteLoadingWidget();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: const SizedBox.shrink();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const CollectPhotosCardWidget(),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_localFilesSubscription.cancel();
|
|
||||||
_collectionUpdatesSubscription.cancel();
|
|
||||||
_loggedOutEvent.cancel();
|
|
||||||
_debouncer.cancelDebounceTimer();
|
|
||||||
_debouncerForDeferringLoad.cancelDebounceTimer();
|
|
||||||
_tabChangeEvent.cancel();
|
|
||||||
_canLoadDeferredWidgets.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool get wantKeepAlive => true;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,15 @@ 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/empty_state.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 +63,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,7 +73,20 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
|||||||
leading: true,
|
leading: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// This can be used to defer loading of widgets in this tab until the tab is
|
||||||
|
// selected for a certain amount of time. This will not turn true until the
|
||||||
|
// user has been in the tab for 500ms. This is to prevent loading widgets when
|
||||||
|
// the user is just switching tabs quickly.
|
||||||
|
final _canLoadDeferredWidgets = ValueNotifier<bool>(false);
|
||||||
|
final _debouncerForDeferringLoad = Debouncer(
|
||||||
|
const Duration(milliseconds: 500),
|
||||||
|
);
|
||||||
|
|
||||||
static const int _kOnEnteItemLimitCount = 12;
|
static const int _kOnEnteItemLimitCount = 12;
|
||||||
|
static const heroTagPrefix = "outgoing_collection";
|
||||||
|
static const maxThumbnailWidth = 224.0;
|
||||||
|
static const crossAxisSpacing = 8.0;
|
||||||
|
static const horizontalPadding = 16.0;
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -97,6 +124,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
|
||||||
@@ -107,7 +155,23 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
|||||||
future: CollectionsService.instance.getCollectionForOnEnteSection(),
|
future: CollectionsService.instance.getCollectionForOnEnteSection(),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.hasData) {
|
if (snapshot.hasData) {
|
||||||
return _getCollectionsGalleryWidget(snapshot.data!);
|
return FutureBuilder<SharedCollections>(
|
||||||
|
future: Future.value(CollectionsService.instance.getSharedCollections()),
|
||||||
|
builder: (context, sharedSnapshot) {
|
||||||
|
if (sharedSnapshot.hasData) {
|
||||||
|
return _getCollectionsGalleryWidget(snapshot.data!, sharedSnapshot.data!);
|
||||||
|
} else if (sharedSnapshot.hasError) {
|
||||||
|
_logger.severe(
|
||||||
|
"Failed to load shared collections",
|
||||||
|
sharedSnapshot.error,
|
||||||
|
sharedSnapshot.stackTrace,
|
||||||
|
);
|
||||||
|
return _getCollectionsGalleryWidget(snapshot.data!, null);
|
||||||
|
} else {
|
||||||
|
return const EnteLoadingWidget();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
} else if (snapshot.hasError) {
|
} else if (snapshot.hasError) {
|
||||||
return Text(snapshot.error.toString());
|
return Text(snapshot.error.toString());
|
||||||
} else {
|
} else {
|
||||||
@@ -117,7 +181,7 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _getCollectionsGalleryWidget(List<Collection> collections) {
|
Widget _getCollectionsGalleryWidget(List<Collection> collections, SharedCollections? sharedCollections) {
|
||||||
final TextStyle trashAndHiddenTextStyle =
|
final TextStyle trashAndHiddenTextStyle =
|
||||||
Theme.of(context).textTheme.titleMedium!.copyWith(
|
Theme.of(context).textTheme.titleMedium!.copyWith(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
@@ -220,6 +284,8 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Add shared collections content
|
||||||
|
if (sharedCollections != null) ..._getSharedCollectionsSlivers(sharedCollections),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child:
|
child:
|
||||||
SizedBox(height: 64 + MediaQuery.paddingOf(context).bottom),
|
SizedBox(height: 64 + MediaQuery.paddingOf(context).bottom),
|
||||||
@@ -236,6 +302,279 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Widget> _getSharedCollectionsSlivers(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);
|
||||||
|
|
||||||
|
List<Widget> slivers = [];
|
||||||
|
|
||||||
|
// Add divider before shared content
|
||||||
|
slivers.add(
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Divider(
|
||||||
|
color: getEnteColorScheme(context).strokeFaint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 12)));
|
||||||
|
|
||||||
|
// Shared with you section
|
||||||
|
if (collections.incoming.isNotEmpty ||
|
||||||
|
((collections.incoming.length ?? 0) == 0 &&
|
||||||
|
(collections.quickLinks.length ?? 0) == 0 &&
|
||||||
|
(collections.outgoing.length ?? 0) == 0)) {
|
||||||
|
slivers.add(
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SectionOptions(
|
||||||
|
onTap: collections.incoming.isNotEmpty
|
||||||
|
? () {
|
||||||
|
unawaited(
|
||||||
|
routeToPage(
|
||||||
|
context,
|
||||||
|
CollectionListPage(
|
||||||
|
collections.incoming,
|
||||||
|
sectionType: UISectionType.incomingCollections,
|
||||||
|
tag: "incoming",
|
||||||
|
appTitle: sharedWithYou,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
Hero(tag: "incoming", child: sharedWithYou),
|
||||||
|
trailingWidget: collections.incoming.isNotEmpty
|
||||||
|
? IconButtonWidget(
|
||||||
|
icon: Icons.chevron_right,
|
||||||
|
iconButtonType: IconButtonType.secondary,
|
||||||
|
iconColor: colorTheme.blurStrokePressed,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 2)));
|
||||||
|
slivers.add(
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: collections.incoming.isNotEmpty
|
||||||
|
? 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 IncomingAlbumEmptyState(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared by you section
|
||||||
|
if (collections.outgoing.isNotEmpty ||
|
||||||
|
((collections.incoming.length ?? 0) == 0 &&
|
||||||
|
(collections.quickLinks.length ?? 0) == 0 &&
|
||||||
|
(collections.outgoing.length ?? 0) == 0)) {
|
||||||
|
slivers.add(
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SectionOptions(
|
||||||
|
onTap: collections.outgoing.isNotEmpty
|
||||||
|
? () {
|
||||||
|
unawaited(
|
||||||
|
routeToPage(
|
||||||
|
context,
|
||||||
|
CollectionListPage(
|
||||||
|
collections.outgoing,
|
||||||
|
sectionType: UISectionType.outgoingCollections,
|
||||||
|
tag: "outgoing",
|
||||||
|
appTitle: sharedByYou,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
Hero(tag: "outgoing", child: sharedByYou),
|
||||||
|
trailingWidget: collections.outgoing.isNotEmpty
|
||||||
|
? IconButtonWidget(
|
||||||
|
icon: Icons.chevron_right,
|
||||||
|
iconButtonType: IconButtonType.secondary,
|
||||||
|
iconColor: colorTheme.blurStrokePressed,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 2)));
|
||||||
|
slivers.add(
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: collections.outgoing.isNotEmpty
|
||||||
|
? 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 OutgoingAlbumEmptyState(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick links section
|
||||||
|
if (numberOfQuickLinks > 0) {
|
||||||
|
slivers.add(
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 2)));
|
||||||
|
slivers.add(
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 2)));
|
||||||
|
slivers.add(
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: 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 EnteLoadingWidget();
|
||||||
|
} else {
|
||||||
|
return const EnteLoadingWidget();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
slivers.add(const SliverToBoxAdapter(child: CollectPhotosCardWidget()));
|
||||||
|
slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 32)));
|
||||||
|
|
||||||
|
return slivers;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_localFilesSubscription.cancel();
|
_localFilesSubscription.cancel();
|
||||||
@@ -244,7 +583,10 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
|||||||
_favoritesServiceInitCompleteEvent.cancel();
|
_favoritesServiceInitCompleteEvent.cancel();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_debouncer.cancelDebounceTimer();
|
_debouncer.cancelDebounceTimer();
|
||||||
|
_debouncerForDeferringLoad.cancelDebounceTimer();
|
||||||
_albumSortOrderChangeEvent.cancel();
|
_albumSortOrderChangeEvent.cancel();
|
||||||
|
_tabChangeEvent.cancel();
|
||||||
|
_canLoadDeferredWidgets.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user