Add gallery rename and image edit/delete capabilities

codemagic-setup
Hank Grabowski 2023-05-02 19:27:36 -04:00
rodzic 0e07919af4
commit 2bb4492ccf
8 zmienionych plików z 482 dodań i 30 usunięć

Wyświetl plik

@ -150,6 +150,25 @@ class GalleryClient extends FriendicaClient {
_networkStatusService.finishGalleryLoading();
return result;
}
FutureResult<bool, ExecError> 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<ImageEntry, ExecError> 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<ImageEntry, ExecError> 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');

Wyświetl plik

@ -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,
);
}

Wyświetl plik

@ -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) ||

Wyświetl plik

@ -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']!,
),
),
],

Wyświetl plik

@ -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<void> renameGallery(
BuildContext context,
GalleryService service,
GalleryData gallery,
) async {
final newName = await showDialog<String>(
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: <Widget>[
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'),
),
);
},

Wyświetl plik

@ -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<NetworkStatusService>();
final service = context
.watch<ActiveProfileSelector<GalleryService>>()
.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<ActiveProfileSelector<GalleryService>>()
.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<ActiveProfileSelector<GalleryService>>()
.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,

Wyświetl plik

@ -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<ImageEditorScreen> createState() => _ImageEditorScreenState();
}
class _ImageEditorScreenState extends State<ImageEditorScreen> {
late final Result<ImageEntry, ExecError> originalImageResult;
final altTextController = TextEditingController();
@override
void initState() {
super.initState();
originalImageResult = getIt<ActiveProfileSelector<GalleryService>>()
.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<GalleryService>>()
.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<Widget> 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<Widget> buildError(ExecError error) {
return [Text('Error loading image: $error')];
}
}

Wyświetl plik

@ -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<GalleryData, ExecError> 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<List<ImageEntry>, 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<ImageEntry, ExecError> 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<ImageEntry, ExecError> 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<ImageEntry, ExecError> 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();
}
}