diff --git a/lib/friendica_client/friendica_client.dart b/lib/friendica_client/friendica_client.dart index 3b1443c..5b62f48 100644 --- a/lib/friendica_client/friendica_client.dart +++ b/lib/friendica_client/friendica_client.dart @@ -150,6 +150,25 @@ class GalleryClient extends FriendicaClient { _networkStatusService.finishGalleryLoading(); return result; } + + FutureResult renameGallery( + String oldGalleryName, String newGalleryName) async { + _networkStatusService.startGalleryLoading(); + _logger.finest(() => 'Getting gallery data'); + final url = + Uri.parse('https://$serverName/api/friendica/photoalbum/update'); + final body = { + 'album': oldGalleryName, + 'album_new': newGalleryName, + }; + final result = await postUrl( + url, + body, + headers: _headers, + ).transform((_) => true); + _networkStatusService.finishGalleryLoading(); + return result.execErrorCast(); + } } class GroupsClient extends FriendicaClient { @@ -272,6 +291,35 @@ class GroupsClient extends FriendicaClient { } } +class ImageClient extends FriendicaClient { + ImageClient(super.credentials) : super(); + + FutureResult editImageData(ImageEntry image) async { + _networkStatusService.startGalleryLoading(); + final uri = Uri.parse('https://$serverName/api/friendica/photo/update'); + final body = { + 'album': image.album, + 'desc': image.description, + 'photo_id': image.id, + }; + + final result = await postUrl(uri, body, headers: _headers) + .andThen((_) => Result.ok(image)); + _networkStatusService.finishGalleryLoading(); + return result.execErrorCast(); + } + + FutureResult deleteImage(ImageEntry image) async { + final uri = Uri.parse( + 'https://$serverName/api/friendica/photo/delete?photo_id=${image.id}', + ); + + final result = await postUrl(uri, {}, headers: _headers) + .andThen((_) => Result.ok(image)); + return result.execErrorCast(); + } +} + class InteractionsClient extends FriendicaClient { static final _logger = Logger('$InteractionsClient'); diff --git a/lib/models/gallery_data.dart b/lib/models/gallery_data.dart index f06b7d7..e906409 100644 --- a/lib/models/gallery_data.dart +++ b/lib/models/gallery_data.dart @@ -4,4 +4,10 @@ class GalleryData { final DateTime created; GalleryData({required this.count, required this.name, required this.created}); + + GalleryData copy({String? name}) => GalleryData( + count: count, + name: name ?? this.name, + created: created, + ); } diff --git a/lib/models/image_entry.dart b/lib/models/image_entry.dart index 3c58a8f..bfec9fa 100644 --- a/lib/models/image_entry.dart +++ b/lib/models/image_entry.dart @@ -25,6 +25,22 @@ class ImageEntry { required this.scales, }); + ImageEntry copy({ + String? description, + }) => + ImageEntry( + id: id, + album: album, + filename: filename, + description: description ?? this.description, + thumbnailUrl: thumbnailUrl, + created: created, + height: height, + width: width, + visibility: visibility, + scales: scales, + ); + @override bool operator ==(Object other) => identical(this, other) || diff --git a/lib/routes.dart b/lib/routes.dart index 4041deb..512c780 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -12,6 +12,7 @@ import 'screens/group_create_screen.dart'; import 'screens/group_editor_screen.dart'; import 'screens/group_management_screen.dart'; import 'screens/home.dart'; +import 'screens/image_editor_screen.dart'; import 'screens/interactions_viewer_screen.dart'; import 'screens/message_thread_screen.dart'; import 'screens/message_threads_browser_screen.dart'; @@ -162,9 +163,16 @@ final appRouter = GoRouter( ), routes: [ GoRoute( - path: 'show/:name', + path: 'show', builder: (context, state) => GalleryScreen( + galleryName: state.extra!.toString(), + ), + ), + GoRoute( + path: 'edit/:name/image/:id', + builder: (context, state) => ImageEditorScreen( galleryName: state.params['name']!, + imageId: state.params['id']!, ), ), ], diff --git a/lib/screens/gallery_browsers_screen.dart b/lib/screens/gallery_browsers_screen.dart index ea05835..74ad999 100644 --- a/lib/screens/gallery_browsers_screen.dart +++ b/lib/screens/gallery_browsers_screen.dart @@ -7,6 +7,7 @@ import '../controls/padding.dart'; import '../controls/standard_appbar.dart'; import '../controls/status_and_refresh_button.dart'; import '../globals.dart'; +import '../models/gallery_data.dart'; import '../services/gallery_service.dart'; import '../services/network_status_service.dart'; import '../utils/active_profile_selector.dart'; @@ -14,6 +15,71 @@ import '../utils/active_profile_selector.dart'; class GalleryBrowsersScreen extends StatelessWidget { static final _logger = Logger('$GalleryBrowsersScreen'); + String? validNameChecker(String? text) { + final newName = text ?? ''; + if (newName.isEmpty) { + return 'Name cannot be empty'; + } + + if (!RegExp( + r"^[a-zA-Z0-9 ]+$", + ).hasMatch(newName)) { + return 'Name must be only letters and numbers'; + } + + return null; + } + + Future renameGallery( + BuildContext context, + GalleryService service, + GalleryData gallery, + ) async { + final newName = await showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + var controller = TextEditingController(text: gallery.name); + return Form( + child: AlertDialog( + title: const Text('Rename Gallery'), + content: TextFormField( + controller: controller, + autovalidateMode: AutovalidateMode.always, + validator: validNameChecker, + ), + actions: [ + ElevatedButton( + child: const Text('OK'), + onPressed: () { + if (validNameChecker(controller.text) != null) { + return; + } + Navigator.pop(context, + controller.text); // showDialog() returns true + }, + ), + ElevatedButton( + child: const Text('Cancel'), + onPressed: () { + Navigator.pop( + context, gallery.name); // showDialog() returns true + }, + ), + ], + ), + ); + }, + ) ?? + ''; + + if (newName.isEmpty || newName == gallery.name) { + return; + } + + await service.renameGallery(gallery, newName); + } + @override Widget build(BuildContext context) { _logger.finest('Building'); @@ -67,17 +133,20 @@ class GalleryBrowsersScreen extends StatelessWidget { return ListView.separated( itemBuilder: (context, index) { final gallery = galleries[index]; - return InkWell( - onTap: () { - context.push('/gallery/show/${gallery.name}'); - }, - child: ListTile( - title: Text(gallery.name), - subtitle: Text( - 'Created: ${gallery.created}', - style: Theme.of(context).textTheme.bodySmall, + return ListTile( + onTap: () => context.push('/gallery/show', extra: gallery.name), + title: Text(gallery.name), + subtitle: Text( + '#Photos: ${gallery.count}, Created: ${gallery.created}', + style: Theme.of(context).textTheme.bodySmall, + ), + trailing: ElevatedButton( + onPressed: () async => await renameGallery( + context, + service, + gallery, ), - trailing: Text('${gallery.count} Images'), + child: const Text('Rename'), ), ); }, diff --git a/lib/screens/gallery_screen.dart b/lib/screens/gallery_screen.dart index 7362556..9b38cff 100644 --- a/lib/screens/gallery_screen.dart +++ b/lib/screens/gallery_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; import 'package:provider/provider.dart'; +import 'package:result_monad/result_monad.dart'; import '../controls/login_aware_cached_network_image.dart'; import '../controls/standard_appbar.dart'; @@ -11,10 +13,10 @@ import '../serializers/friendica/image_entry_friendica_extensions.dart'; import '../services/gallery_service.dart'; import '../services/network_status_service.dart'; import '../utils/active_profile_selector.dart'; +import '../utils/snackbar_builder.dart'; import 'media_viewer_screen.dart'; class GalleryScreen extends StatelessWidget { - static const thumbnailDimension = 350.0; static final _logger = Logger('$GalleryScreen'); final String galleryName; @@ -22,32 +24,52 @@ class GalleryScreen extends StatelessWidget { @override Widget build(BuildContext context) { - _logger.finest('Building'); + _logger.finest('Building $galleryName'); final nss = getIt(); - final service = context - .watch>() - .activeEntry - .value; - final body = service.getGallery(galleryName).fold( - onSuccess: (galleryData) => - buildBody(context, service, galleryData.count), - onError: (error) => buildErrorBody(error.message), - ); return Scaffold( appBar: StandardAppBar.build(context, galleryName, actions: [ StatusAndRefreshButton( valueListenable: nss.imageGalleryLoadingStatus, - refreshFunction: () async => await service.updateGalleryImageList( - galleryName: galleryName, - withNextPage: false, - nextPageOnly: false, - ), + refreshFunction: () async => context + .read>() + .activeEntry + .withResultAsync( + (gs) async => gs.updateGalleryImageList( + galleryName: galleryName, + withNextPage: false, + nextPageOnly: false, + ), + ), busyColor: Theme.of(context).appBarTheme.foregroundColor, ), ]), - body: body, + body: _GalleryScreenBody( + galleryName: galleryName, + ), ); } +} + +class _GalleryScreenBody extends StatelessWidget { + static const thumbnailDimension = 350.0; + static final _logger = Logger('$_GalleryScreenBody'); + final String galleryName; + + const _GalleryScreenBody({required this.galleryName}); + + @override + Widget build(BuildContext context) { + _logger.finest('Building'); + final service = context + .watch>() + .activeEntry + .value; + return service.getGallery(galleryName).fold( + onSuccess: (galleryData) => + buildBody(context, service, galleryData.count), + onError: (error) => buildErrorBody(error.message), + ); + } Widget buildErrorBody(String error) { return Center( @@ -131,6 +153,48 @@ class GalleryScreen extends StatelessWidget { height: thumbnailDimension, imageUrl: image.thumbnailUrl, ), + Positioned( + top: 5.0, + right: 5.0, + child: Row( + children: [ + Card( + color: Theme.of(context) + .scaffoldBackgroundColor + .withOpacity(0.7), + child: IconButton( + onPressed: () => context.push( + '/gallery/edit/$galleryName/image/${image.id}', + ), + icon: const Icon(Icons.edit), + ), + ), + Card( + color: Theme.of(context) + .scaffoldBackgroundColor + .withOpacity(0.7), + child: IconButton( + onPressed: () async { + final confirm = await showYesNoDialog( + context, 'Delete image?'); + if (confirm != true) { + return; + } + await service + .deleteImage(image) + .withError((error) { + if (context.mounted) { + buildSnackbar(context, + 'Error deleting image: $error'); + } + }); + }, + icon: const Icon(Icons.delete), + ), + ), + ], + ), + ), if (image.description.isNotEmpty) Positioned( bottom: 5.0, diff --git a/lib/screens/image_editor_screen.dart b/lib/screens/image_editor_screen.dart new file mode 100644 index 0000000..4e32ea8 --- /dev/null +++ b/lib/screens/image_editor_screen.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:result_monad/result_monad.dart'; + +import '../controls/login_aware_cached_network_image.dart'; +import '../controls/padding.dart'; +import '../controls/responsive_max_width.dart'; +import '../controls/standard_appbar.dart'; +import '../globals.dart'; +import '../models/exec_error.dart'; +import '../models/image_entry.dart'; +import '../models/visibility.dart'; +import '../services/gallery_service.dart'; +import '../utils/active_profile_selector.dart'; +import '../utils/snackbar_builder.dart'; + +class ImageEditorScreen extends StatefulWidget { + final String galleryName; + final String imageId; + + const ImageEditorScreen({ + super.key, + required this.galleryName, + required this.imageId, + }); + + @override + State createState() => _ImageEditorScreenState(); +} + +class _ImageEditorScreenState extends State { + late final Result originalImageResult; + final altTextController = TextEditingController(); + + @override + void initState() { + super.initState(); + originalImageResult = getIt>() + .activeEntry + .andThen((gs) => gs.getImage(widget.galleryName, widget.imageId)) + .withResult((image) { + altTextController.text = image.description; + }).execErrorCast(); + } + + bool get changed => originalImageResult + .transform((image) => image.description != altTextController.text) + .getValueOrElse(() => false); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: StandardAppBar.build( + context, + 'Edit Image', + withDrawer: true, + ), + body: SingleChildScrollView( + child: ResponsiveMaxWidth( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + ...originalImageResult.fold( + onSuccess: (image) => buildEditor(image), + onError: (error) => buildError(error), + ), + const VerticalPadding(), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () async { + if (!changed) { + return; + } + final result = await getIt< + ActiveProfileSelector>() + .activeEntry + .andThenAsync( + (gs) async => await gs + .updateImage(originalImageResult.value.copy( + description: altTextController.text, + )), + ); + + if (!mounted) { + return; + } + + result.match( + onSuccess: (_) => context.pop(), + onError: (error) => buildSnackbar(context, + 'Error attempting to update image: $error'), + ); + }, + child: const Text('Save')), + const HorizontalPadding(), + ElevatedButton( + onPressed: () async { + if (!changed) { + context.pop(); + } + + final ok = await showYesNoDialog( + context, + 'Cancel changes?', + ); + if (ok == true && mounted) { + context.pop(); + } + }, + child: const Text('Cancel')), + ], + ) + ], + ), + ), + ), + ), + ); + } + + List buildEditor(ImageEntry originalImage) { + return [ + Row( + children: [ + const Text('Visibility:'), + const HorizontalPadding(), + originalImage.visibility.type == VisibilityType.public + ? const Icon(Icons.public) + : const Icon(Icons.lock), + ], + ), + const VerticalPadding(), + LoginAwareCachedNetworkImage(imageUrl: originalImage.thumbnailUrl), + const VerticalPadding(), + TextField( + controller: altTextController, + maxLines: 10, + decoration: InputDecoration( + labelText: 'ALT Text', + alignLabelWithHint: true, + border: OutlineInputBorder( + borderSide: const BorderSide(), + borderRadius: BorderRadius.circular(5.0), + ), + ), + ), + ]; + } + + List buildError(ExecError error) { + return [Text('Error loading image: $error')]; + } +} diff --git a/lib/services/gallery_service.dart b/lib/services/gallery_service.dart index 34ad682..72a3d25 100644 --- a/lib/services/gallery_service.dart +++ b/lib/services/gallery_service.dart @@ -56,6 +56,11 @@ class GalleryService extends ChangeNotifier { return result.errorCast(); } + final galleriesReturned = result.value.map((g) => g.name).toList(); + _galleries.clear(); + _galleryPages.removeWhere((key, value) => !galleriesReturned.contains(key)); + _images.removeWhere((key, value) => !galleriesReturned.contains(key)); + for (final gallery in result.value) { _galleries[gallery.name] = gallery; } @@ -83,6 +88,21 @@ class GalleryService extends ChangeNotifier { } } + FutureResult renameGallery( + GalleryData gallery, String newName) async { + if (!_galleries.containsKey(gallery.name)) { + return buildErrorResult( + type: ErrorType.notFound, + message: 'Unknown gallery: ${gallery.name}'); + } + final result = await GalleryClient(profile) + .renameGallery(gallery.name, newName) + .transform((_) => gallery.copy(name: newName)) + .withResultAsync((_) async => await updateGalleries()); + + return result.execErrorCast(); + } + //TODO Paging FutureResult, ExecError> updateGalleryImageList( {required String galleryName, @@ -108,8 +128,6 @@ class GalleryService extends ChangeNotifier { return result.errorCast(); } - print(result.value.length); - imageSet.addAll(result.value); } @@ -117,4 +135,71 @@ class GalleryService extends ChangeNotifier { notifyListeners(); return Result.ok(imageSet.toList(growable: false)); } + + Result getImage(String galleryName, String id) { + if (!_images.containsKey(galleryName)) { + return buildErrorResult( + type: ErrorType.notFound, + message: 'Image gallery $galleryName not known.', + ); + } + + final potentialImages = + _images[galleryName]?.where((i) => i.id == id).toList() ?? []; + + if (potentialImages.isEmpty) { + return buildErrorResult( + type: ErrorType.notFound, + message: 'Image $id not found in gallery $galleryName', + ); + } + + return Result.ok(potentialImages.first); + } + + FutureResult updateImage(ImageEntry image) async { + final images = _images[image.album]; + if (images == null) { + buildErrorResult( + type: ErrorType.notFound, message: 'Album not found ${image.album}'); + } + + final index = _images[image.album]!.indexOf(image); + if (index < 0) { + return buildErrorResult( + type: ErrorType.notFound, + message: 'Image ${image.id} does not exist for ${image.album}'); + } + final result = + await ImageClient(profile).editImageData(image).withResult((_) { + images!.removeAt(index); + images.insert(index, image); + }); + notifyListeners(); + return result.execErrorCast(); + } + + FutureResult deleteImage(ImageEntry image) async { + final images = _images[image.album]; + if (images == null) { + buildErrorResult( + type: ErrorType.notFound, message: 'Album not found ${image.album}'); + } + + final index = _images[image.album]!.indexOf(image); + if (index < 0) { + return buildErrorResult( + type: ErrorType.notFound, + message: 'Image ${image.id} does not exist for ${image.album}'); + } + final result = await ImageClient(profile) + .deleteImage(image) + .withResultAsync((_) async { + images!.removeAt(index); + await updateGalleries(); + notifyListeners(); + }); + + return result.execErrorCast(); + } }