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,19 +21,22 @@ 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( | ||||
|           height: cardSize, | ||||
|           width: cardSize, | ||||
|           decoration: BoxDecoration( | ||||
|             color: isDarkMode ? Colors.grey[800] : Colors.grey[200], | ||||
|           ), | ||||
|         child: SizedBox( | ||||
|           height: cardSize, | ||||
|           width: cardSize, | ||||
|           child: const Center( | ||||
|             child: Icon(Icons.no_photography), | ||||
|           child: Center( | ||||
|             child: Icon( | ||||
|               Icons.no_photography, | ||||
|               size: cardSize * .15, | ||||
|             ), | ||||
|           ), | ||||
|         ); | ||||
| @ -54,20 +58,20 @@ class AlbumThumbnailCard extends StatelessWidget { | ||||
|       } | ||||
| 
 | ||||
|       return GestureDetector( | ||||
|       onTap: () { | ||||
|         AutoRouter.of(context).push(AlbumViewerRoute(albumId: album.id)); | ||||
|       }, | ||||
|         onTap: onTap, | ||||
|         child: Padding( | ||||
|           padding: const EdgeInsets.only(bottom: 32.0), | ||||
|           child: Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             children: [ | ||||
|             ClipRRect( | ||||
|               Expanded( | ||||
|                 child: ClipRRect( | ||||
|                   borderRadius: BorderRadius.circular(8), | ||||
|                   child: album.albumThumbnailAssetId == null | ||||
|                     ? buildEmptyThumbnail() | ||||
|                     : buildAlbumThumbnail(), | ||||
|                 ), | ||||
|               ), | ||||
|               Padding( | ||||
|                 padding: const EdgeInsets.only(top: 8.0), | ||||
|                 child: SizedBox( | ||||
| @ -104,5 +108,7 @@ class AlbumThumbnailCard extends StatelessWidget { | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -112,13 +112,14 @@ class LibraryPage extends HookConsumerWidget { | ||||
|         onTap: () { | ||||
|           AutoRouter.of(context).push(CreateAlbumRoute(isSharedAlbum: false)); | ||||
|         }, | ||||
|         child: Padding( | ||||
|           padding: const EdgeInsets.only(bottom: 32), | ||||
|           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, | ||||
|               Expanded( | ||||
|                 child: Container( | ||||
|                   decoration: BoxDecoration( | ||||
|                     border: Border.all( | ||||
|                       color: Colors.grey, | ||||
| @ -133,17 +134,22 @@ class LibraryPage extends HookConsumerWidget { | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|               Padding( | ||||
|               padding: const EdgeInsets.only(top: 8.0), | ||||
|                 padding: const EdgeInsets.only( | ||||
|                   top: 8.0, | ||||
|                   bottom: 16, | ||||
|                 ), | ||||
|                 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,8 +92,11 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
|     RenderAssetGridRow row, | ||||
|     bool scrolling, | ||||
|   ) { | ||||
|     double size = _getItemSize(context); | ||||
| 
 | ||||
|     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) { | ||||
| @ -116,6 +114,8 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
|             ); | ||||
|           }).toList(), | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   Widget _buildTitle( | ||||
|  | ||||
| @ -11,35 +11,58 @@ class TabControllerPage extends ConsumerWidget { | ||||
| 
 | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final multiselectEnabled = ref.watch(multiselectProvider); | ||||
|     return AutoTabsRouter( | ||||
|       routes: [ | ||||
|         const HomeRoute(), | ||||
|         SearchRoute(), | ||||
|         const SharingRoute(), | ||||
|         const LibraryRoute() | ||||
|       ], | ||||
|       builder: (context, child, animation) { | ||||
|         final tabsRouter = AutoTabsRouter.of(context); | ||||
|         final appRouter = AutoRouter.of(context); | ||||
|         return WillPopScope( | ||||
|           onWillPop: () async { | ||||
|             bool atHomeTab = tabsRouter.activeIndex == 0; | ||||
|             if (!atHomeTab) { | ||||
|               tabsRouter.setActiveIndex(0); | ||||
|             } else { | ||||
|               appRouter.navigateBack(); | ||||
|             } | ||||
|             return atHomeTab; | ||||
| 
 | ||||
|     navigationRail(TabsRouter tabsRouter) { | ||||
|       return NavigationRail( | ||||
|         labelType: NavigationRailLabelType.all, | ||||
|         selectedIndex: tabsRouter.activeIndex, | ||||
|         onDestinationSelected: (index) { | ||||
|           HapticFeedback.selectionClick(); | ||||
|           tabsRouter.setActiveIndex(index); | ||||
|         }, | ||||
|           child: Scaffold( | ||||
|             body: FadeTransition( | ||||
|               opacity: animation, | ||||
|               child: child, | ||||
|         selectedIconTheme: IconThemeData( | ||||
|           color: Theme.of(context).primaryColor, | ||||
|         ), | ||||
|             bottomNavigationBar: multiselectEnabled | ||||
|                 ? null | ||||
|                 : BottomNavigationBar( | ||||
|         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, | ||||
| @ -75,8 +98,63 @@ class TabControllerPage extends ConsumerWidget { | ||||
|             activeIcon: const Icon(Icons.photo_album_rounded), | ||||
|           ) | ||||
|         ], | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     final multiselectEnabled = ref.watch(multiselectProvider); | ||||
|     return AutoTabsRouter( | ||||
|       routes: [ | ||||
|         const HomeRoute(), | ||||
|         SearchRoute(), | ||||
|         const SharingRoute(), | ||||
|         const LibraryRoute() | ||||
|       ], | ||||
|       builder: (context, child, animation) { | ||||
|         final tabsRouter = AutoTabsRouter.of(context); | ||||
|         final appRouter = AutoRouter.of(context); | ||||
|         return WillPopScope( | ||||
|           onWillPop: () async { | ||||
|             bool atHomeTab = tabsRouter.activeIndex == 0; | ||||
|             if (!atHomeTab) { | ||||
|               tabsRouter.setActiveIndex(0); | ||||
|             } else { | ||||
|               appRouter.navigateBack(); | ||||
|             } | ||||
|             return atHomeTab; | ||||
|           }, | ||||
|           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, | ||||
|                       ), | ||||
|                     ), | ||||
|                   ], | ||||
|                 ); | ||||
|               }              return Scaffold( | ||||
|                body: body, | ||||
|                bottomNavigationBar: multiselectEnabled | ||||
|                   ? null | ||||
|                   : bottom, | ||||
|             ); | ||||
|           },), | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user