1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(mobile): Responsive layout improvements with a navigation rail and album grid (#1583)

This commit is contained in:
martyfuhry 2023-02-08 14:42:45 -05:00 committed by GitHub
parent 18647203cc
commit dc9da7480c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 281 additions and 176 deletions

View File

@ -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,89 +21,94 @@ 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(
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[800] : Colors.grey[200],
),
child: SizedBox(
height: cardSize, height: cardSize,
width: cardSize, width: cardSize,
child: const Center( decoration: BoxDecoration(
child: Icon(Icons.no_photography), color: isDarkMode ? Colors.grey[800] : Colors.grey[200],
), ),
), child: Center(
); child: Icon(
} Icons.no_photography,
size: cardSize * .15,
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(),
), ),
Padding( ),
padding: const EdgeInsets.only(top: 8.0), );
child: SizedBox( }
width: cardSize,
child: Text( buildAlbumThumbnail() {
album.name, return CachedNetworkImage(
style: const TextStyle( width: cardSize,
fontWeight: FontWeight.bold, 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(
Row( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, children: [
children: [ Text(
Text( album.assetCount == 1
album.assetCount == 1 ? 'album_thumbnail_card_item'
? 'album_thumbnail_card_item' : 'album_thumbnail_card_items',
: 'album_thumbnail_card_items', style: const TextStyle(
style: const TextStyle(
fontSize: 12,
),
).tr(args: ['${album.assetCount}']),
if (album.shared)
const Text(
'album_thumbnail_card_shared',
style: TextStyle(
fontSize: 12, fontSize: 12,
), ),
).tr() ).tr(args: ['${album.assetCount}']),
], if (album.shared)
) const Text(
], 'album_thumbnail_card_shared',
style: TextStyle(
fontSize: 12,
),
).tr()
],
)
],
),
), ),
), );
},
); );
} }
} }

View File

@ -112,37 +112,43 @@ class LibraryPage extends HookConsumerWidget {
onTap: () { onTap: () {
AutoRouter.of(context).push(CreateAlbumRoute(isSharedAlbum: false)); AutoRouter.of(context).push(CreateAlbumRoute(isSharedAlbum: false));
}, },
child: Column( child: Padding(
mainAxisAlignment: MainAxisAlignment.start, padding: const EdgeInsets.only(bottom: 32),
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ mainAxisAlignment: MainAxisAlignment.start,
Container( crossAxisAlignment: CrossAxisAlignment.start,
width: MediaQuery.of(context).size.width / 2 - 18, children: [
height: MediaQuery.of(context).size.width / 2 - 18, Expanded(
decoration: BoxDecoration( child: Container(
border: Border.all( decoration: BoxDecoration(
color: Colors.grey, border: Border.all(
), color: Colors.grey,
borderRadius: BorderRadius.circular(8), ),
), borderRadius: BorderRadius.circular(8),
child: Center( ),
child: Icon( child: Center(
Icons.add_rounded, child: Icon(
size: 28, Icons.add_rounded,
color: Theme.of(context).primaryColor, size: 28,
color: Theme.of(context).primaryColor,
),
),
), ),
), ),
), Padding(
Padding( padding: const EdgeInsets.only(
padding: const EdgeInsets.only(top: 8.0), top: 8.0,
child: const Text( bottom: 16,
'library_page_new_album',
style: TextStyle(
fontWeight: FontWeight.bold,
), ),
).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( 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,
),
), ),
], );
},
), ),
), ),
) ),
], ],
), ),
); );

View File

@ -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,24 +92,29 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
RenderAssetGridRow row, RenderAssetGridRow row,
bool scrolling, bool scrolling,
) { ) {
double size = _getItemSize(context);
return Row( return LayoutBuilder(
key: Key("asset-row-${row.assets.first.id}"), builder: (context, constraints) {
children: row.assets.map((Asset asset) { final size = constraints.maxWidth / widget.assetsPerRow -
bool last = asset.id == row.assets.last.id; 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( return Container(
key: Key("asset-${asset.id}"), key: Key("asset-${asset.id}"),
width: size, width: size,
height: size, height: size,
margin: EdgeInsets.only( margin: EdgeInsets.only(
top: widget.margin, top: widget.margin,
right: last ? 0.0 : widget.margin, right: last ? 0.0 : widget.margin,
), ),
child: _buildThumbnailOrPlaceholder(asset, scrolling), child: _buildThumbnailOrPlaceholder(asset, scrolling),
);
}).toList(),
); );
}).toList(), },
); );
} }

View File

@ -11,6 +11,96 @@ class TabControllerPage extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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); final multiselectEnabled = ref.watch(multiselectProvider);
return AutoTabsRouter( return AutoTabsRouter(
routes: [ routes: [
@ -32,51 +122,39 @@ class TabControllerPage extends ConsumerWidget {
} }
return atHomeTab; return atHomeTab;
}, },
child: Scaffold( child: LayoutBuilder(
body: FadeTransition( builder: (context, constraints) {
opacity: animation, const medium = 600;
child: child, final Widget? bottom;
), final Widget body;
bottomNavigationBar: multiselectEnabled if (constraints.maxWidth < medium) {
? null // Normal phone width
: BottomNavigationBar( bottom = bottomNavigationBar(tabsRouter);
selectedLabelStyle: const TextStyle( body = FadeTransition(
fontSize: 13, opacity: animation,
fontWeight: FontWeight.w600, 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, } return Scaffold(
), body: body,
currentIndex: tabsRouter.activeIndex, bottomNavigationBar: multiselectEnabled
onTap: (index) { ? null
HapticFeedback.selectionClick(); : bottom,
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),
)
],
),
),
); );
}, },
); );