Files
App/lib/widgets/post/compose_card.dart
2025-10-31 19:15:22 +08:00

435 lines
17 KiB
Dart

import 'dart:math' as math;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/models/post.dart';
import 'package:island/models/publisher.dart';
import 'package:island/screens/creators/publishers_form.dart';
import 'package:island/screens/posts/compose.dart';
import 'package:island/services/compose_storage_db.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sheet.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_state_utils.dart';
import 'package:island/widgets/post/compose_submit_utils.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';
/// A dialog-compatible card widget for post composition.
/// This extracts the core compose functionality from PostComposeScreen
/// and adapts it for use within dialogs or other constrained layouts.
class PostComposeCard extends HookConsumerWidget {
final SnPost? originalPost;
final PostComposeInitialState? initialState;
final VoidCallback? onCancel;
final Function()? onSubmit;
final Function(ComposeState)? onStateChanged;
final bool isContained;
final bool showHeader;
final ComposeState? providedState;
const PostComposeCard({
super.key,
this.originalPost,
this.initialState,
this.onCancel,
this.onSubmit,
this.onStateChanged,
this.isContained = false,
this.showHeader = true,
this.providedState,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final submitted = useState(false);
final repliedPost = initialState?.replyingTo ?? originalPost?.repliedPost;
final forwardedPost =
initialState?.forwardingTo ?? originalPost?.forwardedPost;
final theme = Theme.of(context);
// Capture the notifier to avoid using ref after dispose
final notifier = ref.read(composeStorageNotifierProvider.notifier);
// Create compose state
final ComposeState composeState =
providedState ??
useMemoized(
() => ComposeLogic.createState(
originalPost: originalPost,
forwardedPost: forwardedPost,
repliedPost: repliedPost,
postType: 0,
),
[originalPost, forwardedPost, repliedPost],
);
// Add a listener to the entire state to trigger rebuilds
final stateNotifier = useMemoized(
() => Listenable.merge([
composeState.titleController,
composeState.descriptionController,
composeState.contentController,
composeState.visibility,
composeState.attachments,
composeState.attachmentProgress,
composeState.currentPublisher,
composeState.submitting,
]),
[composeState],
);
useListenable(stateNotifier);
// Notify parent of state changes
useEffect(() {
onStateChanged?.call(composeState);
return null;
}, [composeState]);
// Use shared state management utilities
ComposeStateUtils.usePublisherInitialization(ref, composeState);
ComposeStateUtils.useInitialStateLoader(composeState, initialState);
// Dispose state when widget is disposed
useEffect(() {
return () {
if (providedState == null) {
if (!submitted.value &&
originalPost == null &&
composeState.currentPublisher.value != null) {
final hasContent =
composeState.titleController.text.trim().isNotEmpty ||
composeState.descriptionController.text.trim().isNotEmpty ||
composeState.contentController.text.trim().isNotEmpty;
final hasAttachments = composeState.attachments.value.isNotEmpty;
if (hasContent || hasAttachments) {
final draft = SnPost(
id: composeState.draftId,
title: composeState.titleController.text,
description: composeState.descriptionController.text,
content: composeState.contentController.text,
visibility: composeState.visibility.value,
type: composeState.postType,
attachments:
composeState.attachments.value
.where((e) => e.isOnCloud)
.map((e) => e.data as SnCloudFile)
.toList(),
publisher: composeState.currentPublisher.value!,
updatedAt: DateTime.now(),
);
notifier
.saveDraft(draft)
.catchError((e) => debugPrint('Failed to save draft: $e'));
}
}
ComposeLogic.dispose(composeState);
}
};
}, []);
// Helper methods
void showSettingsSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (context) => ComposeSettingsSheet(state: composeState),
);
}
Future<void> performSubmit() async {
await ComposeSubmitUtils.performSubmit(
ref,
composeState,
context,
originalPost: originalPost,
repliedPost: repliedPost,
forwardedPost: forwardedPost,
onSuccess: () {
// Mark as submitted
submitted.value = true;
// Delete draft after successful submission
ref
.read(composeStorageNotifierProvider.notifier)
.deleteDraft(composeState.draftId);
// Reset the form for new composition
ComposeStateUtils.resetForm(composeState);
onSubmit?.call();
},
);
}
final maxHeight = math.min(640.0, MediaQuery.of(context).size.height * 0.8);
return Card(
margin: EdgeInsets.zero,
color: isContained ? Colors.transparent : null,
elevation: isContained ? 0 : null,
child: Container(
constraints: BoxConstraints(maxHeight: maxHeight),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with actions
if (showHeader)
Container(
height: 65,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: theme.colorScheme.outline.withOpacity(0.2),
),
),
),
child: Row(
children: [
const Gap(4),
Text(
'postCompose'.tr(),
style: theme.textTheme.titleMedium!.copyWith(
fontSize: 18,
),
),
const Spacer(),
IconButton(
icon: const Icon(Symbols.settings),
onPressed: showSettingsSheet,
tooltip: 'postSettings'.tr(),
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -2,
),
),
IconButton(
onPressed:
(composeState.submitting.value ||
composeState.currentPublisher.value == null)
? null
: performSubmit,
icon:
composeState.submitting.value
? SizedBox(
width: 24,
height: 24,
child: const CircularProgressIndicator(
strokeWidth: 2,
),
)
: Icon(
originalPost != null
? Symbols.edit
: Symbols.upload,
),
tooltip:
originalPost != null
? 'postUpdate'.tr()
: 'postPublish'.tr(),
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -2,
),
),
if (onCancel != null)
IconButton(
icon: const Icon(Symbols.close),
onPressed: onCancel,
tooltip: 'cancel'.tr(),
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -2,
),
),
],
),
),
// Info banner (reply/forward)
ComposeInfoBanner(
originalPost: originalPost,
replyingTo: repliedPost,
forwardingTo: forwardedPost,
onReferencePostTap: (context, post) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder:
(context) => SheetScaffold(
titleText: 'Post Preview',
child: SingleChildScrollView(
child: PostItem(item: post),
),
),
);
},
),
// Main content area
Expanded(
child: KeyboardListener(
focusNode: FocusNode(),
onKeyEvent:
(event) => ComposeLogic.handleKeyPress(
event,
composeState,
ref,
context,
originalPost: originalPost,
repliedPost: repliedPost,
forwardedPost: forwardedPost,
),
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Row(
spacing: 12,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Publisher profile picture
GestureDetector(
child: ProfilePictureWidget(
fileId:
composeState
.currentPublisher
.value
?.picture
?.id,
radius: 20,
fallbackIcon:
composeState.currentPublisher.value == null
? Symbols.question_mark
: null,
),
onTap: () {
if (composeState.currentPublisher.value == null) {
// No publisher loaded, guide user to create one
if (isContained) {
Navigator.of(context).pop();
}
context.pushNamed('creatorNew').then((value) {
if (value != null) {
composeState.currentPublisher.value =
value as SnPublisher;
ref.invalidate(publishersManagedProvider);
}
});
} else {
// Show modal to select from existing publishers
showModalBottomSheet(
isScrollControlled: true,
useRootNavigator: true,
context: context,
builder: (context) => const PublisherModal(),
).then((value) {
if (value != null) {
composeState.currentPublisher.value = value;
}
});
}
},
).padding(top: 8),
// Post content form
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ComposeFormFields(
state: composeState,
showPublisherAvatar: false,
onPublisherTap: () {
if (composeState.currentPublisher.value ==
null) {
// No publisher loaded, guide user to create one
if (isContained) {
Navigator.of(context).pop();
}
context.pushNamed('creatorNew').then((
value,
) {
if (value != null) {
composeState.currentPublisher.value =
value as SnPublisher;
ref.invalidate(
publishersManagedProvider,
);
}
});
} else {
// Show modal to select from existing publishers
showModalBottomSheet(
isScrollControlled: true,
useRootNavigator: true,
context: context,
builder:
(context) => const PublisherModal(),
).then((value) {
if (value != null) {
composeState.currentPublisher.value =
value;
}
});
}
},
),
const Gap(8),
ComposeAttachments(
state: composeState,
isCompact: true,
),
],
),
),
],
),
),
),
),
),
// Bottom toolbar
SizedBox(
height: 65,
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(8),
bottomRight: Radius.circular(8),
),
child: ComposeToolbar(
state: composeState,
originalPost: originalPost,
isCompact: true,
),
),
),
],
),
),
);
}
}