feat(mobile): new mobile UI (#12582)

This commit is contained in:
Alex 2024-10-10 15:44:14 +07:00 committed by GitHub
parent b59abdff3d
commit e9813315e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1960 additions and 1274 deletions

View file

@ -1,324 +1,248 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/partner.provider.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
import 'package:immich_mobile/widgets/common/immich_app_bar.dart';
import 'package:immich_mobile/widgets/common/user_avatar.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
class LibraryPage extends HookConsumerWidget {
class LibraryPage extends ConsumerWidget {
const LibraryPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
final albums = ref.watch(albumProvider);
final albumSortOption = ref.watch(albumSortByOptionsProvider);
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
useEffect(
() {
ref.read(albumProvider.notifier).getAllAlbums();
return null;
},
[],
);
Widget buildSortButton() {
return PopupMenuButton(
position: PopupMenuPosition.over,
itemBuilder: (BuildContext context) {
return AlbumSortMode.values
.map<PopupMenuEntry<AlbumSortMode>>((option) {
final selected = albumSortOption == option;
return PopupMenuItem(
value: option,
return Scaffold(
appBar: ImmichAppBar(),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 12.0),
child: Icon(
Icons.check,
color:
selected ? context.primaryColor : Colors.transparent,
),
ActionButton(
onPressed: () => context.pushRoute(const FavoritesRoute()),
icon: Icons.favorite_outline_rounded,
label: 'favorites'.tr(),
),
Text(
option.label.tr(),
style: TextStyle(
color: selected ? context.primaryColor : null,
fontSize: 14.0,
),
const SizedBox(width: 8),
ActionButton(
onPressed: () => context.pushRoute(const ArchiveRoute()),
icon: Icons.archive_outlined,
label: 'archived'.tr(),
),
],
),
);
}).toList();
},
onSelected: (AlbumSortMode value) {
final selected = albumSortOption == value;
// Switch direction
if (selected) {
ref
.read(albumSortOrderProvider.notifier)
.changeSortDirection(!albumSortIsReverse);
} else {
ref.read(albumSortByOptionsProvider.notifier).changeSortMode(value);
}
},
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 5),
child: Icon(
albumSortIsReverse
? Icons.arrow_downward_rounded
: Icons.arrow_upward_rounded,
size: 14,
color: context.primaryColor,
),
),
Text(
albumSortOption.label.tr(),
style: context.textTheme.labelLarge?.copyWith(
color: context.primaryColor,
),
const SizedBox(height: 8),
Row(
children: [
ActionButton(
onPressed: () => context.pushRoute(const SharedLinkRoute()),
icon: Icons.link_outlined,
label: 'shared_links'.tr(),
),
const SizedBox(width: 8),
trashEnabled
? ActionButton(
onPressed: () => context.pushRoute(const TrashRoute()),
icon: Icons.delete_outline_rounded,
label: 'trash'.tr(),
)
: const SizedBox.shrink(),
],
),
const SizedBox(height: 12),
const Wrap(
spacing: 8,
runSpacing: 8,
children: [
PeopleCollectionCard(),
PlacesCollectionCard(),
LocalAlbumsCollectionCard(),
],
),
const SizedBox(height: 12),
QuickAccessButtons(),
const SizedBox(
height: 32,
),
],
),
);
}
Widget buildCreateAlbumButton() {
return LayoutBuilder(
builder: (context, constraints) {
var cardSize = constraints.maxWidth;
return GestureDetector(
onTap: () =>
context.pushRoute(CreateAlbumRoute(isSharedAlbum: false)),
child: Padding(
padding:
const EdgeInsets.only(bottom: 32), // Adjust padding to suit
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: cardSize,
height: cardSize,
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: Center(
child: Icon(
Icons.add_rounded,
size: 28,
color: context.primaryColor,
),
),
),
Padding(
padding: const EdgeInsets.only(
top: 8.0,
bottom: 16,
),
child: Text(
'library_page_new_album',
style: context.textTheme.labelLarge?.copyWith(
color: context.colorScheme.onSurface,
),
).tr(),
),
],
),
),
);
},
);
}
Widget buildLibraryNavButton(
String label,
IconData icon,
Function() onClick,
) {
return Expanded(
child: FilledButton.icon(
onPressed: onClick,
label: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(
label,
style: TextStyle(
color: context.colorScheme.onSurface,
),
),
),
style: FilledButton.styleFrom(
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
backgroundColor: context.colorScheme.surfaceContainer,
alignment: Alignment.centerLeft,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(20)),
),
),
icon: Icon(
icon,
color: context.primaryColor,
),
),
);
}
final remote = albums.where((a) => a.isRemote).toList();
final sorted = albumSortOption.sortFn(remote, albumSortIsReverse);
final local = albums.where((a) => a.isLocal).toList();
Widget? shareTrashButton() {
return trashEnabled
? InkWell(
onTap: () => context.pushRoute(const TrashRoute()),
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Icon(
Icons.delete_rounded,
size: 25,
semanticLabel: 'profile_drawer_trash'.tr(),
),
)
: null;
}
return Scaffold(
appBar: ImmichAppBar(
action: shareTrashButton(),
),
body: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(
left: 12.0,
right: 12.0,
top: 24.0,
bottom: 12.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
buildLibraryNavButton(
"library_page_favorites".tr(), Icons.favorite_border, () {
context.navigateTo(const FavoritesRoute());
}),
const SizedBox(width: 12.0),
buildLibraryNavButton(
"library_page_archive".tr(), Icons.archive_outlined, () {
context.navigateTo(const ArchiveRoute());
}),
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(
top: 12.0,
left: 12.0,
right: 12.0,
bottom: 20.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'library_page_albums',
style: context.textTheme.bodyLarge?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
).tr(),
buildSortButton(),
],
),
),
),
SliverPadding(
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: () => context.pushRoute(
AlbumViewerRoute(
albumId: sorted[index - 1].id,
),
),
);
},
class QuickAccessButtons extends ConsumerWidget {
const QuickAccessButtons({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final partners = ref.watch(partnerSharedWithProvider);
return Container(
decoration: BoxDecoration(
border: Border.all(
color: context.colorScheme.onSurface.withAlpha(10),
width: 1,
),
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: [
context.colorScheme.primary.withAlpha(10),
context.colorScheme.primary.withAlpha(15),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: ListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
bottomLeft: Radius.circular(partners.isEmpty ? 20 : 0),
bottomRight: Radius.circular(partners.isEmpty ? 20 : 0),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(
top: 12.0,
left: 12.0,
right: 12.0,
bottom: 20.0,
leading: const Icon(
Icons.group_outlined,
size: 26,
),
title: Text(
'partners'.tr(),
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'library_page_device_albums',
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
).tr(),
),
onTap: () => context.pushRoute(const PartnerRoute()),
),
PartnerList(partners: partners),
],
),
);
}
}
class PartnerList extends ConsumerWidget {
const PartnerList({super.key, required this.partners});
final List<User> partners;
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView.builder(
physics: const NeverScrollableScrollPhysics(),
itemCount: partners.length,
shrinkWrap: true,
itemBuilder: (context, index) {
final partner = partners[index];
final isLastItem = index == partners.length - 1;
return ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(isLastItem ? 20 : 0),
bottomRight: Radius.circular(isLastItem ? 20 : 0),
),
),
contentPadding: const EdgeInsets.only(
left: 12.0,
right: 18.0,
),
leading: userAvatar(context, partner, radius: 16),
title: Text(
"partner_list_user_photos",
style: TextStyle(
fontWeight: FontWeight.w500,
),
).tr(
namedArgs: {
'user': partner.name,
},
),
onTap: () => context.pushRoute(
(PartnerDetailRoute(partner: partner)),
),
);
},
);
}
}
class PeopleCollectionCard extends ConsumerWidget {
const PeopleCollectionCard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final people = ref.watch(getAllPeopleProvider);
final size = MediaQuery.of(context).size.width * 0.5 - 20;
return GestureDetector(
onTap: () => context.pushRoute(const PeopleCollectionRoute()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: size,
width: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: [
context.colorScheme.primary.withAlpha(30),
context.colorScheme.primary.withAlpha(25),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: people.widgetWhen(
onData: (people) {
return GridView.count(
crossAxisCount: 2,
padding: const EdgeInsets.all(12),
crossAxisSpacing: 8,
mainAxisSpacing: 8,
physics: const NeverScrollableScrollPhysics(),
children: people.take(4).map((person) {
return CircleAvatar(
backgroundImage: NetworkImage(
getFaceThumbnailUrl(person.id),
headers: ApiService.getRequestHeaders(),
),
);
}).toList(),
);
},
),
),
SliverPadding(
padding: const EdgeInsets.all(12.0),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 250,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: .7,
),
delegate: SliverChildBuilderDelegate(
childCount: local.length,
(context, index) => AlbumThumbnailCard(
album: local[index],
onTap: () => context.pushRoute(
AlbumViewerRoute(
albumId: local[index].id,
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'people'.tr(),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
@ -327,3 +251,158 @@ class LibraryPage extends HookConsumerWidget {
);
}
}
class LocalAlbumsCollectionCard extends HookConsumerWidget {
const LocalAlbumsCollectionCard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(localAlbumsProvider);
final size = MediaQuery.of(context).size.width * 0.5 - 20;
return GestureDetector(
onTap: () => context.pushRoute(
const LocalAlbumsRoute(),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: size,
width: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: [
context.colorScheme.primary.withAlpha(30),
context.colorScheme.primary.withAlpha(25),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: GridView.count(
crossAxisCount: 2,
padding: const EdgeInsets.all(12),
crossAxisSpacing: 8,
mainAxisSpacing: 8,
physics: const NeverScrollableScrollPhysics(),
children: albums.take(4).map((album) {
return AlbumThumbnailCard(
album: album,
showTitle: false,
);
}).toList(),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'on_this_device'.tr(),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
}
class PlacesCollectionCard extends StatelessWidget {
const PlacesCollectionCard({super.key});
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size.width * 0.5 - 20;
return GestureDetector(
onTap: () => context.pushRoute(const PlacesCollectionRoute()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: size,
width: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: context.colorScheme.secondaryContainer.withAlpha(100),
),
child: IgnorePointer(
child: MapThumbnail(
zoom: 8,
centre: const LatLng(
21.44950,
-157.91959,
),
showAttribution: false,
themeMode:
context.isDarkTheme ? ThemeMode.dark : ThemeMode.light,
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'places'.tr(),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
}
class ActionButton extends StatelessWidget {
final VoidCallback onPressed;
final IconData icon;
final String label;
const ActionButton({
super.key,
required this.onPressed,
required this.icon,
required this.label,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: FilledButton.icon(
onPressed: onPressed,
label: Padding(
padding: const EdgeInsets.only(left: 4.0),
child: Text(
label,
style: TextStyle(
color: context.colorScheme.onSurface,
fontSize: 15,
),
),
),
style: FilledButton.styleFrom(
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
backgroundColor: context.colorScheme.surfaceContainerLow,
alignment: Alignment.centerLeft,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(25)),
side: BorderSide(
color: context.colorScheme.onSurface.withAlpha(10),
width: 1,
),
),
),
icon: Icon(
icon,
color: context.primaryColor,
),
),
);
}
}

View file

@ -0,0 +1,55 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
@RoutePage()
class LocalAlbumsPage extends HookConsumerWidget {
const LocalAlbumsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(localAlbumsProvider);
return Scaffold(
appBar: AppBar(
title: Text('on_this_device'.tr()),
),
body: ListView.builder(
padding: const EdgeInsets.all(18.0),
itemCount: albums.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: LargeLeadingTile(
leadingPadding: const EdgeInsets.only(
right: 16,
),
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: ImmichThumbnail(
asset: albums[index].thumbnail.value,
width: 80,
height: 80,
),
),
title: Text(
albums[index].name,
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
subtitle: Text('${albums[index].assetCount} items'),
onTap: () => context
.pushRoute(AlbumViewerRoute(albumId: albums[index].id)),
),
);
},
),
);
}
}

View file

@ -0,0 +1,161 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/partner.provider.dart';
import 'package:immich_mobile/services/partner.service.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/user_avatar.dart';
@RoutePage()
class PartnerPage extends HookConsumerWidget {
const PartnerPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final List<User> partners = ref.watch(partnerSharedByProvider);
final availableUsers = ref.watch(partnerAvailableProvider);
addNewUsersHandler() async {
final users = availableUsers.value;
if (users == null || users.isEmpty) {
ImmichToast.show(
context: context,
msg: "partner_page_no_more_users".tr(),
);
return;
}
final selectedUser = await showDialog<User>(
context: context,
builder: (context) {
return SimpleDialog(
title: const Text("partner_page_select_partner").tr(),
children: [
for (User u in users)
SimpleDialogOption(
onPressed: () => context.pop(u),
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: userAvatar(context, u),
),
Text(u.name),
],
),
),
],
);
},
);
if (selectedUser != null) {
final ok =
await ref.read(partnerServiceProvider).addPartner(selectedUser);
if (ok) {
ref.invalidate(partnerSharedByProvider);
} else {
ImmichToast.show(
context: context,
msg: "partner_page_partner_add_failed".tr(),
toastType: ToastType.error,
);
}
}
}
onDeleteUser(User u) {
return showDialog(
context: context,
builder: (BuildContext context) {
return ConfirmDialog(
title: "partner_page_stop_sharing_title",
content: "partner_page_stop_sharing_content".tr(args: [u.name]),
onOk: () => ref.read(partnerServiceProvider).removePartner(u),
);
},
);
}
buildUserList(List<User> users) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: Text(
"partner_page_shared_to_title",
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface.withAlpha(200),
),
).tr(),
),
if (users.isNotEmpty)
ListView.builder(
shrinkWrap: true,
itemCount: users.length,
itemBuilder: ((context, index) {
return ListTile(
leading: userAvatar(context, users[index]),
title: Text(
users[index].email,
style: context.textTheme.bodyLarge,
),
trailing: IconButton(
icon: const Icon(Icons.person_remove),
onPressed: () => onDeleteUser(users[index]),
),
);
}),
),
if (users.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: const Text(
"partner_page_empty_message",
style: TextStyle(fontSize: 14),
).tr(),
),
Align(
alignment: Alignment.center,
child: ElevatedButton.icon(
onPressed: availableUsers.whenOrNull(
data: (data) => addNewUsersHandler,
),
icon: const Icon(Icons.person_add),
label: const Text("partner_page_add_partner").tr(),
),
),
],
),
),
],
);
}
return Scaffold(
appBar: AppBar(
title: const Text("partners").tr(),
elevation: 0,
centerTitle: false,
actions: [
IconButton(
onPressed:
availableUsers.whenOrNull(data: (data) => addNewUsersHandler),
icon: const Icon(Icons.person_add),
tooltip: "partner_page_add_partner".tr(),
),
],
),
body: buildUserList(partners),
);
}
}

