💄 Optimize explore compose region
This commit is contained in:
@@ -1085,5 +1085,7 @@
|
|||||||
"noChangelogProvided": "No changelog provided.",
|
"noChangelogProvided": "No changelog provided.",
|
||||||
"useSecondarySourceForDownload": "Use secondary source for download",
|
"useSecondarySourceForDownload": "Use secondary source for download",
|
||||||
"installUpdate": "Install update",
|
"installUpdate": "Install update",
|
||||||
"openReleasePage": "Open release page"
|
"openReleasePage": "Open release page",
|
||||||
|
"postCompose": "Compose Post",
|
||||||
|
"postPublish": "Publish Post"
|
||||||
}
|
}
|
||||||
|
@@ -13,12 +13,13 @@ import 'package:island/pods/event_calendar.dart';
|
|||||||
import 'package:island/pods/userinfo.dart';
|
import 'package:island/pods/userinfo.dart';
|
||||||
import 'package:island/screens/notification.dart';
|
import 'package:island/screens/notification.dart';
|
||||||
import 'package:island/services/responsive.dart';
|
import 'package:island/services/responsive.dart';
|
||||||
import 'package:island/widgets/account/fortune_graph.dart';
|
|
||||||
import 'package:island/widgets/app_scaffold.dart';
|
import 'package:island/widgets/app_scaffold.dart';
|
||||||
import 'package:island/models/post.dart';
|
import 'package:island/models/post.dart';
|
||||||
import 'package:island/widgets/check_in.dart';
|
import 'package:island/widgets/check_in.dart';
|
||||||
import 'package:island/widgets/post/post_featured.dart';
|
import 'package:island/widgets/post/post_featured.dart';
|
||||||
import 'package:island/widgets/post/post_item.dart';
|
import 'package:island/widgets/post/post_item.dart';
|
||||||
|
import 'package:island/widgets/post/compose_card.dart';
|
||||||
import 'package:island/screens/tabs.dart';
|
import 'package:island/screens/tabs.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
@@ -222,29 +223,32 @@ class ExploreScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
isNoBackground: false,
|
isNoBackground: false,
|
||||||
floatingActionButton: InkWell(
|
floatingActionButton:
|
||||||
onLongPress: () {
|
isWide
|
||||||
context.pushNamed('postCompose', queryParameters: {'type': '1'}).then(
|
? null
|
||||||
(value) {
|
: InkWell(
|
||||||
if (value != null) {
|
onLongPress: () {
|
||||||
activitiesNotifier.forceRefresh();
|
context
|
||||||
}
|
.pushNamed('postCompose', queryParameters: {'type': '1'})
|
||||||
},
|
.then((value) {
|
||||||
);
|
if (value != null) {
|
||||||
},
|
activitiesNotifier.forceRefresh();
|
||||||
child: FloatingActionButton(
|
}
|
||||||
heroTag: Key("explore-page-fab"),
|
});
|
||||||
onPressed: () {
|
},
|
||||||
context.pushNamed('postCompose').then((value) {
|
child: FloatingActionButton(
|
||||||
if (value != null) {
|
heroTag: Key("explore-page-fab"),
|
||||||
activitiesNotifier.forceRefresh();
|
onPressed: () {
|
||||||
}
|
context.pushNamed('postCompose').then((value) {
|
||||||
});
|
if (value != null) {
|
||||||
},
|
activitiesNotifier.forceRefresh();
|
||||||
child: const Icon(Symbols.edit),
|
}
|
||||||
),
|
});
|
||||||
),
|
},
|
||||||
floatingActionButtonLocation: TabbedFabLocation(context),
|
child: const Icon(Symbols.edit),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
floatingActionButtonLocation: isWide ? null : TabbedFabLocation(context),
|
||||||
body:
|
body:
|
||||||
isWide
|
isWide
|
||||||
? _buildWideBody(
|
? _buildWideBody(
|
||||||
@@ -345,12 +349,9 @@ class ExploreScreen extends HookConsumerWidget {
|
|||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
PostFeaturedList(),
|
PostFeaturedList(),
|
||||||
FortuneGraphWidget(
|
PostComposeCard(
|
||||||
margin: EdgeInsets.zero,
|
onSubmit: (post) {
|
||||||
events: events as AsyncValue<List<SnEventCalendarEntry>>,
|
activitiesNotifier.forceRefresh();
|
||||||
constrainWidth: true,
|
|
||||||
onPointSelected: (DateTime day) {
|
|
||||||
selectedDay.value = day;
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
842
lib/widgets/post/compose_card.dart
Normal file
842
lib/widgets/post/compose_card.dart
Normal file
@@ -0,0 +1,842 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
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:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:island/models/post.dart';
|
||||||
|
import 'package:island/pods/network.dart';
|
||||||
|
import 'package:island/screens/creators/publishers.dart';
|
||||||
|
import 'package:island/screens/posts/compose.dart';
|
||||||
|
import 'package:island/services/compose_storage_db.dart';
|
||||||
|
import 'package:island/services/responsive.dart';
|
||||||
|
import 'package:island/widgets/attachment_uploader.dart';
|
||||||
|
import 'package:island/widgets/content/attachment_preview.dart';
|
||||||
|
import 'package:island/widgets/content/cloud_files.dart';
|
||||||
|
import 'package:island/widgets/post/compose_shared.dart';
|
||||||
|
import 'package:island/widgets/post/post_item.dart';
|
||||||
|
import 'package:island/widgets/post/publishers_modal.dart';
|
||||||
|
import 'package:island/widgets/post/compose_settings_sheet.dart';
|
||||||
|
import 'package:island/widgets/post/compose_toolbar.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(SnPost)? onSubmit;
|
||||||
|
final Function(ComposeState)? onStateChanged;
|
||||||
|
|
||||||
|
const PostComposeCard({
|
||||||
|
super.key,
|
||||||
|
this.originalPost,
|
||||||
|
this.initialState,
|
||||||
|
this.onCancel,
|
||||||
|
this.onSubmit,
|
||||||
|
this.onStateChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final repliedPost = initialState?.replyingTo ?? originalPost?.repliedPost;
|
||||||
|
final forwardedPost =
|
||||||
|
initialState?.forwardingTo ?? originalPost?.forwardedPost;
|
||||||
|
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final publishers = ref.watch(publishersManagedProvider);
|
||||||
|
|
||||||
|
// Create compose state
|
||||||
|
final state = 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([
|
||||||
|
state.titleController,
|
||||||
|
state.descriptionController,
|
||||||
|
state.contentController,
|
||||||
|
state.visibility,
|
||||||
|
state.attachments,
|
||||||
|
state.attachmentProgress,
|
||||||
|
state.currentPublisher,
|
||||||
|
state.submitting,
|
||||||
|
]),
|
||||||
|
[state],
|
||||||
|
);
|
||||||
|
useListenable(stateNotifier);
|
||||||
|
|
||||||
|
// Notify parent of state changes
|
||||||
|
useEffect(() {
|
||||||
|
onStateChanged?.call(state);
|
||||||
|
return null;
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
// Initialize publisher once when data is available
|
||||||
|
useEffect(() {
|
||||||
|
if (publishers.value?.isNotEmpty ?? false) {
|
||||||
|
if (state.currentPublisher.value == null) {
|
||||||
|
state.currentPublisher.value = publishers.value!.first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [publishers]);
|
||||||
|
|
||||||
|
// Load initial state if provided
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// Dispose state when widget is disposed
|
||||||
|
useEffect(() {
|
||||||
|
return () => ComposeLogic.dispose(state);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset form to clean state for new composition
|
||||||
|
void resetForm() {
|
||||||
|
// Clear text fields
|
||||||
|
state.titleController.clear();
|
||||||
|
state.descriptionController.clear();
|
||||||
|
state.contentController.clear();
|
||||||
|
state.slugController.clear();
|
||||||
|
|
||||||
|
// Reset visibility to default (0 = public)
|
||||||
|
state.visibility.value = 0;
|
||||||
|
|
||||||
|
// Clear attachments
|
||||||
|
state.attachments.value = [];
|
||||||
|
|
||||||
|
// Clear attachment progress
|
||||||
|
state.attachmentProgress.value = {};
|
||||||
|
|
||||||
|
// Clear tags
|
||||||
|
state.tagsController.clearTags();
|
||||||
|
|
||||||
|
// Clear categories
|
||||||
|
state.categories.value = [];
|
||||||
|
|
||||||
|
// Clear embed view
|
||||||
|
state.embedView.value = null;
|
||||||
|
|
||||||
|
// Clear poll
|
||||||
|
state.pollId.value = null;
|
||||||
|
|
||||||
|
// Clear realm
|
||||||
|
state.realm.value = null;
|
||||||
|
|
||||||
|
// Generate new draft ID for fresh composition
|
||||||
|
// Note: We don't recreate the entire state, just reset the fields
|
||||||
|
// The existing state object is reused for continuity
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
void showSettingsSheet() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => ComposeSettingsSheet(state: state),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> performSubmit() async {
|
||||||
|
if (state.submitting.value) return;
|
||||||
|
|
||||||
|
// Don't submit empty posts (no content and no attachments)
|
||||||
|
final hasContent =
|
||||||
|
state.titleController.text.trim().isNotEmpty ||
|
||||||
|
state.descriptionController.text.trim().isNotEmpty ||
|
||||||
|
state.contentController.text.trim().isNotEmpty;
|
||||||
|
final hasAttachments = state.attachments.value.isNotEmpty;
|
||||||
|
|
||||||
|
if (!hasContent && !hasAttachments) {
|
||||||
|
// Show error message if context is mounted
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('postContentEmpty'.tr())));
|
||||||
|
}
|
||||||
|
return; // Don't submit empty posts
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.submitting.value = true;
|
||||||
|
|
||||||
|
// Upload any local attachments first
|
||||||
|
await Future.wait(
|
||||||
|
state.attachments.value
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.where((entry) => entry.value.isOnDevice)
|
||||||
|
.map(
|
||||||
|
(entry) => ComposeLogic.uploadAttachment(ref, state, entry.key),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prepare API request
|
||||||
|
final client = ref.read(apiClientProvider);
|
||||||
|
final isNewPost = originalPost == null;
|
||||||
|
final endpoint =
|
||||||
|
'/sphere${isNewPost ? '/posts' : '/posts/${originalPost!.id}'}';
|
||||||
|
|
||||||
|
// Create request payload
|
||||||
|
final payload = {
|
||||||
|
'title': state.titleController.text,
|
||||||
|
'description': state.descriptionController.text,
|
||||||
|
'content': state.contentController.text,
|
||||||
|
if (state.slugController.text.isNotEmpty)
|
||||||
|
'slug': state.slugController.text,
|
||||||
|
'visibility': state.visibility.value,
|
||||||
|
'attachments':
|
||||||
|
state.attachments.value
|
||||||
|
.where((e) => e.isOnCloud)
|
||||||
|
.map((e) => e.data.id)
|
||||||
|
.toList(),
|
||||||
|
'type': state.postType,
|
||||||
|
if (repliedPost != null) 'replied_post_id': repliedPost.id,
|
||||||
|
if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id,
|
||||||
|
'tags': state.tagsController.getTags,
|
||||||
|
'categories': state.categories.value.map((e) => e.slug).toList(),
|
||||||
|
if (state.realm.value != null) 'realm_id': state.realm.value?.id,
|
||||||
|
if (state.pollId.value != null) 'poll_id': state.pollId.value,
|
||||||
|
if (state.embedView.value != null)
|
||||||
|
'embed_view': state.embedView.value!.toJson(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send request
|
||||||
|
final response = await client.request(
|
||||||
|
endpoint,
|
||||||
|
queryParameters: {'pub': state.currentPublisher.value?.name},
|
||||||
|
data: payload,
|
||||||
|
options: Options(method: isNewPost ? 'POST' : 'PATCH'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create the post object from the response for the callback
|
||||||
|
final post = SnPost.fromJson(response.data);
|
||||||
|
|
||||||
|
// Delete draft after successful submission
|
||||||
|
await ref
|
||||||
|
.read(composeStorageNotifierProvider.notifier)
|
||||||
|
.deleteDraft(state.draftId);
|
||||||
|
|
||||||
|
// Reset the form for new composition
|
||||||
|
resetForm();
|
||||||
|
|
||||||
|
// Call the success callback with the created/updated post
|
||||||
|
onSubmit?.call(post);
|
||||||
|
} catch (err) {
|
||||||
|
// Show error message if context is mounted
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('Error: $err')));
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
state.submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildWideAttachmentGrid() {
|
||||||
|
return GridView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
crossAxisSpacing: 8,
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
),
|
||||||
|
itemCount: state.attachments.value.length,
|
||||||
|
itemBuilder: (context, idx) {
|
||||||
|
final progressMap = state.attachmentProgress.value;
|
||||||
|
return AttachmentPreview(
|
||||||
|
isCompact: true,
|
||||||
|
item: state.attachments.value[idx],
|
||||||
|
progress: progressMap[idx],
|
||||||
|
onRequestUpload: () async {
|
||||||
|
final config = await showModalBottomSheet<AttachmentUploadConfig>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder:
|
||||||
|
(context) => AttachmentUploaderSheet(
|
||||||
|
ref: ref,
|
||||||
|
state: state,
|
||||||
|
index: idx,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (config != null) {
|
||||||
|
await ComposeLogic.uploadAttachment(
|
||||||
|
ref,
|
||||||
|
state,
|
||||||
|
idx,
|
||||||
|
poolId: config.poolId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx),
|
||||||
|
onUpdate:
|
||||||
|
(value) => ComposeLogic.updateAttachment(state, value, idx),
|
||||||
|
onMove: (delta) {
|
||||||
|
state.attachments.value = ComposeLogic.moveAttachment(
|
||||||
|
state.attachments.value,
|
||||||
|
idx,
|
||||||
|
delta,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildNarrowAttachmentList() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
for (var idx = 0; idx < state.attachments.value.length; idx++)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: () {
|
||||||
|
final progressMap = state.attachmentProgress.value;
|
||||||
|
return AttachmentPreview(
|
||||||
|
item: state.attachments.value[idx],
|
||||||
|
progress: progressMap[idx],
|
||||||
|
onRequestUpload: () async {
|
||||||
|
final config =
|
||||||
|
await showModalBottomSheet<AttachmentUploadConfig>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder:
|
||||||
|
(context) => AttachmentUploaderSheet(
|
||||||
|
ref: ref,
|
||||||
|
state: state,
|
||||||
|
index: idx,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (config != null) {
|
||||||
|
await ComposeLogic.uploadAttachment(
|
||||||
|
ref,
|
||||||
|
state,
|
||||||
|
idx,
|
||||||
|
poolId: config.poolId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDelete:
|
||||||
|
() => ComposeLogic.deleteAttachment(ref, state, idx),
|
||||||
|
onUpdate:
|
||||||
|
(value) =>
|
||||||
|
ComposeLogic.updateAttachment(state, value, idx),
|
||||||
|
onMove: (delta) {
|
||||||
|
state.attachments.value = ComposeLogic.moveAttachment(
|
||||||
|
state.attachments.value,
|
||||||
|
idx,
|
||||||
|
delta,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 400),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header with actions
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: theme.colorScheme.outline.withOpacity(0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
originalPost != null
|
||||||
|
? 'postEditing'.tr()
|
||||||
|
: 'postCompose'.tr(),
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Symbols.settings),
|
||||||
|
onPressed: showSettingsSheet,
|
||||||
|
tooltip: 'postSettings'.tr(),
|
||||||
|
visualDensity: const VisualDensity(
|
||||||
|
horizontal: -4,
|
||||||
|
vertical: -4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: state.submitting.value ? null : performSubmit,
|
||||||
|
icon:
|
||||||
|
state.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: -4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (onCancel != null)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Symbols.close),
|
||||||
|
onPressed: onCancel,
|
||||||
|
tooltip: 'cancel'.tr(),
|
||||||
|
visualDensity: const VisualDensity(
|
||||||
|
horizontal: -4,
|
||||||
|
vertical: -4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Info banner (reply/forward)
|
||||||
|
_buildInfoBanner(context),
|
||||||
|
|
||||||
|
// Main content area
|
||||||
|
Expanded(
|
||||||
|
child: KeyboardListener(
|
||||||
|
focusNode: FocusNode(),
|
||||||
|
onKeyEvent:
|
||||||
|
(event) => ComposeLogic.handleKeyPress(
|
||||||
|
event,
|
||||||
|
state,
|
||||||
|
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: 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: 8),
|
||||||
|
|
||||||
|
// Post content form
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: state.titleController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'postTitle'.tr(),
|
||||||
|
border: InputBorder.none,
|
||||||
|
isCollapsed: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 8,
|
||||||
|
horizontal: 8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
onTapOutside:
|
||||||
|
(_) =>
|
||||||
|
FocusManager.instance.primaryFocus
|
||||||
|
?.unfocus(),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: state.descriptionController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'postDescription'.tr(),
|
||||||
|
border: InputBorder.none,
|
||||||
|
isCollapsed: true,
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(
|
||||||
|
8,
|
||||||
|
4,
|
||||||
|
8,
|
||||||
|
12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
minLines: 1,
|
||||||
|
maxLines: 3,
|
||||||
|
onTapOutside:
|
||||||
|
(_) =>
|
||||||
|
FocusManager.instance.primaryFocus
|
||||||
|
?.unfocus(),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: state.contentController,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: InputBorder.none,
|
||||||
|
hintText: 'postContent'.tr(),
|
||||||
|
isCollapsed: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 8,
|
||||||
|
horizontal: 8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
maxLines: null,
|
||||||
|
onTapOutside:
|
||||||
|
(_) =>
|
||||||
|
FocusManager.instance.primaryFocus
|
||||||
|
?.unfocus(),
|
||||||
|
),
|
||||||
|
|
||||||
|
const Gap(8),
|
||||||
|
|
||||||
|
// Attachments preview
|
||||||
|
if (state.attachments.value.isNotEmpty)
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final isWide = isWideScreen(context);
|
||||||
|
return isWide
|
||||||
|
? buildWideAttachmentGrid()
|
||||||
|
: buildNarrowAttachmentList();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox.shrink(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bottom toolbar
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(8),
|
||||||
|
bottomRight: Radius.circular(8),
|
||||||
|
),
|
||||||
|
child: ComposeToolbar(state: state, originalPost: originalPost),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoBanner(BuildContext context) {
|
||||||
|
final effectiveRepliedPost =
|
||||||
|
initialState?.replyingTo ?? originalPost?.repliedPost;
|
||||||
|
final effectiveForwardedPost =
|
||||||
|
initialState?.forwardingTo ?? originalPost?.forwardedPost;
|
||||||
|
|
||||||
|
// Show editing banner when editing a post
|
||||||
|
if (originalPost != null) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
color: Theme.of(context).colorScheme.primaryContainer,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Symbols.edit,
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Text(
|
||||||
|
'postEditing'.tr(),
|
||||||
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).padding(horizontal: 16, vertical: 8),
|
||||||
|
),
|
||||||
|
// Show reply/forward banners below editing banner if they exist
|
||||||
|
if (effectiveRepliedPost != null)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Symbols.reply, size: 16),
|
||||||
|
const Gap(4),
|
||||||
|
Text(
|
||||||
|
'postReplyingTo'.tr(),
|
||||||
|
style: Theme.of(context).textTheme.labelMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
_buildCompactReferencePost(context, effectiveRepliedPost),
|
||||||
|
],
|
||||||
|
).padding(all: 16),
|
||||||
|
),
|
||||||
|
if (effectiveForwardedPost != null)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Symbols.forward, size: 16),
|
||||||
|
const Gap(4),
|
||||||
|
Text(
|
||||||
|
'postForwardingTo'.tr(),
|
||||||
|
style: Theme.of(context).textTheme.labelMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
_buildCompactReferencePost(context, effectiveForwardedPost),
|
||||||
|
],
|
||||||
|
).padding(all: 16),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show banner for replies
|
||||||
|
if (effectiveRepliedPost != null) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Symbols.reply, size: 16),
|
||||||
|
const Gap(4),
|
||||||
|
Text(
|
||||||
|
'postReplyingTo'.tr(),
|
||||||
|
style: Theme.of(context).textTheme.labelMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
_buildCompactReferencePost(context, effectiveRepliedPost),
|
||||||
|
],
|
||||||
|
).padding(all: 16),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show banner for forwards
|
||||||
|
if (effectiveForwardedPost != null) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Symbols.forward, size: 16),
|
||||||
|
const Gap(4),
|
||||||
|
Text(
|
||||||
|
'postForwardingTo'.tr(),
|
||||||
|
style: Theme.of(context).textTheme.labelMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
_buildCompactReferencePost(context, effectiveForwardedPost),
|
||||||
|
],
|
||||||
|
).padding(all: 16),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCompactReferencePost(BuildContext context, SnPost post) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ProfilePictureWidget(
|
||||||
|
fileId: post.publisher.picture?.id,
|
||||||
|
radius: 16,
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
post.publisher.nick,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (post.title?.isNotEmpty ?? false)
|
||||||
|
Text(
|
||||||
|
post.title!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 13,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (post.content?.isNotEmpty ?? false)
|
||||||
|
Text(
|
||||||
|
post.content!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (post.attachments.isNotEmpty)
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Symbols.attach_file,
|
||||||
|
size: 12,
|
||||||
|
color: Theme.of(context).colorScheme.secondary,
|
||||||
|
),
|
||||||
|
const Gap(4),
|
||||||
|
Text(
|
||||||
|
'postHasAttachments'.plural(post.attachments.length),
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.secondary,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Symbols.open_in_full,
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.outline,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
46
lib/widgets/post/compose_dialog.dart
Normal file
46
lib/widgets/post/compose_dialog.dart
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:island/models/post.dart';
|
||||||
|
import 'package:island/screens/posts/compose.dart';
|
||||||
|
import 'package:island/widgets/post/compose_card.dart';
|
||||||
|
|
||||||
|
/// A dialog that wraps PostComposeCard for easy use in dialogs.
|
||||||
|
/// This provides a convenient way to show the compose interface in a modal dialog.
|
||||||
|
class PostComposeDialog extends HookConsumerWidget {
|
||||||
|
final SnPost? originalPost;
|
||||||
|
final PostComposeInitialState? initialState;
|
||||||
|
|
||||||
|
const PostComposeDialog({super.key, this.originalPost, this.initialState});
|
||||||
|
|
||||||
|
static Future<SnPost?> show(
|
||||||
|
BuildContext context, {
|
||||||
|
SnPost? originalPost,
|
||||||
|
PostComposeInitialState? initialState,
|
||||||
|
}) {
|
||||||
|
return showDialog<SnPost>(
|
||||||
|
context: context,
|
||||||
|
useRootNavigator: false,
|
||||||
|
builder:
|
||||||
|
(context) => PostComposeDialog(
|
||||||
|
originalPost: originalPost,
|
||||||
|
initialState: initialState,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return Dialog(
|
||||||
|
insetPadding: const EdgeInsets.all(16),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 600, maxHeight: 700),
|
||||||
|
child: PostComposeCard(
|
||||||
|
originalPost: originalPost,
|
||||||
|
initialState: initialState,
|
||||||
|
onCancel: () => Navigator.of(context).pop(),
|
||||||
|
onSubmit: (post) => Navigator.of(context).pop(post),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user