Compare commits

...

2 Commits

Author SHA1 Message Date
Prateek Sunal
0e783c5d9b Add feed functionality with new tab and components
- Add feed tab to navigation with new models and service
- Implement FeedService for data management
- Create feed UI components and tab layout
- Update home navigation to include feed tab
2025-08-26 17:54:28 +00:00
Prateek Sunal
d45458f6fd Move sharing tab content to collections tab and make sharing tab empty
- Moved SharedCollectionsTab functionality to UserCollectionsTab
- Added shared collections sections (incoming, outgoing, quick links, contacts) to collections tab
- Simplified SharedCollectionsTab to display empty state
- Collections tab now shows both user collections and shared collections in one place
- Added proper tab change event handling for deferred loading of contacts

Co-authored-by: Claude <claude@anthropic.com>
2025-08-26 15:52:20 +00:00
16 changed files with 1930 additions and 390 deletions

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

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

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

View File

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

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

View File

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

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

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

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

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

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

View File

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

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

View File

@@ -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/settings/app_update_dialog.dart";
import "package:photos/ui/settings_page.dart";
import "package:photos/ui/tabs/shared_collections_tab.dart";
import "package:photos/ui/tabs/feed_tab.dart";
import "package:photos/ui/tabs/user_collections_tab.dart";
import "package:photos/ui/viewer/actions/file_viewer.dart";
import "package:photos/ui/viewer/file/detail_page.dart";
@@ -87,7 +87,7 @@ class HomeWidget extends StatefulWidget {
}
class _HomeWidgetState extends State<HomeWidget> {
static const _sharedCollectionTab = SharedCollectionsTab();
static const _feedTab = FeedTab();
static const _searchTab = SearchTab();
static final _settingsPage = SettingsPage(
emailNotifier: UserService.instance.emailValueNotifier,
@@ -761,7 +761,7 @@ class _HomeWidgetState extends State<HomeWidget> {
selectedFiles: _selectedFiles,
),
UserCollectionsTab(selectedAlbums: _selectedAlbums),
_sharedCollectionTab,
_feedTab,
_searchTab,
],
);

View File

@@ -1,395 +1,16 @@
import 'dart:async';
import "dart:math";
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});
@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
Widget build(BuildContext context) {
super.build(context);
return FutureBuilder<SharedCollections>(
future: Future.value(CollectionsService.instance.getSharedCollections()),
builder: (context, snapshot) {
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),
],
),
return const Center(
child: Text(
"Sharing tab is empty",
style: TextStyle(fontSize: 16, color: Colors.grey),
),
);
}
@override
void dispose() {
_localFilesSubscription.cancel();
_collectionUpdatesSubscription.cancel();
_loggedOutEvent.cancel();
_debouncer.cancelDebounceTimer();
_debouncerForDeferringLoad.cancelDebounceTimer();
_tabChangeEvent.cancel();
_canLoadDeferredWidgets.dispose();
super.dispose();
}
@override
bool get wantKeepAlive => true;
}

View File

@@ -1,19 +1,26 @@
import 'dart:async';
import "dart:math";
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import "package:photos/core/configuration.dart";
import "package:photos/core/constants.dart";
import 'package:photos/core/event_bus.dart';
import "package:photos/events/album_sort_order_change_event.dart";
import 'package:photos/events/collection_updated_event.dart';
import "package:photos/events/favorites_service_init_complete_event.dart";
import 'package:photos/events/local_photos_updated_event.dart';
import "package:photos/events/tab_changed_event.dart";
import 'package:photos/events/user_logged_out_event.dart';
import "package:photos/generated/l10n.dart";
import 'package:photos/models/collection/collection.dart';
import 'package:photos/models/collection/collection_items.dart';
import "package:photos/models/search/generic_search_result.dart";
import "package:photos/models/selected_albums.dart";
import 'package:photos/services/collections_service.dart';
import "package:photos/services/search_service.dart";
import "package:photos/theme/ente_theme.dart";
import "package:photos/ui/collections/album/row_item.dart";
import "package:photos/ui/collections/button/archived_button.dart";
import "package:photos/ui/collections/button/hidden_button.dart";
import "package:photos/ui/collections/button/trash_button.dart";
@@ -25,9 +32,15 @@ import "package:photos/ui/collections/flex_grid_view.dart";
import 'package:photos/ui/common/loading_widget.dart';
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
import "package:photos/ui/tabs/section_title.dart";
import "package:photos/ui/tabs/shared/all_quick_links_page.dart";
import "package:photos/ui/tabs/shared/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/delete_empty_albums.dart";
import "package:photos/ui/viewer/gallery/collect_photos_card_widget.dart";
import "package:photos/ui/viewer/gallery/collection_page.dart";
import "package:photos/ui/viewer/gallery/empty_state.dart";
import "package:photos/ui/viewer/search_tab/contacts_section.dart";
import "package:photos/utils/navigation_util.dart";
import "package:photos/utils/standalone/debouncer.dart";
@@ -50,6 +63,7 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
late StreamSubscription<FavoritesServiceInitCompleteEvent>
_favoritesServiceInitCompleteEvent;
late StreamSubscription<AlbumSortOrderChangeEvent> _albumSortOrderChangeEvent;
late StreamSubscription<TabChangedEvent> _tabChangeEvent;
String _loadReason = "init";
final _scrollController = ScrollController();
@@ -59,7 +73,20 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
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 heroTagPrefix = "outgoing_collection";
static const maxThumbnailWidth = 224.0;
static const crossAxisSpacing = 8.0;
static const horizontalPadding = 16.0;
@override
void initState() {
super.initState();
@@ -97,6 +124,27 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
_loadReason = event.reason;
setState(() {});
});
_tabChangeEvent = Bus.instance.on<TabChangedEvent>().listen((event) {
if (event.selectedIndex == 1) {
_debouncerForDeferringLoad.run(() async {
_logger.info("Loading deferred widgets in collections tab");
if (mounted) {
_canLoadDeferredWidgets.value = true;
await _tabChangeEvent.cancel();
Future.delayed(
Duration.zero,
() => _debouncerForDeferringLoad.cancelDebounceTimer(),
);
}
});
} else {
_debouncerForDeferringLoad.cancelDebounceTimer();
if (mounted) {
_canLoadDeferredWidgets.value = false;
}
}
});
}
@override
@@ -107,7 +155,23 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
future: CollectionsService.instance.getCollectionForOnEnteSection(),
builder: (context, snapshot) {
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) {
return Text(snapshot.error.toString());
} 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 =
Theme.of(context).textTheme.titleMedium!.copyWith(
color: Theme.of(context)
@@ -220,6 +284,8 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
),
),
),
// Add shared collections content
if (sharedCollections != null) ..._getSharedCollectionsSlivers(sharedCollections),
SliverToBoxAdapter(
child:
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
void dispose() {
_localFilesSubscription.cancel();
@@ -244,7 +583,10 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
_favoritesServiceInitCompleteEvent.cancel();
_scrollController.dispose();
_debouncer.cancelDebounceTimer();
_debouncerForDeferringLoad.cancelDebounceTimer();
_albumSortOrderChangeEvent.cancel();
_tabChangeEvent.cancel();
_canLoadDeferredWidgets.dispose();
super.dispose();
}