diff --git a/docs/pubspec.yaml b/docs/pubspec.yaml index c359855e..b29adc09 100644 --- a/docs/pubspec.yaml +++ b/docs/pubspec.yaml @@ -7,5 +7,4 @@ dev_dependencies: flutter: sdk: flutter flutter_state_notifier: ^1.0.0 - stream_feeds: - path: ../packages/stream_feeds + stream_feeds: ^0.1.0 diff --git a/melos.yaml b/melos.yaml index e0540a46..f2f49b88 100644 --- a/melos.yaml +++ b/melos.yaml @@ -22,6 +22,7 @@ command: auto_route: ^10.0.0 cached_network_image: ^3.4.1 collection: ^1.18.0 + chewie: ^1.11.3 dio: ^5.9.0 equatable: ^2.0.5 flutter_state_notifier: ^1.0.0 @@ -35,6 +36,7 @@ command: http: ^1.1.0 intl: ">=0.18.1 <=0.21.0" jiffy: ^6.3.2 + photo_view: ^0.15.0 json_annotation: ^4.9.0 meta: ^1.9.1 retrofit: ^4.6.0 @@ -42,6 +44,7 @@ command: shared_preferences: ^2.5.3 state_notifier: ^1.0.0 stream_core: ^0.1.0 + video_player: ^2.10.0 uuid: ^4.5.1 # List of all the dev_dependencies used in the project. diff --git a/pubspec.lock b/pubspec.lock index e829c7a1..7d723c79 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -157,10 +157,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mustache_template: dependency: transitive description: diff --git a/sample_app/analysis_options.yaml b/sample_app/analysis_options.yaml index 84b4cef9..a7af1156 100644 --- a/sample_app/analysis_options.yaml +++ b/sample_app/analysis_options.yaml @@ -1,5 +1,11 @@ include: ../analysis_options.yaml +analyzer: + # TODO: not working if added on the root analysis file + exclude: + # exclude all the generated files + - lib/**/*.*.dart + linter: rules: cascade_invocations: false diff --git a/sample_app/android/app/build.gradle.kts b/sample_app/android/app/build.gradle.kts index 93d2249f..ee3b74ca 100644 --- a/sample_app/android/app/build.gradle.kts +++ b/sample_app/android/app/build.gradle.kts @@ -8,11 +8,12 @@ plugins { android { namespace = "io.getstream.feeds.flutter.sample" compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion + ndkVersion = "27.0.12077973" compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 + isCoreLibraryDesugaringEnabled = true } kotlinOptions { @@ -20,10 +21,7 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "io.getstream.feeds.flutter.sample" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode @@ -42,3 +40,7 @@ android { flutter { source = "../.." } + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") +} \ No newline at end of file diff --git a/sample_app/android/settings.gradle.kts b/sample_app/android/settings.gradle.kts index a439442c..11662c30 100644 --- a/sample_app/android/settings.gradle.kts +++ b/sample_app/android/settings.gradle.kts @@ -19,7 +19,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.7.0" apply false - id("org.jetbrains.kotlin.android") version "1.8.22" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false } include(":app") diff --git a/sample_app/lib/navigation/app_router.dart b/sample_app/lib/navigation/app_router.dart index 6243e872..79d20303 100644 --- a/sample_app/lib/navigation/app_router.dart +++ b/sample_app/lib/navigation/app_router.dart @@ -1,10 +1,14 @@ import 'package:auto_route/auto_route.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; import 'package:injectable/injectable.dart'; +import 'package:stream_feeds/stream_feeds.dart'; import '../screens/choose_user/choose_user_screen.dart'; import '../screens/home/home_screen.dart'; - import '../screens/user_feed/user_feed_screen.dart'; +import '../widgets/attachment_gallery/attachment_gallery.dart'; +import '../widgets/attachment_gallery/attachment_metadata.dart'; import 'guards/auth_guard.dart'; part 'app_router.gr.dart'; @@ -41,6 +45,36 @@ class AppRouter extends RootStackRouter { page: ChooseUserRoute.page, keepHistory: false, ), + AutoRoute( + path: '/attachment_gallery', + page: AttachmentGalleryRoute.page, + fullscreenDialog: true, + guards: [_authGuard], + ), ]; } } + +/// Shell route for attachment gallery +@RoutePage() +class AttachmentGalleryPage extends StatelessWidget { + const AttachmentGalleryPage({ + super.key, + required this.attachments, + required this.metadata, + this.initialIndex = 0, + }); + + final List attachments; + final AttachmentMetadata metadata; + final int initialIndex; + + @override + Widget build(BuildContext context) { + return AttachmentGallery( + attachments: attachments, + metadata: metadata, + initialIndex: initialIndex, + ); + } +} diff --git a/sample_app/lib/navigation/app_router.gr.dart b/sample_app/lib/navigation/app_router.gr.dart index 15d3e3e9..b94fca89 100644 --- a/sample_app/lib/navigation/app_router.gr.dart +++ b/sample_app/lib/navigation/app_router.gr.dart @@ -10,6 +10,81 @@ part of 'app_router.dart'; +/// generated route for +/// [AttachmentGalleryPage] +class AttachmentGalleryRoute extends PageRouteInfo { + AttachmentGalleryRoute({ + Key? key, + required List attachments, + required AttachmentMetadata metadata, + int initialIndex = 0, + List? children, + }) : super( + AttachmentGalleryRoute.name, + args: AttachmentGalleryRouteArgs( + key: key, + attachments: attachments, + metadata: metadata, + initialIndex: initialIndex, + ), + initialChildren: children, + ); + + static const String name = 'AttachmentGalleryRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return AttachmentGalleryPage( + key: args.key, + attachments: args.attachments, + metadata: args.metadata, + initialIndex: args.initialIndex, + ); + }, + ); +} + +class AttachmentGalleryRouteArgs { + const AttachmentGalleryRouteArgs({ + this.key, + required this.attachments, + required this.metadata, + this.initialIndex = 0, + }); + + final Key? key; + + final List attachments; + + final AttachmentMetadata metadata; + + final int initialIndex; + + @override + String toString() { + return 'AttachmentGalleryRouteArgs{key: $key, attachments: $attachments, metadata: $metadata, initialIndex: $initialIndex}'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! AttachmentGalleryRouteArgs) return false; + return key == other.key && + const ListEquality().equals(attachments, other.attachments) && + metadata == other.metadata && + initialIndex == other.initialIndex; + } + + @override + int get hashCode => + key.hashCode ^ + const ListEquality().hash(attachments) ^ + metadata.hashCode ^ + initialIndex.hashCode; +} + /// generated route for /// [ChooseUserScreen] class ChooseUserRoute extends PageRouteInfo { diff --git a/sample_app/lib/screens/user_feed/feed/user_feed_item.dart b/sample_app/lib/screens/user_feed/feed/user_feed_item.dart index 5f71ca8c..06205b50 100644 --- a/sample_app/lib/screens/user_feed/feed/user_feed_item.dart +++ b/sample_app/lib/screens/user_feed/feed/user_feed_item.dart @@ -1,9 +1,12 @@ +import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:stream_feeds/stream_feeds.dart'; +import '../../../navigation/app_router.dart'; import '../../../theme/extensions/theme_extensions.dart'; import '../../../utils/date_time_extensions.dart'; import '../../../widgets/action_button.dart'; +import '../../../widgets/attachment_gallery/attachment_metadata.dart'; import '../../../widgets/attachments/attachments.dart'; import '../../../widgets/user_avatar.dart'; @@ -137,7 +140,19 @@ class _ActivityBody extends StatelessWidget { AttachmentGrid( attachments: attachments, onAttachmentTap: (attachment) { - // TODO: Implement fullscreen attachment view + final initialIndex = attachments.indexOf(attachment); + + context.pushRoute( + AttachmentGalleryRoute( + attachments: attachments, + initialIndex: initialIndex >= 0 ? initialIndex : 0, + metadata: AttachmentMetadata( + author: data.user, + createdAt: data.createdAt, + caption: data.text, + ), + ), + ); }, ), ], diff --git a/sample_app/lib/widgets/attachment_gallery/attachment_gallery.dart b/sample_app/lib/widgets/attachment_gallery/attachment_gallery.dart new file mode 100644 index 00000000..82185c47 --- /dev/null +++ b/sample_app/lib/widgets/attachment_gallery/attachment_gallery.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../theme/theme.dart'; +import 'attachment_gallery_chrome.dart'; +import 'attachment_gallery_item.dart'; +import 'attachment_metadata.dart'; + +class AttachmentGallery extends StatefulWidget { + const AttachmentGallery({ + super.key, + this.initialIndex = 0, + required this.attachments, + required this.metadata, + }) : assert(initialIndex >= 0, 'Initial index must be non-negative'); + + final int initialIndex; + final List attachments; + final AttachmentMetadata metadata; + + @override + State createState() => _AttachmentGalleryState(); +} + +class _AttachmentGalleryState extends State { + late final PageController _pageController; + late final _currentPage = ValueNotifier(widget.initialIndex); + + late final _isDisplayingDetail = ValueNotifier(true); + void switchDisplayingDetail() { + _isDisplayingDetail.value = !_isDisplayingDetail.value; + } + + @override + void initState() { + super.initState(); + _pageController = PageController(initialPage: _currentPage.value); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: ValueListenableBuilder( + valueListenable: _currentPage, + builder: (context, currentPage, child) { + return Stack( + children: [ + if (child case final child?) child, + // Chrome overlay + ValueListenableBuilder( + valueListenable: _isDisplayingDetail, + builder: (context, isVisible, child) { + return AttachmentGalleryChrome( + isVisible: isVisible, + metadata: widget.metadata, + currentAttachment: widget.attachments[currentPage], + currentIndex: currentPage + 1, + totalCount: widget.attachments.length, + ); + }, + ), + ], + ); + }, + child: InkWell( + onTap: switchDisplayingDetail, + child: PageView.builder( + controller: _pageController, + itemCount: widget.attachments.length, + onPageChanged: (page) => _currentPage.value = page, + itemBuilder: (context, index) { + final attachment = widget.attachments[index]; + return ValueListenableBuilder( + valueListenable: _isDisplayingDetail, + builder: (context, displayingDetail, child) { + final padding = MediaQuery.paddingOf(context); + + return AnimatedContainer( + duration: kThemeAnimationDuration, + color: switch (displayingDetail) { + true => context.appColors.appBg, + false => AppColorTokens.black, + }, + padding: EdgeInsetsDirectional.only( + top: padding.top + kToolbarHeight, + bottom: padding.bottom + kToolbarHeight, + ), + child: child, + ); + }, + child: AttachmentGalleryItem(attachment: attachment), + ); + }, + ), + ), + ), + ); + } +} diff --git a/sample_app/lib/widgets/attachment_gallery/attachment_gallery_chrome.dart b/sample_app/lib/widgets/attachment_gallery/attachment_gallery_chrome.dart new file mode 100644 index 00000000..5513f5f0 --- /dev/null +++ b/sample_app/lib/widgets/attachment_gallery/attachment_gallery_chrome.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import 'attachment_gallery_footer.dart'; +import 'attachment_gallery_header.dart'; +import 'attachment_metadata.dart'; + +/// Chrome overlay for the attachment gallery containing header and footer. +/// +/// Displays the header with author info and timestamp at the top, and the footer +/// with attachment index at the bottom. Both elements animate in opposite directions +/// when visibility changes. +class AttachmentGalleryChrome extends StatelessWidget { + const AttachmentGalleryChrome({ + super.key, + required this.isVisible, + required this.metadata, + required this.currentAttachment, + required this.currentIndex, + required this.totalCount, + }); + + /// Whether the chrome elements should be visible. + final bool isVisible; + + /// Metadata containing author and timestamp information. + final AttachmentMetadata metadata; + + /// The currently displayed attachment. + final Attachment currentAttachment; + + /// The current attachment index (1-based). + final int currentIndex; + + /// The total number of attachments. + final int totalCount; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Top chrome - slides up when hidden + AnimatedSlide( + offset: isVisible ? Offset.zero : const Offset(0, -1), + duration: kThemeAnimationDuration, + curve: Curves.easeInOut, + child: AnimatedOpacity( + opacity: isVisible ? 1.0 : 0.0, + duration: kThemeAnimationDuration, + curve: Curves.easeInOut, + child: AttachmentGalleryHeader( + metadata: metadata, + currentAttachment: currentAttachment, + ), + ), + ), + // Bottom chrome - slides down when hidden + AnimatedSlide( + offset: isVisible ? Offset.zero : const Offset(0, 1), + duration: kThemeAnimationDuration, + curve: Curves.easeInOut, + child: AnimatedOpacity( + opacity: isVisible ? 1.0 : 0.0, + duration: kThemeAnimationDuration, + curve: Curves.easeInOut, + child: AttachmentGalleryFooter( + currentIndex: currentIndex, + totalCount: totalCount, + ), + ), + ), + ], + ); + } +} diff --git a/sample_app/lib/widgets/attachment_gallery/attachment_gallery_footer.dart b/sample_app/lib/widgets/attachment_gallery/attachment_gallery_footer.dart new file mode 100644 index 00000000..08249a8a --- /dev/null +++ b/sample_app/lib/widgets/attachment_gallery/attachment_gallery_footer.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +import '../../theme/extensions/theme_extensions.dart'; + +class AttachmentGalleryFooter extends StatelessWidget { + const AttachmentGalleryFooter({ + super.key, + required this.currentIndex, + required this.totalCount, + }); + + final int currentIndex; + final int totalCount; + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: SizedBox( + height: kToolbarHeight, + child: Center( + child: Chip( + label: Text('$currentIndex / $totalCount'), + labelStyle: context.appTextStyles.body, + backgroundColor: context.appColors.barsBg, + avatar: const Icon(Icons.photo_library_outlined), + iconTheme: IconThemeData(color: context.appColors.textHighEmphasis), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide(color: context.appColors.borders), + ), + ), + ), + ), + ); + } +} diff --git a/sample_app/lib/widgets/attachment_gallery/attachment_gallery_header.dart b/sample_app/lib/widgets/attachment_gallery/attachment_gallery_header.dart new file mode 100644 index 00000000..b698414a --- /dev/null +++ b/sample_app/lib/widgets/attachment_gallery/attachment_gallery_header.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../theme/extensions/theme_extensions.dart'; +import '../../utils/date_time_extensions.dart'; +import 'attachment_metadata.dart'; + +class AttachmentGalleryHeader extends StatelessWidget { + const AttachmentGalleryHeader({ + super.key, + required this.metadata, + required this.currentAttachment, + }); + + final AttachmentMetadata metadata; + final Attachment currentAttachment; + + @override + Widget build(BuildContext context) { + return AppBar( + centerTitle: true, + backgroundColor: context.appColors.barsBg, + title: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + metadata.author.name ?? 'Unknown User', + style: context.appTextStyles.headlineBold, + ), + Text( + metadata.createdAt.displayRelativeTime, + style: context.appTextStyles.footnote.copyWith( + color: context.appColors.textLowEmphasis, + ), + ), + ], + ), + ); + } +} diff --git a/sample_app/lib/widgets/attachment_gallery/attachment_gallery_item.dart b/sample_app/lib/widgets/attachment_gallery/attachment_gallery_item.dart new file mode 100644 index 00000000..19823360 --- /dev/null +++ b/sample_app/lib/widgets/attachment_gallery/attachment_gallery_item.dart @@ -0,0 +1,183 @@ +import 'dart:async'; + +import 'package:chewie/chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:video_player/video_player.dart'; + +import '../../theme/theme.dart'; + +class AttachmentGalleryItem extends StatelessWidget { + const AttachmentGalleryItem({ + super.key, + required this.attachment, + }); + + final Attachment attachment; + + @override + Widget build(BuildContext context) { + return switch (attachment.type) { + AttachmentType.image => PhotoItem(photo: attachment), + AttachmentType.giphy => PhotoItem(photo: attachment), + AttachmentType.video => VideoItem(video: attachment), + _ => const UnsupportedItem(), + }; + } +} + +class PhotoItem extends StatelessWidget { + const PhotoItem({super.key, required this.photo}); + + final Attachment photo; + + @override + Widget build(BuildContext context) { + final photoUrl = photo.imageUrl; + + if (photoUrl == null) return const ErrorItem(); + + return PhotoView( + imageProvider: NetworkImage(photoUrl), + maxScale: PhotoViewComputedScale.covered, + minScale: PhotoViewComputedScale.contained, + errorBuilder: (_, __, ___) => const ErrorItem(), + backgroundDecoration: const BoxDecoration(color: Colors.transparent), + ); + } +} + +class VideoItem extends StatefulWidget { + const VideoItem({super.key, required this.video}); + + final Attachment video; + + @override + State createState() => _VideoItemState(); +} + +class _VideoItemState extends State { + late final VideoPlayerController _videoPlayerController; + + ChewieController? _chewieController; + Future _initializeController() async { + await _videoPlayerController.initialize(); + _chewieController = ChewieController( + zoomAndPan: true, + showOptions: false, + videoPlayerController: _videoPlayerController, + ); + + if (mounted) setState(() {}); + } + + @override + void initState() { + super.initState(); + + final videoUrl = Uri.parse(widget.video.assetUrl ?? ''); + _videoPlayerController = VideoPlayerController.networkUrl(videoUrl); + + _initializeController(); + } + + @override + void dispose() { + _chewieController?.dispose(); + _videoPlayerController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_chewieController case final controller?) { + return Center( + child: AspectRatio( + aspectRatio: _videoPlayerController.value.aspectRatio, + child: Chewie(controller: controller), + ), + ); + } + + return const Center(child: CircularProgressIndicator.adaptive()); + } +} + +class UnsupportedItem extends StatelessWidget { + const UnsupportedItem({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + height: 300, + decoration: BoxDecoration( + color: context.appColors.disabled, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + size: 64, + Icons.attachment, + color: context.appColors.textHighEmphasis, + ), + const SizedBox(height: 16), + Text( + 'Unsupported Format', + style: context.appTextStyles.headlineBold, + ), + const SizedBox(height: 8), + Text( + 'This attachment type is not supported in gallery view', + textAlign: TextAlign.center, + style: context.appTextStyles.footnote.copyWith( + color: context.appColors.textLowEmphasis, + ), + ), + ], + ), + ); + } +} + +class ErrorItem extends StatelessWidget { + const ErrorItem({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + height: 300, + decoration: BoxDecoration( + color: context.appColors.disabled, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + size: 64, + Icons.error_outline, + color: context.appColors.textHighEmphasis, + ), + const SizedBox(height: 16), + Text( + 'Error Loading Attachment', + style: context.appTextStyles.headlineBold, + ), + const SizedBox(height: 8), + Text( + 'There was an error loading this attachment', + textAlign: TextAlign.center, + style: context.appTextStyles.footnote.copyWith( + color: context.appColors.textLowEmphasis, + ), + ), + ], + ), + ); + } +} diff --git a/sample_app/lib/widgets/attachment_gallery/attachment_metadata.dart b/sample_app/lib/widgets/attachment_gallery/attachment_metadata.dart new file mode 100644 index 00000000..d698e8b7 --- /dev/null +++ b/sample_app/lib/widgets/attachment_gallery/attachment_metadata.dart @@ -0,0 +1,23 @@ +import 'package:stream_feeds/stream_feeds.dart'; + +/// Metadata for displaying attachments in the gallery. +/// +/// Encapsulates all the necessary information for displaying attachment details +/// including author information, timestamps, and content context without coupling +/// the gallery to specific domain models. +class AttachmentMetadata { + const AttachmentMetadata({ + required this.author, + required this.createdAt, + this.caption, + }); + + /// The author of the content. + final UserData author; + + /// When the content was created. + final DateTime createdAt; + + /// Optional text caption associated with the attachments. + final String? caption; +} diff --git a/sample_app/pubspec.yaml b/sample_app/pubspec.yaml index 8315d48b..310e2c5d 100644 --- a/sample_app/pubspec.yaml +++ b/sample_app/pubspec.yaml @@ -10,6 +10,7 @@ environment: dependencies: auto_route: ^10.0.0 cached_network_image: ^3.4.1 + chewie: ^1.11.3 collection: ^1.18.0 file_picker: ^8.1.4 flex_color_scheme: ^8.1.1 @@ -22,9 +23,10 @@ dependencies: google_fonts: ^6.3.0 injectable: ^2.5.1 jiffy: ^6.3.2 + photo_view: ^0.15.0 shared_preferences: ^2.5.3 - stream_feeds: - path: ../packages/stream_feeds + stream_feeds: ^0.1.0 + video_player: ^2.10.0 dev_dependencies: auto_route_generator: ^10.0.0