From 5e1a164d06cd9873e6c2ae6e45b3ad9ac1c98f33 Mon Sep 17 00:00:00 2001 From: Hank Grabowski Date: Tue, 24 Dec 2024 13:39:33 -0500 Subject: [PATCH] Initial implementation of the a user's media screen --- .../friendica_timelines_client_services.dart | 35 ++++ ...friendica_timelines_client_services.g.dart | 172 ++++++++++++++++++ .../timeline_services.dart | 43 +++++ .../timeline_services.g.dart | 171 +++++++++++++++++ lib/routes.dart | 8 + lib/screens/user_media_screen.dart | 85 +++++++++ lib/screens/user_profile_screen.dart | 18 +- 7 files changed, 527 insertions(+), 5 deletions(-) create mode 100644 lib/screens/user_media_screen.dart diff --git a/lib/riverpod_controllers/networking/friendica_timelines_client_services.dart b/lib/riverpod_controllers/networking/friendica_timelines_client_services.dart index 105eb8d..15b9d1d 100644 --- a/lib/riverpod_controllers/networking/friendica_timelines_client_services.dart +++ b/lib/riverpod_controllers/networking/friendica_timelines_client_services.dart @@ -6,6 +6,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../globals.dart'; import '../../models/auth/profile.dart'; import '../../models/exec_error.dart'; +import '../../models/networking/paged_response.dart'; import '../../models/networking/paging_data.dart'; import '../../models/timeline_entry.dart'; import '../../models/timeline_identifiers.dart'; @@ -47,6 +48,40 @@ Future, ExecError>> timeline( return result.execErrorCast(); } +@riverpod +Future>, ExecError>> + userMediaTimelineClient( + Ref ref, + Profile profile, + String accountId, { + required PagingData page, +}) async { + final type = + TimelineIdentifiers(timeline: TimelineType.profile, auxData: accountId); + ref.read(timelineLoadingStatusProvider(profile, type)); + Future.microtask( + () async => + ref.read(timelineLoadingStatusProvider(profile, type).notifier).begin(), + ); + + final String timelineQPs = _typeToTimelineQueryParameters(type); + final baseUrl = + 'https://${profile.serverName}/api/v1/accounts/$accountId/statuses'; + final url = + '$baseUrl?only_media=true&${page.toQueryParameters()}&$timelineQPs'; + final request = Uri.parse(url); + _logger.info( + () => 'Getting ${type.toHumanKey()} media only with paging data $page'); + final result = await ref + .read(getApiListRequestProvider(profile, request).future) + .transformAsync((response) async { + final entries = await _timelineEntriesFromJson(ref, profile, response.data); + return response.map((_) => entries); + }); + ref.read(timelineLoadingStatusProvider(profile, type).notifier).end(); + return result.execErrorCast(); +} + Future> _timelineEntriesFromJson( Ref ref, Profile profile, diff --git a/lib/riverpod_controllers/networking/friendica_timelines_client_services.g.dart b/lib/riverpod_controllers/networking/friendica_timelines_client_services.g.dart index e539b88..beac592 100644 --- a/lib/riverpod_controllers/networking/friendica_timelines_client_services.g.dart +++ b/lib/riverpod_controllers/networking/friendica_timelines_client_services.g.dart @@ -194,5 +194,177 @@ class _TimelineProviderElement extends AutoDisposeFutureProviderElement< @override PagingData get page => (origin as TimelineProvider).page; } + +String _$userMediaTimelineClientHash() => + r'e4a21707ca98374e69ad289412f8accea32e367d'; + +/// See also [userMediaTimelineClient]. +@ProviderFor(userMediaTimelineClient) +const userMediaTimelineClientProvider = UserMediaTimelineClientFamily(); + +/// See also [userMediaTimelineClient]. +class UserMediaTimelineClientFamily extends Family< + AsyncValue>, ExecError>>> { + /// See also [userMediaTimelineClient]. + const UserMediaTimelineClientFamily(); + + /// See also [userMediaTimelineClient]. + UserMediaTimelineClientProvider call( + Profile profile, + String accountId, { + required PagingData page, + }) { + return UserMediaTimelineClientProvider( + profile, + accountId, + page: page, + ); + } + + @override + UserMediaTimelineClientProvider getProviderOverride( + covariant UserMediaTimelineClientProvider provider, + ) { + return call( + provider.profile, + provider.accountId, + page: provider.page, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'userMediaTimelineClientProvider'; +} + +/// See also [userMediaTimelineClient]. +class UserMediaTimelineClientProvider extends AutoDisposeFutureProvider< + Result>, ExecError>> { + /// See also [userMediaTimelineClient]. + UserMediaTimelineClientProvider( + Profile profile, + String accountId, { + required PagingData page, + }) : this._internal( + (ref) => userMediaTimelineClient( + ref as UserMediaTimelineClientRef, + profile, + accountId, + page: page, + ), + from: userMediaTimelineClientProvider, + name: r'userMediaTimelineClientProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$userMediaTimelineClientHash, + dependencies: UserMediaTimelineClientFamily._dependencies, + allTransitiveDependencies: + UserMediaTimelineClientFamily._allTransitiveDependencies, + profile: profile, + accountId: accountId, + page: page, + ); + + UserMediaTimelineClientProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.profile, + required this.accountId, + required this.page, + }) : super.internal(); + + final Profile profile; + final String accountId; + final PagingData page; + + @override + Override overrideWith( + FutureOr>, ExecError>> Function( + UserMediaTimelineClientRef provider) + create, + ) { + return ProviderOverride( + origin: this, + override: UserMediaTimelineClientProvider._internal( + (ref) => create(ref as UserMediaTimelineClientRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + profile: profile, + accountId: accountId, + page: page, + ), + ); + } + + @override + AutoDisposeFutureProviderElement< + Result>, ExecError>> createElement() { + return _UserMediaTimelineClientProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is UserMediaTimelineClientProvider && + other.profile == profile && + other.accountId == accountId && + other.page == page; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, profile.hashCode); + hash = _SystemHash.combine(hash, accountId.hashCode); + hash = _SystemHash.combine(hash, page.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin UserMediaTimelineClientRef on AutoDisposeFutureProviderRef< + Result>, ExecError>> { + /// The parameter `profile` of this provider. + Profile get profile; + + /// The parameter `accountId` of this provider. + String get accountId; + + /// The parameter `page` of this provider. + PagingData get page; +} + +class _UserMediaTimelineClientProviderElement + extends AutoDisposeFutureProviderElement< + Result>, ExecError>> + with UserMediaTimelineClientRef { + _UserMediaTimelineClientProviderElement(super.provider); + + @override + Profile get profile => (origin as UserMediaTimelineClientProvider).profile; + @override + String get accountId => (origin as UserMediaTimelineClientProvider).accountId; + @override + PagingData get page => (origin as UserMediaTimelineClientProvider).page; +} // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/riverpod_controllers/timeline_services.dart b/lib/riverpod_controllers/timeline_services.dart index f6e8dda..b82e75e 100644 --- a/lib/riverpod_controllers/timeline_services.dart +++ b/lib/riverpod_controllers/timeline_services.dart @@ -1,10 +1,15 @@ import 'package:logging/logging.dart'; +import 'package:result_monad/result_monad.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:stack_trace/stack_trace.dart'; import '../models/auth/profile.dart'; +import '../models/exec_error.dart'; +import '../models/media_attachment.dart'; +import '../models/networking/paging_data.dart'; import '../models/timeline.dart'; import '../models/timeline_identifiers.dart'; +import '../riverpod_controllers/networking/friendica_timelines_client_services.dart'; import 'entry_tree_item_services.dart'; part 'timeline_services.g.dart'; @@ -147,3 +152,41 @@ class TimelineManager extends _$TimelineManager { return hadItem; } } + +@riverpod +class UserMediaTimeline extends _$UserMediaTimeline { + static const limit = 50; + var nextPage = const PagingData(limit: limit); + var media = []; + + @override + Future, ExecError>> build( + Profile profile, String accountId) async { + await updateTimeline(reset: true, withNotification: false); + return Result.ok(media); + } + + Future, ExecError>> updateTimeline( + {bool reset = true, bool withNotification = true}) async { + if (reset) { + nextPage = const PagingData(limit: limit); + media.clear(); + } + + final result = await ref.watch( + userMediaTimelineClientProvider(profile, accountId, page: nextPage) + .future); + return result + .withResult((result) { + for (final entries in result.data) { + media.addAll(entries.mediaAttachments); + } + nextPage = result.next!; + if (withNotification) { + ref.notifyListeners(); + } + }) + .transform((_) => media) + .execErrorCast(); + } +} diff --git a/lib/riverpod_controllers/timeline_services.g.dart b/lib/riverpod_controllers/timeline_services.g.dart index a5ee342..8baf777 100644 --- a/lib/riverpod_controllers/timeline_services.g.dart +++ b/lib/riverpod_controllers/timeline_services.g.dart @@ -340,5 +340,176 @@ class _TimelineManagerProviderElement TimelineIdentifiers get timelineId => (origin as TimelineManagerProvider).timelineId; } + +String _$userMediaTimelineHash() => r'f13ba417b1d5550392f973af9e86c099efd60491'; + +abstract class _$UserMediaTimeline extends BuildlessAutoDisposeAsyncNotifier< + Result, ExecError>> { + late final Profile profile; + late final String accountId; + + FutureOr, ExecError>> build( + Profile profile, + String accountId, + ); +} + +/// See also [UserMediaTimeline]. +@ProviderFor(UserMediaTimeline) +const userMediaTimelineProvider = UserMediaTimelineFamily(); + +/// See also [UserMediaTimeline]. +class UserMediaTimelineFamily + extends Family, ExecError>>> { + /// See also [UserMediaTimeline]. + const UserMediaTimelineFamily(); + + /// See also [UserMediaTimeline]. + UserMediaTimelineProvider call( + Profile profile, + String accountId, + ) { + return UserMediaTimelineProvider( + profile, + accountId, + ); + } + + @override + UserMediaTimelineProvider getProviderOverride( + covariant UserMediaTimelineProvider provider, + ) { + return call( + provider.profile, + provider.accountId, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'userMediaTimelineProvider'; +} + +/// See also [UserMediaTimeline]. +class UserMediaTimelineProvider extends AutoDisposeAsyncNotifierProviderImpl< + UserMediaTimeline, Result, ExecError>> { + /// See also [UserMediaTimeline]. + UserMediaTimelineProvider( + Profile profile, + String accountId, + ) : this._internal( + () => UserMediaTimeline() + ..profile = profile + ..accountId = accountId, + from: userMediaTimelineProvider, + name: r'userMediaTimelineProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$userMediaTimelineHash, + dependencies: UserMediaTimelineFamily._dependencies, + allTransitiveDependencies: + UserMediaTimelineFamily._allTransitiveDependencies, + profile: profile, + accountId: accountId, + ); + + UserMediaTimelineProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.profile, + required this.accountId, + }) : super.internal(); + + final Profile profile; + final String accountId; + + @override + FutureOr, ExecError>> runNotifierBuild( + covariant UserMediaTimeline notifier, + ) { + return notifier.build( + profile, + accountId, + ); + } + + @override + Override overrideWith(UserMediaTimeline Function() create) { + return ProviderOverride( + origin: this, + override: UserMediaTimelineProvider._internal( + () => create() + ..profile = profile + ..accountId = accountId, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + profile: profile, + accountId: accountId, + ), + ); + } + + @override + AutoDisposeAsyncNotifierProviderElement, ExecError>> createElement() { + return _UserMediaTimelineProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is UserMediaTimelineProvider && + other.profile == profile && + other.accountId == accountId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, profile.hashCode); + hash = _SystemHash.combine(hash, accountId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin UserMediaTimelineRef on AutoDisposeAsyncNotifierProviderRef< + Result, ExecError>> { + /// The parameter `profile` of this provider. + Profile get profile; + + /// The parameter `accountId` of this provider. + String get accountId; +} + +class _UserMediaTimelineProviderElement + extends AutoDisposeAsyncNotifierProviderElement, ExecError>> with UserMediaTimelineRef { + _UserMediaTimelineProviderElement(super.provider); + + @override + Profile get profile => (origin as UserMediaTimelineProvider).profile; + @override + String get accountId => (origin as UserMediaTimelineProvider).accountId; +} // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/routes.dart b/lib/routes.dart index 52be3fe..40cd7de 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -28,6 +28,7 @@ import 'screens/settings_screen.dart'; import 'screens/sign_in.dart'; import 'screens/splash.dart'; import 'screens/tags_timeline_screen.dart'; +import 'screens/user_media_screen.dart'; import 'screens/user_posts_screen.dart'; import 'screens/user_profile_screen.dart'; @@ -50,6 +51,7 @@ class ScreenPaths { static String signup = '/signup'; static String userProfile = '/user_profile'; static String userPosts = '/user_posts'; + static String userMedia = '/user_media'; static String likes = '/likes'; static String reshares = '/reshares'; static String explore = '/explore'; @@ -271,6 +273,12 @@ final routes = [ builder: (context, state) => UserPostsScreen(userId: state.pathParameters['id']!), ), + GoRoute( + path: '/user_media/:id', + name: ScreenPaths.userMedia, + builder: (context, state) => + UserMediaScreen(userId: state.pathParameters['id']!), + ), GoRoute( path: '/likes/:id', name: ScreenPaths.likes, diff --git a/lib/screens/user_media_screen.dart b/lib/screens/user_media_screen.dart new file mode 100644 index 0000000..8a4c6ad --- /dev/null +++ b/lib/screens/user_media_screen.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../controls/async_value_widget.dart'; +import '../controls/error_message_widget.dart'; +import '../controls/media_attachment_viewer_control.dart'; +import '../controls/standard_appbar.dart'; +import '../models/timeline_identifiers.dart'; +import '../riverpod_controllers/account_services.dart'; +import '../riverpod_controllers/networking/network_status_services.dart'; +import '../riverpod_controllers/timeline_services.dart'; + +class UserMediaScreen extends ConsumerStatefulWidget { + final String userId; + + const UserMediaScreen({super.key, required this.userId}); + + @override + ConsumerState createState() => _UserMediaScreenState(); +} + +class _UserMediaScreenState extends ConsumerState { + static const thumbnailDimension = 350.0; + + @override + Widget build(BuildContext context) { + final profile = ref.watch(activeProfileProvider); + final timeline = TimelineIdentifiers.profile(widget.userId); + final loading = ref.watch(timelineLoadingStatusProvider(profile, timeline)); + + return Scaffold( + appBar: StandardAppBar.build( + context, + 'User Posts', + actions: [], + ), + body: Center( + child: Column( + children: [ + if (loading) const LinearProgressIndicator(), + Expanded( + child: AsyncValueWidget( + ref.watch(userMediaTimelineProvider(profile, widget.userId)), + valueBuilder: (_, __, result) { + return result.fold( + onSuccess: (media) { + if (media.isEmpty) { + return const ErrorMessageWidget( + message: 'No media for this user'); + } + + return GridView.builder( + itemCount: media.length, + padding: const EdgeInsets.all(5.0), + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: thumbnailDimension), + itemBuilder: (context, index) { + if (index == media.length - 1) { + ref + .read(userMediaTimelineProvider( + profile, widget.userId) + .notifier) + .updateTimeline( + reset: false, + withNotification: true, + ); + } + return Padding( + padding: const EdgeInsets.all(2.0), + child: MediaAttachmentViewerControl( + attachments: [media[index]], index: 0), + ); + }); + }, + onError: (error) => + ErrorMessageWidget(message: error.message)); + }), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/user_profile_screen.dart b/lib/screens/user_profile_screen.dart index 496a8e8..89eb64e 100644 --- a/lib/screens/user_profile_screen.dart +++ b/lib/screens/user_profile_screen.dart @@ -81,11 +81,19 @@ class _UserProfileScreenState extends ConsumerState { runSpacing: 10.0, children: [ ElevatedButton( - onPressed: () => context.pushNamed( - ScreenPaths.userPosts, - pathParameters: {'id': connectionProfile.id}, - ), - child: const Text('Posts')), + onPressed: () => context.pushNamed( + ScreenPaths.userPosts, + pathParameters: {'id': connectionProfile.id}, + ), + child: const Text('Posts'), + ), + ElevatedButton( + onPressed: () => context.pushNamed( + ScreenPaths.userMedia, + pathParameters: {'id': connectionProfile.id}, + ), + child: const Text('Media'), + ), ElevatedButton( onPressed: () async => await openProfileExternal(context, connectionProfile),