From dc9da7480c3ba656e5d1689c4d3c2ca58ed847e2 Mon Sep 17 00:00:00 2001 From: martyfuhry Date: Wed, 8 Feb 2023 14:42:45 -0500 Subject: [PATCH] feat(mobile): Responsive layout improvements with a navigation rail and album grid (#1583) --- .../album/ui/album_thumbnail_card.dart | 152 ++++++++-------- .../lib/modules/album/views/library_page.dart | 99 +++++++---- .../home/ui/asset_grid/immich_asset_grid.dart | 40 ++--- .../lib/shared/views/tab_controller_page.dart | 166 +++++++++++++----- 4 files changed, 281 insertions(+), 176 deletions(-) diff --git a/mobile/lib/modules/album/ui/album_thumbnail_card.dart b/mobile/lib/modules/album/ui/album_thumbnail_card.dart index 4887e7d05c..36ce62676f 100644 --- a/mobile/lib/modules/album/ui/album_thumbnail_card.dart +++ b/mobile/lib/modules/album/ui/album_thumbnail_card.dart @@ -1,18 +1,19 @@ -import 'package:auto_route/auto_route.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; import 'package:immich_mobile/constants/hive_box.dart'; -import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:openapi/api.dart'; class AlbumThumbnailCard extends StatelessWidget { + final Function()? onTap; + const AlbumThumbnailCard({ Key? key, required this.album, + this.onTap, }) : super(key: key); final Album album; @@ -20,89 +21,94 @@ class AlbumThumbnailCard extends StatelessWidget { @override Widget build(BuildContext context) { var box = Hive.box(userInfoBox); - var cardSize = MediaQuery.of(context).size.width / 2 - 18; var isDarkMode = Theme.of(context).brightness == Brightness.dark; + return LayoutBuilder( + builder: (context, constraints) { + var cardSize = constraints.maxWidth; - buildEmptyThumbnail() { - return Container( - decoration: BoxDecoration( - color: isDarkMode ? Colors.grey[800] : Colors.grey[200], - ), - child: SizedBox( + buildEmptyThumbnail() { + return Container( height: cardSize, width: cardSize, - child: const Center( - child: Icon(Icons.no_photography), + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey[800] : Colors.grey[200], ), - ), - ); - } - - buildAlbumThumbnail() { - return CachedNetworkImage( - width: cardSize, - height: cardSize, - fit: BoxFit.cover, - fadeInDuration: const Duration(milliseconds: 200), - imageUrl: getAlbumThumbnailUrl( - album, - type: ThumbnailFormat.JPEG, - ), - httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, - cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG), - ); - } - - return GestureDetector( - onTap: () { - AutoRouter.of(context).push(AlbumViewerRoute(albumId: album.id)); - }, - child: Padding( - padding: const EdgeInsets.only(bottom: 32.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: album.albumThumbnailAssetId == null - ? buildEmptyThumbnail() - : buildAlbumThumbnail(), + child: Center( + child: Icon( + Icons.no_photography, + size: cardSize * .15, ), - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: SizedBox( - width: cardSize, - child: Text( - album.name, - style: const TextStyle( - fontWeight: FontWeight.bold, + ), + ); + } + + buildAlbumThumbnail() { + return CachedNetworkImage( + width: cardSize, + height: cardSize, + fit: BoxFit.cover, + fadeInDuration: const Duration(milliseconds: 200), + imageUrl: getAlbumThumbnailUrl( + album, + type: ThumbnailFormat.JPEG, + ), + httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, + cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG), + ); + } + + return GestureDetector( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.only(bottom: 32.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: album.albumThumbnailAssetId == null + ? buildEmptyThumbnail() + : buildAlbumThumbnail(), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: SizedBox( + width: cardSize, + child: Text( + album.name, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), ), ), ), - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - album.assetCount == 1 - ? 'album_thumbnail_card_item' - : 'album_thumbnail_card_items', - style: const TextStyle( - fontSize: 12, - ), - ).tr(args: ['${album.assetCount}']), - if (album.shared) - const Text( - 'album_thumbnail_card_shared', - style: TextStyle( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + album.assetCount == 1 + ? 'album_thumbnail_card_item' + : 'album_thumbnail_card_items', + style: const TextStyle( fontSize: 12, ), - ).tr() - ], - ) - ], + ).tr(args: ['${album.assetCount}']), + if (album.shared) + const Text( + 'album_thumbnail_card_shared', + style: TextStyle( + fontSize: 12, + ), + ).tr() + ], + ) + ], + ), ), - ), + ); + }, ); } } diff --git a/mobile/lib/modules/album/views/library_page.dart b/mobile/lib/modules/album/views/library_page.dart index 7c9475d3a4..08e3327161 100644 --- a/mobile/lib/modules/album/views/library_page.dart +++ b/mobile/lib/modules/album/views/library_page.dart @@ -112,37 +112,43 @@ class LibraryPage extends HookConsumerWidget { onTap: () { AutoRouter.of(context).push(CreateAlbumRoute(isSharedAlbum: false)); }, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: MediaQuery.of(context).size.width / 2 - 18, - height: MediaQuery.of(context).size.width / 2 - 18, - decoration: BoxDecoration( - border: Border.all( - color: Colors.grey, - ), - borderRadius: BorderRadius.circular(8), - ), - child: Center( - child: Icon( - Icons.add_rounded, - size: 28, - color: Theme.of(context).primaryColor, + child: Padding( + padding: const EdgeInsets.only(bottom: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: Colors.grey, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Icon( + Icons.add_rounded, + size: 28, + color: Theme.of(context).primaryColor, + ), + ), ), ), - ), - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: const Text( - 'library_page_new_album', - style: TextStyle( - fontWeight: FontWeight.bold, + Padding( + padding: const EdgeInsets.only( + top: 8.0, + bottom: 16, ), - ).tr(), - ) - ], + child: const Text( + 'library_page_new_album', + style: TextStyle( + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + ], + ), ), ); } @@ -185,6 +191,8 @@ class LibraryPage extends HookConsumerWidget { ); } + final sorted = sortedAlbums(); + return Scaffold( appBar: buildAppBar(), body: CustomScrollView( @@ -234,20 +242,33 @@ class LibraryPage extends HookConsumerWidget { ), ), SliverPadding( - padding: const EdgeInsets.only(left: 12.0, right: 12, bottom: 50), - sliver: SliverToBoxAdapter( - child: Wrap( - spacing: 12, - children: [ - buildCreateAlbumButton(), - for (var album in sortedAlbums()) - AlbumThumbnailCard( - album: album, + padding: const EdgeInsets.all(12.0), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 250, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: .7, + ), + delegate: SliverChildBuilderDelegate( + childCount: sorted.length + 1, + (context, index) { + if (index == 0) { + return buildCreateAlbumButton(); + } + + return AlbumThumbnailCard( + album: sorted[index - 1], + onTap: () => AutoRouter.of(context).push( + AlbumViewerRoute( + albumId: sorted[index - 1].id, + ), ), - ], + ); + }, ), ), - ) + ), ], ), ); diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart index 863631267e..3db0da6753 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart @@ -66,11 +66,6 @@ class ImmichAssetGridState extends State { assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null; } - double _getItemSize(BuildContext context) { - return MediaQuery.of(context).size.width / widget.assetsPerRow - - widget.margin * (widget.assetsPerRow - 1) / widget.assetsPerRow; - } - Widget _buildThumbnailOrPlaceholder( Asset asset, bool placeholder, @@ -97,24 +92,29 @@ class ImmichAssetGridState extends State { RenderAssetGridRow row, bool scrolling, ) { - double size = _getItemSize(context); - return Row( - key: Key("asset-row-${row.assets.first.id}"), - children: row.assets.map((Asset asset) { - bool last = asset.id == row.assets.last.id; + return LayoutBuilder( + builder: (context, constraints) { + final size = constraints.maxWidth / widget.assetsPerRow - + widget.margin * (widget.assetsPerRow - 1) / widget.assetsPerRow; + return Row( + key: Key("asset-row-${row.assets.first.id}"), + children: row.assets.map((Asset asset) { + bool last = asset.id == row.assets.last.id; - return Container( - key: Key("asset-${asset.id}"), - width: size, - height: size, - margin: EdgeInsets.only( - top: widget.margin, - right: last ? 0.0 : widget.margin, - ), - child: _buildThumbnailOrPlaceholder(asset, scrolling), + return Container( + key: Key("asset-${asset.id}"), + width: size, + height: size, + margin: EdgeInsets.only( + top: widget.margin, + right: last ? 0.0 : widget.margin, + ), + child: _buildThumbnailOrPlaceholder(asset, scrolling), + ); + }).toList(), ); - }).toList(), + }, ); } diff --git a/mobile/lib/shared/views/tab_controller_page.dart b/mobile/lib/shared/views/tab_controller_page.dart index 8f41963c50..42f98eefbe 100644 --- a/mobile/lib/shared/views/tab_controller_page.dart +++ b/mobile/lib/shared/views/tab_controller_page.dart @@ -11,6 +11,96 @@ class TabControllerPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + + navigationRail(TabsRouter tabsRouter) { + return NavigationRail( + labelType: NavigationRailLabelType.all, + selectedIndex: tabsRouter.activeIndex, + onDestinationSelected: (index) { + HapticFeedback.selectionClick(); + tabsRouter.setActiveIndex(index); + }, + selectedIconTheme: IconThemeData( + color: Theme.of(context).primaryColor, + ), + selectedLabelTextStyle: TextStyle( + color: Theme.of(context).primaryColor, + ), + useIndicator: false, + destinations: [ + NavigationRailDestination( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + 4, + left: 4, + right: 4, + bottom: 4, + ), + icon: const Icon(Icons.photo_outlined), + selectedIcon: const Icon(Icons.photo), + label: const Text('tab_controller_nav_photos').tr(), + ), + NavigationRailDestination( + padding: const EdgeInsets.all(4), + icon: const Icon(Icons.search_rounded), + selectedIcon: const Icon(Icons.search), + label: const Text('tab_controller_nav_search').tr(), + ), + NavigationRailDestination( + padding: const EdgeInsets.all(4), + icon: const Icon(Icons.share_rounded), + selectedIcon: const Icon(Icons.share), + label: const Text('tab_controller_nav_sharing').tr(), + ), + NavigationRailDestination( + padding: const EdgeInsets.all(4), + icon: const Icon(Icons.photo_album_outlined), + selectedIcon: const Icon(Icons.photo_album), + label: const Text('tab_controller_nav_library').tr(), + ), + ], + ); + } + + bottomNavigationBar(TabsRouter tabsRouter) { + return BottomNavigationBar( + selectedLabelStyle: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + unselectedLabelStyle: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + currentIndex: tabsRouter.activeIndex, + onTap: (index) { + HapticFeedback.selectionClick(); + tabsRouter.setActiveIndex(index); + }, + items: [ + BottomNavigationBarItem( + label: 'tab_controller_nav_photos'.tr(), + icon: const Icon(Icons.photo_outlined), + activeIcon: const Icon(Icons.photo), + ), + BottomNavigationBarItem( + label: 'tab_controller_nav_search'.tr(), + icon: const Icon(Icons.search_rounded), + activeIcon: const Icon(Icons.search), + ), + BottomNavigationBarItem( + label: 'tab_controller_nav_sharing'.tr(), + icon: const Icon(Icons.group_outlined), + activeIcon: const Icon(Icons.group), + ), + BottomNavigationBarItem( + label: 'tab_controller_nav_library'.tr(), + icon: const Icon(Icons.photo_album_outlined), + activeIcon: const Icon(Icons.photo_album_rounded), + ) + ], + ); + } + final multiselectEnabled = ref.watch(multiselectProvider); return AutoTabsRouter( routes: [ @@ -32,51 +122,39 @@ class TabControllerPage extends ConsumerWidget { } return atHomeTab; }, - child: Scaffold( - body: FadeTransition( - opacity: animation, - child: child, - ), - bottomNavigationBar: multiselectEnabled - ? null - : BottomNavigationBar( - selectedLabelStyle: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, + child: LayoutBuilder( + builder: (context, constraints) { + const medium = 600; + final Widget? bottom; + final Widget body; + if (constraints.maxWidth < medium) { + // Normal phone width + bottom = bottomNavigationBar(tabsRouter); + body = FadeTransition( + opacity: animation, + child: child, + ); + } else { + // Medium tablet width + bottom = null; + body = Row( + children: [ + navigationRail(tabsRouter), + Expanded( + child: FadeTransition( + opacity: animation, + child: child, + ), ), - unselectedLabelStyle: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - ), - currentIndex: tabsRouter.activeIndex, - onTap: (index) { - HapticFeedback.selectionClick(); - tabsRouter.setActiveIndex(index); - }, - items: [ - BottomNavigationBarItem( - label: 'tab_controller_nav_photos'.tr(), - icon: const Icon(Icons.photo_outlined), - activeIcon: const Icon(Icons.photo), - ), - BottomNavigationBarItem( - label: 'tab_controller_nav_search'.tr(), - icon: const Icon(Icons.search_rounded), - activeIcon: const Icon(Icons.search), - ), - BottomNavigationBarItem( - label: 'tab_controller_nav_sharing'.tr(), - icon: const Icon(Icons.group_outlined), - activeIcon: const Icon(Icons.group), - ), - BottomNavigationBarItem( - label: 'tab_controller_nav_library'.tr(), - icon: const Icon(Icons.photo_album_outlined), - activeIcon: const Icon(Icons.photo_album_rounded), - ) - ], - ), - ), + ], + ); + } return Scaffold( + body: body, + bottomNavigationBar: multiselectEnabled + ? null + : bottom, + ); + },), ); }, );