View file

@ -0,0 +1,120 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/providers/partner.provider.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@RoutePage()
class PartnerDetailPage extends HookConsumerWidget {
const PartnerDetailPage({super.key, required this.partner});
final User partner;
@override
Widget build(BuildContext context, WidgetRef ref) {
final inTimeline = useState(partner.inTimeline);
bool toggleInProcess = false;
useEffect(
() {
Future.microtask(
() async => {
await ref.read(assetProvider.notifier).getAllAsset(),
},
);
return null;
},
[],
);
void toggleInTimeline() async {
if (toggleInProcess) return;
toggleInProcess = true;
try {
final ok = await ref
.read(partnerSharedWithProvider.notifier)
.updatePartner(partner, inTimeline: !inTimeline.value);
if (ok) {
inTimeline.value = !inTimeline.value;
final action = inTimeline.value ? "shown on" : "hidden from";
ImmichToast.show(
context: context,
toastType: ToastType.success,
durationInSecond: 1,
msg: "${partner.name}'s assets $action your timeline",
);
} else {
ImmichToast.show(
context: context,
toastType: ToastType.error,
durationInSecond: 1,
msg: "Failed to toggle the timeline setting",
);
}
} finally {
toggleInProcess = false;
}
}
return Scaffold(
appBar: ref.watch(multiselectProvider)
? null
: AppBar(
title: Text(partner.name),
elevation: 0,
centerTitle: false,
),
body: MultiselectGrid(
topWidget: Padding(
padding: const EdgeInsets.only(left: 8.0, right: 8.0, top: 16.0),
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: context.colorScheme.onSurface.withAlpha(10),
width: 1,
),
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: [
context.colorScheme.primary.withAlpha(10),
context.colorScheme.primary.withAlpha(15),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
title: Text(
"Show in timeline",
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.primary,
),
),
subtitle: Text(
"Show photos and videos from this user in your timeline",
style: context.textTheme.bodyMedium,
),
trailing: Switch(
value: inTimeline.value,
onChanged: (_) => toggleInTimeline(),
),
),
),
),
),
renderListProvider: assetsProvider(partner.isarId),
onRefresh: () => ref.read(assetProvider.notifier).getAllAsset(),
deleteEnabled: false,
favoriteEnabled: false,
),
);
}
}

