Compare commits
1 Commits
release_ac
...
testing-fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdd14d8e6c |
103
mobile/apps/photos/lib/models/feed/feed_models.dart
Normal file
103
mobile/apps/photos/lib/models/feed/feed_models.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
class FeedUser {
|
||||
final String id;
|
||||
final String name;
|
||||
final String avatarUrl;
|
||||
|
||||
const FeedUser({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.avatarUrl,
|
||||
});
|
||||
}
|
||||
|
||||
class FeedPhoto {
|
||||
final String id;
|
||||
final String url;
|
||||
final String? description;
|
||||
|
||||
const FeedPhoto({
|
||||
required this.id,
|
||||
required this.url,
|
||||
this.description,
|
||||
});
|
||||
}
|
||||
|
||||
enum FeedItemType {
|
||||
memory,
|
||||
album,
|
||||
photos,
|
||||
video,
|
||||
}
|
||||
|
||||
class FeedItem {
|
||||
final String id;
|
||||
final FeedUser user;
|
||||
final FeedItemType type;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final List<FeedPhoto> photos;
|
||||
final bool isLiked;
|
||||
final int likeCount;
|
||||
final String timeAgo;
|
||||
final bool isVideo;
|
||||
|
||||
const FeedItem({
|
||||
required this.id,
|
||||
required this.user,
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.photos,
|
||||
this.isLiked = false,
|
||||
this.likeCount = 0,
|
||||
required this.timeAgo,
|
||||
this.isVideo = false,
|
||||
});
|
||||
|
||||
FeedItem copyWith({
|
||||
bool? isLiked,
|
||||
int? likeCount,
|
||||
}) {
|
||||
return FeedItem(
|
||||
id: id,
|
||||
user: user,
|
||||
type: type,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
photos: photos,
|
||||
isLiked: isLiked ?? this.isLiked,
|
||||
likeCount: likeCount ?? this.likeCount,
|
||||
timeAgo: timeAgo,
|
||||
isVideo: isVideo,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationItem {
|
||||
final String id;
|
||||
final FeedUser user;
|
||||
final String action;
|
||||
final String timeAgo;
|
||||
final FeedPhoto? photo;
|
||||
final bool isRead;
|
||||
|
||||
const NotificationItem({
|
||||
required this.id,
|
||||
required this.user,
|
||||
required this.action,
|
||||
required this.timeAgo,
|
||||
this.photo,
|
||||
this.isRead = false,
|
||||
});
|
||||
|
||||
NotificationItem copyWith({bool? isRead}) {
|
||||
return NotificationItem(
|
||||
id: id,
|
||||
user: user,
|
||||
action: action,
|
||||
timeAgo: timeAgo,
|
||||
photo: photo,
|
||||
isRead: isRead ?? this.isRead,
|
||||
);
|
||||
}
|
||||
}
|
||||
289
mobile/apps/photos/lib/services/feed/feed_data_service.dart
Normal file
289
mobile/apps/photos/lib/services/feed/feed_data_service.dart
Normal file
@@ -0,0 +1,289 @@
|
||||
import 'package:photos/models/feed/feed_models.dart';
|
||||
|
||||
class FeedDataService {
|
||||
static const List<FeedUser> _mockUsers = [
|
||||
FeedUser(
|
||||
id: "1",
|
||||
name: "Bob",
|
||||
avatarUrl: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face",
|
||||
),
|
||||
FeedUser(
|
||||
id: "2",
|
||||
name: "Alice",
|
||||
avatarUrl: "https://images.unsplash.com/photo-1494790108755-2616b09c7bec?w=150&h=150&fit=crop&crop=face",
|
||||
),
|
||||
FeedUser(
|
||||
id: "3",
|
||||
name: "Charlie",
|
||||
avatarUrl: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face",
|
||||
),
|
||||
FeedUser(
|
||||
id: "4",
|
||||
name: "Diana",
|
||||
avatarUrl: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face",
|
||||
),
|
||||
];
|
||||
|
||||
static List<FeedItem> getMockFeedItems() {
|
||||
return [
|
||||
FeedItem(
|
||||
id: "1",
|
||||
user: _mockUsers[0],
|
||||
type: FeedItemType.memory,
|
||||
title: "Trip to paris",
|
||||
subtitle: "shared a memory",
|
||||
timeAgo: "2h ago",
|
||||
photos: [
|
||||
const FeedPhoto(
|
||||
id: "1",
|
||||
url: "https://images.unsplash.com/photo-1499856871958-5b9627545d1a?w=400&h=600&fit=crop&crop=center",
|
||||
description: "Beautiful archway in Paris with a silhouette of a person",
|
||||
),
|
||||
const FeedPhoto(
|
||||
id: "1a",
|
||||
url: "https://images.unsplash.com/photo-1502602898536-47ad22581b52?w=400&h=600&fit=crop",
|
||||
description: "Eiffel Tower at sunset",
|
||||
),
|
||||
const FeedPhoto(
|
||||
id: "1b",
|
||||
url: "https://images.unsplash.com/photo-1471623643120-e6ccc3452a4e?w=400&h=600&fit=crop",
|
||||
description: "Paris street with classic architecture",
|
||||
),
|
||||
const FeedPhoto(
|
||||
id: "1c",
|
||||
url: "https://images.unsplash.com/photo-1549144511-f099e773c147?w=400&h=600&fit=crop",
|
||||
description: "Seine river with Notre Dame",
|
||||
),
|
||||
],
|
||||
),
|
||||
FeedItem(
|
||||
id: "2",
|
||||
user: _mockUsers[0],
|
||||
type: FeedItemType.photos,
|
||||
title: "Maldives",
|
||||
subtitle: "shared 3 photos",
|
||||
timeAgo: "4h ago",
|
||||
likeCount: 12,
|
||||
photos: [
|
||||
const FeedPhoto(
|
||||
id: "2",
|
||||
url: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=600&fit=crop",
|
||||
description: "Woman sitting on wooden walkway overlooking tropical landscape",
|
||||
),
|
||||
const FeedPhoto(
|
||||
id: "3",
|
||||
url: "https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=400&h=600&fit=crop",
|
||||
description: "Tropical beach with crystal clear water",
|
||||
),
|
||||
const FeedPhoto(
|
||||
id: "4",
|
||||
url: "https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=400&h=600&fit=crop",
|
||||
description: "Overwater bungalows in Maldives",
|
||||
),
|
||||
],
|
||||
),
|
||||
FeedItem(
|
||||
id: "3",
|
||||
user: _mockUsers[0],
|
||||
type: FeedItemType.photos,
|
||||
title: "Maldives",
|
||||
subtitle: "shared 3 photos",
|
||||
timeAgo: "6h ago",
|
||||
isLiked: true,
|
||||
likeCount: 8,
|
||||
photos: [
|
||||
const FeedPhoto(
|
||||
id: "5",
|
||||
url: "https://images.unsplash.com/photo-1544198365-f5d60b6d8190?w=400&h=300&fit=crop",
|
||||
description: "Woman with drink on beach",
|
||||
),
|
||||
const FeedPhoto(
|
||||
id: "6",
|
||||
url: "https://images.unsplash.com/photo-1544551763-77ef2d0cfc6c?w=400&h=300&fit=crop",
|
||||
description: "Couple enjoying tropical vacation",
|
||||
),
|
||||
const FeedPhoto(
|
||||
id: "7",
|
||||
url: "https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=400&h=300&fit=crop",
|
||||
description: "Beautiful lagoon view",
|
||||
),
|
||||
],
|
||||
),
|
||||
FeedItem(
|
||||
id: "4",
|
||||
user: _mockUsers[0],
|
||||
type: FeedItemType.video,
|
||||
title: "Maldives",
|
||||
subtitle: "shared a video",
|
||||
timeAgo: "1d ago",
|
||||
likeCount: 25,
|
||||
isVideo: true,
|
||||
photos: [
|
||||
const FeedPhoto(
|
||||
id: "8",
|
||||
url: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=600&fit=crop",
|
||||
description: "Video thumbnail of tropical scenery",
|
||||
),
|
||||
],
|
||||
),
|
||||
FeedItem(
|
||||
id: "5",
|
||||
user: _mockUsers[0],
|
||||
type: FeedItemType.album,
|
||||
title: "Pets",
|
||||
subtitle: "liked an Album",
|
||||
timeAgo: "2d ago",
|
||||
likeCount: 15,
|
||||
photos: [
|
||||
const FeedPhoto(
|
||||
id: "9",
|
||||
url: "https://images.unsplash.com/photo-1552053831-71594a27632d?w=400&h=600&fit=crop",
|
||||
description: "Happy dog in a grassy field",
|
||||
),
|
||||
],
|
||||
),
|
||||
FeedItem(
|
||||
id: "6",
|
||||
user: _mockUsers[1],
|
||||
type: FeedItemType.memory,
|
||||
title: "Mountain Adventure",
|
||||
subtitle: "shared a memory",
|
||||
timeAgo: "3d ago",
|
||||
likeCount: 32,
|
||||
photos: [
|
||||
const FeedPhoto(
|
||||
id: "10",
|
||||
url: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=600&fit=crop",
|
||||
description: "Breathtaking mountain landscape view",
|
||||
),
|
||||
],
|
||||
),
|
||||
FeedItem(
|
||||
id: "7",
|
||||
user: _mockUsers[2],
|
||||
type: FeedItemType.photos,
|
||||
title: "City Lights",
|
||||
subtitle: "shared 5 photos",
|
||||
timeAgo: "1w ago",
|
||||
isLiked: true,
|
||||
likeCount: 45,
|
||||
photos: [
|
||||
const FeedPhoto(
|
||||
id: "11",
|
||||
url: "https://images.unsplash.com/photo-1519501025264-65ba15a82390?w=400&h=600&fit=crop",
|
||||
description: "Urban cityscape at night",
|
||||
),
|
||||
const FeedPhoto(
|
||||
id: "12",
|
||||
url: "https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?w=400&h=600&fit=crop",
|
||||
description: "City lights reflecting on water",
|
||||
),
|
||||
const FeedPhoto(
|
||||
id: "13",
|
||||
url: "https://images.unsplash.com/photo-1514565131-fce0801e5785?w=400&h=600&fit=crop",
|
||||
description: "Busy street with neon lights",
|
||||
),
|
||||
const FeedPhoto(
|
||||
id: "14",
|
||||
url: "https://images.unsplash.com/photo-1519501025264-65ba15a82390?w=400&h=600&fit=crop",
|
||||
description: "Skyscraper view from below",
|
||||
),
|
||||
const FeedPhoto(
|
||||
id: "15",
|
||||
url: "https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?w=400&h=600&fit=crop",
|
||||
description: "City panorama at sunset",
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static List<NotificationItem> getMockNotifications() {
|
||||
return [
|
||||
NotificationItem(
|
||||
id: "1",
|
||||
user: _mockUsers[0],
|
||||
action: "Liked your photo",
|
||||
timeAgo: "40m ago",
|
||||
photo: const FeedPhoto(
|
||||
id: "n1",
|
||||
url: "https://images.unsplash.com/photo-1552053831-71594a27632d?w=100&h=100&fit=crop",
|
||||
),
|
||||
),
|
||||
NotificationItem(
|
||||
id: "2",
|
||||
user: _mockUsers[1],
|
||||
action: "Liked your photo",
|
||||
timeAgo: "2hrs ago",
|
||||
photo: const FeedPhoto(
|
||||
id: "n2",
|
||||
url: "https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=100&h=100&fit=crop",
|
||||
),
|
||||
),
|
||||
NotificationItem(
|
||||
id: "3",
|
||||
user: _mockUsers[2],
|
||||
action: "Liked your photo",
|
||||
timeAgo: "1 day ago",
|
||||
photo: const FeedPhoto(
|
||||
id: "n3",
|
||||
url: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=100&h=100&fit=crop",
|
||||
),
|
||||
),
|
||||
NotificationItem(
|
||||
id: "4",
|
||||
user: _mockUsers[0],
|
||||
action: "Liked your album",
|
||||
timeAgo: "14 days ago",
|
||||
photo: const FeedPhoto(
|
||||
id: "n4",
|
||||
url: "https://images.unsplash.com/photo-1519501025264-65ba15a82390?w=100&h=100&fit=crop",
|
||||
),
|
||||
),
|
||||
NotificationItem(
|
||||
id: "5",
|
||||
user: _mockUsers[3],
|
||||
action: "Liked your photo",
|
||||
timeAgo: "2 mnths ago",
|
||||
photo: const FeedPhoto(
|
||||
id: "n5",
|
||||
url: "https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?w=100&h=100&fit=crop",
|
||||
),
|
||||
),
|
||||
// Read notifications
|
||||
NotificationItem(
|
||||
id: "6",
|
||||
user: _mockUsers[0],
|
||||
action: "Liked your photo",
|
||||
timeAgo: "40m ago",
|
||||
isRead: true,
|
||||
photo: const FeedPhoto(
|
||||
id: "n6",
|
||||
url: "https://images.unsplash.com/photo-1552053831-71594a27632d?w=100&h=100&fit=crop",
|
||||
),
|
||||
),
|
||||
NotificationItem(
|
||||
id: "7",
|
||||
user: _mockUsers[1],
|
||||
action: "Liked your photo",
|
||||
timeAgo: "2hrs ago",
|
||||
isRead: true,
|
||||
photo: const FeedPhoto(
|
||||
id: "n7",
|
||||
url: "https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=100&h=100&fit=crop",
|
||||
),
|
||||
),
|
||||
NotificationItem(
|
||||
id: "8",
|
||||
user: _mockUsers[2],
|
||||
action: "Liked your photo",
|
||||
timeAgo: "1 day ago",
|
||||
isRead: true,
|
||||
photo: const FeedPhoto(
|
||||
id: "n8",
|
||||
url: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=100&h=100&fit=crop",
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -276,12 +276,12 @@ class _CollectionActionSheetState extends State<CollectionActionSheet> {
|
||||
isDisabled: _selectedCollections.isEmpty,
|
||||
onTap: () async {
|
||||
if (widget.selectedPeople != null) {
|
||||
final ProgressDialog? dialog = createProgressDialog(
|
||||
final ProgressDialog dialog = createProgressDialog(
|
||||
context,
|
||||
AppLocalizations.of(context).uploadingFilesToAlbum,
|
||||
isDismissible: true,
|
||||
);
|
||||
await dialog?.show();
|
||||
await dialog.show();
|
||||
for (final collection in _selectedCollections) {
|
||||
try {
|
||||
await smartAlbumsService.addPeopleToSmartAlbum(
|
||||
@@ -297,7 +297,7 @@ class _CollectionActionSheetState extends State<CollectionActionSheet> {
|
||||
}
|
||||
}
|
||||
unawaited(smartAlbumsService.syncSmartAlbums());
|
||||
await dialog?.hide();
|
||||
await dialog.hide();
|
||||
return;
|
||||
}
|
||||
final CollectionActions collectionActions =
|
||||
|
||||
126
mobile/apps/photos/lib/ui/feed/feed_tab.dart
Normal file
126
mobile/apps/photos/lib/ui/feed/feed_tab.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/models/feed/feed_models.dart';
|
||||
import 'package:photos/services/feed/feed_data_service.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/feed/feed_viewer_page.dart';
|
||||
import 'package:photos/ui/feed/notifications_page.dart';
|
||||
import 'package:photos/ui/feed/widgets/feed_item_widget.dart';
|
||||
import 'package:photos/utils/navigation_util.dart';
|
||||
|
||||
class FeedTab extends StatefulWidget {
|
||||
const FeedTab({super.key});
|
||||
|
||||
@override
|
||||
State<FeedTab> createState() => _FeedTabState();
|
||||
}
|
||||
|
||||
class _FeedTabState extends State<FeedTab> {
|
||||
List<FeedItem> _feedItems = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadFeedItems();
|
||||
}
|
||||
|
||||
void _loadFeedItems() {
|
||||
setState(() {
|
||||
_feedItems = FeedDataService.getMockFeedItems();
|
||||
});
|
||||
}
|
||||
|
||||
void _onLike(int index) {
|
||||
setState(() {
|
||||
final item = _feedItems[index];
|
||||
_feedItems[index] = item.copyWith(
|
||||
isLiked: !item.isLiked,
|
||||
likeCount: item.isLiked ? item.likeCount - 1 : item.likeCount + 1,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _onFeedItemTap(FeedItem item) {
|
||||
routeToPage(
|
||||
context,
|
||||
FeedViewerPage(
|
||||
feedItem: item,
|
||||
onLike: () {
|
||||
final index = _feedItems.indexWhere((f) => f.id == item.id);
|
||||
if (index != -1) {
|
||||
_onLike(index);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onNotificationsTap() {
|
||||
routeToPage(context, const NotificationsPage());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.backgroundBase,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 32),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Feed',
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: _onNotificationsTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(
|
||||
Icons.notifications_outlined,
|
||||
size: 28,
|
||||
color: colorScheme.textBase,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Feed content
|
||||
Expanded(
|
||||
child: _feedItems.isEmpty
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
: ListView.builder(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
|
||||
itemCount: _feedItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _feedItems[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: FeedItemWidget(
|
||||
item: item,
|
||||
onTap: () => _onFeedItemTap(item),
|
||||
onLike: () => _onLike(index),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
322
mobile/apps/photos/lib/ui/feed/feed_viewer_page.dart
Normal file
322
mobile/apps/photos/lib/ui/feed/feed_viewer_page.dart
Normal file
@@ -0,0 +1,322 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/models/feed/feed_models.dart';
|
||||
import 'package:photos/ui/feed/notifications_page.dart';
|
||||
import 'package:photos/ui/feed/widgets/feed_user_avatar.dart';
|
||||
import 'package:photos/utils/navigation_util.dart';
|
||||
|
||||
class FeedViewerPage extends StatefulWidget {
|
||||
final FeedItem feedItem;
|
||||
final VoidCallback? onLike;
|
||||
|
||||
const FeedViewerPage({
|
||||
required this.feedItem,
|
||||
this.onLike,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FeedViewerPage> createState() => _FeedViewerPageState();
|
||||
}
|
||||
|
||||
class _FeedViewerPageState extends State<FeedViewerPage> {
|
||||
late PageController _pageController;
|
||||
int _currentPhotoIndex = 0;
|
||||
late bool _isLiked;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController = PageController();
|
||||
_isLiked = widget.feedItem.isLiked;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onLike() {
|
||||
setState(() {
|
||||
_isLiked = !_isLiked;
|
||||
});
|
||||
widget.onLike?.call();
|
||||
}
|
||||
|
||||
void _onNotificationsTap() {
|
||||
routeToPage(context, const NotificationsPage());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
// Main content
|
||||
_buildMainContent(),
|
||||
// Top bar
|
||||
_buildTopBar(),
|
||||
// Bottom overlay
|
||||
_buildBottomOverlay(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMainContent() {
|
||||
if (widget.feedItem.photos.length == 1) {
|
||||
return _buildSinglePhoto();
|
||||
} else {
|
||||
return _buildMultiplePhotos();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSinglePhoto() {
|
||||
final photo = widget.feedItem.photos.first;
|
||||
return Center(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: photo.url,
|
||||
fit: BoxFit.contain,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
placeholder: (context, url) => const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => const Center(
|
||||
child: Icon(
|
||||
Icons.error,
|
||||
color: Colors.white,
|
||||
size: 50,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMultiplePhotos() {
|
||||
return PageView.builder(
|
||||
controller: _pageController,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
_currentPhotoIndex = index;
|
||||
});
|
||||
},
|
||||
itemCount: widget.feedItem.photos.length,
|
||||
itemBuilder: (context, index) {
|
||||
final photo = widget.feedItem.photos[index];
|
||||
return Center(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: photo.url,
|
||||
fit: BoxFit.contain,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
placeholder: (context, url) => const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => const Center(
|
||||
child: Icon(
|
||||
Icons.error,
|
||||
color: Colors.white,
|
||||
size: 50,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopBar() {
|
||||
return SafeArea(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Feed',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: _onNotificationsTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: const Icon(
|
||||
Icons.notifications_outlined,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomOverlay() {
|
||||
return Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black54,
|
||||
Colors.black87,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Photo indicators for multiple photos
|
||||
if (widget.feedItem.photos.length > 1) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(
|
||||
widget.feedItem.photos.length,
|
||||
(index) => Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: index == _currentPhotoIndex
|
||||
? Colors.white
|
||||
: Colors.white.withOpacity(0.4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
// Action buttons
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// User info and description
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
FeedUserAvatar(
|
||||
avatarUrl: widget.feedItem.user.avatarUrl,
|
||||
name: widget.feedItem.user.name,
|
||||
size: 40,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
widget.feedItem.user.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (widget.feedItem.photos.first.description != null)
|
||||
Text(
|
||||
widget.feedItem.photos.first.description!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Action buttons
|
||||
Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _onLike,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Icon(
|
||||
_isLiked ? Icons.favorite : Icons.favorite_border,
|
||||
color: _isLiked ? Colors.red : Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: const Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: const Icon(
|
||||
Icons.share_outlined,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Progress bar (placeholder)
|
||||
Container(
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
199
mobile/apps/photos/lib/ui/feed/notifications_page.dart
Normal file
199
mobile/apps/photos/lib/ui/feed/notifications_page.dart
Normal file
@@ -0,0 +1,199 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/models/feed/feed_models.dart';
|
||||
import 'package:photos/services/feed/feed_data_service.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/feed/widgets/feed_user_avatar.dart';
|
||||
|
||||
class NotificationsPage extends StatefulWidget {
|
||||
const NotificationsPage({super.key});
|
||||
|
||||
@override
|
||||
State<NotificationsPage> createState() => _NotificationsPageState();
|
||||
}
|
||||
|
||||
class _NotificationsPageState extends State<NotificationsPage> {
|
||||
List<NotificationItem> _unreadNotifications = [];
|
||||
List<NotificationItem> _readNotifications = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadNotifications();
|
||||
}
|
||||
|
||||
void _loadNotifications() {
|
||||
final notifications = FeedDataService.getMockNotifications();
|
||||
setState(() {
|
||||
_unreadNotifications = notifications.where((n) => !n.isRead).toList();
|
||||
_readNotifications = notifications.where((n) => n.isRead).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.backgroundBase,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(
|
||||
Icons.arrow_back,
|
||||
color: colorScheme.textBase,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Content
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Unread section
|
||||
if (_unreadNotifications.isNotEmpty) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
child: Text(
|
||||
'Unread',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
..._unreadNotifications.map((notification) =>
|
||||
_buildNotificationItem(notification, colorScheme),),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
// Read section
|
||||
if (_readNotifications.isNotEmpty) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
child: Text(
|
||||
'Read',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
..._readNotifications.map((notification) =>
|
||||
_buildNotificationItem(notification, colorScheme),),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotificationItem(NotificationItem notification, colorScheme) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// User avatar
|
||||
FeedUserAvatar(
|
||||
avatarUrl: notification.user.avatarUrl,
|
||||
name: notification.user.name,
|
||||
size: 50,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Notification details
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: colorScheme.textBase,
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: notification.user.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ${notification.action}',
|
||||
style: TextStyle(
|
||||
color: colorScheme.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
notification.timeAgo,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: colorScheme.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Photo thumbnail if available
|
||||
if (notification.photo != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: notification.photo!.url,
|
||||
width: 60,
|
||||
height: 60,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: Colors.grey[300],
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Colors.grey[300],
|
||||
child: const Center(
|
||||
child: Icon(Icons.error),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
455
mobile/apps/photos/lib/ui/feed/widgets/feed_item_widget.dart
Normal file
455
mobile/apps/photos/lib/ui/feed/widgets/feed_item_widget.dart
Normal file
@@ -0,0 +1,455 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/models/feed/feed_models.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/feed/widgets/feed_user_avatar.dart';
|
||||
|
||||
class FeedItemWidget extends StatelessWidget {
|
||||
final FeedItem item;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onLike;
|
||||
|
||||
const FeedItemWidget({
|
||||
required this.item,
|
||||
this.onTap,
|
||||
this.onLike,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.06),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// User header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
FeedUserAvatar(
|
||||
avatarUrl: item.user.avatarUrl,
|
||||
name: item.user.name,
|
||||
size: 40,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.user.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 16,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
item.subtitle,
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
color: Colors.black87,
|
||||
height: 1.1,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: onLike,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(
|
||||
item.isLiked ? Icons.favorite : Icons.favorite_border,
|
||||
color: item.isLiked ? Colors.red : colorScheme.textMuted,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Content
|
||||
_buildContent(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context) {
|
||||
if (item.photos.length == 1) {
|
||||
return _buildSinglePhoto(context);
|
||||
} else if (item.type == FeedItemType.memory) {
|
||||
return _buildMemoryCarousel(context);
|
||||
} else {
|
||||
return _buildMultiplePhotos(context);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSinglePhoto(BuildContext context) {
|
||||
final photo = item.photos.first;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 400,
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: photo.url,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: Colors.grey[100],
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Colors.grey[100],
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.image_outlined,
|
||||
size: 48,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Image unavailable',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Title overlay for memories and albums
|
||||
if (item.type == FeedItemType.memory || item.type == FeedItemType.album)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(16),
|
||||
bottomRight: Radius.circular(16),
|
||||
),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black38,
|
||||
Colors.black54,
|
||||
],
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
item.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: Offset(0, 1),
|
||||
blurRadius: 3,
|
||||
color: Colors.black26,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Video play button
|
||||
if (item.isVideo)
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black54,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow,
|
||||
color: Colors.white,
|
||||
size: 30,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMultiplePhotos(BuildContext context) {
|
||||
final photosToShow = item.photos.take(3).toList();
|
||||
final remainingCount = item.photos.length > 3 ? item.photos.length - 3 : 0;
|
||||
|
||||
return Container(
|
||||
height: 300,
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// Top row - 2 images side by side
|
||||
if (photosToShow.length >= 2)
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildPhotoItem(photosToShow[0], false, 0),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Expanded(
|
||||
child: _buildPhotoItem(photosToShow[1], false, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Bottom row - 1 image full width (if 3+ photos)
|
||||
if (photosToShow.length >= 3) ...[
|
||||
const SizedBox(height: 2),
|
||||
Expanded(
|
||||
child: _buildPhotoItem(
|
||||
photosToShow[2],
|
||||
remainingCount > 0,
|
||||
remainingCount,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMemoryCarousel(BuildContext context) {
|
||||
final photo = item.photos.first;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 400,
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: photo.url,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: Colors.grey[100],
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Colors.grey[100],
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.image_outlined,
|
||||
size: 48,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Image unavailable',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Title overlay for memories
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(16),
|
||||
bottomRight: Radius.circular(16),
|
||||
),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black38,
|
||||
Colors.black54,
|
||||
],
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
item.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: Offset(0, 1),
|
||||
blurRadius: 3,
|
||||
color: Colors.black26,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Carousel indicator
|
||||
Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'1/${item.photos.length}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhotoItem(FeedPhoto photo, bool showOverlay, int remainingCount) {
|
||||
return Stack(
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: photo.url,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: Colors.grey[100],
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Colors.grey[100],
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.image_outlined,
|
||||
size: 24,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Image unavailable',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 8,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Overlay for remaining photos count
|
||||
if (showOverlay)
|
||||
Container(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'+$remainingCount',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
56
mobile/apps/photos/lib/ui/feed/widgets/feed_user_avatar.dart
Normal file
56
mobile/apps/photos/lib/ui/feed/widgets/feed_user_avatar.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FeedUserAvatar extends StatelessWidget {
|
||||
final String avatarUrl;
|
||||
final double size;
|
||||
final String name;
|
||||
|
||||
const FeedUserAvatar({
|
||||
required this.avatarUrl,
|
||||
this.size = 40.0,
|
||||
required this.name,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
child: ClipOval(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: avatarUrl,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: Colors.grey[300],
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
color: Colors.grey[600],
|
||||
size: size * 0.6,
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Colors.grey[300],
|
||||
child: Center(
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
fontSize: size * 0.4,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ class _HomeBottomNavigationBarState extends State<HomeBottomNavigationBar> {
|
||||
),
|
||||
GButton(
|
||||
margin: const EdgeInsets.fromLTRB(10, 6, 8, 6),
|
||||
icon: Icons.people_outlined,
|
||||
icon: Icons.dynamic_feed_outlined,
|
||||
iconColor: enteColorScheme.tabIcon,
|
||||
iconActiveColor: strokeBaseLight,
|
||||
text: '',
|
||||
|
||||
@@ -54,6 +54,7 @@ import 'package:photos/ui/collections/collection_action_sheet.dart';
|
||||
import "package:photos/ui/components/buttons/button_widget.dart";
|
||||
import "package:photos/ui/components/models/button_type.dart";
|
||||
import 'package:photos/ui/extents_page_view.dart';
|
||||
import "package:photos/ui/feed/feed_tab.dart";
|
||||
import 'package:photos/ui/home/grant_permissions_widget.dart';
|
||||
import 'package:photos/ui/home/header_widget.dart';
|
||||
import 'package:photos/ui/home/home_bottom_nav_bar.dart';
|
||||
@@ -64,7 +65,6 @@ import 'package:photos/ui/home/start_backup_hook_widget.dart';
|
||||
import 'package:photos/ui/notification/update/change_log_page.dart';
|
||||
import "package:photos/ui/settings/app_update_dialog.dart";
|
||||
import "package:photos/ui/settings_page.dart";
|
||||
import "package:photos/ui/tabs/shared_collections_tab.dart";
|
||||
import "package:photos/ui/tabs/user_collections_tab.dart";
|
||||
import "package:photos/ui/viewer/actions/file_viewer.dart";
|
||||
import "package:photos/ui/viewer/file/detail_page.dart";
|
||||
@@ -87,7 +87,6 @@ class HomeWidget extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HomeWidgetState extends State<HomeWidget> {
|
||||
static const _sharedCollectionTab = SharedCollectionsTab();
|
||||
static const _searchTab = SearchTab();
|
||||
static final _settingsPage = SettingsPage(
|
||||
emailNotifier: UserService.instance.emailValueNotifier,
|
||||
@@ -761,7 +760,7 @@ class _HomeWidgetState extends State<HomeWidget> {
|
||||
selectedFiles: _selectedFiles,
|
||||
),
|
||||
UserCollectionsTab(selectedAlbums: _selectedAlbums),
|
||||
_sharedCollectionTab,
|
||||
const FeedTab(),
|
||||
_searchTab,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import 'dart:async';
|
||||
import "dart:math";
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import "package:photos/core/configuration.dart";
|
||||
import "package:photos/core/constants.dart";
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import "package:photos/events/album_sort_order_change_event.dart";
|
||||
import 'package:photos/events/collection_updated_event.dart';
|
||||
import "package:photos/events/favorites_service_init_complete_event.dart";
|
||||
import 'package:photos/events/local_photos_updated_event.dart';
|
||||
import "package:photos/events/tab_changed_event.dart";
|
||||
import 'package:photos/events/user_logged_out_event.dart';
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import 'package:photos/models/collection/collection.dart';
|
||||
import 'package:photos/models/collection/collection_items.dart';
|
||||
import "package:photos/models/search/generic_search_result.dart";
|
||||
import "package:photos/models/selected_albums.dart";
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import "package:photos/services/search_service.dart";
|
||||
import "package:photos/theme/ente_theme.dart";
|
||||
import "package:photos/ui/collections/album/row_item.dart";
|
||||
import "package:photos/ui/collections/button/archived_button.dart";
|
||||
import "package:photos/ui/collections/button/hidden_button.dart";
|
||||
import "package:photos/ui/collections/button/trash_button.dart";
|
||||
@@ -25,9 +32,14 @@ import "package:photos/ui/collections/flex_grid_view.dart";
|
||||
import 'package:photos/ui/common/loading_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
|
||||
import "package:photos/ui/tabs/section_title.dart";
|
||||
import "package:photos/ui/tabs/shared/all_quick_links_page.dart";
|
||||
import "package:photos/ui/tabs/shared/quick_link_album_item.dart";
|
||||
import "package:photos/ui/viewer/actions/album_selection_overlay_bar.dart";
|
||||
import "package:photos/ui/viewer/actions/delete_empty_albums.dart";
|
||||
import "package:photos/ui/viewer/gallery/collect_photos_card_widget.dart";
|
||||
import "package:photos/ui/viewer/gallery/collection_page.dart";
|
||||
import "package:photos/ui/viewer/gallery/empty_state.dart";
|
||||
import "package:photos/ui/viewer/search_tab/contacts_section.dart";
|
||||
import "package:photos/utils/navigation_util.dart";
|
||||
import "package:photos/utils/standalone/debouncer.dart";
|
||||
|
||||
@@ -50,6 +62,7 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
||||
late StreamSubscription<FavoritesServiceInitCompleteEvent>
|
||||
_favoritesServiceInitCompleteEvent;
|
||||
late StreamSubscription<AlbumSortOrderChangeEvent> _albumSortOrderChangeEvent;
|
||||
late StreamSubscription<TabChangedEvent> _tabChangeEvent;
|
||||
|
||||
String _loadReason = "init";
|
||||
final _scrollController = ScrollController();
|
||||
@@ -59,6 +72,16 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
||||
leading: true,
|
||||
);
|
||||
|
||||
// For shared collections functionality
|
||||
final _canLoadDeferredWidgets = ValueNotifier<bool>(false);
|
||||
final _debouncerForDeferringLoad = Debouncer(
|
||||
const Duration(milliseconds: 500),
|
||||
);
|
||||
static const heroTagPrefix = "outgoing_collection";
|
||||
static const maxThumbnailWidth = 224.0;
|
||||
static const crossAxisSpacing = 8.0;
|
||||
static const horizontalPadding = 16.0;
|
||||
|
||||
static const int _kOnEnteItemLimitCount = 12;
|
||||
@override
|
||||
void initState() {
|
||||
@@ -97,6 +120,27 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
||||
_loadReason = event.reason;
|
||||
setState(() {});
|
||||
});
|
||||
|
||||
_tabChangeEvent = Bus.instance.on<TabChangedEvent>().listen((event) {
|
||||
if (event.selectedIndex == 1) {
|
||||
_debouncerForDeferringLoad.run(() async {
|
||||
_logger.info("Loading deferred widgets in collections tab");
|
||||
if (mounted) {
|
||||
_canLoadDeferredWidgets.value = true;
|
||||
await _tabChangeEvent.cancel();
|
||||
Future.delayed(
|
||||
Duration.zero,
|
||||
() => _debouncerForDeferringLoad.cancelDebounceTimer(),
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
_debouncerForDeferringLoad.cancelDebounceTimer();
|
||||
if (mounted) {
|
||||
_canLoadDeferredWidgets.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -198,6 +242,8 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
||||
enableSelectionMode: true,
|
||||
)
|
||||
: const SliverToBoxAdapter(child: EmptyState()),
|
||||
// Shared Collections Content
|
||||
_buildSharedCollectionsSections(),
|
||||
SliverToBoxAdapter(
|
||||
child: Divider(
|
||||
color: getEnteColorScheme(context).strokeFaint,
|
||||
@@ -236,6 +282,251 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSharedCollectionsSections() {
|
||||
return FutureBuilder<SharedCollections>(
|
||||
future: Future.value(CollectionsService.instance.getSharedCollections()),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return _getSharedCollectionsContent(snapshot.data!);
|
||||
} else if (snapshot.hasError) {
|
||||
_logger.severe(
|
||||
"failed to load shared collections",
|
||||
snapshot.error,
|
||||
snapshot.stackTrace,
|
||||
);
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
} else {
|
||||
return const SliverToBoxAdapter(child: EnteLoadingWidget());
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getSharedCollectionsContent(SharedCollections collections) {
|
||||
const maxQuickLinks = 4;
|
||||
final numberOfQuickLinks = collections.quickLinks.length;
|
||||
final double screenWidth = MediaQuery.sizeOf(context).width;
|
||||
final int albumsCountInRow = max(screenWidth ~/ maxThumbnailWidth, 3);
|
||||
final totalHorizontalPadding = (albumsCountInRow - 1) * crossAxisSpacing;
|
||||
final sideOfThumbnail =
|
||||
(screenWidth - totalHorizontalPadding - horizontalPadding) /
|
||||
albumsCountInRow;
|
||||
const quickLinkTitleHeroTag = "quick_link_title";
|
||||
final SectionTitle sharedWithYou =
|
||||
SectionTitle(title: AppLocalizations.of(context).sharedWithYou);
|
||||
final SectionTitle sharedByYou =
|
||||
SectionTitle(title: AppLocalizations.of(context).sharedByYou);
|
||||
final colorTheme = getEnteColorScheme(context);
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
// Check if we have any shared collections content to show
|
||||
if (collections.incoming.isEmpty &&
|
||||
collections.outgoing.isEmpty &&
|
||||
numberOfQuickLinks == 0)
|
||||
const SizedBox.shrink()
|
||||
else ...[
|
||||
const SizedBox(height: 24),
|
||||
// Incoming Collections (Shared with you)
|
||||
if (collections.incoming.isNotEmpty) ...[
|
||||
SectionOptions(
|
||||
onTap: () {
|
||||
unawaited(
|
||||
routeToPage(
|
||||
context,
|
||||
CollectionListPage(
|
||||
collections.incoming,
|
||||
sectionType: UISectionType.incomingCollections,
|
||||
tag: "incoming",
|
||||
appTitle: sharedWithYou,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
Hero(tag: "incoming", child: sharedWithYou),
|
||||
trailingWidget: IconButtonWidget(
|
||||
icon: Icons.chevron_right,
|
||||
iconButtonType: IconButtonType.secondary,
|
||||
iconColor: colorTheme.blurStrokePressed,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
SizedBox(
|
||||
height: sideOfThumbnail + 46,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: horizontalPadding / 2,
|
||||
),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: horizontalPadding / 2,
|
||||
),
|
||||
child: AlbumRowItemWidget(
|
||||
collections.incoming[index],
|
||||
sideOfThumbnail,
|
||||
tag: "incoming",
|
||||
showFileCount: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: collections.incoming.length,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
// Outgoing Collections (Shared by you)
|
||||
if (collections.outgoing.isNotEmpty) ...[
|
||||
SectionOptions(
|
||||
onTap: () {
|
||||
unawaited(
|
||||
routeToPage(
|
||||
context,
|
||||
CollectionListPage(
|
||||
collections.outgoing,
|
||||
sectionType: UISectionType.outgoingCollections,
|
||||
tag: "outgoing",
|
||||
appTitle: sharedByYou,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
Hero(tag: "outgoing", child: sharedByYou),
|
||||
trailingWidget: IconButtonWidget(
|
||||
icon: Icons.chevron_right,
|
||||
iconButtonType: IconButtonType.secondary,
|
||||
iconColor: colorTheme.blurStrokePressed,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
SizedBox(
|
||||
height: sideOfThumbnail + 46,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: horizontalPadding / 2,
|
||||
),
|
||||
child: AlbumRowItemWidget(
|
||||
collections.outgoing[index],
|
||||
sideOfThumbnail,
|
||||
tag: "outgoing",
|
||||
showFileCount: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: collections.outgoing.length,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
// Quick Links
|
||||
if (numberOfQuickLinks > 0) ...[
|
||||
SectionOptions(
|
||||
onTap: numberOfQuickLinks > maxQuickLinks
|
||||
? () {
|
||||
unawaited(
|
||||
routeToPage(
|
||||
context,
|
||||
AllQuickLinksPage(
|
||||
titleHeroTag: quickLinkTitleHeroTag,
|
||||
quickLinks: collections.quickLinks,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
Hero(
|
||||
tag: quickLinkTitleHeroTag,
|
||||
child: SectionTitle(
|
||||
title: AppLocalizations.of(context).quickLinks,
|
||||
),
|
||||
),
|
||||
trailingWidget: numberOfQuickLinks > maxQuickLinks
|
||||
? IconButtonWidget(
|
||||
icon: Icons.chevron_right,
|
||||
iconButtonType: IconButtonType.secondary,
|
||||
iconColor: colorTheme.blurStrokePressed,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 12,
|
||||
left: 12,
|
||||
right: 12,
|
||||
),
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) {
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
final thumbnail = await CollectionsService
|
||||
.instance
|
||||
.getCover(collections.quickLinks[index]);
|
||||
final page = CollectionPage(
|
||||
CollectionWithThumbnail(
|
||||
collections.quickLinks[index],
|
||||
thumbnail,
|
||||
),
|
||||
tagPrefix: heroTagPrefix,
|
||||
);
|
||||
// ignore: unawaited_futures
|
||||
routeToPage(context, page);
|
||||
},
|
||||
child: QuickLinkAlbumItem(
|
||||
c: collections.quickLinks[index],
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) {
|
||||
return const SizedBox(height: 4);
|
||||
},
|
||||
itemCount: min(numberOfQuickLinks, maxQuickLinks),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
// Contacts Section (deferred loading)
|
||||
ValueListenableBuilder(
|
||||
valueListenable: _canLoadDeferredWidgets,
|
||||
builder: (context, value, _) {
|
||||
return value
|
||||
? FutureBuilder(
|
||||
future: SearchService.instance
|
||||
.getAllContactsSearchResults(kSearchSectionLimit),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ContactsSection(
|
||||
snapshot.data as List<GenericSearchResult>,
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
_logger.severe(
|
||||
"failed to load contacts section",
|
||||
snapshot.error,
|
||||
snapshot.stackTrace,
|
||||
);
|
||||
return const SizedBox.shrink();
|
||||
} else {
|
||||
return const EnteLoadingWidget();
|
||||
}
|
||||
},
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
const CollectPhotosCardWidget(),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_localFilesSubscription.cancel();
|
||||
@@ -245,6 +536,9 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
||||
_scrollController.dispose();
|
||||
_debouncer.cancelDebounceTimer();
|
||||
_albumSortOrderChangeEvent.cancel();
|
||||
_tabChangeEvent.cancel();
|
||||
_debouncerForDeferringLoad.cancelDebounceTimer();
|
||||
_canLoadDeferredWidgets.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -53,11 +53,11 @@ const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600);
|
||||
/// {@end-tool}
|
||||
///
|
||||
/// A scrollbar track can be added using [trackVisibility]. This can also be
|
||||
/// drawn when triggered by a hover event, or based on any [MaterialState] by
|
||||
/// drawn when triggered by a hover event, or based on any [WidgetState] by
|
||||
/// using [ScrollbarThemeData.trackVisibility].
|
||||
///
|
||||
/// The [thickness] of the track and scrollbar thumb can be changed dynamically
|
||||
/// in response to [MaterialState]s using [ScrollbarThemeData.thickness].
|
||||
/// in response to [WidgetState]s using [ScrollbarThemeData.thickness].
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user