mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-30 18:35:00 -04:00 
			
		
		
		
	feat(mobile): Responsive layout improvements with a navigation rail and album grid (#1583)
This commit is contained in:
		
							parent
							
								
									18647203cc
								
							
						
					
					
						commit
						dc9da7480c
					
				| @ -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() | ||||
|                 ], | ||||
|               ) | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|       ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -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, | ||||
|                       ), | ||||
|                     ), | ||||
|                 ], | ||||
|                   ); | ||||
|                 }, | ||||
|               ), | ||||
|             ), | ||||
|           ) | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|  | ||||
| @ -66,11 +66,6 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
|         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<ImmichAssetGrid> { | ||||
|     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(), | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -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, | ||||
|             ); | ||||
|           },), | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user