View file

@ -0,0 +1,104 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/search/person_name_edit_form.dart';
@RoutePage()
class PeopleCollectionPage extends HookConsumerWidget {
const PeopleCollectionPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final people = ref.watch(getAllPeopleProvider);
final headers = ApiService.getRequestHeaders();
showNameEditModel(
String personId,
String personName,
) {
return showDialog(
context: context,
builder: (BuildContext context) {
return PersonNameEditForm(personId: personId, personName: personName);
},
);
}
return Scaffold(
appBar: AppBar(
title: Text('people'.tr()),
),
body: people.when(
data: (people) {
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
childAspectRatio: 0.85,
),
padding: const EdgeInsets.symmetric(vertical: 32),
itemCount: people.length,
itemBuilder: (context, index) {
final person = people[index];
return Column(
children: [
GestureDetector(
onTap: () {
context.pushRoute(
PersonResultRoute(
personId: person.id,
personName: person.name,
),
);
},
child: Material(
shape: const CircleBorder(side: BorderSide.none),
elevation: 3,
child: CircleAvatar(
maxRadius: 96 / 2,
backgroundImage: NetworkImage(
getFaceThumbnailUrl(person.id),
headers: headers,
),
),
),
),
const SizedBox(height: 12),
GestureDetector(
onTap: () => showNameEditModel(person.id, person.name),
child: person.name.isEmpty
? Text(
'add_a_name'.tr(),
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: context.colorScheme.primary,
),
)
: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
person.name,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
),
],
);
},
);
},
error: (error, stack) => const Text("error"),
loading: () => const CircularProgressIndicator(),
),
);
}
}

