From 690d9072049ce3b4692924b37af45e035ac15e6c Mon Sep 17 00:00:00 2001 From: Pratik-canopas Date: Fri, 12 Apr 2024 17:03:30 +0530 Subject: [PATCH] Implement media metadata details screen --- app/assets/images/icons/google-drive.svg | 8 + app/assets/images/icons/google_photos.svg | 6 - app/assets/locales/app_en.arb | 18 +- app/lib/components/thumbnail_builder.dart | 119 ++++++++++++ app/lib/domain/assets/assets_paths.dart | 2 +- .../flow/home/components/app_media_item.dart | 69 +------ .../multi_selection_done_button.dart | 2 +- .../media_metadata_details.dart | 175 ++++++++++++++++++ .../components/download_require_view.dart | 2 +- .../media_preview/components/top_bar.dart | 7 +- .../components/transfer_item.dart | 114 ++---------- app/lib/ui/navigation/app_router.dart | 15 ++ 12 files changed, 365 insertions(+), 172 deletions(-) create mode 100644 app/assets/images/icons/google-drive.svg delete mode 100644 app/assets/images/icons/google_photos.svg create mode 100644 app/lib/components/thumbnail_builder.dart create mode 100644 app/lib/ui/flow/media_metadata_details/media_metadata_details.dart diff --git a/app/assets/images/icons/google-drive.svg b/app/assets/images/icons/google-drive.svg new file mode 100644 index 0000000..987eb2c --- /dev/null +++ b/app/assets/images/icons/google-drive.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/assets/images/icons/google_photos.svg b/app/assets/images/icons/google_photos.svg deleted file mode 100644 index 44b3fb3..0000000 --- a/app/assets/images/icons/google_photos.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/assets/locales/app_en.arb b/app/assets/locales/app_en.arb index 81d8549..c467ea3 100644 --- a/app/assets/locales/app_en.arb +++ b/app/assets/locales/app_en.arb @@ -20,7 +20,7 @@ "common_delete_from_google_drive": "Delete from Google Drive", "common_delete_from_device": "Delete from Device", "common_cancel": "Cancel", - + "common_not_available": "N/A", "no_internet_connection_error": "No internet connection! Please check your network and try again.", "something_went_wrong_error": "Something went wrong! Please try again later.", @@ -71,6 +71,18 @@ "download_in_progress_text": "Download in progress", "download_require_text": "Download required", - "download_require_message": "To watch the video, simply download it first. Tap the download button to begin downloading the video." - + "download_require_message": "To watch the video, simply download it first. Tap the download button to begin downloading the video.", + + "name_text": "Name", + "size_text": "Size", + "created_at_text": "Created at", + "modified_at_text": "Modified at", + "mimetype_text": "MIME Type", + "duration_text": "Duration", + "location_text": "Location", + "resolution_text": "Resolution", + "orientation_text": "Orientation", + "path_text": "Path", + "display_size_text": "Display Size", + "source_text": "Source" } \ No newline at end of file diff --git a/app/lib/components/thumbnail_builder.dart b/app/lib/components/thumbnail_builder.dart new file mode 100644 index 0000000..0c34678 --- /dev/null +++ b/app/lib/components/thumbnail_builder.dart @@ -0,0 +1,119 @@ +import 'dart:typed_data'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:data/models/media/media.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/indicators/circular_progress_indicator.dart'; + +class AppMediaThumbnail extends StatelessWidget { + final Object? heroTag; + final AppMedia media; + final Size size; + final double radius; + final Future? thumbnailByte; + + const AppMediaThumbnail({ + super.key, + required this.size, + this.heroTag, + this.radius = 4, + required this.thumbnailByte, + required this.media, + }); + + @override + Widget build(BuildContext context) { + if (media.sources.contains(AppMediaSource.local)) { + return FutureBuilder( + future: thumbnailByte, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done && + snapshot.hasData) { + return ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: Hero( + tag: heroTag ?? '', + child: Image.memory( + snapshot.data!, + height: size.height, + width: size.width, + fit: BoxFit.cover, + ), + ), + ); + } else if (snapshot.hasError) { + return AppMediaErrorPlaceHolder( + size: size, + ); + } else { + return AppMediaPlaceHolder( + showLoader: false, + size: size, + ); + } + }, + ); + } else { + return Hero( + tag: heroTag ?? '', + child: ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: CachedNetworkImage( + imageUrl: media.thumbnailLink ?? '', + width: size.width, + height: size.height, + fit: BoxFit.cover, + errorWidget: (context, url, error) => AppMediaErrorPlaceHolder( + size: size, + ), + progressIndicatorBuilder: (context, url, progress) => + AppMediaPlaceHolder( + size: size, + value: progress.progress, + )), + ), + ); + } + } +} + +class AppMediaPlaceHolder extends StatelessWidget { + final double? value; + final Size? size; + final bool showLoader; + + const AppMediaPlaceHolder( + {super.key, this.value, this.showLoader = true, this.size}); + + @override + Widget build(BuildContext context) { + return Container( + height: size?.height, + width: size?.width, + color: context.colorScheme.containerHighOnSurface, + alignment: Alignment.center, + child: showLoader ? AppCircularProgressIndicator(value: value) : null, + ); + } +} + +class AppMediaErrorPlaceHolder extends StatelessWidget { + final Size? size; + + const AppMediaErrorPlaceHolder({super.key, this.size}); + + @override + Widget build(BuildContext context) { + return Container( + height: size?.height, + width: size?.width, + color: context.colorScheme.containerNormalOnSurface, + alignment: Alignment.center, + child: Icon( + CupertinoIcons.exclamationmark_circle, + color: context.colorScheme.onPrimary, + size: 32, + ), + ); + } +} diff --git a/app/lib/domain/assets/assets_paths.dart b/app/lib/domain/assets/assets_paths.dart index c4bd56a..332d3d7 100644 --- a/app/lib/domain/assets/assets_paths.dart +++ b/app/lib/domain/assets/assets_paths.dart @@ -9,5 +9,5 @@ class PathImages { } class PathIcons { - String get googlePhotos => 'assets/images/icons/google_photos.svg'; + String get googleDrive => 'assets/images/icons/google-drive.svg'; } diff --git a/app/lib/ui/flow/home/components/app_media_item.dart b/app/lib/ui/flow/home/components/app_media_item.dart index 12efc36..4cd07c9 100644 --- a/app/lib/ui/flow/home/components/app_media_item.dart +++ b/app/lib/ui/flow/home/components/app_media_item.dart @@ -1,6 +1,5 @@ -import 'dart:async'; import 'dart:typed_data'; -import 'package:cached_network_image/cached_network_image.dart'; +import 'package:cloud_gallery/components/thumbnail_builder.dart'; import 'package:cloud_gallery/domain/formatter/duration_formatter.dart'; import 'package:data/models/app_process/app_process.dart'; import 'package:data/models/media/media.dart'; @@ -35,7 +34,7 @@ class AppMediaItem extends StatefulWidget { class _AppMediaItemState extends State with AutomaticKeepAliveClientMixin { - late Future thumbnailByte; + Future? thumbnailByte; @override void initState() { @@ -93,64 +92,14 @@ class _AppMediaItemState extends State Widget _buildMediaView( {required BuildContext context, required BoxConstraints constraints}) { - if (widget.media.sources.contains(AppMediaSource.local)) { - return FutureBuilder( - future: thumbnailByte, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done && - snapshot.hasData) { - return Hero( - tag: widget.media, - child: Image.memory( - snapshot.data!, - width: constraints.maxWidth, - height: constraints.maxHeight, - fit: BoxFit.cover, - ), - ); - } else { - return _buildPlaceholder(context: context, showLoader: false); - } - }, - ); - } else { - return Hero( - tag: widget.media, - child: CachedNetworkImage( - imageUrl: widget.media.thumbnailLink ?? '', - width: constraints.maxWidth, - height: constraints.maxHeight, - fit: BoxFit.cover, - errorWidget: (context, url, error) => _buildErrorWidget(context), - progressIndicatorBuilder: (context, url, progress) => - _buildPlaceholder( - context: context, - value: progress.progress, - )), - ); - } + return AppMediaThumbnail( + size: constraints.biggest, + thumbnailByte: thumbnailByte, + media: widget.media, + heroTag: widget.media, + ); } - Widget _buildPlaceholder( - {required BuildContext context, - double? value, - bool showLoader = true}) => - Container( - color: context.colorScheme.containerHighOnSurface, - alignment: Alignment.center, - child: showLoader ? AppCircularProgressIndicator(value: value) : null, - ); - - Widget _buildErrorWidget(BuildContext context) => Container( - color: context.colorScheme.containerNormalOnSurface, - alignment: Alignment.center, - child: Icon( - CupertinoIcons.exclamationmark_circle, - color: context.colorScheme.onPrimary, - size: 32, - ), - ); - Widget _sourceIndicators({required BuildContext context}) { return Row( children: [ @@ -161,7 +110,7 @@ class _AppMediaItemState extends State children: [ if (widget.media.sources.contains(AppMediaSource.googleDrive)) SvgPicture.asset( - Assets.images.icons.googlePhotos, + Assets.images.icons.googleDrive, height: 14, width: 14, ), diff --git a/app/lib/ui/flow/home/components/multi_selection_done_button.dart b/app/lib/ui/flow/home/components/multi_selection_done_button.dart index f806095..32909b8 100644 --- a/app/lib/ui/flow/home/components/multi_selection_done_button.dart +++ b/app/lib/ui/flow/home/components/multi_selection_done_button.dart @@ -39,7 +39,7 @@ class MultiSelectionDoneButton extends ConsumerWidget { if (showUploadToDriveButton) AppSheetAction( icon: SvgPicture.asset( - Assets.images.icons.googlePhotos, + Assets.images.icons.googleDrive, height: 24, width: 24, ), diff --git a/app/lib/ui/flow/media_metadata_details/media_metadata_details.dart b/app/lib/ui/flow/media_metadata_details/media_metadata_details.dart new file mode 100644 index 0000000..8f53390 --- /dev/null +++ b/app/lib/ui/flow/media_metadata_details/media_metadata_details.dart @@ -0,0 +1,175 @@ +import 'dart:typed_data'; +import 'package:cloud_gallery/components/app_page.dart'; +import 'package:cloud_gallery/components/thumbnail_builder.dart'; +import 'package:cloud_gallery/domain/assets/assets_paths.dart'; +import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; +import 'package:cloud_gallery/domain/formatter/byte_formatter.dart'; +import 'package:cloud_gallery/domain/formatter/date_formatter.dart'; +import 'package:cloud_gallery/domain/formatter/duration_formatter.dart'; +import 'package:data/models/media/media.dart'; +import 'package:data/models/media/media_extension.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/text/app_text_style.dart'; + +class MediaMetadataDetailsScreen extends StatefulWidget { + final AppMedia media; + + const MediaMetadataDetailsScreen({super.key, required this.media}); + + @override + State createState() => + _MediaMetadataDetailsScreenState(); +} + +class _MediaMetadataDetailsScreenState + extends State { + Future? thumbnailByte; + + @override + void initState() { + if (widget.media.sources.contains(AppMediaSource.local)) { + thumbnailByte = widget.media.loadThumbnail(); + } + super.initState(); + } + + @override + Widget build(BuildContext context) { + return AppPage( + title: '', + body: Builder(builder: (context) { + return Material( + color: Colors.transparent, + child: ListView( + padding: context.systemPadding, + children: [ + Stack( + alignment: Alignment.center, + children: [ + AppMediaThumbnail( + size: Size(context.mediaQuerySize.width, 200), + thumbnailByte: thumbnailByte, + media: widget.media, + radius: 0, + ), + if (widget.media.type.isVideo) + Icon(Icons.play_arrow_rounded, + size: 50, color: context.colorScheme.onPrimary), + ], + ), + const SizedBox(height: 16), + DetailsTile( + title: context.l10n.name_text, + subtitle: (widget.media.name?.trim().isNotEmpty ?? false) + ? widget.media.name! + : context.l10n.common_not_available, + ), + DetailsTile( + title: context.l10n.path_text, + subtitle: widget.media.path, + ), + DetailsTile( + title: context.l10n.created_at_text, + subtitle: widget.media.createdTime == null + ? context.l10n.common_not_available + : "${widget.media.createdTime?.format(context, DateFormatType.dayMonthYear)}, ${widget.media.createdTime?.format(context, DateFormatType.time)}", + ), + DetailsTile( + title: context.l10n.modified_at_text, + subtitle: widget.media.modifiedTime == null + ? context.l10n.common_not_available + : "${widget.media.modifiedTime?.format(context, DateFormatType.dayMonthYear)}, ${widget.media.modifiedTime?.format(context, DateFormatType.time)}", + ), + DetailsTile( + title: context.l10n.mimetype_text, + subtitle: widget.media.mimeType ?? + context.l10n.common_not_available, + ), + DetailsTile( + title: context.l10n.size_text, + subtitle: + int.tryParse(widget.media.size ?? '')?.formatBytes ?? + context.l10n.common_not_available, + ), + if (widget.media.type.isVideo) + DetailsTile( + title: context.l10n.duration_text, + subtitle: widget.media.videoDuration?.format ?? + context.l10n.common_not_available, + ), + DetailsTile( + title: context.l10n.location_text, + subtitle: widget.media.latitude == null || + widget.media.longitude == null + ? context.l10n.common_not_available + : '${widget.media.latitude}, ${widget.media.longitude}', + ), + DetailsTile( + title: context.l10n.orientation_text, + subtitle: widget.media.orientation?.name ?? 'N/A', + ), + DetailsTile( + title: context.l10n.resolution_text, + subtitle: widget.media.displayHeight == null || + widget.media.displayWidth == null + ? context.l10n.common_not_available + : '${widget.media.displayWidth?.toInt()} x ${widget.media.displayHeight?.toInt()}', + ), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + dense: true, + title: Text( + context.l10n.source_text, + style: AppTextStyles.body.copyWith( + color: context.colorScheme.textPrimary, + ), + ), + subtitle: Row( + children: [ + if (widget.media.sources.contains(AppMediaSource.local)) + Icon(Icons.phone_android_rounded, + color: context.colorScheme.textSecondary, + size: 20), + if (widget.media.sources + .contains(AppMediaSource.googleDrive)) + SvgPicture.asset( + Assets.images.icons.googleDrive, + width: 20, + ) + ], + )) + ], + ), + ); + })); + } +} + +class DetailsTile extends StatelessWidget { + final String title; + final String subtitle; + + const DetailsTile({super.key, required this.title, required this.subtitle}); + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + dense: true, + title: Text( + title, + style: AppTextStyles.body.copyWith( + color: context.colorScheme.textPrimary, + ), + ), + subtitle: Text( + subtitle, + style: AppTextStyles.body2.copyWith( + color: context.colorScheme.textSecondary, + ), + ), + ); + } +} diff --git a/app/lib/ui/flow/media_preview/components/download_require_view.dart b/app/lib/ui/flow/media_preview/components/download_require_view.dart index 9dafe1e..8981bec 100644 --- a/app/lib/ui/flow/media_preview/components/download_require_view.dart +++ b/app/lib/ui/flow/media_preview/components/download_require_view.dart @@ -30,7 +30,7 @@ class DownloadRequireView extends StatelessWidget { child: Image.network( height: double.infinity, width: double.infinity, - media.thumbnailLink!, + media.thumbnailLink ?? '', fit: BoxFit.cover, ), ), diff --git a/app/lib/ui/flow/media_preview/components/top_bar.dart b/app/lib/ui/flow/media_preview/components/top_bar.dart index 2c5cc58..58cea83 100644 --- a/app/lib/ui/flow/media_preview/components/top_bar.dart +++ b/app/lib/ui/flow/media_preview/components/top_bar.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; +import 'package:cloud_gallery/ui/navigation/app_router.dart'; import 'package:data/models/media/media_extension.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -39,11 +40,11 @@ class PreviewTopBar extends StatelessWidget { actions: [ ActionButton( onPressed: () { - ///TODO: media details + AppRouter.mediaMetaDataDetails(media: media).push(context); }, icon: Icon( CupertinoIcons.info, - color: context.colorScheme.textSecondary, + color: context.colorScheme.textPrimary, size: 22, ), ), @@ -76,7 +77,7 @@ class PreviewTopBar extends StatelessWidget { child: Row( children: [ SvgPicture.asset( - Assets.images.icons.googlePhotos, + Assets.images.icons.googleDrive, width: 20, height: 20, ), diff --git a/app/lib/ui/flow/media_transfer/components/transfer_item.dart b/app/lib/ui/flow/media_transfer/components/transfer_item.dart index aff7a6a..017336f 100644 --- a/app/lib/ui/flow/media_transfer/components/transfer_item.dart +++ b/app/lib/ui/flow/media_transfer/components/transfer_item.dart @@ -1,5 +1,4 @@ import 'dart:typed_data'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:cloud_gallery/domain/extensions/context_extensions.dart'; import 'package:cloud_gallery/domain/formatter/byte_formatter.dart'; import 'package:data/models/app_process/app_process.dart'; @@ -10,8 +9,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:style/buttons/action_button.dart'; import 'package:style/extensions/context_extensions.dart'; -import 'package:style/indicators/circular_progress_indicator.dart'; import 'package:style/text/app_text_style.dart'; +import '../../../../components/thumbnail_builder.dart'; class ProcessItem extends StatefulWidget { final AppProcess process; @@ -25,6 +24,16 @@ class ProcessItem extends StatefulWidget { } class _ProcessItemState extends State { + Future? thumbnailByte; + + @override + void initState() { + if (widget.process.media.sources.contains(AppMediaSource.local)) { + thumbnailByte = + widget.process.media.loadThumbnail(size: const Size(80, 80)); + } + super.initState(); + } @override Widget build(BuildContext context) { @@ -56,7 +65,8 @@ class _ProcessItemState extends State { color: context.colorScheme.textSecondary, ), ), - if (widget.process.progress != null && widget.process.status.isProcessing) ...[ + if (widget.process.progress != null && + widget.process.status.isProcessing) ...[ LinearProgressIndicator( value: widget.process.progress?.percentageInPoint, backgroundColor: context.colorScheme.outline, @@ -96,100 +106,10 @@ class _ProcessItemState extends State { } Widget _buildThumbnailView({required BuildContext context}) { - if (widget.process.media.sources.contains(AppMediaSource.local)) { - return FutureByteLoader( - bytes: widget.process.media.loadThumbnail(size: const Size(100, 100)), - builder: (context, bytes) => Container( - width: 60, - height: 60, - decoration: BoxDecoration( - color: context.colorScheme.containerHighOnSurface, - borderRadius: BorderRadius.circular(4), - image: DecorationImage( - image: MemoryImage(bytes!), - fit: BoxFit.cover, - ), - ), - ), - errorWidget: (context, error) => _buildErrorWidget(context), - placeholder: (context) => _buildPlaceholder(context: context), - ); - } else { - return CachedNetworkImage( - imageUrl: widget.process.media.thumbnailLink!, - width: 80, - height: 80, - fit: BoxFit.cover, - errorWidget: (context, url, error) => _buildErrorWidget(context), - progressIndicatorBuilder: (context, url, progress) => - _buildPlaceholder( - context: context, - value: progress.progress, - )); - } - } - - Widget _buildPlaceholder( - {required BuildContext context, - double? value, - bool showLoader = true}) => - Container( - color: context.colorScheme.containerHighOnSurface, - alignment: Alignment.center, - child: showLoader ? AppCircularProgressIndicator(value: value) : null, - ); - - Widget _buildErrorWidget(BuildContext context) => Container( - color: context.colorScheme.containerNormalOnSurface, - alignment: Alignment.center, - child: Icon( - CupertinoIcons.exclamationmark_circle, - color: context.colorScheme.onPrimary, - size: 32, - ), - ); -} - -class FutureByteLoader extends StatefulWidget { - final Future bytes; - final Widget Function(BuildContext context, Uint8List? bytes) builder; - final Widget Function(BuildContext context) placeholder; - final Widget Function(BuildContext context, Object? error) errorWidget; - - const FutureByteLoader( - {super.key, - required this.bytes, - required this.builder, - required this.placeholder, - required this.errorWidget}); - - @override - State createState() => _FutureByteLoaderState(); -} - -class _FutureByteLoaderState extends State { - late Future bytes; - - @override - void initState() { - bytes = widget.bytes; - super.initState(); - } - - @override - Widget build(BuildContext context) { - return FutureBuilder( - future: bytes, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done && - snapshot.hasData) { - return widget.builder(context, snapshot.data); - } else if (snapshot.hasError) { - return widget.errorWidget(context, snapshot.error); - } else { - return widget.placeholder(context); - } - }, + return AppMediaThumbnail( + size: const Size(80, 80), + thumbnailByte: thumbnailByte, + media: widget.process.media, ); } } diff --git a/app/lib/ui/navigation/app_router.dart b/app/lib/ui/navigation/app_router.dart index 4fc3f77..0524095 100644 --- a/app/lib/ui/navigation/app_router.dart +++ b/app/lib/ui/navigation/app_router.dart @@ -5,6 +5,7 @@ import 'package:data/models/media/media.dart'; import 'package:flutter/cupertino.dart'; import 'package:go_router/go_router.dart'; import '../flow/home/home_screen.dart'; +import '../flow/media_metadata_details/media_metadata_details.dart'; import '../flow/media_preview/media_preview_screen.dart'; import 'app_route.dart'; @@ -39,11 +40,24 @@ class AppRouter { ), ); + static AppRoute mediaMetaDataDetails( + {required AppMedia media}) => + AppRoute( + AppRoutePath.metaDataDetails, + builder: (context) => MediaMetadataDetailsScreen( + media: media, + ), + ); + static final routes = [ home.goRoute, onBoard.goRoute, accounts.goRoute, mediaTransfer.goRoute, + GoRoute( + path: AppRoutePath.metaDataDetails, + builder: (context, state) => state.widget(context), + ), GoRoute( path: AppRoutePath.preview, pageBuilder: (context, state) { @@ -66,4 +80,5 @@ class AppRoutePath { static const accounts = '/accounts'; static const preview = '/preview'; static const transfer = '/transfer'; + static const metaDataDetails = '/metadata-details'; }