399 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			399 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
import 'package:easy_localization/easy_localization.dart';
 | 
						|
import 'package:flutter/material.dart';
 | 
						|
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
						|
import 'package:freezed_annotation/freezed_annotation.dart';
 | 
						|
import 'package:gap/gap.dart';
 | 
						|
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
						|
import 'package:island/models/file.dart';
 | 
						|
import 'package:island/models/post.dart';
 | 
						|
import 'package:island/screens/creators/publishers_form.dart';
 | 
						|
import 'package:island/screens/posts/compose_article.dart';
 | 
						|
import 'package:island/screens/posts/post_detail.dart';
 | 
						|
import 'package:island/services/compose_storage_db.dart';
 | 
						|
import 'package:island/widgets/app_scaffold.dart';
 | 
						|
import 'package:island/widgets/content/cloud_files.dart';
 | 
						|
import 'package:island/widgets/post/compose_attachments.dart';
 | 
						|
import 'package:island/widgets/post/compose_form_fields.dart';
 | 
						|
import 'package:island/widgets/post/compose_info_banner.dart';
 | 
						|
import 'package:island/widgets/post/compose_settings_sheet.dart';
 | 
						|
import 'package:island/widgets/post/compose_shared.dart';
 | 
						|
import 'package:island/widgets/post/compose_toolbar.dart';
 | 
						|
import 'package:island/widgets/post/post_item.dart';
 | 
						|
import 'package:island/widgets/post/publishers_modal.dart';
 | 
						|
import 'package:material_symbols_icons/symbols.dart';
 | 
						|
import 'package:styled_widget/styled_widget.dart';
 | 
						|
 | 
						|
part 'compose.freezed.dart';
 | 
						|
part 'compose.g.dart';
 | 
						|
 | 
						|
@freezed
 | 
						|
sealed class PostComposeInitialState with _$PostComposeInitialState {
 | 
						|
  const factory PostComposeInitialState({
 | 
						|
    String? title,
 | 
						|
    String? description,
 | 
						|
    String? content,
 | 
						|
    @Default([]) List<UniversalFile> attachments,
 | 
						|
    int? visibility,
 | 
						|
    SnPost? replyingTo,
 | 
						|
    SnPost? forwardingTo,
 | 
						|
  }) = _PostComposeInitialState;
 | 
						|
 | 
						|
  factory PostComposeInitialState.fromJson(Map<String, dynamic> json) =>
 | 
						|
      _$PostComposeInitialStateFromJson(json);
 | 
						|
}
 | 
						|
 | 
						|