View file

@ -0,0 +1,125 @@
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:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/providers/search/search_page_state.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
class PlacesCollectionPage extends HookConsumerWidget {
const PlacesCollectionPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final places = ref.watch(getAllPlacesProvider);
return Scaffold(
appBar: AppBar(
title: Text('places'.tr()),
),
body: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
height: 200,
width: context.width,
child: MapThumbnail(
onTap: (_, __) => context.pushRoute(const MapRoute()),
zoom: 8,
centre: const LatLng(
21.44950,
-157.91959,
),
showAttribution: false,
themeMode:
context.isDarkTheme ? ThemeMode.dark : ThemeMode.light,
),
),
),
places.when(
data: (places) {
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: places.length,
itemBuilder: (context, index) {
final place = places[index];
return PlaceTile(id: place.id, name: place.label);
},
);
},
error: (error, stask) => const Text('Error getting places'),
loading: () => Center(child: const CircularProgressIndicator()),
),
],
),
);
}
}
class PlaceTile extends StatelessWidget {
const PlaceTile({super.key, required this.id, required this.name});
final String id;
final String name;
@override
Widget build(BuildContext context) {
final thumbnailUrl =
'${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail';
void navigateToPlace() {
context.pushRoute(
SearchInputRoute(
prefilter: SearchFilter(
people: {},
location: SearchLocationFilter(
city: name,
),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(
isNotInAlbum: false,
isArchive: false,
isFavorite: false,
),
mediaType: AssetType.other,
),
),
);
}
return LargeLeadingTile(
onTap: () => navigateToPlace(),
title: Text(
name,
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
leading: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: CachedNetworkImage(
width: 80,
height: 80,
fit: BoxFit.cover,
imageUrl: thumbnailUrl,
httpHeaders: ApiService.getRequestHeaders(),
errorWidget: (context, url, error) =>
const Icon(Icons.image_not_supported_outlined),
),
),
);
}
}

