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:cached_network_image/cached_network_image.dart'; | ||||||
| import 'package:easy_localization/easy_localization.dart'; | import 'package:easy_localization/easy_localization.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:hive/hive.dart'; | import 'package:hive/hive.dart'; | ||||||
| import 'package:immich_mobile/constants/hive_box.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/shared/models/album.dart'; | ||||||
| import 'package:immich_mobile/utils/image_url_builder.dart'; | import 'package:immich_mobile/utils/image_url_builder.dart'; | ||||||
| import 'package:openapi/api.dart'; | import 'package:openapi/api.dart'; | ||||||
| 
 | 
 | ||||||
| class AlbumThumbnailCard extends StatelessWidget { | class AlbumThumbnailCard extends StatelessWidget { | ||||||
|  |   final Function()? onTap; | ||||||
|  | 
 | ||||||
|   const AlbumThumbnailCard({ |   const AlbumThumbnailCard({ | ||||||
|     Key? key, |     Key? key, | ||||||
|     required this.album, |     required this.album, | ||||||
|  |     this.onTap, | ||||||
|   }) : super(key: key); |   }) : super(key: key); | ||||||
| 
 | 
 | ||||||
|   final Album album; |   final Album album; | ||||||
| @ -20,19 +21,22 @@ class AlbumThumbnailCard extends StatelessWidget { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     var box = Hive.box(userInfoBox); |     var box = Hive.box(userInfoBox); | ||||||
|     var cardSize = MediaQuery.of(context).size.width / 2 - 18; |  | ||||||
|     var isDarkMode = Theme.of(context).brightness == Brightness.dark; |     var isDarkMode = Theme.of(context).brightness == Brightness.dark; | ||||||
|  |     return LayoutBuilder( | ||||||
|  |       builder: (context, constraints) { | ||||||
|  |       var cardSize = constraints.maxWidth; | ||||||
| 
 | 
 | ||||||
|       buildEmptyThumbnail() { |       buildEmptyThumbnail() { | ||||||
|         return Container( |         return Container( | ||||||
|  |           height: cardSize, | ||||||
|  |           width: cardSize, | ||||||
|           decoration: BoxDecoration( |           decoration: BoxDecoration( | ||||||
|             color: isDarkMode ? Colors.grey[800] : Colors.grey[200], |             color: isDarkMode ? Colors.grey[800] : Colors.grey[200], | ||||||
|           ), |           ), | ||||||
|         child: SizedBox( |           child: Center( | ||||||
|           height: cardSize, |             child: Icon( | ||||||
|           width: cardSize, |               Icons.no_photography, | ||||||
|           child: const Center( |               size: cardSize * .15, | ||||||
|             child: Icon(Icons.no_photography), |  | ||||||
|             ), |             ), | ||||||
|           ), |           ), | ||||||
|         ); |         ); | ||||||
| @ -54,20 +58,20 @@ class AlbumThumbnailCard extends StatelessWidget { | |||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       return GestureDetector( |       return GestureDetector( | ||||||
|       onTap: () { |         onTap: onTap, | ||||||
|         AutoRouter.of(context).push(AlbumViewerRoute(albumId: album.id)); |  | ||||||
|       }, |  | ||||||
|         child: Padding( |         child: Padding( | ||||||
|           padding: const EdgeInsets.only(bottom: 32.0), |           padding: const EdgeInsets.only(bottom: 32.0), | ||||||
|           child: Column( |           child: Column( | ||||||
|             crossAxisAlignment: CrossAxisAlignment.start, |             crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|             children: [ |             children: [ | ||||||
|             ClipRRect( |               Expanded( | ||||||
|  |                 child: ClipRRect( | ||||||
|                   borderRadius: BorderRadius.circular(8), |                   borderRadius: BorderRadius.circular(8), | ||||||
|                   child: album.albumThumbnailAssetId == null |                   child: album.albumThumbnailAssetId == null | ||||||
|                     ? buildEmptyThumbnail() |                     ? buildEmptyThumbnail() | ||||||
|                     : buildAlbumThumbnail(), |                     : buildAlbumThumbnail(), | ||||||
|                 ), |                 ), | ||||||
|  |               ), | ||||||
|               Padding( |               Padding( | ||||||
|                 padding: const EdgeInsets.only(top: 8.0), |                 padding: const EdgeInsets.only(top: 8.0), | ||||||
|                 child: SizedBox( |                 child: SizedBox( | ||||||
| @ -104,5 +108,7 @@ class AlbumThumbnailCard extends StatelessWidget { | |||||||
|           ), |           ), | ||||||
|         ), |         ), | ||||||
|       ); |       ); | ||||||
|  |       }, | ||||||
|  |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -112,13 +112,14 @@ class LibraryPage extends HookConsumerWidget { | |||||||
|         onTap: () { |         onTap: () { | ||||||
|           AutoRouter.of(context).push(CreateAlbumRoute(isSharedAlbum: false)); |           AutoRouter.of(context).push(CreateAlbumRoute(isSharedAlbum: false)); | ||||||
|         }, |         }, | ||||||
|  |         child: Padding( | ||||||
|  |           padding: const EdgeInsets.only(bottom: 32), | ||||||
|           child: Column( |           child: Column( | ||||||
|             mainAxisAlignment: MainAxisAlignment.start, |             mainAxisAlignment: MainAxisAlignment.start, | ||||||
|             crossAxisAlignment: CrossAxisAlignment.start, |             crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|             children: [ |             children: [ | ||||||
|             Container( |               Expanded( | ||||||
|               width: MediaQuery.of(context).size.width / 2 - 18, |                 child: Container( | ||||||
|               height: MediaQuery.of(context).size.width / 2 - 18, |  | ||||||
|                   decoration: BoxDecoration( |                   decoration: BoxDecoration( | ||||||
|                     border: Border.all( |                     border: Border.all( | ||||||
|                       color: Colors.grey, |                       color: Colors.grey, | ||||||
| @ -133,17 +134,22 @@ class LibraryPage extends HookConsumerWidget { | |||||||
|                     ), |                     ), | ||||||
|                   ), |                   ), | ||||||
|                 ), |                 ), | ||||||
|  |               ), | ||||||
|               Padding( |               Padding( | ||||||
|               padding: const EdgeInsets.only(top: 8.0), |                 padding: const EdgeInsets.only( | ||||||
|  |                   top: 8.0, | ||||||
|  |                   bottom: 16, | ||||||
|  |                 ), | ||||||
|                 child: const Text( |                 child: const Text( | ||||||
|                   'library_page_new_album', |                   'library_page_new_album', | ||||||
|                   style: TextStyle( |                   style: TextStyle( | ||||||
|                     fontWeight: FontWeight.bold, |                     fontWeight: FontWeight.bold, | ||||||
|                   ), |                   ), | ||||||
|                 ).tr(), |                 ).tr(), | ||||||
|             ) |               ), | ||||||
|             ], |             ], | ||||||
|           ), |           ), | ||||||
|  |         ), | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -185,6 +191,8 @@ class LibraryPage extends HookConsumerWidget { | |||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     final sorted = sortedAlbums(); | ||||||
|  | 
 | ||||||
|     return Scaffold( |     return Scaffold( | ||||||
|       appBar: buildAppBar(), |       appBar: buildAppBar(), | ||||||
|       body: CustomScrollView( |       body: CustomScrollView( | ||||||
| @ -234,20 +242,33 @@ class LibraryPage extends HookConsumerWidget { | |||||||
|             ), |             ), | ||||||
|           ), |           ), | ||||||
|           SliverPadding( |           SliverPadding( | ||||||
|             padding: const EdgeInsets.only(left: 12.0, right: 12, bottom: 50), |             padding: const EdgeInsets.all(12.0), | ||||||
|             sliver: SliverToBoxAdapter( |             sliver: SliverGrid( | ||||||
|               child: Wrap( |               gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( | ||||||
|                 spacing: 12, |                 maxCrossAxisExtent: 250, | ||||||
|                 children: [ |                 mainAxisSpacing: 12, | ||||||
|                   buildCreateAlbumButton(), |                 crossAxisSpacing: 12, | ||||||
|                   for (var album in sortedAlbums()) |                 childAspectRatio: .7, | ||||||
|                     AlbumThumbnailCard( |               ), | ||||||
|                       album: album, |               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; |         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( |   Widget _buildThumbnailOrPlaceholder( | ||||||
|     Asset asset, |     Asset asset, | ||||||
|     bool placeholder, |     bool placeholder, | ||||||
| @ -97,8 +92,11 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | |||||||
|     RenderAssetGridRow row, |     RenderAssetGridRow row, | ||||||
|     bool scrolling, |     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( |         return Row( | ||||||
|           key: Key("asset-row-${row.assets.first.id}"), |           key: Key("asset-row-${row.assets.first.id}"), | ||||||
|           children: row.assets.map((Asset asset) { |           children: row.assets.map((Asset asset) { | ||||||
| @ -116,6 +114,8 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | |||||||
|             ); |             ); | ||||||
|           }).toList(), |           }).toList(), | ||||||
|         ); |         ); | ||||||
|  |       }, | ||||||
|  |     ); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   Widget _buildTitle( |   Widget _buildTitle( | ||||||
|  | |||||||
| @ -11,35 +11,58 @@ class TabControllerPage extends ConsumerWidget { | |||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context, WidgetRef ref) { |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|     final multiselectEnabled = ref.watch(multiselectProvider); | 
 | ||||||
|     return AutoTabsRouter( |     navigationRail(TabsRouter tabsRouter) { | ||||||
|       routes: [ |       return NavigationRail( | ||||||
|         const HomeRoute(), |         labelType: NavigationRailLabelType.all, | ||||||
|         SearchRoute(), |         selectedIndex: tabsRouter.activeIndex, | ||||||
|         const SharingRoute(), |         onDestinationSelected: (index) { | ||||||
|         const LibraryRoute() |           HapticFeedback.selectionClick(); | ||||||
|       ], |           tabsRouter.setActiveIndex(index); | ||||||
|       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: Scaffold( |         selectedIconTheme: IconThemeData( | ||||||
|             body: FadeTransition( |           color: Theme.of(context).primaryColor, | ||||||
|               opacity: animation, |  | ||||||
|               child: child, |  | ||||||
|         ), |         ), | ||||||
|             bottomNavigationBar: multiselectEnabled |         selectedLabelTextStyle: TextStyle( | ||||||
|                 ? null |           color: Theme.of(context).primaryColor, | ||||||
|                 : BottomNavigationBar( |         ), | ||||||
|  |         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( |         selectedLabelStyle: const TextStyle( | ||||||
|           fontSize: 13, |           fontSize: 13, | ||||||
|           fontWeight: FontWeight.w600, |           fontWeight: FontWeight.w600, | ||||||
| @ -75,8 +98,63 @@ class TabControllerPage extends ConsumerWidget { | |||||||
|             activeIcon: const Icon(Icons.photo_album_rounded), |             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