import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/remote_album/drift_album_option.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/remote_album_sliver_app_bar.dart'; @RoutePage() class RemoteAlbumPage extends ConsumerStatefulWidget { final RemoteAlbum album; const RemoteAlbumPage({ super.key, required this.album, }); @override ConsumerState createState() => _RemoteAlbumPageState(); } class _RemoteAlbumPageState extends ConsumerState { @override void initState() { super.initState(); } Future addAssets(BuildContext context) async { final albumAssets = await ref.read(remoteAlbumProvider.notifier).getAssets(widget.album.id); final newAssets = await context.pushRoute>( DriftAssetSelectionTimelineRoute( lockedSelectionAssets: albumAssets.toSet(), ), ); if (newAssets == null || newAssets.isEmpty) { return; } final added = await ref.read(remoteAlbumProvider.notifier).addAssets( widget.album.id, newAssets.map((asset) { final remoteAsset = asset as RemoteAsset; return remoteAsset.id; }).toList(), ); if (added > 0) { ImmichToast.show( context: context, msg: "assets_added_to_album_count".t( context: context, args: { 'count': added.toString(), }, ), toastType: ToastType.success, ); } } Future addUsers(BuildContext context) async { final newUsers = await context.pushRoute>( DriftUserSelectionRoute(album: widget.album), ); if (newUsers == null || newUsers.isEmpty) { return; } try { await ref.read(remoteAlbumProvider.notifier).addUsers(widget.album.id, newUsers); if (newUsers.isNotEmpty) { ImmichToast.show( context: context, msg: "users_added_to_album_count".t( context: context, args: { 'count': newUsers.length, }, ), toastType: ToastType.success, ); } ref.invalidate(remoteAlbumSharedUsersProvider(widget.album.id)); } catch (e) { ImmichToast.show( context: context, msg: "Failed to add users to album: ${e.toString()}", toastType: ToastType.error, ); } } Future toggleAlbumOrder() async { await ref.read(remoteAlbumProvider.notifier).toggleAlbumOrder( widget.album.id, ); ref.invalidate(timelineServiceProvider); } Future deleteAlbum(BuildContext context) async { final confirmed = await showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: Text('delete_album'.t(context: context)), content: Column( mainAxisSize: MainAxisSize.min, children: [ Text( 'album_delete_confirmation'.t( context: context, args: {'album': widget.album.name}, ), ), const SizedBox(height: 8), Text( 'album_delete_confirmation_description'.t(context: context), ), ], ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: Text('cancel'.t(context: context)), ), TextButton( onPressed: () => Navigator.of(context).pop(true), style: TextButton.styleFrom( foregroundColor: Theme.of(context).colorScheme.error, ), child: Text('delete_album'.t(context: context)), ), ], ); }, ); if (confirmed == true) { try { await ref.read(remoteAlbumProvider.notifier).deleteAlbum(widget.album.id); ImmichToast.show( context: context, msg: 'album_deleted'.t(context: context), toastType: ToastType.success, ); context.pushRoute(const DriftAlbumsRoute()); } catch (e) { ImmichToast.show( context: context, msg: 'album_viewer_appbar_share_err_delete'.t(context: context), toastType: ToastType.error, ); } } } Future showEditTitleAndDescription(BuildContext context) async { final result = await showDialog<_EditAlbumData?>( context: context, barrierDismissible: true, builder: (context) => _EditAlbumDialog(album: widget.album), ); if (result != null && context.mounted) { HapticFeedback.mediumImpact(); } } void showOptionSheet(BuildContext context) { final user = ref.watch(currentUserProvider); final isOwner = user != null ? user.id == widget.album.ownerId : false; showModalBottomSheet( context: context, backgroundColor: context.colorScheme.surface, isScrollControlled: false, builder: (context) { return DriftRemoteAlbumOption( onDeleteAlbum: isOwner ? () async { await deleteAlbum(context); if (context.mounted) { context.pop(); } } : null, onAddUsers: isOwner ? () async { await addUsers(context); context.pop(); } : null, onAddPhotos: () async { await addAssets(context); context.pop(); }, onToggleAlbumOrder: () async { await toggleAlbumOrder(); context.pop(); }, onEditAlbum: () async { context.pop(); await showEditTitleAndDescription(context); }, ); }, ); } @override Widget build(BuildContext context) { return ProviderScope( overrides: [ timelineServiceProvider.overrideWith( (ref) { final timelineService = ref.watch(timelineFactoryProvider).remoteAlbum(albumId: widget.album.id); ref.onDispose(timelineService.dispose); return timelineService; }, ), ], child: Timeline( appBar: RemoteAlbumSliverAppBar( icon: Icons.photo_album_outlined, onShowOptions: () => showOptionSheet(context), onToggleAlbumOrder: () => toggleAlbumOrder(), onEditTitle: () => showEditTitleAndDescription(context), ), bottomSheet: RemoteAlbumBottomSheet( album: widget.album, ), ), ); } } class _EditAlbumData { final String name; final String? description; const _EditAlbumData({ required this.name, this.description, }); } class _EditAlbumDialog extends ConsumerStatefulWidget { final RemoteAlbum album; const _EditAlbumDialog({ required this.album, }); @override ConsumerState<_EditAlbumDialog> createState() => _EditAlbumDialogState(); } class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> { late final TextEditingController titleController; late final TextEditingController descriptionController; final formKey = GlobalKey(); @override void initState() { super.initState(); titleController = TextEditingController(text: widget.album.name); descriptionController = TextEditingController( text: widget.album.description.isEmpty ? '' : widget.album.description, ); } @override void dispose() { titleController.dispose(); descriptionController.dispose(); super.dispose(); } Future _handleSave() async { if (formKey.currentState?.validate() != true) return; try { final newTitle = titleController.text.trim(); final newDescription = descriptionController.text.trim(); await ref.read(remoteAlbumProvider.notifier).updateAlbum( widget.album.id, name: newTitle, description: newDescription.isEmpty ? null : newDescription, ); if (mounted) { Navigator.of(context).pop( _EditAlbumData( name: newTitle, description: newDescription.isEmpty ? null : newDescription, ), ); } } catch (e) { if (mounted) { ImmichToast.show( context: context, msg: 'album_update_error'.t(context: context), toastType: ToastType.error, ); } } } @override Widget build(BuildContext context) { return Dialog( insetPadding: const EdgeInsets.all(24), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all( Radius.circular(16), ), ), child: SingleChildScrollView( child: Container( padding: const EdgeInsets.all(16), constraints: const BoxConstraints(maxWidth: 550), child: Form( key: formKey, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Icon( Icons.edit_outlined, color: context.colorScheme.primary, size: 24, ), const SizedBox(width: 12), Text( 'edit_album'.t(context: context), style: context.textTheme.titleMedium, ), ], ), const SizedBox(height: 24), // Album Name Text( 'album_name'.t(context: context).toUpperCase(), style: context.textTheme.labelSmall?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 4), TextFormField( controller: titleController, maxLines: 1, textCapitalization: TextCapitalization.sentences, decoration: InputDecoration( border: const OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(12)), ), filled: true, fillColor: context.colorScheme.surface, ), validator: (value) { if (value == null || value.trim().isEmpty) { return 'album_name_required'.t(context: context); } return null; }, ), const SizedBox(height: 18), // Description Text( 'description'.t(context: context).toUpperCase(), style: context.textTheme.labelSmall?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 4), TextFormField( controller: descriptionController, maxLines: 4, textCapitalization: TextCapitalization.sentences, decoration: InputDecoration( border: const OutlineInputBorder( borderRadius: BorderRadius.all( Radius.circular(12), ), ), filled: true, fillColor: context.colorScheme.surface, ), ), const SizedBox(height: 24), // Action Buttons Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: () => Navigator.of(context).pop(null), child: Text('cancel'.t(context: context)), ), const SizedBox(width: 12), FilledButton( onPressed: _handleSave, child: Text('save'.t(context: context)), ), ], ), ], ), ), ), ), ); } }