View file

@ -0,0 +1,129 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
import 'package:immich_mobile/providers/shared_link.provider.dart';
import 'package:immich_mobile/widgets/shared_link/shared_link_item.dart';
@RoutePage()
class SharedLinkPage extends HookConsumerWidget {
const SharedLinkPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final sharedLinks = ref.watch(sharedLinksStateProvider);
useEffect(
() {
ref.read(sharedLinksStateProvider.notifier).fetchLinks();
return () {
if (!context.mounted) return;
ref.invalidate(sharedLinksStateProvider);
};
},
[],
);
Widget buildNoShares() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: const Text(
"shared_link_manage_links",
style: TextStyle(
fontSize: 14,
color: Colors.grey,
fontWeight: FontWeight.bold,
),
).tr(),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: const Text(
"shared_link_empty",
style: TextStyle(fontSize: 14),
).tr(),
),
),
Expanded(
child: Center(
child: Icon(
Icons.link_off,
size: 100,
color: context.themeData.iconTheme.color?.withOpacity(0.5),
),
),
),
],
);
}
Widget buildSharesList(List<SharedLink> links) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0, bottom: 30.0),
child: Text(
"shared_link_manage_links",
style: context.textTheme.labelLarge?.copyWith(
color: context.textTheme.labelLarge?.color?.withAlpha(200),
),
).tr(),
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth > 600) {
// Two column
return GridView.builder(
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisExtent: 180,
),
itemCount: links.length,
itemBuilder: (context, index) {
return SharedLinkItem(links.elementAt(index));
},
);
}
// Single column
return ListView.builder(
itemCount: links.length,
itemBuilder: (context, index) {
return SharedLinkItem(links.elementAt(index));
},
);
},
),
),
],
);
}
return Scaffold(
appBar: AppBar(
title: const Text("shared_link_app_bar_title").tr(),
elevation: 0,
centerTitle: false,
),
body: SafeArea(
child: sharedLinks.widgetWhen(
onError: (error, stackTrace) => buildNoShares(),
onData: (links) =>
links.isNotEmpty ? buildSharesList(links) : buildNoShares(),
),
),
);
}
}

