mirror of
https://github.com/immich-app/immich.git
synced 2025-06-01 04:36:19 -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