diff --git a/lib/controls/app_bottom_nav_bar.dart b/lib/controls/app_bottom_nav_bar.dart index 799474e..307a149 100644 --- a/lib/controls/app_bottom_nav_bar.dart +++ b/lib/controls/app_bottom_nav_bar.dart @@ -10,6 +10,7 @@ enum NavBarButtons { timelines, notifications, messages, + gallery, contacts, profile, } @@ -49,6 +50,9 @@ class AppBottomNavBar extends StatelessWidget { case NavBarButtons.profile: context.pushNamed(ScreenPaths.profile); break; + case NavBarButtons.gallery: + context.pushNamed(ScreenPaths.gallery); + break; } }, type: BottomNavigationBarType.fixed, @@ -65,12 +69,14 @@ class AppBottomNavBar extends StatelessWidget { return 0; case NavBarButtons.notifications: return 1; - case NavBarButtons.messages: + case NavBarButtons.gallery: return 2; - case NavBarButtons.contacts: + case NavBarButtons.messages: return 3; - case NavBarButtons.profile: + case NavBarButtons.contacts: return 4; + case NavBarButtons.profile: + return 5; } } @@ -84,14 +90,18 @@ class AppBottomNavBar extends StatelessWidget { } if (index == 2) { - return NavBarButtons.messages; + return NavBarButtons.gallery; } if (index == 3) { - return NavBarButtons.contacts; + return NavBarButtons.messages; } if (index == 4) { + return NavBarButtons.contacts; + } + + if (index == 5) { return NavBarButtons.profile; } @@ -114,6 +124,11 @@ class AppBottomNavBar extends StatelessWidget { ? Icons.notifications_active : Icons.notifications), ), + const BottomNavigationBarItem( + label: 'Gallery', + icon: Icon(Icons.photo_library_outlined), + activeIcon: Icon(Icons.photo_library), + ), const BottomNavigationBarItem( label: 'Messages', icon: Icon(Icons.messenger_outline), diff --git a/lib/friendica_client.dart b/lib/friendica_client.dart index 160640b..bd11503 100644 --- a/lib/friendica_client.dart +++ b/lib/friendica_client.dart @@ -8,10 +8,14 @@ import 'models/TimelineIdentifiers.dart'; import 'models/connection.dart'; import 'models/credentials.dart'; import 'models/exec_error.dart'; +import 'models/gallery_data.dart'; import 'models/group_data.dart'; +import 'models/image_entry.dart'; import 'models/timeline_entry.dart'; import 'models/user_notification.dart'; import 'serializers/friendica/connection_friendica_extensions.dart'; +import 'serializers/friendica/gallery_data_friendica_extensions.dart'; +import 'serializers/friendica/image_entry_friendica_extensions.dart'; import 'serializers/mastodon/group_data_mastodon_extensions.dart'; import 'serializers/mastodon/notification_mastodon_extension.dart'; import 'serializers/mastodon/timeline_entry_mastodon_extensions.dart'; @@ -68,6 +72,34 @@ class FriendicaClient { return response.mapValue((value) => true); } + FutureResult, ExecError> getGalleryData() async { + _logger.finest(() => 'Getting gallery data'); + final url = 'https://$serverName/api/friendica/photoalbums'; + final request = Uri.parse(url); + return (await _getApiListRequest(request).andThenSuccessAsync( + (albumsJson) async => albumsJson + .map((json) => GalleryDataFriendicaExtensions.fromJson(json)) + .toList())) + .mapError((error) => error is ExecError + ? error + : ExecError(type: ErrorType.localError, message: error.toString())); + } + + FutureResult, ExecError> getGalleryImages( + String galleryName) async { + _logger.finest(() => 'Getting gallery data'); + final url = + 'https://$serverName/api/friendica/photoalbum?album=$galleryName'; + final request = Uri.parse(url); + return (await _getApiListRequest(request).andThenSuccessAsync( + (imagesJson) async => imagesJson + .map((json) => ImageEntryFriendicaExtension.fromJson(json)) + .toList())) + .mapError((error) => error is ExecError + ? error + : ExecError(type: ErrorType.localError, message: error.toString())); + } + FutureResult, ExecError> getGroups() async { _logger.finest(() => 'Getting group (Mastodon List) data'); final url = 'https://$serverName/api/v1/lists'; diff --git a/lib/main.dart b/lib/main.dart index 14415c5..9f63515 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,7 @@ import 'routes.dart'; import 'services/auth_service.dart'; import 'services/connections_manager.dart'; import 'services/entry_manager_service.dart'; +import 'services/gallery_service.dart'; import 'services/notifications_manager.dart'; import 'services/secrets_service.dart'; import 'services/timeline_manager.dart'; @@ -39,6 +40,7 @@ void main() async { final entryManagerService = EntryManagerService(); final timelineManager = TimelineManager(); getIt.registerLazySingleton(() => ConnectionsManager()); + getIt.registerLazySingleton(() => GalleryService()); getIt.registerSingleton(entryManagerService); getIt.registerSingleton(secretsService); getIt.registerSingleton(authService); @@ -83,6 +85,10 @@ class App extends StatelessWidget { create: (_) => getIt(), lazy: true, ), + ChangeNotifierProvider( + create: (_) => getIt(), + lazy: true, + ), ChangeNotifierProvider( create: (_) => getIt(), ), diff --git a/lib/models/gallery_data.dart b/lib/models/gallery_data.dart new file mode 100644 index 0000000..f06b7d7 --- /dev/null +++ b/lib/models/gallery_data.dart @@ -0,0 +1,7 @@ +class GalleryData { + final int count; + final String name; + final DateTime created; + + GalleryData({required this.count, required this.name, required this.created}); +} diff --git a/lib/models/image_entry.dart b/lib/models/image_entry.dart index 0772393..2e56df0 100644 --- a/lib/models/image_entry.dart +++ b/lib/models/image_entry.dart @@ -1,19 +1,29 @@ class ImageEntry { - final String postId; - final String localFilename; - final String url; + final String id; + final String album; + final String filename; + final String description; + final String thumbnailUrl; + final DateTime created; + final int height; + final int width; - ImageEntry( - {required this.postId, required this.localFilename, required this.url}); + ImageEntry({ + required this.id, + required this.album, + required this.filename, + required this.description, + required this.thumbnailUrl, + required this.created, + required this.height, + required this.width, + }); - ImageEntry.fromJson(Map json) - : postId = json['postId'] ?? '', - localFilename = json['localFilename'] ?? '', - url = json['url'] ?? ''; + @override + bool operator ==(Object other) => + identical(this, other) || + other is ImageEntry && runtimeType == other.runtimeType && id == other.id; - Map toJson() => { - 'postId': postId, - 'localFilename': localFilename, - 'url': url, - }; + @override + int get hashCode => id.hashCode; } diff --git a/lib/routes.dart b/lib/routes.dart index 9cd1680..d2b9b6a 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -2,6 +2,8 @@ import 'package:go_router/go_router.dart'; import 'globals.dart'; import 'screens/editor.dart'; +import 'screens/gallery_browsers_screen.dart'; +import 'screens/gallery_screen.dart'; import 'screens/home.dart'; import 'screens/notifications_screen.dart'; import 'screens/post_screen.dart'; @@ -15,6 +17,7 @@ import 'services/auth_service.dart'; class ScreenPaths { static String splash = '/splash'; static String timelines = '/'; + static String gallery = '/gallery'; static String profile = '/profile'; static String notifications = '/notifications'; static String signin = '/signin'; @@ -73,6 +76,21 @@ final appRouter = GoRouter( child: ProfileScreen(), ), ), + GoRoute( + path: ScreenPaths.gallery, + name: ScreenPaths.gallery, + pageBuilder: (context, state) => NoTransitionPage( + child: GalleryBrowsersScreen(), + ), + routes: [ + GoRoute( + path: 'show/:name', + builder: (context, state) => GalleryScreen( + galleryName: state.params['name']!, + ), + ), + ], + ), GoRoute( path: ScreenPaths.notifications, name: ScreenPaths.notifications, diff --git a/lib/screens/gallery_browsers_screen.dart b/lib/screens/gallery_browsers_screen.dart new file mode 100644 index 0000000..034957c --- /dev/null +++ b/lib/screens/gallery_browsers_screen.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; +import 'package:provider/provider.dart'; + +import '../controls/app_bottom_nav_bar.dart'; +import '../controls/padding.dart'; +import '../services/gallery_service.dart'; + +class GalleryBrowsersScreen extends StatelessWidget { + static final _logger = Logger('$GalleryBrowsersScreen'); + + @override + Widget build(BuildContext context) { + _logger.finest('Building'); + final service = context.watch(); + return Scaffold( + body: RefreshIndicator( + onRefresh: () async { + print('Refresh gallery list'); + }, + child: RefreshIndicator( + onRefresh: () async { + await service.updateGalleries(); + }, + child: buildBody(context, service)), + ), + bottomNavigationBar: AppBottomNavBar( + currentButton: NavBarButtons.gallery, + ), + ); + } + + Widget buildBody(BuildContext context, GalleryService service) { + final galleries = service.getGalleries(); + + if (galleries.isEmpty && service.loaded) { + return const SingleChildScrollView( + child: Center( + child: Text('No Galleries'), + ), + ); + } + + if (galleries.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: const [ + Text('Loading galleries'), + VerticalPadding(), + CircularProgressIndicator(), + ], + ), + ); + } + + 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.caption, + ), + trailing: Text('${gallery.count} Images'), + ), + ); + }, + separatorBuilder: (context, index) { + return const Divider(); + }, + itemCount: galleries.length, + ); + } +} diff --git a/lib/screens/gallery_screen.dart b/lib/screens/gallery_screen.dart new file mode 100644 index 0000000..ffc08d5 --- /dev/null +++ b/lib/screens/gallery_screen.dart @@ -0,0 +1,102 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:provider/provider.dart'; + +import '../controls/padding.dart'; +import '../serializers/friendica/image_entry_friendica_extensions.dart'; +import '../services/gallery_service.dart'; +import 'image_viewer_screen.dart'; + +class GalleryScreen extends StatelessWidget { + static const thumbnailDimension = 100.0; + static final _logger = Logger('$GalleryScreen'); + final String galleryName; + + const GalleryScreen({super.key, required this.galleryName}); + + @override + Widget build(BuildContext context) { + _logger.finest('Building'); + final service = context.watch(); + return Scaffold( + appBar: AppBar( + title: Text(galleryName), + ), + body: RefreshIndicator( + onRefresh: () async { + print('Refresh $galleryName image list'); + }, + child: RefreshIndicator( + onRefresh: () async { + await service.updateGalleryImageList(galleryName); + }, + child: buildBody(context, service)), + ), + ); + } + + Widget buildBody(BuildContext context, GalleryService service) { + final imageResult = service.getGalleryImageList(galleryName); + if (imageResult.isFailure) { + return SingleChildScrollView( + child: Center( + child: Text('Error getting images for gallery: ${imageResult.error}'), + ), + ); + } + + final images = imageResult.value; + if (images.isEmpty && service.loaded) { + return const SingleChildScrollView( + child: Center( + child: Text('No images'), + ), + ); + } + + if (images.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: const [ + Text('Loading images'), + VerticalPadding(), + CircularProgressIndicator(), + ], + ), + ); + } + + return ListView.separated( + itemBuilder: (context, index) { + final image = images[index]; + return InkWell( + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (context) { + return ImageViewerScreen(attachment: image.toMediaAttachment()); + })); + }, + child: ListTile( + leading: CachedNetworkImage( + width: thumbnailDimension, + height: thumbnailDimension, + imageUrl: image.thumbnailUrl, + ), + title: Text(image.filename), + subtitle: Text( + image.description, + style: Theme.of(context).textTheme.caption, + ), + trailing: Text(image.created.toString()), + ), + ); + }, + separatorBuilder: (context, index) { + return const Divider(); + }, + itemCount: images.length, + ); + } +} diff --git a/lib/serializers/friendica/gallery_data_friendica_extensions.dart b/lib/serializers/friendica/gallery_data_friendica_extensions.dart new file mode 100644 index 0000000..a109983 --- /dev/null +++ b/lib/serializers/friendica/gallery_data_friendica_extensions.dart @@ -0,0 +1,8 @@ +import '../../models/gallery_data.dart'; + +extension GalleryDataFriendicaExtensions on GalleryData { + static GalleryData fromJson(Map json) => GalleryData( + count: json['count'] ?? -1, + name: json['name'] ?? 'Unknown', + created: DateTime.tryParse(json['created']) ?? DateTime(0)); +} diff --git a/lib/serializers/friendica/image_entry_friendica_extensions.dart b/lib/serializers/friendica/image_entry_friendica_extensions.dart new file mode 100644 index 0000000..ffe0560 --- /dev/null +++ b/lib/serializers/friendica/image_entry_friendica_extensions.dart @@ -0,0 +1,31 @@ +import '../../models/attachment_media_type_enum.dart'; +import '../../models/image_entry.dart'; +import '../../models/media_attachment.dart'; + +extension ImageEntryFriendicaExtension on ImageEntry { + static ImageEntry fromJson(Map json) => ImageEntry( + id: json['id'], + album: json['album'], + filename: json['filename'], + description: json['desc'], + thumbnailUrl: json['thumb'], + created: DateTime.tryParse(json['created']) ?? DateTime(0), + height: json['height'], + width: json['width'], + ); + + MediaAttachment toMediaAttachment() { + final thumbUri = Uri.parse(thumbnailUrl); + final extension = thumbUri.pathSegments.last.split('.').last; + final newFileName = '$id-0.$extension'; + final fullFileUri = Uri.https(thumbUri.authority, '/photo/$newFileName'); + return MediaAttachment( + uri: fullFileUri, + creationTimestamp: created.millisecondsSinceEpoch, + metadata: {}, + thumbnailUri: thumbUri, + title: filename, + explicitType: AttachmentMediaType.image, + description: description); + } +} diff --git a/lib/services/gallery_service.dart b/lib/services/gallery_service.dart new file mode 100644 index 0000000..c770a2d --- /dev/null +++ b/lib/services/gallery_service.dart @@ -0,0 +1,90 @@ +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; +import 'package:result_monad/result_monad.dart'; + +import '../globals.dart'; +import '../models/exec_error.dart'; +import '../models/gallery_data.dart'; +import '../models/image_entry.dart'; +import 'auth_service.dart'; + +class GalleryService extends ChangeNotifier { + static final _logger = Logger('$GalleryService'); + final _galleries = {}; + final _images = >{}; + var _loaded = false; + + bool get loaded => _loaded; + + List getGalleries() { + if (_galleries.isEmpty) { + updateGalleries(); + } + + return _galleries.values.toList(growable: false); + } + + FutureResult, ExecError> updateGalleries() async { + final auth = getIt(); + final clientResult = auth.currentClient; + if (clientResult.isFailure) { + _logger.severe('Error getting Friendica client: ${clientResult.error}'); + return clientResult.errorCast(); + } + + final client = clientResult.value; + final result = await client.getGalleryData(); + if (result.isFailure) { + return result.errorCast(); + } + + for (final gallery in result.value) { + _galleries[gallery.name] = gallery; + } + + _loaded = true; + notifyListeners(); + return Result.ok(_galleries.values.toList(growable: false)); + } + + Result, ExecError> getGalleryImageList(String galleryName) { + if (!_galleries.containsKey(galleryName)) { + return Result.error( + ExecError( + type: ErrorType.localError, + message: 'Unknown Gallery: $galleryName', + ), + ); + } + + if (!_images.containsKey(galleryName)) { + updateGalleryImageList(galleryName); + return Result.ok([]); + } else { + return Result.ok(_images[galleryName]!.toList(growable: false)); + } + } + + //TODO Paging + FutureResult, ExecError> updateGalleryImageList( + String galleryName) async { + final auth = getIt(); + final clientResult = auth.currentClient; + if (clientResult.isFailure) { + _logger.severe('Error getting Friendica client: ${clientResult.error}'); + return clientResult.errorCast(); + } + + final client = clientResult.value; + final result = await client.getGalleryImages(galleryName); + if (result.isFailure) { + return result.errorCast(); + } + + final imageSet = _images.putIfAbsent(galleryName, () => {}); + imageSet.addAll(result.value); + + notifyListeners(); + return Result.ok(imageSet.toList(growable: false)); + } +}