View file

@ -0,0 +1,531 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
import 'package:immich_mobile/providers/shared_link.provider.dart';
import 'package:immich_mobile/services/shared_link.service.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/utils/url_helper.dart';
@RoutePage()
class SharedLinkEditPage extends HookConsumerWidget {
final SharedLink? existingLink;
final List<String>? assetsList;
final String? albumId;
const SharedLinkEditPage({
super.key,
this.existingLink,
this.assetsList,
this.albumId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
const padding = 20.0;
final themeData = context.themeData;
final colorScheme = context.colorScheme;
final descriptionController =
useTextEditingController(text: existingLink?.description ?? "");
final descriptionFocusNode = useFocusNode();
final passwordController =
useTextEditingController(text: existingLink?.password ?? "");
final showMetadata = useState(existingLink?.showMetadata ?? true);
final allowDownload = useState(existingLink?.allowDownload ?? true);
final allowUpload = useState(existingLink?.allowUpload ?? false);
final editExpiry = useState(false);
final expiryAfter = useState(0);
final newShareLink = useState("");
Widget buildLinkTitle() {
if (existingLink != null) {
if (existingLink!.type == SharedLinkSource.album) {
return Row(
children: [
const Text(
'shared_link_public_album',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
const Text(
" | ",
style: TextStyle(fontWeight: FontWeight.bold),
),
Text(
existingLink!.title,
style: TextStyle(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
);
}
if (existingLink!.type == SharedLinkSource.individual) {
return Row(
children: [
const Text(
'shared_link_individual_shared',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
const Text(
" | ",
style: TextStyle(fontWeight: FontWeight.bold),
),
Expanded(
child: Text(
existingLink!.description ?? "--",
style: TextStyle(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
],
);
}
}
return const Text(
"shared_link_create_info",
style: TextStyle(fontWeight: FontWeight.bold),
).tr();
}
Widget buildDescriptionField() {
return TextField(
controller: descriptionController,
enabled: newShareLink.value.isEmpty,
focusNode: descriptionFocusNode,
textInputAction: TextInputAction.done,
autofocus: false,
decoration: InputDecoration(
labelText: 'shared_link_edit_description'.tr(),
labelStyle: TextStyle(
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
floatingLabelBehavior: FloatingLabelBehavior.always,
border: const OutlineInputBorder(),
hintText: 'shared_link_edit_description_hint'.tr(),
hintStyle: const TextStyle(
fontWeight: FontWeight.normal,
fontSize: 14,
),
disabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.grey.withOpacity(0.5)),
),
),
onTapOutside: (_) => descriptionFocusNode.unfocus(),
);
}
Widget buildPasswordField() {
return TextField(
controller: passwordController,
enabled: newShareLink.value.isEmpty,
autofocus: false,
decoration: InputDecoration(
labelText: 'shared_link_edit_password'.tr(),
labelStyle: TextStyle(
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
floatingLabelBehavior: FloatingLabelBehavior.always,
border: const OutlineInputBorder(),
hintText: 'shared_link_edit_password_hint'.tr(),
hintStyle: const TextStyle(
fontWeight: FontWeight.normal,
fontSize: 14,
),
disabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.grey.withOpacity(0.5)),
),
),
);
}
Widget buildShowMetaButton() {
return SwitchListTile.adaptive(
value: showMetadata.value,
onChanged: newShareLink.value.isEmpty
? (value) => showMetadata.value = value
: null,
activeColor: colorScheme.primary,
dense: true,
title: Text(
"shared_link_edit_show_meta",
style: themeData.textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
);
}
Widget buildAllowDownloadButton() {
return SwitchListTile.adaptive(
value: allowDownload.value,
onChanged: newShareLink.value.isEmpty
? (value) => allowDownload.value = value
: null,
activeColor: colorScheme.primary,
dense: true,
title: Text(
"shared_link_edit_allow_download",
style: themeData.textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
);
}
Widget buildAllowUploadButton() {
return SwitchListTile.adaptive(
value: allowUpload.value,
onChanged: newShareLink.value.isEmpty
? (value) => allowUpload.value = value
: null,
activeColor: colorScheme.primary,
dense: true,
title: Text(
"shared_link_edit_allow_upload",
style: themeData.textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
);
}
Widget buildEditExpiryButton() {
return SwitchListTile.adaptive(
value: editExpiry.value,
onChanged: newShareLink.value.isEmpty
? (value) => editExpiry.value = value
: null,
activeColor: colorScheme.primary,
dense: true,
title: Text(
"shared_link_edit_change_expiry",
style: themeData.textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
);
}
Widget buildExpiryAfterButton() {
return DropdownMenu(
label: Text(
"shared_link_edit_expire_after",
style: TextStyle(
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
).tr(),
enableSearch: false,
enableFilter: false,
width: context.width - 40,
initialSelection: expiryAfter.value,
enabled: newShareLink.value.isEmpty &&
(existingLink == null || editExpiry.value),
onSelected: (value) {
expiryAfter.value = value!;
},
dropdownMenuEntries: [
DropdownMenuEntry(
value: 0,
label: "shared_link_edit_expire_after_option_never".tr(),
),
DropdownMenuEntry(
value: 30,
label:
"shared_link_edit_expire_after_option_minutes".tr(args: ["30"]),
),
DropdownMenuEntry(
value: 60,
label: "shared_link_edit_expire_after_option_hour".tr(),
),
DropdownMenuEntry(
value: 60 * 6,
label: "shared_link_edit_expire_after_option_hours".tr(args: ["6"]),
),
DropdownMenuEntry(
value: 60 * 24,
label: "shared_link_edit_expire_after_option_day".tr(),
),
DropdownMenuEntry(
value: 60 * 24 * 7,
label: "shared_link_edit_expire_after_option_days".tr(args: ["7"]),
),
DropdownMenuEntry(
value: 60 * 24 * 30,
label: "shared_link_edit_expire_after_option_days".tr(args: ["30"]),
),
DropdownMenuEntry(
value: 60 * 24 * 30 * 3,
label:
"shared_link_edit_expire_after_option_months".tr(args: ["3"]),
),
DropdownMenuEntry(
value: 60 * 24 * 30 * 12,
label: "shared_link_edit_expire_after_option_year".tr(args: ["1"]),
),
],
);
}
void copyLinkToClipboard() {
Clipboard.setData(ClipboardData(text: newShareLink.value)).then((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
"shared_link_clipboard_copied_massage",
style: context.textTheme.bodyLarge?.copyWith(
color: context.primaryColor,
),
).tr(),
duration: const Duration(seconds: 2),
),
);
});
}
Widget buildNewLinkField() {
return Column(
children: [
const Padding(
padding: EdgeInsets.only(
top: 20,
bottom: 20,
),
child: Divider(),
),
TextFormField(
readOnly: true,
initialValue: newShareLink.value,
decoration: InputDecoration(
border: const OutlineInputBorder(),
enabledBorder: themeData.inputDecorationTheme.focusedBorder,
suffixIcon: IconButton(
onPressed: copyLinkToClipboard,
icon: const Icon(Icons.copy),
),
),
),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Align(
alignment: Alignment.bottomRight,
child: ElevatedButton(
onPressed: () {
context.maybePop();
},
child: const Text(
"share_done",
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
).tr(),
),
),
),
],
);
}
DateTime calculateExpiry() {
return DateTime.now().add(Duration(minutes: expiryAfter.value));
}
Future<void> handleNewLink() async {
final newLink =
await ref.read(sharedLinkServiceProvider).createSharedLink(
albumId: albumId,
assetIds: assetsList,
showMeta: showMetadata.value,
allowDownload: allowDownload.value,
allowUpload: allowUpload.value,
description: descriptionController.text.isEmpty
? null
: descriptionController.text,
password: passwordController.text.isEmpty
? null
: passwordController.text,
expiresAt: expiryAfter.value == 0 ? null : calculateExpiry(),
);
ref.invalidate(sharedLinksStateProvider);
final externalDomain = ref.read(
serverInfoProvider.select((s) => s.serverConfig.externalDomain),
);
var serverUrl =
externalDomain.isNotEmpty ? externalDomain : getServerUrl();
if (serverUrl != null && !serverUrl.endsWith('/')) {
serverUrl += '/';
}
if (newLink != null && serverUrl != null) {
newShareLink.value = "${serverUrl}share/${newLink.key}";
copyLinkToClipboard();
} else if (newLink == null) {
ImmichToast.show(
context: context,
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
msg: 'shared_link_create_error'.tr(),
);
}
}
Future<void> handleEditLink() async {
bool? download;
bool? upload;
bool? meta;
String? desc;
String? password;
DateTime? expiry;
bool? changeExpiry;
if (allowDownload.value != existingLink!.allowDownload) {
download = allowDownload.value;
}
if (allowUpload.value != existingLink!.allowUpload) {
upload = allowUpload.value;
}
if (showMetadata.value != existingLink!.showMetadata) {
meta = showMetadata.value;
}
if (descriptionController.text != existingLink!.description) {
desc = descriptionController.text;
}
if (passwordController.text != existingLink!.password) {
password = passwordController.text;
}
if (editExpiry.value) {
expiry = expiryAfter.value == 0 ? null : calculateExpiry();
changeExpiry = true;
}
await ref.read(sharedLinkServiceProvider).updateSharedLink(
existingLink!.id,
showMeta: meta,
allowDownload: download,
allowUpload: upload,
description: desc,
password: password,
expiresAt: expiry,
changeExpiry: changeExpiry,
);
ref.invalidate(sharedLinksStateProvider);
context.maybePop();
}
return Scaffold(
appBar: AppBar(
title: Text(
existingLink == null
? "shared_link_create_app_bar_title"
: "shared_link_edit_app_bar_title",
).tr(),
elevation: 0,
leading: const CloseButton(),
centerTitle: false,
),
body: SafeArea(
child: ListView(
children: [
Padding(
padding: const EdgeInsets.all(padding),
child: buildLinkTitle(),
),
Padding(
padding: const EdgeInsets.all(padding),
child: buildDescriptionField(),
),
Padding(
padding: const EdgeInsets.all(padding),
child: buildPasswordField(),
),
Padding(
padding: const EdgeInsets.only(
left: padding,
right: padding,
bottom: padding,
),
child: buildShowMetaButton(),
),
Padding(
padding: const EdgeInsets.only(
left: padding,
right: padding,
bottom: padding,
),
child: buildAllowDownloadButton(),
),
Padding(
padding:
const EdgeInsets.only(left: padding, right: 20, bottom: 20),
child: buildAllowUploadButton(),
),
if (existingLink != null)
Padding(
padding: const EdgeInsets.only(
left: padding,
right: padding,
bottom: padding,
),
child: buildEditExpiryButton(),
),
Padding(
padding: const EdgeInsets.only(
left: padding,
right: padding,
bottom: padding,
),
child: buildExpiryAfterButton(),
),
if (newShareLink.value.isEmpty)
Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(
right: padding + 10,
bottom: padding,
),
child: ElevatedButton(
onPressed:
existingLink != null ? handleEditLink : handleNewLink,
child: Text(
existingLink != null
? "shared_link_edit_submit_button"
: "shared_link_create_submit_button",
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
).tr(),
),
),
),
if (newShareLink.value.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
left: padding,
right: padding,
bottom: padding,
),
child: buildNewLinkField(),
),
],
),
),
);
}
}