diff --git a/lib/route.dart b/lib/route.dart index 34882eb..8dd1022 100644 --- a/lib/route.dart +++ b/lib/route.dart @@ -28,5 +28,7 @@ class AppRouter extends RootStackRouter { path: '/account/me/publishers/:id', ), AutoRoute(page: PostComposeRoute.page, path: '/posts/compose'), + AutoRoute(page: PostDetailRoute.page, path: '/posts/:id'), + AutoRoute(page: PostEditRoute.page, path: '/posts/:id/edit'), ]; } diff --git a/lib/route.gr.dart b/lib/route.gr.dart index c9b0769..08522f2 100644 --- a/lib/route.gr.dart +++ b/lib/route.gr.dart @@ -9,27 +9,29 @@ // coverage:ignore-file // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'package:auto_route/auto_route.dart' as _i10; -import 'package:flutter/material.dart' as _i11; +import 'package:auto_route/auto_route.dart' as _i11; +import 'package:flutter/material.dart' as _i12; +import 'package:island/models/post.dart' as _i13; import 'package:island/screens/account.dart' as _i1; import 'package:island/screens/account/me.dart' as _i6; import 'package:island/screens/account/me/publishers.dart' as _i3; -import 'package:island/screens/account/me/update.dart' as _i9; +import 'package:island/screens/account/me/update.dart' as _i10; import 'package:island/screens/auth/create_account.dart' as _i2; import 'package:island/screens/auth/login.dart' as _i5; -import 'package:island/screens/auth/tabs.dart' as _i8; +import 'package:island/screens/auth/tabs.dart' as _i9; import 'package:island/screens/explore.dart' as _i4; import 'package:island/screens/posts/compose.dart' as _i7; +import 'package:island/screens/posts/detail.dart' as _i8; /// generated route for /// [_i1.AccountScreen] -class AccountRoute extends _i10.PageRouteInfo { - const AccountRoute({List<_i10.PageRouteInfo>? children}) +class AccountRoute extends _i11.PageRouteInfo { + const AccountRoute({List<_i11.PageRouteInfo>? children}) : super(AccountRoute.name, initialChildren: children); static const String name = 'AccountRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i11.PageInfo page = _i11.PageInfo( name, builder: (data) { return const _i1.AccountScreen(); @@ -39,13 +41,13 @@ class AccountRoute extends _i10.PageRouteInfo { /// generated route for /// [_i2.CreateAccountScreen] -class CreateAccountRoute extends _i10.PageRouteInfo { - const CreateAccountRoute({List<_i10.PageRouteInfo>? children}) +class CreateAccountRoute extends _i11.PageRouteInfo { + const CreateAccountRoute({List<_i11.PageRouteInfo>? children}) : super(CreateAccountRoute.name, initialChildren: children); static const String name = 'CreateAccountRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i11.PageInfo page = _i11.PageInfo( name, builder: (data) { return const _i2.CreateAccountScreen(); @@ -55,11 +57,11 @@ class CreateAccountRoute extends _i10.PageRouteInfo { /// generated route for /// [_i3.EditPublisherScreen] -class EditPublisherRoute extends _i10.PageRouteInfo { +class EditPublisherRoute extends _i11.PageRouteInfo { EditPublisherRoute({ - _i11.Key? key, + _i12.Key? key, String? name, - List<_i10.PageRouteInfo>? children, + List<_i11.PageRouteInfo>? children, }) : super( EditPublisherRoute.name, args: EditPublisherRouteArgs(key: key, name: name), @@ -69,7 +71,7 @@ class EditPublisherRoute extends _i10.PageRouteInfo { static const String name = 'EditPublisherRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i11.PageInfo page = _i11.PageInfo( name, builder: (data) { final pathParams = data.inheritedPathParams; @@ -84,7 +86,7 @@ class EditPublisherRoute extends _i10.PageRouteInfo { class EditPublisherRouteArgs { const EditPublisherRouteArgs({this.key, this.name}); - final _i11.Key? key; + final _i12.Key? key; final String? name; @@ -96,13 +98,13 @@ class EditPublisherRouteArgs { /// generated route for /// [_i4.ExploreScreen] -class ExploreRoute extends _i10.PageRouteInfo { - const ExploreRoute({List<_i10.PageRouteInfo>? children}) +class ExploreRoute extends _i11.PageRouteInfo { + const ExploreRoute({List<_i11.PageRouteInfo>? children}) : super(ExploreRoute.name, initialChildren: children); static const String name = 'ExploreRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i11.PageInfo page = _i11.PageInfo( name, builder: (data) { return const _i4.ExploreScreen(); @@ -112,13 +114,13 @@ class ExploreRoute extends _i10.PageRouteInfo { /// generated route for /// [_i5.LoginScreen] -class LoginRoute extends _i10.PageRouteInfo { - const LoginRoute({List<_i10.PageRouteInfo>? children}) +class LoginRoute extends _i11.PageRouteInfo { + const LoginRoute({List<_i11.PageRouteInfo>? children}) : super(LoginRoute.name, initialChildren: children); static const String name = 'LoginRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i11.PageInfo page = _i11.PageInfo( name, builder: (data) { return const _i5.LoginScreen(); @@ -128,13 +130,13 @@ class LoginRoute extends _i10.PageRouteInfo { /// generated route for /// [_i3.ManagedPublisherScreen] -class ManagedPublisherRoute extends _i10.PageRouteInfo { - const ManagedPublisherRoute({List<_i10.PageRouteInfo>? children}) +class ManagedPublisherRoute extends _i11.PageRouteInfo { + const ManagedPublisherRoute({List<_i11.PageRouteInfo>? children}) : super(ManagedPublisherRoute.name, initialChildren: children); static const String name = 'ManagedPublisherRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i11.PageInfo page = _i11.PageInfo( name, builder: (data) { return const _i3.ManagedPublisherScreen(); @@ -144,13 +146,13 @@ class ManagedPublisherRoute extends _i10.PageRouteInfo { /// generated route for /// [_i6.MyselfProfileScreen] -class MyselfProfileRoute extends _i10.PageRouteInfo { - const MyselfProfileRoute({List<_i10.PageRouteInfo>? children}) +class MyselfProfileRoute extends _i11.PageRouteInfo { + const MyselfProfileRoute({List<_i11.PageRouteInfo>? children}) : super(MyselfProfileRoute.name, initialChildren: children); static const String name = 'MyselfProfileRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i11.PageInfo page = _i11.PageInfo( name, builder: (data) { return const _i6.MyselfProfileScreen(); @@ -160,13 +162,13 @@ class MyselfProfileRoute extends _i10.PageRouteInfo { /// generated route for /// [_i3.NewPublisherScreen] -class NewPublisherRoute extends _i10.PageRouteInfo { - const NewPublisherRoute({List<_i10.PageRouteInfo>? children}) +class NewPublisherRoute extends _i11.PageRouteInfo { + const NewPublisherRoute({List<_i11.PageRouteInfo>? children}) : super(NewPublisherRoute.name, initialChildren: children); static const String name = 'NewPublisherRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i11.PageInfo page = _i11.PageInfo( name, builder: (data) { return const _i3.NewPublisherScreen(); @@ -176,48 +178,156 @@ class NewPublisherRoute extends _i10.PageRouteInfo { /// generated route for /// [_i7.PostComposeScreen] -class PostComposeRoute extends _i10.PageRouteInfo { - const PostComposeRoute({List<_i10.PageRouteInfo>? children}) - : super(PostComposeRoute.name, initialChildren: children); +class PostComposeRoute extends _i11.PageRouteInfo { + PostComposeRoute({ + _i12.Key? key, + _i13.SnPost? originalPost, + List<_i11.PageRouteInfo>? children, + }) : super( + PostComposeRoute.name, + args: PostComposeRouteArgs(key: key, originalPost: originalPost), + initialChildren: children, + ); static const String name = 'PostComposeRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i11.PageInfo page = _i11.PageInfo( name, builder: (data) { - return const _i7.PostComposeScreen(); + final args = data.argsAs( + orElse: () => const PostComposeRouteArgs(), + ); + return _i7.PostComposeScreen( + key: args.key, + originalPost: args.originalPost, + ); }, ); } +class PostComposeRouteArgs { + const PostComposeRouteArgs({this.key, this.originalPost}); + + final _i12.Key? key; + + final _i13.SnPost? originalPost; + + @override + String toString() { + return 'PostComposeRouteArgs{key: $key, originalPost: $originalPost}'; + } +} + /// generated route for -/// [_i8.TabsScreen] -class TabsRoute extends _i10.PageRouteInfo { - const TabsRoute({List<_i10.PageRouteInfo>? children}) +/// [_i8.PostDetailScreen] +class PostDetailRoute extends _i11.PageRouteInfo { + PostDetailRoute({ + _i12.Key? key, + required int id, + List<_i11.PageRouteInfo>? children, + }) : super( + PostDetailRoute.name, + args: PostDetailRouteArgs(key: key, id: id), + rawPathParams: {'id': id}, + initialChildren: children, + ); + + static const String name = 'PostDetailRoute'; + + static _i11.PageInfo page = _i11.PageInfo( + name, + builder: (data) { + final pathParams = data.inheritedPathParams; + final args = data.argsAs( + orElse: () => PostDetailRouteArgs(id: pathParams.getInt('id')), + ); + return _i8.PostDetailScreen(key: args.key, id: args.id); + }, + ); +} + +class PostDetailRouteArgs { + const PostDetailRouteArgs({this.key, required this.id}); + + final _i12.Key? key; + + final int id; + + @override + String toString() { + return 'PostDetailRouteArgs{key: $key, id: $id}'; + } +} + +/// generated route for +/// [_i7.PostEditScreen] +class PostEditRoute extends _i11.PageRouteInfo { + PostEditRoute({ + _i12.Key? key, + required int id, + List<_i11.PageRouteInfo>? children, + }) : super( + PostEditRoute.name, + args: PostEditRouteArgs(key: key, id: id), + rawPathParams: {'id': id}, + initialChildren: children, + ); + + static const String name = 'PostEditRoute'; + + static _i11.PageInfo page = _i11.PageInfo( + name, + builder: (data) { + final pathParams = data.inheritedPathParams; + final args = data.argsAs( + orElse: () => PostEditRouteArgs(id: pathParams.getInt('id')), + ); + return _i7.PostEditScreen(key: args.key, id: args.id); + }, + ); +} + +class PostEditRouteArgs { + const PostEditRouteArgs({this.key, required this.id}); + + final _i12.Key? key; + + final int id; + + @override + String toString() { + return 'PostEditRouteArgs{key: $key, id: $id}'; + } +} + +/// generated route for +/// [_i9.TabsScreen] +class TabsRoute extends _i11.PageRouteInfo { + const TabsRoute({List<_i11.PageRouteInfo>? children}) : super(TabsRoute.name, initialChildren: children); static const String name = 'TabsRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i11.PageInfo page = _i11.PageInfo( name, builder: (data) { - return const _i8.TabsScreen(); + return const _i9.TabsScreen(); }, ); } /// generated route for -/// [_i9.UpdateProfileScreen] -class UpdateProfileRoute extends _i10.PageRouteInfo { - const UpdateProfileRoute({List<_i10.PageRouteInfo>? children}) +/// [_i10.UpdateProfileScreen] +class UpdateProfileRoute extends _i11.PageRouteInfo { + const UpdateProfileRoute({List<_i11.PageRouteInfo>? children}) : super(UpdateProfileRoute.name, initialChildren: children); static const String name = 'UpdateProfileRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i11.PageInfo page = _i11.PageInfo( name, builder: (data) { - return const _i9.UpdateProfileScreen(); + return const _i10.UpdateProfileScreen(); }, ); } diff --git a/lib/screens/posts/compose.dart b/lib/screens/posts/compose.dart index c16841f..96ff36a 100644 --- a/lib/screens/posts/compose.dart +++ b/lib/screens/posts/compose.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:auto_route/auto_route.dart'; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; @@ -12,6 +13,7 @@ import 'package:island/models/post.dart'; import 'package:island/pods/config.dart'; import 'package:island/pods/network.dart'; import 'package:island/screens/account/me/publishers.dart'; +import 'package:island/screens/posts/detail.dart'; import 'package:island/services/file.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; @@ -19,9 +21,34 @@ import 'package:island/widgets/content/cloud_files.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:styled_widget/styled_widget.dart'; +@RoutePage() +class PostEditScreen extends HookConsumerWidget { + final int id; + const PostEditScreen({super.key, @PathParam('id') required this.id}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final post = ref.watch(postProvider(id)); + return post.when( + data: (post) => PostComposeScreen(originalPost: post), + loading: + () => AppScaffold( + appBar: AppBar(leading: const PageBackButton()), + body: const Center(child: CircularProgressIndicator()), + ), + error: + (e, _) => AppScaffold( + appBar: AppBar(leading: const PageBackButton()), + body: Text('Error: $e', textAlign: TextAlign.center), + ), + ); + } +} + @RoutePage() class PostComposeScreen extends HookConsumerWidget { - const PostComposeScreen({super.key}); + final SnPost? originalPost; + const PostComposeScreen({super.key, this.originalPost}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -37,10 +64,29 @@ class PostComposeScreen extends HookConsumerWidget { }, [publishers]); // Contains the XFile, ByteData, or SnCloudFile - final attachments = useState>([]); - final contentController = useTextEditingController(); - final titleController = useTextEditingController(); - final descriptionController = useTextEditingController(); + final attachments = useState>( + originalPost?.attachments + .map( + (e) => UniversalFile( + data: e, + type: switch (e.mimeType?.split('/').firstOrNull) { + 'image' => UniversalFileType.image, + 'video' => UniversalFileType.video, + 'audio' => UniversalFileType.audio, + _ => UniversalFileType.file, + }, + ), + ) + .toList() ?? + [], + ); + final contentController = useTextEditingController( + text: originalPost?.content, + ); + final titleController = useTextEditingController(text: originalPost?.title); + final descriptionController = useTextEditingController( + text: originalPost?.description, + ); final submitting = useState(false); @@ -149,6 +195,7 @@ class PostComposeScreen extends HookConsumerWidget { .map((e) => e.data.id) .toList(), }, + options: Options(headers: {'X-Pub': currentPublisher.value?.name}), ); if (context.mounted) { context.maybePop(true); diff --git a/lib/screens/posts/detail.dart b/lib/screens/posts/detail.dart new file mode 100644 index 0000000..603f4f2 --- /dev/null +++ b/lib/screens/posts/detail.dart @@ -0,0 +1,67 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/post.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/widgets/app_scaffold.dart'; +import 'package:island/widgets/post/post_item.dart'; +import 'package:island/widgets/post/post_quick_reply.dart'; +import 'package:island/widgets/post/post_replies.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:styled_widget/styled_widget.dart'; + +part 'detail.g.dart'; + +@riverpod +Future post(Ref ref, int id) async { + final client = ref.watch(apiClientProvider); + final resp = await client.get('/posts/$id'); + return SnPost.fromJson(resp.data); +} + +@RoutePage() +class PostDetailScreen extends HookConsumerWidget { + final int id; + const PostDetailScreen({super.key, @PathParam('id') required this.id}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final post = ref.watch(postProvider(id)); + + return AppScaffold( + appBar: AppBar(title: const Text('Post')), + body: post.when( + data: + (post) => Stack( + fit: StackFit.expand, + children: [ + Column( + children: [ + PostItem(item: post!), + const Divider(height: 1), + Expanded(child: PostRepliesList(postId: id)), + Gap(MediaQuery.of(context).padding.bottom), + ], + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Material( + elevation: 2, + child: PostQuickReply(parent: post).padding( + bottom: MediaQuery.of(context).padding.bottom, + top: 16, + horizontal: 16, + ), + ), + ), + ], + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Text('Error: $e'), + ), + ); + } +} diff --git a/lib/screens/posts/detail.g.dart b/lib/screens/posts/detail.g.dart new file mode 100644 index 0000000..adee3ac --- /dev/null +++ b/lib/screens/posts/detail.g.dart @@ -0,0 +1,144 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'detail.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$postHash() => r'58de03954e284b5c04544b61ccb9cadfc45e9422'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [post]. +@ProviderFor(post) +const postProvider = PostFamily(); + +/// See also [post]. +class PostFamily extends Family> { + /// See also [post]. + const PostFamily(); + + /// See also [post]. + PostProvider call(int id) { + return PostProvider(id); + } + + @override + PostProvider getProviderOverride(covariant PostProvider provider) { + return call(provider.id); + } + + 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'postProvider'; +} + +/// See also [post]. +class PostProvider extends AutoDisposeFutureProvider { + /// See also [post]. + PostProvider(int id) + : this._internal( + (ref) => post(ref as PostRef, id), + from: postProvider, + name: r'postProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$postHash, + dependencies: PostFamily._dependencies, + allTransitiveDependencies: PostFamily._allTransitiveDependencies, + id: id, + ); + + PostProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.id, + }) : super.internal(); + + final int id; + + @override + Override overrideWith(FutureOr Function(PostRef provider) create) { + return ProviderOverride( + origin: this, + override: PostProvider._internal( + (ref) => create(ref as PostRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + id: id, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _PostProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is PostProvider && other.id == id; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, id.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin PostRef on AutoDisposeFutureProviderRef { + /// The parameter `id` of this provider. + int get id; +} + +class _PostProviderElement extends AutoDisposeFutureProviderElement + with PostRef { + _PostProviderElement(super.provider); + + @override + int get id => (origin as PostProvider).id; +} + +// 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/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index fa0d04f..d9a1212 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -1,5 +1,7 @@ +import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:island/models/post.dart'; +import 'package:island/route.gr.dart'; import 'package:island/widgets/content/cloud_file_collection.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/markdown.dart'; @@ -8,7 +10,13 @@ import 'package:styled_widget/styled_widget.dart'; class PostItem extends StatelessWidget { final SnPost item; final EdgeInsets? padding; - const PostItem({super.key, required this.item, this.padding}); + final bool isOpenable; + const PostItem({ + super.key, + required this.item, + this.padding, + this.isOpenable = true, + }); @override Widget build(BuildContext context) { @@ -26,13 +34,20 @@ class PostItem extends StatelessWidget { children: [ ProfilePictureWidget(item: item.publisher.picture), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(item.publisher.nick).bold(), - if (item.content.isNotEmpty) - MarkdownTextContent(content: item.content), - ], + child: GestureDetector( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.publisher.nick).bold(), + if (item.content.isNotEmpty) + MarkdownTextContent(content: item.content), + ], + ), + onTap: () { + if (isOpenable) { + context.router.push(PostDetailRoute(id: item.id)); + } + }, ), ), ], diff --git a/lib/widgets/post/post_quick_reply.dart b/lib/widgets/post/post_quick_reply.dart new file mode 100644 index 0000000..11bcc51 --- /dev/null +++ b/lib/widgets/post/post_quick_reply.dart @@ -0,0 +1,107 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/post.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/screens/account/me/publishers.dart'; +import 'package:island/widgets/alert.dart'; +import 'package:island/widgets/content/cloud_files.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class PostQuickReply extends HookConsumerWidget { + final SnPost parent; + final Function? onPosted; + const PostQuickReply({super.key, required this.parent, this.onPosted}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final publishers = ref.watch(publishersManagedProvider); + + final currentPublisher = useState(null); + + useEffect(() { + if (publishers.value?.isNotEmpty ?? false) { + currentPublisher.value = publishers.value!.first; + } + return null; + }, [publishers]); + + final submitting = useState(false); + + final contentController = useTextEditingController(); + + Future performAction() async { + if (!contentController.text.isNotEmpty) { + return; + } + + submitting.value = true; + try { + final client = ref.watch(apiClientProvider); + await client.post( + '/posts', + data: { + 'content': contentController.text, + 'replied_post_id': parent.id, + }, + options: Options(headers: {'X-Pub': currentPublisher.value?.name}), + ); + contentController.clear(); + onPosted?.call(); + } catch (err) { + showErrorAlert(err); + } finally { + submitting.value = false; + } + } + + return publishers.when( + data: + (data) => Row( + spacing: 8, + children: [ + ProfilePictureWidget( + item: currentPublisher.value?.picture, + radius: 16, + ).padding(right: 4), + Expanded( + child: TextField( + controller: contentController, + decoration: InputDecoration( + hintText: 'Post your reply', + border: const OutlineInputBorder(), + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), + style: TextStyle(fontSize: 14), + maxLines: null, + onTapOutside: + (_) => FocusManager.instance.primaryFocus?.unfocus(), + ), + ), + IconButton( + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + icon: + submitting.value + ? SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator(strokeWidth: 3), + ) + : Icon(LucideIcons.send, size: 20), + color: Theme.of(context).colorScheme.primary, + onPressed: submitting.value ? null : performAction, + ), + ], + ), + loading: () => const SizedBox.shrink(), + error: (e, _) => const SizedBox.shrink(), + ); + } +} diff --git a/lib/widgets/post/post_replies.dart b/lib/widgets/post/post_replies.dart new file mode 100644 index 0000000..44e53f7 --- /dev/null +++ b/lib/widgets/post/post_replies.dart @@ -0,0 +1,116 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/post.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/widgets/post/post_item.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; + +class PostRepliesList extends HookConsumerWidget { + final int postId; + const PostRepliesList({super.key, required this.postId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final postAsync = ref.watch(postRepliesProvider(postId)); + + return RefreshIndicator( + onRefresh: + () => Future.sync((() { + ref.invalidate(postRepliesProvider(postId)); + })), + child: postAsync.when( + data: + (controller) => RefreshIndicator( + onRefresh: + () => Future.sync((() { + ref.invalidate(postRepliesProvider(postId)); + })), + child: InfiniteList( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom, + ), + itemCount: controller.posts.length, + isLoading: controller.isLoading, + hasReachedMax: controller.hasReachedMax, + onFetchData: controller.fetchMore, + itemBuilder: (context, index) { + final post = controller.posts[index]; + return PostItem(item: post); + }, + separatorBuilder: (_, __) => const Divider(height: 1), + emptyBuilder: (context) { + return Column( + children: [ + Text( + 'No replies', + textAlign: TextAlign.center, + ).fontSize(18).bold(), + Text('Why not start a discussion?'), + ], + ).padding(vertical: 16); + }, + ), + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: + (e, _) => GestureDetector( + child: Center( + child: Text('Error: $e', textAlign: TextAlign.center), + ), + onTap: () { + ref.invalidate(postRepliesProvider(postId)); + }, + ), + ), + ); + } +} + +final postRepliesProvider = FutureProviderFamily<_PostRepliesController, int>(( + ref, + postId, +) async { + final client = ref.watch(apiClientProvider); + final controller = _PostRepliesController(client, postId); + await controller.fetchMore(); + return controller; +}); + +class _PostRepliesController { + _PostRepliesController(this._dio, this.parentId); + + final Dio _dio; + final int parentId; + final List posts = []; + bool isLoading = false; + bool hasReachedMax = false; + int offset = 0; + final int take = 20; + int total = 0; + + Future fetchMore() async { + if (isLoading || hasReachedMax) return; + isLoading = true; + + final response = await _dio.get( + '/posts/$parentId/replies', + queryParameters: {'offset': offset, 'take': take}, + ); + + final List fetched = + (response.data as List) + .map((e) => SnPost.fromJson(e as Map)) + .toList(); + + final headerTotal = int.tryParse(response.headers['x-total']?.first ?? ''); + if (headerTotal != null) total = headerTotal; + + posts.addAll(fetched); + offset += fetched.length; + if (posts.length >= total) hasReachedMax = true; + + isLoading = false; + } +}