class PostEditScreen extends HookConsumerWidget {
 | 
						|
  final String id;
 | 
						|
  const PostEditScreen({super.key, 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(
 | 
						|
            isNoBackground: false,
 | 
						|
            appBar: AppBar(leading: const PageBackButton()),
 | 
						|
            body: const Center(child: CircularProgressIndicator()),
 | 
						|
          ),
 | 
						|
      error:
 | 
						|
          (e, _) => AppScaffold(
 | 
						|
            isNoBackground: false,
 | 
						|
            appBar: AppBar(leading: const PageBackButton()),
 | 
						|
            body: Text('Error: $e', textAlign: TextAlign.center),
 | 
						|
          ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class PostComposeScreen extends HookConsumerWidget {
 | 
						|
  final SnPost? originalPost;
 | 
						|
  final int? type;
 | 
						|
  final PostComposeInitialState? initialState;
 | 
						|
  const PostComposeScreen({
 | 
						|
    super.key,
 | 
						|
    this.type,
 | 
						|
    this.initialState,
 | 
						|
    this.originalPost,
 | 
						|
  });
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context, WidgetRef ref) {
 | 
						|
    // Determine the compose type: auto-detect from edited post or use query parameter
 | 
						|
    final composeType = originalPost?.type ?? type ?? 0;
 | 
						|
    final repliedPost = initialState?.replyingTo ?? originalPost?.repliedPost;
 | 
						|
    final forwardedPost =
 | 
						|
        initialState?.forwardingTo ?? originalPost?.forwardedPost;
 | 
						|
 | 
						|
    // If type is 1 (article), return ArticleComposeScreen
 | 
						|
    if (composeType == 1) {
 | 
						|
      return ArticleComposeScreen(originalPost: originalPost);
 | 
						|
    }
 | 
						|
 | 
						|
    // When editing, preserve the original replied/forwarded post references
 | 
						|
    final effectiveRepliedPost = repliedPost ?? originalPost?.repliedPost;
 | 
						|
    final effectiveForwardedPost = forwardedPost ?? originalPost?.forwardedPost;
 | 
						|
 | 
						|
    final publishers = ref.watch(publishersManagedProvider);
 | 
						|
    final state = useMemoized(
 | 
						|
      () => ComposeLogic.createState(
 | 
						|
        originalPost: originalPost,
 | 
						|
        forwardedPost: effectiveForwardedPost,
 | 
						|
        repliedPost: effectiveRepliedPost,
 | 
						|
        postType: 0, // Regular post type
 | 
						|
      ),
 | 
						|
      [originalPost, effectiveForwardedPost, effectiveRepliedPost],
 | 
						|
    );
 | 
						|
 | 
						|
    // Add a listener to the entire state to trigger rebuilds
 | 
						|
    final stateNotifier = useMemoized(
 | 
						|
      () => Listenable.merge([
 | 
						|
        state.titleController,
 | 
						|
        state.descriptionController,
 | 
						|
        state.contentController,
 | 
						|
        state.visibility,
 | 
						|
        state.attachments,
 | 
						|
        state.attachmentProgress,
 | 
						|
        state.currentPublisher,
 | 
						|
        state.submitting,
 | 
						|
      ]),
 | 
						|
      [state],
 | 
						|
    );
 | 
						|
    useListenable(stateNotifier);
 | 
						|
 | 
						|
    // Start auto-save when component mounts
 | 
						|
    useEffect(() {
 | 
						|
      if (originalPost == null) {
 | 
						|
        // Only auto-save for new posts, not edits
 | 
						|
        state.startAutoSave(ref);
 | 
						|
      }
 | 
						|
      return () => state.stopAutoSave();
 | 
						|
    }, [state]);
 | 
						|
 | 
						|
    // Initialize publisher once when data is available
 | 
						|
    useEffect(() {
 | 
						|
      if (publishers.value?.isNotEmpty ?? false) {
 | 
						|
        if (state.currentPublisher.value == null) {
 | 
						|
          // If no publisher is set, use the first available one
 | 
						|
          state.currentPublisher.value = publishers.value!.first;
 | 
						|
        }
 | 
						|
      }
 | 
						|
      return null;
 | 
						|
    }, [publishers]);
 | 
						|
 | 
						|
    // Load initial state if provided (for sharing functionality)
 | 
						|
    useEffect(() {
 | 
						|
      if (initialState != null) {
 | 
						|
        state.titleController.text = initialState!.title ?? '';
 | 
						|
        state.descriptionController.text = initialState!.description ?? '';
 | 
						|
        state.contentController.text = initialState!.content ?? '';
 | 
						|
        if (initialState!.visibility != null) {
 | 
						|
          state.visibility.value = initialState!.visibility!;
 | 
						|
        }
 | 
						|
        if (initialState!.attachments.isNotEmpty) {
 | 
						|
          state.attachments.value = List.from(initialState!.attachments);
 | 
						|
        }
 | 
						|
      }
 | 
						|
      return null;
 | 
						|
    }, [initialState]);
 | 
						|
 | 
						|
    // Load draft if available (only for new posts without initial state)
 | 
						|
    useEffect(() {
 | 
						|
      if (originalPost == null &&
 | 
						|
          effectiveForwardedPost == null &&
 | 
						|
          effectiveRepliedPost == null &&
 | 
						|
          initialState == null) {
 | 
						|
        // Try to load the most recent draft
 | 
						|
        final drafts = ref.read(composeStorageNotifierProvider);
 | 
						|
        if (drafts.isNotEmpty) {
 | 
						|
          final mostRecentDraft = drafts.values.reduce(
 | 
						|
            (a, b) =>
 | 
						|
                (a.updatedAt ?? DateTime(0)).isAfter(b.updatedAt ?? DateTime(0))
 | 
						|
                    ? a
 | 
						|
                    : b,
 | 
						|
          );
 | 
						|
 | 
						|
          // Only load if the draft has meaningful content
 | 
						|
          if (mostRecentDraft.content?.isNotEmpty == true ||
 | 
						|
              mostRecentDraft.title?.isNotEmpty == true) {
 | 
						|
            state.titleController.text = mostRecentDraft.title ?? '';
 | 
						|
            state.descriptionController.text =
 | 
						|
                mostRecentDraft.description ?? '';
 | 
						|
            state.contentController.text = mostRecentDraft.content ?? '';
 | 
						|
            state.visibility.value = mostRecentDraft.visibility;
 | 
						|
          }
 | 
						|
        }
 | 
						|
      }
 | 
						|
      return null;
 | 
						|
    }, []);
 | 
						|
 | 
						|
    // Dispose state when widget is disposed
 | 
						|
    useEffect(() {
 | 
						|
      return () {
 | 
						|
        state.stopAutoSave();
 | 
						|
        ComposeLogic.dispose(state);
 | 
						|
      };
 | 
						|
    }, []);
 | 
						|
 | 
						|
    // Helper methods
 | 
						|
 | 
						|
    void showSettingsSheet() {
 | 
						|
      showModalBottomSheet(
 | 
						|
        context: context,
 | 
						|
        isScrollControlled: true,
 | 
						|
        builder: (context) => ComposeSettingsSheet(state: state),
 | 
						|
      );
 | 
						|
    }
 | 
						|
 | 
						|
    return PopScope(
 | 
						|
      onPopInvoked: (_) {
 | 
						|
        if (originalPost == null) {
 | 
						|
          ComposeLogic.saveDraft(ref, state);
 | 
						|
        }
 | 
						|
      },
 | 
						|
      child: AppScaffold(
 | 
						|
        isNoBackground: false,
 | 
						|
        appBar: AppBar(
 | 
						|
          leading: const PageBackButton(),
 | 
						|
          actions: [
 | 
						|
            IconButton(
 | 
						|
              icon: const Icon(Symbols.settings),
 | 
						|
              onPressed: showSettingsSheet,
 | 
						|
              tooltip: 'postSettings'.tr(),
 | 
						|
            ),
 | 
						|
            IconButton(
 | 
						|
              onPressed:
 | 
						|
                  state.submitting.value
 | 
						|
                      ? null
 | 
						|
                      : () => ComposeLogic.performAction(
 | 
						|
                        ref,
 | 
						|
                        state,
 | 
						|
                        context,
 | 
						|
                        originalPost: originalPost,
 | 
						|
                        repliedPost: repliedPost,
 | 
						|
                        forwardedPost: forwardedPost,
 | 
						|
                      ),
 | 
						|
              icon:
 | 
						|
                  state.submitting.value
 | 
						|
                      ? SizedBox(
 | 
						|
                        width: 28,
 | 
						|
                        height: 28,
 | 
						|
                        child: const CircularProgressIndicator(
 | 
						|
                          color: Colors.white,
 | 
						|
                          strokeWidth: 2.5,
 | 
						|
                        ),
 | 
						|
                      ).center()
 | 
						|
                      : Icon(
 | 
						|
                        originalPost != null ? Symbols.edit : Symbols.upload,
 | 
						|
                      ),
 | 
						|
            ),
 | 
						|
            const Gap(8),
 | 
						|
          ],
 | 
						|
        ),
 | 
						|
        body: Column(
 | 
						|
          crossAxisAlignment: CrossAxisAlignment.start,
 | 
						|
          children: [
 | 
						|
            // Reply/Forward info section
 | 
						|
            ComposeInfoBanner(
 | 
						|
              originalPost: originalPost,
 | 
						|
              replyingTo: repliedPost,
 | 
						|
              forwardingTo: forwardedPost,
 | 
						|
              onReferencePostTap: (context, post) {
 | 
						|
                showModalBottomSheet(
 | 
						|
                  context: context,
 | 
						|
                  isScrollControlled: true,
 | 
						|
                  backgroundColor: Colors.transparent,
 | 
						|
                  builder:
 | 
						|
                      (context) => DraggableScrollableSheet(
 | 
						|
                        initialChildSize: 0.7,
 | 
						|
                        maxChildSize: 0.9,
 | 
						|
                        minChildSize: 0.5,
 | 
						|
                        builder:
 | 
						|
                            (context, scrollController) => Container(
 | 
						|
                              decoration: BoxDecoration(
 | 
						|
                                color:
 | 
						|
                                    Theme.of(context).scaffoldBackgroundColor,
 | 
						|
                                borderRadius: const BorderRadius.vertical(
 | 
						|
                                  top: Radius.circular(16),
 | 
						|
                                ),
 | 
						|
                              ),
 | 
						|
                              child: Column(
 | 
						|
                                children: [
 | 
						|
                                  Container(
 | 
						|
                                    width: 40,
 | 
						|
                                    height: 4,
 | 
						|
                                    margin: const EdgeInsets.symmetric(
 | 
						|
                                      vertical: 8,
 | 
						|
                                    ),
 | 
						|
                                    decoration: BoxDecoration(
 | 
						|
                                      color:
 | 
						|
                                          Theme.of(context).colorScheme.outline,
 | 
						|
                                      borderRadius: BorderRadius.circular(2),
 | 
						|
                                    ),
 | 
						|
                                  ),
 | 
						|
                                  Expanded(
 | 
						|
                                    child: SingleChildScrollView(
 | 
						|
                                      controller: scrollController,
 | 
						|
                                      padding: const EdgeInsets.all(16),
 | 
						|
                                      child: PostItem(item: post),
 | 
						|
                                    ),
 | 
						|
                                  ),
 | 
						|
                                ],
 | 
						|
                              ),
 | 
						|
                            ),
 | 
						|
                      ),
 | 
						|
                );
 | 
						|
              },
 | 
						|
            ),
 | 
						|
 | 
						|
            // Main content area
 | 
						|
            Expanded(
 | 
						|
              child: ConstrainedBox(
 | 
						|
                constraints: const BoxConstraints(maxWidth: 560),
 | 
						|
                child: Row(
 | 
						|
                  spacing: 12,
 | 
						|
                  crossAxisAlignment: CrossAxisAlignment.start,
 | 
						|
                  children: [
 | 
						|
                    // Publisher profile picture
 | 
						|
                    GestureDetector(
 | 
						|
                      child: ProfilePictureWidget(
 | 
						|
                        fileId: state.currentPublisher.value?.picture?.id,
 | 
						|
                        radius: 20,
 | 
						|
                        fallbackIcon:
 | 
						|
                            state.currentPublisher.value == null
 | 
						|
                                ? Symbols.question_mark
 | 
						|
                                : null,
 | 
						|
                      ),
 | 
						|
                      onTap: () {
 | 
						|
                        showModalBottomSheet(
 | 
						|
                          isScrollControlled: true,
 | 
						|
                          context: context,
 | 
						|
                          builder: (context) => const PublisherModal(),
 | 
						|
                        ).then((value) {
 | 
						|
                          if (value != null) {
 | 
						|
                            state.currentPublisher.value = value;
 | 
						|
                          }
 | 
						|
                        });
 | 
						|
                      },
 | 
						|
                    ).padding(top: 16),
 | 
						|
 | 
						|
                    // Post content form
 | 
						|
                    Expanded(
 | 
						|
                      child: KeyboardListener(
 | 
						|
                        focusNode: FocusNode(),
 | 
						|
                        onKeyEvent:
 | 
						|
                            (event) => ComposeLogic.handleKeyPress(
 | 
						|
                              event,
 | 
						|
                              state,
 | 
						|
                              ref,
 | 
						|
                              context,
 | 
						|
                              originalPost: originalPost,
 | 
						|
                              repliedPost: repliedPost,
 | 
						|
                              forwardedPost: forwardedPost,
 | 
						|
                            ),
 | 
						|
                        child: SingleChildScrollView(
 | 
						|
                          padding: const EdgeInsets.symmetric(vertical: 16),
 | 
						|
                          child: Column(
 | 
						|
                            crossAxisAlignment: CrossAxisAlignment.start,
 | 
						|
                            children: [
 | 
						|
                              ComposeFormFields(
 | 
						|
                                state: state,
 | 
						|
                                showPublisherAvatar: false,
 | 
						|
                                onPublisherTap: () {
 | 
						|
                                  showModalBottomSheet(
 | 
						|
                                    isScrollControlled: true,
 | 
						|
                                    context: context,
 | 
						|
                                    builder:
 | 
						|
                                        (context) => const PublisherModal(),
 | 
						|
                                  ).then((value) {
 | 
						|
                                    if (value != null) {
 | 
						|
                                      state.currentPublisher.value = value;
 | 
						|
                                    }
 | 
						|
                                  });
 | 
						|
                                },
 | 
						|
                              ),
 | 
						|
                              const Gap(8),
 | 
						|
                              ComposeAttachments(
 | 
						|
                                state: state,
 | 
						|
                                isCompact: false,
 | 
						|
                              ),
 | 
						|
                            ],
 | 
						|
                          ),
 | 
						|
                        ),
 | 
						|
                      ),
 | 
						|
                    ),
 | 
						|
                  ],
 | 
						|
                ).padding(horizontal: 16),
 | 
						|
              ).alignment(Alignment.topCenter),
 | 
						|
            ),
 | 
						|
 | 
						|
            // Bottom toolbar
 | 
						|
            ComposeToolbar(state: state, originalPost: originalPost),
 | 
						|
          ],
 | 
						|
        ),
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 |