🎨 Use feature based folder structure
This commit is contained in:
328
lib/posts/posts_widgets/compose_sheet.dart
Normal file
328
lib/posts/posts_widgets/compose_sheet.dart
Normal file
@@ -0,0 +1,328 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/drive/drive_models/file.dart';
|
||||
import 'package:island/posts/posts/post_detail.dart';
|
||||
import 'package:island/posts/posts_models/post.dart';
|
||||
import 'package:island/posts/compose.dart';
|
||||
import 'package:island/posts/compose_storage_db.dart';
|
||||
import 'package:island/core/services/responsive.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_card.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_shared.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_state_utils.dart';
|
||||
import 'package:material_symbols_icons/symbols.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 PostComposeSheet extends HookConsumerWidget {
|
||||
final SnPost? originalPost;
|
||||
final PostComposeInitialState? initialState;
|
||||
final bool isBottomSheet;
|
||||
|
||||
const PostComposeSheet({
|
||||
super.key,
|
||||
this.originalPost,
|
||||
this.initialState,
|
||||
this.isBottomSheet = false,
|
||||
});
|
||||
|
||||
static Future<bool?> show(
|
||||
BuildContext context, {
|
||||
SnPost? originalPost,
|
||||
PostComposeInitialState? initialState,
|
||||
}) {
|
||||
// Check if editing an article
|
||||
if (originalPost != null && originalPost.type == 1) {
|
||||
context.pushNamed('articleEdit', pathParameters: {'id': originalPost.id});
|
||||
return Future.value(true);
|
||||
}
|
||||
|
||||
return showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder: (context) => PostComposeSheet(
|
||||
originalPost: originalPost,
|
||||
initialState: initialState,
|
||||
isBottomSheet: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final drafts = ref.watch(composeStorageProvider);
|
||||
final restoredInitialState = useState<PostComposeInitialState?>(null);
|
||||
final prompted = useState(false);
|
||||
|
||||
// Fetch full post data if we're editing a post
|
||||
final fullPostData = originalPost != null
|
||||
? ref.watch(postProvider(originalPost!.id))
|
||||
: const AsyncValue.data(null);
|
||||
|
||||
// Use the full post data if available, otherwise fall back to originalPost
|
||||
final effectiveOriginalPost = fullPostData.when(
|
||||
data: (fullPost) => fullPost ?? originalPost,
|
||||
loading: () => originalPost,
|
||||
error: (_, _) => originalPost,
|
||||
);
|
||||
|
||||
final repliedPost =
|
||||
initialState?.replyingTo ?? effectiveOriginalPost?.repliedPost;
|
||||
final forwardedPost =
|
||||
initialState?.forwardingTo ?? effectiveOriginalPost?.forwardedPost;
|
||||
|
||||
// Create compose state
|
||||
final ComposeState state = useMemoized(
|
||||
() => ComposeLogic.createState(
|
||||
originalPost: effectiveOriginalPost,
|
||||
forwardedPost: forwardedPost,
|
||||
repliedPost: repliedPost,
|
||||
postType: 0,
|
||||
),
|
||||
[effectiveOriginalPost, 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);
|
||||
|
||||
// Use shared state management utilities
|
||||
ComposeStateUtils.usePublisherInitialization(ref, state);
|
||||
ComposeStateUtils.useInitialStateLoader(state, initialState);
|
||||
|
||||
useEffect(() {
|
||||
if (!prompted.value &&
|
||||
originalPost == null &&
|
||||
initialState?.replyingTo == null &&
|
||||
initialState?.forwardingTo == null &&
|
||||
drafts.isNotEmpty) {
|
||||
prompted.value = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_showRestoreDialog(ref, restoredInitialState);
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [drafts, prompted.value]);
|
||||
|
||||
// Dispose state when widget is disposed
|
||||
useEffect(
|
||||
() =>
|
||||
() => ComposeLogic.dispose(state),
|
||||
[],
|
||||
);
|
||||
|
||||
// Helper methods for actions
|
||||
void showSettingsSheet() {
|
||||
ComposeLogic.showSettingsSheet(context, state);
|
||||
}
|
||||
|
||||
Future<void> performSubmit() async {
|
||||
await ComposeLogic.performSubmit(
|
||||
ref,
|
||||
state,
|
||||
context,
|
||||
originalPost: effectiveOriginalPost,
|
||||
repliedPost: repliedPost,
|
||||
forwardedPost: forwardedPost,
|
||||
onSuccess: () {
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final actions = [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.settings),
|
||||
onPressed: showSettingsSheet,
|
||||
tooltip: 'postSettings'.tr(),
|
||||
),
|
||||
IconButton(
|
||||
onPressed:
|
||||
(state.submitting.value || state.currentPublisher.value == null)
|
||||
? null
|
||||
: performSubmit,
|
||||
icon: state.submitting.value
|
||||
? SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: const CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(
|
||||
effectiveOriginalPost != null ? Symbols.edit : Symbols.upload,
|
||||
),
|
||||
tooltip: effectiveOriginalPost != null
|
||||
? 'postUpdate'.tr()
|
||||
: 'postPublish'.tr(),
|
||||
),
|
||||
];
|
||||
|
||||
// Tablet will show a virtual keyboard, so we adjust the height factor accordingly
|
||||
final isTablet =
|
||||
isWideScreen(context) &&
|
||||
!kIsWeb &&
|
||||
(Platform.isAndroid || Platform.isIOS);
|
||||
|
||||
return SheetScaffold(
|
||||
heightFactor: isTablet ? 0.95 : 0.8,
|
||||
titleText: 'postCompose'.tr(),
|
||||
actions: actions,
|
||||
child: PostComposeCard(
|
||||
originalPost: effectiveOriginalPost,
|
||||
initialState: restoredInitialState.value ?? initialState,
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onSubmit: () {
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
isContained: true,
|
||||
showHeader: false,
|
||||
providedState: state,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showRestoreDialog(
|
||||
WidgetRef ref,
|
||||
ValueNotifier<PostComposeInitialState?> restoredInitialState,
|
||||
) async {
|
||||
final drafts = ref.read(composeStorageProvider);
|
||||
if (drafts.isNotEmpty) {
|
||||
final latestDraft = drafts.values.last;
|
||||
|
||||
final restore = await showDialog<bool>(
|
||||
context: ref.context,
|
||||
useRootNavigator: true,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('restoreDraftTitle'.tr()),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('restoreDraftMessage'.tr()),
|
||||
const SizedBox(height: 16),
|
||||
_buildCompactDraftPreview(context, latestDraft),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text('no'.tr()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text('yes'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (restore == true) {
|
||||
// Delete the old draft
|
||||
await ref
|
||||
.read(composeStorageProvider.notifier)
|
||||
.deleteDraft(latestDraft.id);
|
||||
restoredInitialState.value = PostComposeInitialState(
|
||||
title: latestDraft.title,
|
||||
description: latestDraft.description,
|
||||
content: latestDraft.content,
|
||||
visibility: latestDraft.visibility,
|
||||
attachments: latestDraft.attachments
|
||||
.map((e) => UniversalFile.fromAttachment(e))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildCompactDraftPreview(BuildContext context, SnPost draft) {
|
||||
return 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: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.description,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'draft'.tr(),
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (draft.title?.isNotEmpty ?? false)
|
||||
Text(
|
||||
draft.title!,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (draft.content?.isNotEmpty ?? false)
|
||||
Text(
|
||||
draft.content!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (draft.attachments.isNotEmpty)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.attach_file,
|
||||
size: 12,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${draft.attachments.length} attachment${draft.attachments.length > 1 ? 's' : ''}',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
127
lib/posts/posts_widgets/post/article_sidebar_panel.dart
Normal file
127
lib/posts/posts_widgets/post/article_sidebar_panel.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
|
||||
enum SidebarPanelType { attachments, settings }
|
||||
|
||||
class ArticleSidebarPanelWidget extends HookConsumerWidget {
|
||||
final Widget attachmentsContent;
|
||||
final Widget settingsContent;
|
||||
final VoidCallback onClose;
|
||||
final bool isWide;
|
||||
final double width;
|
||||
|
||||
const ArticleSidebarPanelWidget({
|
||||
super.key,
|
||||
required this.attachmentsContent,
|
||||
required this.settingsContent,
|
||||
required this.onClose,
|
||||
required this.isWide,
|
||||
this.width = 480,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
final activePanel = useState<SidebarPanelType>(
|
||||
SidebarPanelType.attachments,
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildHeader(context, activePanel, colorScheme, onClose, theme),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
child: activePanel.value == SidebarPanelType.attachments
|
||||
? Container(
|
||||
key: const ValueKey(SidebarPanelType.attachments),
|
||||
alignment: Alignment.topCenter,
|
||||
child: attachmentsContent,
|
||||
)
|
||||
: Container(
|
||||
key: const ValueKey(SidebarPanelType.settings),
|
||||
alignment: Alignment.topCenter,
|
||||
child: settingsContent,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(
|
||||
BuildContext context,
|
||||
ValueNotifier<SidebarPanelType> activePanel,
|
||||
ColorScheme colorScheme,
|
||||
VoidCallback onClose,
|
||||
ThemeData theme,
|
||||
) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildSegmentedTabs(activePanel, colorScheme, theme),
|
||||
const Spacer(),
|
||||
if (!isWide)
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.close),
|
||||
onPressed: onClose,
|
||||
tooltip: 'close'.tr(),
|
||||
padding: const EdgeInsets.all(8),
|
||||
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSegmentedTabs(
|
||||
ValueNotifier<SidebarPanelType> activePanel,
|
||||
ColorScheme colorScheme,
|
||||
ThemeData theme,
|
||||
) {
|
||||
return SegmentedButton<SidebarPanelType>(
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: SidebarPanelType.attachments,
|
||||
label: Text('attachments'.tr()),
|
||||
icon: const Icon(Symbols.attach_file, size: 18),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: SidebarPanelType.settings,
|
||||
label: Text('settings'.tr()),
|
||||
icon: const Icon(Symbols.settings, size: 18),
|
||||
),
|
||||
],
|
||||
selected: {activePanel.value},
|
||||
onSelectionChanged: (Set<SidebarPanelType> selected) {
|
||||
if (selected.isNotEmpty) {
|
||||
activePanel.value = selected.first;
|
||||
}
|
||||
},
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return colorScheme.secondaryContainer;
|
||||
}
|
||||
return colorScheme.surfaceContainerHighest;
|
||||
}),
|
||||
foregroundColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return colorScheme.onSecondaryContainer;
|
||||
}
|
||||
return colorScheme.onSurface;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
395
lib/posts/posts_widgets/post/compose_attachments.dart
Normal file
395
lib/posts/posts_widgets/post/compose_attachments.dart
Normal file
@@ -0,0 +1,395 @@
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/drive/drive_models/file.dart';
|
||||
import 'package:island/core/services/responsive.dart';
|
||||
import 'package:island/drive/drive_service.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_shared.dart';
|
||||
import 'package:island/shared/widgets/attachment_uploader.dart';
|
||||
import 'package:island/core/widgets/content/attachment_preview.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
/// A reusable widget for displaying attachments in compose screens.
|
||||
/// Supports both grid and list layouts based on screen width.
|
||||
class ComposeAttachments extends ConsumerWidget {
|
||||
final ComposeState state;
|
||||
final bool isCompact;
|
||||
|
||||
const ComposeAttachments({
|
||||
super.key,
|
||||
required this.state,
|
||||
this.isCompact = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (state.attachments.value.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = isWideScreen(context);
|
||||
return isWide ? _buildWideGrid(ref) : _buildNarrowList(ref);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWideGrid(WidgetRef ref) {
|
||||
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) {
|
||||
return _buildAttachmentItem(ref, idx, isCompact: true);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNarrowList(WidgetRef ref) {
|
||||
return Column(
|
||||
children: [
|
||||
for (var idx = 0; idx < state.attachments.value.length; idx++)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: _buildAttachmentItem(ref, idx, isCompact: false),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAttachmentItem(
|
||||
WidgetRef ref,
|
||||
int idx, {
|
||||
required bool isCompact,
|
||||
}) {
|
||||
final progressMap = state.attachmentProgress.value;
|
||||
return AttachmentPreview(
|
||||
isCompact: isCompact,
|
||||
item: state.attachments.value[idx],
|
||||
progress: progressMap[idx],
|
||||
isUploading: progressMap.containsKey(idx),
|
||||
onRequestUpload: () async {
|
||||
final config = await showModalBottomSheet<AttachmentUploadConfig>(
|
||||
context: ref.context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ArticleComposeAttachments extends HookConsumerWidget {
|
||||
final ComposeState state;
|
||||
final EdgeInsets? padding;
|
||||
|
||||
const ArticleComposeAttachments({
|
||||
super.key,
|
||||
required this.state,
|
||||
this.padding,
|
||||
});
|
||||
|
||||
Future<void> _handleDroppedFiles(
|
||||
DropDoneDetails details,
|
||||
ComposeState state,
|
||||
) async {
|
||||
final newFiles = <UniversalFile>[];
|
||||
|
||||
for (final xfile in details.files) {
|
||||
// Create UniversalFile with default type first
|
||||
final uf = UniversalFile(data: xfile, type: UniversalFileType.file);
|
||||
// Use FileUploader.getMimeType to get proper MIME type
|
||||
final mimeType = FileUploader.getMimeType(uf);
|
||||
final fileType = switch (mimeType.split('/').firstOrNull) {
|
||||
'image' => UniversalFileType.image,
|
||||
'video' => UniversalFileType.video,
|
||||
'audio' => UniversalFileType.audio,
|
||||
_ => UniversalFileType.file,
|
||||
};
|
||||
|
||||
// Update the file type
|
||||
final correctedUf = UniversalFile(data: xfile, type: fileType);
|
||||
newFiles.add(correctedUf);
|
||||
}
|
||||
|
||||
if (newFiles.isNotEmpty) {
|
||||
state.attachments.value = [...state.attachments.value, ...newFiles];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Padding(
|
||||
padding: padding ?? EdgeInsets.all(16),
|
||||
child: ValueListenableBuilder<String?>(
|
||||
valueListenable: state.thumbnailId,
|
||||
builder: (context, thumbnailId, _) {
|
||||
return ValueListenableBuilder<List<UniversalFile>>(
|
||||
valueListenable: state.attachments,
|
||||
builder: (context, attachments, _) {
|
||||
return HookBuilder(
|
||||
builder: (context) {
|
||||
final isDragging = useState(false);
|
||||
return DropTarget(
|
||||
onDragDone: (details) async =>
|
||||
await _handleDroppedFiles(details, state),
|
||||
onDragEntered: (details) => isDragging.value = true,
|
||||
onDragExited: (details) => isDragging.value = false,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
decoration: isDragging.value
|
||||
? BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
)
|
||||
: null,
|
||||
child: Padding(
|
||||
padding: isDragging.value
|
||||
? const EdgeInsets.all(8)
|
||||
: EdgeInsets.zero,
|
||||
child: attachments.isEmpty
|
||||
? AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
width: double.infinity,
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.withOpacity(0.3),
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline.withOpacity(0.5),
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.upload,
|
||||
size: 48,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'dropFilesHere',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
).tr(),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'dragAndDropToAttach',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant
|
||||
.withOpacity(0.7),
|
||||
),
|
||||
).tr(),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ValueListenableBuilder<Map<int, double?>>(
|
||||
valueListenable: state.attachmentProgress,
|
||||
builder: (context, progressMap, _) {
|
||||
return Wrap(
|
||||
runSpacing: 8,
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (
|
||||
var idx = 0;
|
||||
idx < attachments.length;
|
||||
idx++
|
||||
)
|
||||
_AnimatedAttachmentItem(
|
||||
index: idx,
|
||||
item: attachments[idx],
|
||||
progress: progressMap[idx],
|
||||
isUploading: progressMap.containsKey(
|
||||
idx,
|
||||
),
|
||||
thumbnailId: thumbnailId,
|
||||
onSetThumbnail: (id) =>
|
||||
ComposeLogic.setThumbnail(
|
||||
state,
|
||||
id,
|
||||
),
|
||||
onRequestUpload: () async {
|
||||
final config =
|
||||
await showModalBottomSheet<
|
||||
AttachmentUploadConfig
|
||||
>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder: (context) =>
|
||||
AttachmentUploaderSheet(
|
||||
ref: ref,
|
||||
state: state,
|
||||
index: idx,
|
||||
),
|
||||
);
|
||||
if (config != null) {
|
||||
await ComposeLogic.uploadAttachment(
|
||||
ref,
|
||||
state,
|
||||
idx,
|
||||
poolId: config.poolId,
|
||||
);
|
||||
}
|
||||
},
|
||||
onUpdate: (value) =>
|
||||
ComposeLogic.updateAttachment(
|
||||
state,
|
||||
value,
|
||||
idx,
|
||||
),
|
||||
onDelete: () =>
|
||||
ComposeLogic.deleteAttachment(
|
||||
ref,
|
||||
state,
|
||||
idx,
|
||||
),
|
||||
onInsert: () =>
|
||||
ComposeLogic.insertAttachment(
|
||||
ref,
|
||||
state,
|
||||
idx,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AnimatedAttachmentItem extends HookWidget {
|
||||
final int index;
|
||||
final UniversalFile item;
|
||||
final double? progress;
|
||||
final bool isUploading;
|
||||
final String? thumbnailId;
|
||||
final Function(String?) onSetThumbnail;
|
||||
final VoidCallback onRequestUpload;
|
||||
final Function(UniversalFile) onUpdate;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onInsert;
|
||||
|
||||
const _AnimatedAttachmentItem({
|
||||
required this.index,
|
||||
required this.item,
|
||||
required this.progress,
|
||||
required this.isUploading,
|
||||
required this.thumbnailId,
|
||||
required this.onSetThumbnail,
|
||||
required this.onRequestUpload,
|
||||
required this.onUpdate,
|
||||
required this.onDelete,
|
||||
required this.onInsert,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final animationController = useAnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
|
||||
final fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: animationController, curve: Curves.easeOut),
|
||||
);
|
||||
|
||||
final slideAnimation =
|
||||
Tween<Offset>(begin: const Offset(0, 0.1), end: Offset.zero).animate(
|
||||
CurvedAnimation(
|
||||
parent: animationController,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
final delay = Duration(milliseconds: 50 * index);
|
||||
Future.delayed(delay, () {
|
||||
animationController.forward();
|
||||
});
|
||||
return null;
|
||||
}, [index]);
|
||||
|
||||
return FadeTransition(
|
||||
opacity: fadeAnimation,
|
||||
child: SlideTransition(
|
||||
position: slideAnimation,
|
||||
child: AttachmentPreview(
|
||||
isCompact: true,
|
||||
item: item,
|
||||
progress: progress,
|
||||
isUploading: isUploading,
|
||||
thumbnailId: thumbnailId,
|
||||
onSetThumbnail: onSetThumbnail,
|
||||
onRequestUpload: onRequestUpload,
|
||||
onUpdate: onUpdate,
|
||||
onDelete: onDelete,
|
||||
onInsert: onInsert,
|
||||
bordered: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
425
lib/posts/posts_widgets/post/compose_card.dart
Normal file
425
lib/posts/posts_widgets/post/compose_card.dart
Normal file
@@ -0,0 +1,425 @@
|
||||
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:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/creators/creators/publishers_form.dart';
|
||||
import 'package:island/drive/drive_models/file.dart';
|
||||
import 'package:island/posts/posts_models/post.dart';
|
||||
import 'package:island/posts/posts_models/publisher.dart';
|
||||
import 'package:island/posts/compose.dart';
|
||||
import 'package:island/posts/compose_storage_db.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_attachments.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_form_fields.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_info_banner.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_shared.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_state_utils.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_toolbar.dart';
|
||||
import 'package:island/posts/posts_widgets/post/post_item.dart';
|
||||
import 'package:island/posts/posts_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(composeStorageProvider.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() {
|
||||
ComposeLogic.showSettingsSheet(context, composeState);
|
||||
}
|
||||
|
||||
Future<void> performSubmit() async {
|
||||
await ComposeLogic.performSubmit(
|
||||
ref,
|
||||
composeState,
|
||||
context,
|
||||
originalPost: originalPost,
|
||||
repliedPost: repliedPost,
|
||||
forwardedPost: forwardedPost,
|
||||
onSuccess: () {
|
||||
// Mark as submitted
|
||||
submitted.value = true;
|
||||
|
||||
// Delete draft after successful submission
|
||||
ref
|
||||
.read(composeStorageProvider.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();
|
||||
}
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder: (context) =>
|
||||
const NewPublisherScreen(),
|
||||
).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();
|
||||
}
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder: (context) =>
|
||||
const NewPublisherScreen(),
|
||||
).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
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(8),
|
||||
bottomRight: Radius.circular(8),
|
||||
),
|
||||
child: ComposeToolbar(
|
||||
state: composeState,
|
||||
originalPost: originalPost,
|
||||
useSafeArea: isContained,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
289
lib/posts/posts_widgets/post/compose_dialog.dart
Normal file
289
lib/posts/posts_widgets/post/compose_dialog.dart
Normal file
@@ -0,0 +1,289 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/drive/drive_models/file.dart';
|
||||
import 'package:island/posts/posts_models/post.dart';
|
||||
import 'package:island/posts/compose.dart';
|
||||
import 'package:island/posts/compose_storage_db.dart';
|
||||
import 'package:island/core/services/event_bus.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_card.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_shared.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_state_utils.dart';
|
||||
import 'package:material_symbols_icons/symbols.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;
|
||||
final bool isBottomSheet;
|
||||
|
||||
const PostComposeDialog({
|
||||
super.key,
|
||||
this.originalPost,
|
||||
this.initialState,
|
||||
this.isBottomSheet = false,
|
||||
});
|
||||
|
||||
static Future<bool?> show(
|
||||
BuildContext context, {
|
||||
SnPost? originalPost,
|
||||
PostComposeInitialState? initialState,
|
||||
}) {
|
||||
return showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder: (context) => PostComposeDialog(
|
||||
originalPost: originalPost,
|
||||
initialState: initialState,
|
||||
isBottomSheet: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final drafts = ref.watch(composeStorageProvider);
|
||||
final restoredInitialState = useState<PostComposeInitialState?>(null);
|
||||
final prompted = useState(false);
|
||||
|
||||
final repliedPost = initialState?.replyingTo ?? originalPost?.repliedPost;
|
||||
final forwardedPost =
|
||||
initialState?.forwardingTo ?? originalPost?.forwardedPost;
|
||||
|
||||
// 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);
|
||||
|
||||
// Use shared state management utilities
|
||||
ComposeStateUtils.usePublisherInitialization(ref, state);
|
||||
ComposeStateUtils.useInitialStateLoader(state, initialState);
|
||||
|
||||
useEffect(() {
|
||||
if (!prompted.value &&
|
||||
originalPost == null &&
|
||||
initialState?.replyingTo == null &&
|
||||
initialState?.forwardingTo == null &&
|
||||
drafts.isNotEmpty) {
|
||||
prompted.value = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_showRestoreDialog(ref, restoredInitialState);
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [drafts, prompted.value]);
|
||||
|
||||
// Helper methods for actions
|
||||
void showSettingsSheet() {
|
||||
ComposeLogic.showSettingsSheet(context, state);
|
||||
}
|
||||
|
||||
Future<void> performSubmit() async {
|
||||
await ComposeLogic.performSubmit(
|
||||
ref,
|
||||
state,
|
||||
context,
|
||||
originalPost: originalPost,
|
||||
repliedPost: repliedPost,
|
||||
forwardedPost: forwardedPost,
|
||||
onSuccess: () {
|
||||
// Fire event to notify listeners that a post was created
|
||||
eventBus.fire(PostCreatedEvent());
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final actions = [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.settings),
|
||||
onPressed: showSettingsSheet,
|
||||
tooltip: 'postSettings'.tr(),
|
||||
),
|
||||
IconButton(
|
||||
onPressed:
|
||||
(state.submitting.value || state.currentPublisher.value == null)
|
||||
? 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(),
|
||||
),
|
||||
];
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: 'postCompose'.tr(),
|
||||
actions: actions,
|
||||
child: PostComposeCard(
|
||||
originalPost: originalPost,
|
||||
initialState: restoredInitialState.value ?? initialState,
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onSubmit: () {
|
||||
// Fire event to notify listeners that a post was created
|
||||
eventBus.fire(PostCreatedEvent());
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
isContained: true,
|
||||
showHeader: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showRestoreDialog(
|
||||
WidgetRef ref,
|
||||
ValueNotifier<PostComposeInitialState?> restoredInitialState,
|
||||
) async {
|
||||
final drafts = ref.read(composeStorageProvider);
|
||||
if (drafts.isNotEmpty) {
|
||||
final latestDraft = drafts.values.last;
|
||||
|
||||
final restore = await showDialog<bool>(
|
||||
context: ref.context,
|
||||
useRootNavigator: true,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('restoreDraftTitle'.tr()),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('restoreDraftMessage'.tr()),
|
||||
const SizedBox(height: 16),
|
||||
_buildCompactDraftPreview(context, latestDraft),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text('no'.tr()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text('yes'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (restore == true) {
|
||||
// Delete the old draft
|
||||
await ref
|
||||
.read(composeStorageProvider.notifier)
|
||||
.deleteDraft(latestDraft.id);
|
||||
restoredInitialState.value = PostComposeInitialState(
|
||||
title: latestDraft.title,
|
||||
description: latestDraft.description,
|
||||
content: latestDraft.content,
|
||||
visibility: latestDraft.visibility,
|
||||
attachments: latestDraft.attachments
|
||||
.map((e) => UniversalFile.fromAttachment(e))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildCompactDraftPreview(BuildContext context, SnPost draft) {
|
||||
return 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: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.description,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'draft'.tr(),
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (draft.title?.isNotEmpty ?? false)
|
||||
Text(
|
||||
draft.title!,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (draft.content?.isNotEmpty ?? false)
|
||||
Text(
|
||||
draft.content!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (draft.attachments.isNotEmpty)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.attach_file,
|
||||
size: 12,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${draft.attachments.length} attachment${draft.attachments.length > 1 ? 's' : ''}',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
359
lib/posts/posts_widgets/post/compose_embed_sheet.dart
Normal file
359
lib/posts/posts_widgets/post/compose_embed_sheet.dart
Normal file
@@ -0,0 +1,359 @@
|
||||
import 'package:dropdown_button2/dropdown_button2.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/posts/posts_models/post.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_shared.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class ComposeEmbedSheet extends HookConsumerWidget {
|
||||
final ComposeState state;
|
||||
|
||||
const ComposeEmbedSheet({super.key, required this.state});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
// Listen to embed view changes
|
||||
final currentEmbedView = useValueListenable(state.embedView);
|
||||
|
||||
// Form state
|
||||
final uriController = useTextEditingController();
|
||||
final aspectRatioController = useTextEditingController();
|
||||
final selectedRenderer = useState<PostEmbedViewRenderer>(
|
||||
PostEmbedViewRenderer.webView,
|
||||
);
|
||||
final tabController = useTabController(initialLength: 2);
|
||||
final iframeController = useTextEditingController();
|
||||
|
||||
void clearForm() {
|
||||
uriController.clear();
|
||||
aspectRatioController.clear();
|
||||
iframeController.clear();
|
||||
selectedRenderer.value = PostEmbedViewRenderer.webView;
|
||||
}
|
||||
|
||||
// Populate form when embed view changes
|
||||
useEffect(() {
|
||||
if (currentEmbedView != null) {
|
||||
uriController.text = currentEmbedView.uri;
|
||||
aspectRatioController.text =
|
||||
currentEmbedView.aspectRatio?.toString() ?? '';
|
||||
selectedRenderer.value = currentEmbedView.renderer;
|
||||
} else {
|
||||
clearForm();
|
||||
}
|
||||
return null;
|
||||
}, [currentEmbedView]);
|
||||
|
||||
void saveEmbedView() {
|
||||
final uri = uriController.text.trim();
|
||||
if (uri.isEmpty) {
|
||||
showSnackBar('embedUriRequired'.tr());
|
||||
return;
|
||||
}
|
||||
|
||||
final aspectRatio = aspectRatioController.text.trim().isNotEmpty
|
||||
? double.tryParse(aspectRatioController.text.trim())
|
||||
: null;
|
||||
|
||||
final embedView = SnPostEmbedView(
|
||||
uri: uri,
|
||||
aspectRatio: aspectRatio,
|
||||
renderer: selectedRenderer.value,
|
||||
);
|
||||
|
||||
if (currentEmbedView != null) {
|
||||
ComposeLogic.updateEmbedView(state, embedView);
|
||||
} else {
|
||||
ComposeLogic.setEmbedView(state, embedView);
|
||||
}
|
||||
}
|
||||
|
||||
void parseIframe() {
|
||||
final iframe = iframeController.text.trim();
|
||||
if (iframe.isEmpty) return;
|
||||
|
||||
final srcMatch = RegExp(r'src="([^"]*)"').firstMatch(iframe);
|
||||
final widthMatch = RegExp(r'width="([^"]*)"').firstMatch(iframe);
|
||||
final heightMatch = RegExp(r'height="([^"]*)"').firstMatch(iframe);
|
||||
|
||||
if (srcMatch != null) {
|
||||
uriController.text = srcMatch.group(1)!;
|
||||
}
|
||||
|
||||
if (widthMatch != null && heightMatch != null) {
|
||||
final w = double.tryParse(widthMatch.group(1)!);
|
||||
final h = double.tryParse(heightMatch.group(1)!);
|
||||
if (w != null && h != null && h != 0) {
|
||||
aspectRatioController.text = (w / h).toStringAsFixed(3);
|
||||
}
|
||||
}
|
||||
|
||||
tabController.animateTo(1);
|
||||
}
|
||||
|
||||
void deleteEmbed(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text('deleteEmbed').tr(),
|
||||
content: Text('deleteEmbedConfirm').tr(),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: Text('cancel').tr(),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ComposeLogic.deleteEmbedView(state);
|
||||
clearForm();
|
||||
Navigator.of(dialogContext).pop();
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: Text('delete').tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: 'embedView'.tr(),
|
||||
heightFactor: 0.7,
|
||||
child: Column(
|
||||
children: [
|
||||
// Header with save button when editing
|
||||
if (currentEmbedView != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'editEmbed'.tr(),
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: saveEmbedView,
|
||||
style: ButtonStyle(visualDensity: VisualDensity.compact),
|
||||
child: Text('save'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Tab bar
|
||||
TabBar(
|
||||
controller: tabController,
|
||||
tabs: [
|
||||
Tab(text: 'auto'.tr()),
|
||||
Tab(text: 'manual'.tr()),
|
||||
],
|
||||
),
|
||||
|
||||
// Content area
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
children: [
|
||||
// Auto tab
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: iframeController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'iframeCode'.tr(),
|
||||
hintText: 'iframeCodeHint'.tr(),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
maxLines: 5,
|
||||
),
|
||||
const Gap(16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: parseIframe,
|
||||
icon: const Icon(Symbols.auto_fix),
|
||||
label: Text('parseIframe'.tr()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Manual tab
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Form fields
|
||||
TextField(
|
||||
controller: uriController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'embedUri'.tr(),
|
||||
hintText: 'https://example.com',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
),
|
||||
const Gap(16),
|
||||
TextField(
|
||||
controller: aspectRatioController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'aspectRatio'.tr(),
|
||||
hintText: '16/9 = 1.777',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
keyboardType: TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp(r'^\d*\.?\d*$'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(16),
|
||||
DropdownButtonFormField2<PostEmbedViewRenderer>(
|
||||
value: selectedRenderer.value,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'renderer'.tr(),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
selectedItemBuilder: (context) {
|
||||
return PostEmbedViewRenderer.values.map((renderer) {
|
||||
return Text(renderer.name).tr();
|
||||
}).toList();
|
||||
},
|
||||
menuItemStyleData: MenuItemStyleData(
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
items: PostEmbedViewRenderer.values.map((renderer) {
|
||||
return DropdownMenuItem(
|
||||
value: renderer,
|
||||
child: Text(
|
||||
renderer.name,
|
||||
).tr().padding(horizontal: 20),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
selectedRenderer.value = value;
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
// Current embed view display (when exists)
|
||||
if (currentEmbedView != null) ...[
|
||||
const Gap(32),
|
||||
Text(
|
||||
'currentEmbed'.tr(),
|
||||
style: theme.textTheme.titleMedium,
|
||||
).padding(horizontal: 4),
|
||||
const Gap(8),
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHigh,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 12,
|
||||
top: 4,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
currentEmbedView.renderer ==
|
||||
PostEmbedViewRenderer.webView
|
||||
? Symbols.web
|
||||
: Symbols.web,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const Gap(12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
currentEmbedView.uri,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.delete),
|
||||
onPressed: () => deleteEmbed(context),
|
||||
tooltip: 'delete'.tr(),
|
||||
color: colorScheme.error,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(12),
|
||||
Text(
|
||||
'aspectRatio'.tr(),
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const Gap(4),
|
||||
Text(
|
||||
currentEmbedView.aspectRatio != null
|
||||
? currentEmbedView.aspectRatio!
|
||||
.toStringAsFixed(2)
|
||||
: 'notSet'.tr(),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
const Gap(16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: saveEmbedView,
|
||||
icon: const Icon(Symbols.add),
|
||||
label: Text('addEmbed'.tr()),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
330
lib/posts/posts_widgets/post/compose_form_fields.dart
Normal file
330
lib/posts/posts_widgets/post/compose_form_fields.dart
Normal file
@@ -0,0 +1,330 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/accounts/accounts_models/account.dart';
|
||||
import 'package:island/discovery/discovery_models/autocomplete_response.dart';
|
||||
import 'package:island/chat/chat_models/chat.dart';
|
||||
import 'package:island/posts/posts_models/publisher.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_shared.dart';
|
||||
import 'package:island/realms/realms_models/realm.dart';
|
||||
import 'package:island/stickers/stickers_models/sticker.dart';
|
||||
import 'package:island/discovery/discovery_service.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
|
||||
/// A reusable widget for the form fields in compose screens.
|
||||
/// Includes title, description, and content text fields.
|
||||
class ComposeFormFields extends HookConsumerWidget {
|
||||
final ComposeState state;
|
||||
final bool enabled;
|
||||
final bool showPublisherAvatar;
|
||||
final VoidCallback? onPublisherTap;
|
||||
|
||||
const ComposeFormFields({
|
||||
super.key,
|
||||
required this.state,
|
||||
this.enabled = true,
|
||||
this.showPublisherAvatar = true,
|
||||
this.onPublisherTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Row(
|
||||
spacing: 12,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Publisher profile picture
|
||||
if (showPublisherAvatar)
|
||||
GestureDetector(
|
||||
onTap: onPublisherTap,
|
||||
child: ProfilePictureWidget(
|
||||
file: state.currentPublisher.value?.picture,
|
||||
radius: 20,
|
||||
fallbackIcon: state.currentPublisher.value == null
|
||||
? Icons.question_mark
|
||||
: null,
|
||||
),
|
||||
),
|
||||
|
||||
// Post content form
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (state.currentPublisher.value == null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info,
|
||||
size: 16,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Tap the avatar to create a publisher and start composing.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Title field
|
||||
TextField(
|
||||
controller: state.titleController,
|
||||
enabled: enabled && state.currentPublisher.value != null,
|
||||
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(),
|
||||
),
|
||||
|
||||
// Description field
|
||||
TextField(
|
||||
controller: state.descriptionController,
|
||||
enabled: enabled && state.currentPublisher.value != null,
|
||||
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(),
|
||||
),
|
||||
|
||||
// Content field
|
||||
TypeAheadField<AutocompleteSuggestion>(
|
||||
controller: state.contentController,
|
||||
builder: (context, controller, focusNode) {
|
||||
return TextField(
|
||||
focusNode: focusNode,
|
||||
controller: controller,
|
||||
enabled: enabled && state.currentPublisher.value != null,
|
||||
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(),
|
||||
);
|
||||
},
|
||||
suggestionsCallback: (pattern) async {
|
||||
// Only trigger on @ or :
|
||||
final atIndex = pattern.lastIndexOf('@');
|
||||
final colonIndex = pattern.lastIndexOf(':');
|
||||
final triggerIndex = atIndex > colonIndex
|
||||
? atIndex
|
||||
: colonIndex;
|
||||
if (triggerIndex == -1) return [];
|
||||
final chopped = pattern.substring(triggerIndex);
|
||||
if (chopped.contains(' ')) return [];
|
||||
final service = ref.read(autocompleteServiceProvider);
|
||||
try {
|
||||
return await service.getGeneralSuggestions(chopped);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
itemBuilder: (context, suggestion) {
|
||||
String title = 'unknown'.tr();
|
||||
Widget leading = Icon(Icons.help);
|
||||
switch (suggestion.type) {
|
||||
case 'user':
|
||||
final user = SnAccount.fromJson(suggestion.data);
|
||||
title = user.nick;
|
||||
leading = ProfilePictureWidget(
|
||||
file: user.profile.picture,
|
||||
radius: 18,
|
||||
);
|
||||
break;
|
||||
case 'chatroom':
|
||||
final chatRoom = SnChatRoom.fromJson(suggestion.data);
|
||||
title = chatRoom.name ?? 'Chat Room';
|
||||
leading = ProfilePictureWidget(
|
||||
file: chatRoom.picture,
|
||||
radius: 18,
|
||||
);
|
||||
break;
|
||||
case 'realm':
|
||||
final realm = SnRealm.fromJson(suggestion.data);
|
||||
title = realm.name;
|
||||
leading = ProfilePictureWidget(
|
||||
file: realm.picture,
|
||||
radius: 18,
|
||||
);
|
||||
break;
|
||||
case 'publisher':
|
||||
final publisher = SnPublisher.fromJson(suggestion.data);
|
||||
title = publisher.name;
|
||||
leading = ProfilePictureWidget(
|
||||
file: publisher.picture,
|
||||
radius: 18,
|
||||
);
|
||||
break;
|
||||
case 'sticker':
|
||||
final sticker = SnSticker.fromJson(suggestion.data);
|
||||
title = sticker.slug;
|
||||
leading = ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CloudImageWidget(file: sticker.image),
|
||||
),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
return ListTile(
|
||||
leading: leading,
|
||||
title: Text(title),
|
||||
subtitle: Text(suggestion.keyword),
|
||||
dense: true,
|
||||
);
|
||||
},
|
||||
onSelected: (suggestion) {
|
||||
final text = state.contentController.text;
|
||||
final atIndex = text.lastIndexOf('@');
|
||||
final colonIndex = text.lastIndexOf(':');
|
||||
final triggerIndex = atIndex > colonIndex
|
||||
? atIndex
|
||||
: colonIndex;
|
||||
if (triggerIndex == -1) return;
|
||||
final newText = text.replaceRange(
|
||||
triggerIndex,
|
||||
text.length,
|
||||
suggestion.keyword,
|
||||
);
|
||||
state.contentController.value = TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: triggerIndex + suggestion.keyword.length,
|
||||
),
|
||||
);
|
||||
},
|
||||
direction: VerticalDirection.down,
|
||||
hideOnEmpty: true,
|
||||
hideOnLoading: true,
|
||||
debounceDuration: const Duration(milliseconds: 1000),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A specialized form fields widget for article compose with expanded content field.
|
||||
class ArticleComposeFormFields extends StatelessWidget {
|
||||
final ComposeState state;
|
||||
final bool enabled;
|
||||
|
||||
const ArticleComposeFormFields({
|
||||
super.key,
|
||||
required this.state,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Title field
|
||||
TextField(
|
||||
controller: state.titleController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'postTitle',
|
||||
border: InputBorder.none,
|
||||
isCollapsed: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
horizontal: 8,
|
||||
),
|
||||
),
|
||||
style: theme.textTheme.titleMedium,
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
|
||||
// Description field
|
||||
TextField(
|
||||
controller: state.descriptionController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'postDescription',
|
||||
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(),
|
||||
),
|
||||
|
||||
// Content field (expanded)
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: state.contentController,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: 'postContent',
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 16,
|
||||
horizontal: 8,
|
||||
),
|
||||
),
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
354
lib/posts/posts_widgets/post/compose_fund.dart
Normal file
354
lib/posts/posts_widgets/post/compose_fund.dart
Normal file
@@ -0,0 +1,354 @@
|
||||
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/wallet/wallet.dart';
|
||||
import 'package:island/wallet/wallet_models/wallet.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:island/core/widgets/payment/payment_overlay.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
/// Bottom sheet for selecting or creating a fund. Returns SnWalletFund via Navigator.pop.
|
||||
class ComposeFundSheet extends HookConsumerWidget {
|
||||
const ComposeFundSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isPushing = useState(false);
|
||||
final errorText = useState<String?>(null);
|
||||
|
||||
final fundsData = ref.watch(walletFundsProvider);
|
||||
|
||||
return SheetScaffold(
|
||||
heightFactor: 0.6,
|
||||
titleText: 'fund'.tr(),
|
||||
child: DefaultTabController(
|
||||
length: 2,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'fundsRecent'.tr()),
|
||||
Tab(text: 'fundCreateNew'.tr()),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
// Link/Select existing fund list
|
||||
fundsData.when(
|
||||
data: (funds) => funds.items.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.money_bag,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
const Gap(16),
|
||||
Text(
|
||||
'noFundsCreated'.tr(),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: funds.items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final fund = funds.items[index];
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: InkWell(
|
||||
onTap: () => Navigator.of(context).pop(fund),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.money_bag,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
fill: 1,
|
||||
),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${fund.totalAmount.toStringAsFixed(2)} ${fund.currency}',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getFundStatusColor(
|
||||
context,
|
||||
fund.status,
|
||||
).withOpacity(0.1),
|
||||
borderRadius:
|
||||
BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
_getFundStatusText(fund.status),
|
||||
style: TextStyle(
|
||||
color: _getFundStatusColor(
|
||||
context,
|
||||
fund.status,
|
||||
),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (fund.message != null &&
|
||||
fund.message!.isNotEmpty) ...[
|
||||
const Gap(8),
|
||||
Text(
|
||||
fund.message!,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
const Gap(8),
|
||||
Text(
|
||||
'${'recipients'.tr()}: ${fund.recipients.where((r) => r.isReceived).length}/${fund.recipients.length}',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) =>
|
||||
Center(child: Text('Error: $error')),
|
||||
),
|
||||
|
||||
// Create new fund and return it
|
||||
SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'fundCreateNewHint',
|
||||
).tr().fontSize(13).opacity(0.85).padding(bottom: 8),
|
||||
if (errorText.value != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 4,
|
||||
),
|
||||
child: Text(
|
||||
errorText.value!,
|
||||
style: TextStyle(color: Colors.red[700]),
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FilledButton.icon(
|
||||
icon: isPushing.value
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Symbols.add_circle),
|
||||
label: Text('create'.tr()),
|
||||
onPressed: isPushing.value
|
||||
? null
|
||||
: () async {
|
||||
errorText.value = null;
|
||||
|
||||
isPushing.value = true;
|
||||
// Show modal bottom sheet with fund creation form and await result
|
||||
final result =
|
||||
await showModalBottomSheet<
|
||||
Map<String, dynamic>
|
||||
>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
builder: (context) =>
|
||||
const CreateFundSheet(),
|
||||
);
|
||||
|
||||
if (result == null) {
|
||||
isPushing.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!context.mounted) return;
|
||||
|
||||
final client = ref.read(
|
||||
apiClientProvider,
|
||||
);
|
||||
showLoadingModal(context);
|
||||
|
||||
final resp = await client.post(
|
||||
'/wallet/wallets/funds',
|
||||
data: result,
|
||||
options: Options(
|
||||
headers: {'X-Noop': true},
|
||||
),
|
||||
);
|
||||
|
||||
final fund = SnWalletFund.fromJson(
|
||||
resp.data,
|
||||
);
|
||||
|
||||
if (fund.status == 0) {
|
||||
// Return the fund that was just created (but not yet paid)
|
||||
if (context.mounted) {
|
||||
hideLoadingModal(context);
|
||||
Navigator.of(context).pop(fund);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final orderResp = await client.post(
|
||||
'/wallet/wallets/funds/${fund.id}/order',
|
||||
);
|
||||
final order = SnWalletOrder.fromJson(
|
||||
orderResp.data,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
hideLoadingModal(context);
|
||||
}
|
||||
|
||||
// Show payment overlay to complete the payment
|
||||
if (!context.mounted) return;
|
||||
final paidOrder =
|
||||
await PaymentOverlay.show(
|
||||
context: context,
|
||||
order: order,
|
||||
enableBiometric: true,
|
||||
);
|
||||
|
||||
if (paidOrder != null &&
|
||||
context.mounted) {
|
||||
showLoadingModal(context);
|
||||
|
||||
// Wait for server to handle order
|
||||
await Future.delayed(
|
||||
const Duration(seconds: 1),
|
||||
);
|
||||
ref.invalidate(walletFundsProvider);
|
||||
|
||||
// Return the created fund
|
||||
final updatedResp = await client.get(
|
||||
'/wallet/wallets/funds/${fund.id}',
|
||||
);
|
||||
final updatedFund =
|
||||
SnWalletFund.fromJson(
|
||||
updatedResp.data,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
hideLoadingModal(context);
|
||||
Navigator.of(
|
||||
context,
|
||||
).pop(updatedFund);
|
||||
}
|
||||
} else {
|
||||
isPushing.value = false;
|
||||
}
|
||||
} catch (err) {
|
||||
if (context.mounted) {
|
||||
hideLoadingModal(context);
|
||||
}
|
||||
errorText.value = err.toString();
|
||||
isPushing.value = false;
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 24),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getFundStatusText(int status) {
|
||||
switch (status) {
|
||||
case 0:
|
||||
return 'fundStatusCreated'.tr();
|
||||
case 1:
|
||||
return 'fundStatusPartial'.tr();
|
||||
case 2:
|
||||
return 'fundStatusCompleted'.tr();
|
||||
case 3:
|
||||
return 'fundStatusExpired'.tr();
|
||||
default:
|
||||
return 'fundStatusUnknown'.tr();
|
||||
}
|
||||
}
|
||||
|
||||
Color _getFundStatusColor(BuildContext context, int status) {
|
||||
switch (status) {
|
||||
case 0:
|
||||
return Colors.blue;
|
||||
case 1:
|
||||
return Colors.orange;
|
||||
case 2:
|
||||
return Colors.green;
|
||||
case 3:
|
||||
return Colors.red;
|
||||
default:
|
||||
return Theme.of(context).colorScheme.primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
266
lib/posts/posts_widgets/post/compose_info_banner.dart
Normal file
266
lib/posts/posts_widgets/post/compose_info_banner.dart
Normal file
@@ -0,0 +1,266 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:island/posts/posts_models/post.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
/// A reusable widget for displaying info banners in compose screens.
|
||||
/// Shows editing, reply, and forward information.
|
||||
class ComposeInfoBanner extends StatelessWidget {
|
||||
final SnPost? originalPost;
|
||||
final SnPost? replyingTo;
|
||||
final SnPost? forwardingTo;
|
||||
final Function(BuildContext, SnPost)? onReferencePostTap;
|
||||
|
||||
const ComposeInfoBanner({
|
||||
super.key,
|
||||
this.originalPost,
|
||||
this.replyingTo,
|
||||
this.forwardingTo,
|
||||
this.onReferencePostTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final effectiveRepliedPost = replyingTo ?? originalPost?.repliedPost;
|
||||
final effectiveForwardedPost = 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)
|
||||
_buildReferenceBanner(
|
||||
context,
|
||||
effectiveRepliedPost,
|
||||
Symbols.reply,
|
||||
'postReplyingTo'.tr(),
|
||||
),
|
||||
if (effectiveForwardedPost != null)
|
||||
_buildReferenceBanner(
|
||||
context,
|
||||
effectiveForwardedPost,
|
||||
Symbols.forward,
|
||||
'postForwardingTo'.tr(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Show banner for replies
|
||||
if (effectiveRepliedPost != null) {
|
||||
return _buildReferenceBanner(
|
||||
context,
|
||||
effectiveRepliedPost,
|
||||
Symbols.reply,
|
||||
'postReplyingTo'.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
// Show banner for forwards
|
||||
if (effectiveForwardedPost != null) {
|
||||
return _buildReferenceBanner(
|
||||
context,
|
||||
effectiveForwardedPost,
|
||||
Symbols.forward,
|
||||
'postForwardingTo'.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget _buildReferenceBanner(
|
||||
BuildContext context,
|
||||
SnPost post,
|
||||
IconData icon,
|
||||
String labelKey,
|
||||
) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 16),
|
||||
const Gap(4),
|
||||
Text(labelKey, style: Theme.of(context).textTheme.labelMedium),
|
||||
],
|
||||
),
|
||||
const Gap(8),
|
||||
CompactReferencePost(
|
||||
post: post,
|
||||
onTap: onReferencePostTap != null
|
||||
? () => onReferencePostTap!(context, post)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
).padding(all: 16),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A compact widget for displaying reference posts (replies/forwards).
|
||||
class CompactReferencePost extends StatelessWidget {
|
||||
final SnPost post;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const CompactReferencePost({super.key, required this.post, this.onTap});
|
||||
|
||||
Widget _buildProfilePicture(BuildContext context) {
|
||||
// Handle publisher case
|
||||
if (post.publisher != null) {
|
||||
return ProfilePictureWidget(file: post.publisher!.picture, radius: 16);
|
||||
}
|
||||
// Handle actor case
|
||||
if (post.actor != null) {
|
||||
final avatarUrl = post.actor!.avatarUrl;
|
||||
if (avatarUrl != null) {
|
||||
return Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.network(
|
||||
avatarUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Icon(
|
||||
Symbols.account_circle,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Fallback
|
||||
return ProfilePictureWidget(file: null, radius: 16);
|
||||
}
|
||||
|
||||
String _getDisplayName() {
|
||||
// Handle publisher case
|
||||
if (post.publisher != null) {
|
||||
return post.publisher!.nick;
|
||||
}
|
||||
// Handle actor case
|
||||
if (post.actor != null) {
|
||||
return post.actor!.displayName ?? post.actor!.username ?? 'Unknown';
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: theme.colorScheme.outline.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildProfilePicture(context),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_getDisplayName(),
|
||||
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.colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (post.content?.isNotEmpty ?? false)
|
||||
Text(
|
||||
post.content!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (post.attachments.isNotEmpty)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.attach_file,
|
||||
size: 12,
|
||||
color: theme.colorScheme.secondary,
|
||||
),
|
||||
const Gap(4),
|
||||
Text(
|
||||
'postHasAttachments'.plural(post.attachments.length),
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.secondary,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onTap != null)
|
||||
Icon(
|
||||
Symbols.open_in_full,
|
||||
size: 16,
|
||||
color: theme.colorScheme.outline,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
181
lib/posts/posts_widgets/post/compose_link_attachments.dart
Normal file
181
lib/posts/posts_widgets/post/compose_link_attachments.dart
Normal file
@@ -0,0 +1,181 @@
|
||||
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/drive/drive_models/file.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/pagination/pagination.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:island/shared/widgets/pagination_list.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
final cloudFileListNotifierProvider = AsyncNotifierProvider.autoDispose(
|
||||
CloudFileListNotifier.new,
|
||||
);
|
||||
|
||||
class CloudFileListNotifier extends AsyncNotifier<PaginationState<SnCloudFile>>
|
||||
with AsyncPaginationController<SnCloudFile> {
|
||||
@override
|
||||
Future<List<SnCloudFile>> fetch() async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final take = 20;
|
||||
|
||||
final queryParameters = {'offset': fetchedCount, 'take': take};
|
||||
|
||||
final response = await client.get(
|
||||
'/drive/files/me',
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
|
||||
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
|
||||
final List<dynamic> data = response.data;
|
||||
return data
|
||||
.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
class ComposeLinkAttachment extends HookConsumerWidget {
|
||||
const ComposeLinkAttachment({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final idController = useTextEditingController();
|
||||
final errorMessage = useState<String?>(null);
|
||||
final provider = cloudFileListNotifierProvider;
|
||||
|
||||
return SheetScaffold(
|
||||
heightFactor: 0.6,
|
||||
titleText: 'linkAttachment'.tr(),
|
||||
child: DefaultTabController(
|
||||
length: 2,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'attachmentsRecentUploads'.tr()),
|
||||
Tab(text: 'attachmentsManualInput'.tr()),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
PaginationList(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
provider: provider,
|
||||
notifier: provider.notifier,
|
||||
itemBuilder: (context, index, item) {
|
||||
final itemType = item.mimeType?.split('/').firstOrNull;
|
||||
return ListTile(
|
||||
leading: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
width: 48,
|
||||
child: switch (itemType) {
|
||||
'image' => CloudImageWidget(file: item),
|
||||
'audio' => const Icon(
|
||||
Symbols.audio_file,
|
||||
fill: 1,
|
||||
).center(),
|
||||
'video' => const Icon(
|
||||
Symbols.video_file,
|
||||
fill: 1,
|
||||
).center(),
|
||||
_ => const Icon(
|
||||
Symbols.body_system,
|
||||
fill: 1,
|
||||
).center(),
|
||||
},
|
||||
),
|
||||
),
|
||||
title: item.name.isEmpty
|
||||
? Text('untitled').tr().italic()
|
||||
: Text(item.name),
|
||||
onTap: () {
|
||||
Navigator.pop(context, item);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: idController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fileId'.tr(),
|
||||
helperText: 'fileIdHint'.tr(),
|
||||
helperMaxLines: 3,
|
||||
errorText: errorMessage.value,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(16),
|
||||
InkWell(
|
||||
child: Text(
|
||||
'fileIdLinkHint',
|
||||
).tr().fontSize(13).opacity(0.85),
|
||||
onTap: () {
|
||||
launchUrlString('https://fs.solian.app');
|
||||
},
|
||||
).padding(horizontal: 14),
|
||||
const Gap(16),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(Symbols.add),
|
||||
label: Text('add'.tr()),
|
||||
onPressed: () async {
|
||||
final fileId = idController.text.trim();
|
||||
if (fileId.isEmpty) {
|
||||
errorMessage.value = 'fileIdCannotBeEmpty'.tr();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final response = await client.get(
|
||||
'/drive/files/$fileId/info',
|
||||
);
|
||||
final SnCloudFile cloudFile =
|
||||
SnCloudFile.fromJson(response.data);
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop(cloudFile);
|
||||
}
|
||||
} catch (e) {
|
||||
errorMessage.value = 'failedToFetchFile'.tr(
|
||||
args: [e.toString()],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 24),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
201
lib/posts/posts_widgets/post/compose_poll.dart
Normal file
201
lib/posts/posts_widgets/post/compose_poll.dart
Normal file
@@ -0,0 +1,201 @@
|
||||
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/creators/creators/poll/poll_list.dart';
|
||||
import 'package:island/polls/poll/poll_editor.dart';
|
||||
import 'package:island/posts/posts_models/poll.dart';
|
||||
import 'package:island/posts/posts_models/publisher.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:island/posts/posts_widgets/post/publishers_modal.dart';
|
||||
import 'package:island/shared/widgets/pagination_list.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
/// Bottom sheet for selecting or creating a poll. Returns SnPoll via Navigator.pop.
|
||||
class ComposePollSheet extends HookConsumerWidget {
|
||||
final SnPublisher? pub;
|
||||
|
||||
const ComposePollSheet({super.key, this.pub});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedPublisher = useState<SnPublisher?>(pub);
|
||||
final isPushing = useState(false);
|
||||
final errorText = useState<String?>(null);
|
||||
|
||||
return SheetScaffold(
|
||||
heightFactor: 0.6,
|
||||
titleText: 'poll'.tr(),
|
||||
child: DefaultTabController(
|
||||
length: 2,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'pollsRecent'.tr()),
|
||||
Tab(text: 'pollCreateNew'.tr()),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
// Link/Select existing poll list
|
||||
PaginationList(
|
||||
provider: pollListNotifierProvider(pub?.name),
|
||||
notifier: pollListNotifierProvider(pub?.name).notifier,
|
||||
itemBuilder: (context, index, poll) {
|
||||
return ListTile(
|
||||
leading: const Icon(Symbols.how_to_vote, fill: 1),
|
||||
title: Text(poll.title ?? 'untitled'.tr()),
|
||||
subtitle: _buildPollSubtitle(poll),
|
||||
onTap: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
).pop(SnPoll.fromPollWithStats(poll));
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Create new poll and return it
|
||||
SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'pollCreateNewHint',
|
||||
).tr().fontSize(13).opacity(0.85).padding(bottom: 8),
|
||||
Card(
|
||||
child: ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
selectedPublisher.value == null
|
||||
? 'publisher'.tr()
|
||||
: selectedPublisher.value!.nick,
|
||||
),
|
||||
subtitle: Text(
|
||||
selectedPublisher.value == null
|
||||
? 'publisherHint'.tr()
|
||||
: '@${selectedPublisher.value?.name}',
|
||||
),
|
||||
leading: selectedPublisher.value == null
|
||||
? const Icon(Symbols.account_circle)
|
||||
: ProfilePictureWidget(
|
||||
file: selectedPublisher.value?.picture,
|
||||
),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () async {
|
||||
final picked =
|
||||
await showModalBottomSheet<SnPublisher>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) =>
|
||||
const PublisherModal(),
|
||||
);
|
||||
if (picked != null) {
|
||||
try {
|
||||
selectedPublisher.value = picked;
|
||||
errorText.value = null;
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
if (errorText.value != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 4,
|
||||
),
|
||||
child: Text(
|
||||
errorText.value!,
|
||||
style: TextStyle(color: Colors.red[700]),
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FilledButton.icon(
|
||||
icon: isPushing.value
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Symbols.add_circle),
|
||||
label: Text('create'.tr()),
|
||||
onPressed: isPushing.value
|
||||
? null
|
||||
: () async {
|
||||
if (pub == null) {
|
||||
errorText.value = 'publisherCannotBeEmpty'
|
||||
.tr();
|
||||
return;
|
||||
}
|
||||
errorText.value = null;
|
||||
|
||||
isPushing.value = true;
|
||||
// Show modal bottom sheet with poll editor and await result
|
||||
final result =
|
||||
await showModalBottomSheet<SnPoll>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
isDismissible: false,
|
||||
enableDrag: false,
|
||||
builder: (context) =>
|
||||
PollEditorScreen(
|
||||
initialPublisher: pub?.name,
|
||||
),
|
||||
);
|
||||
|
||||
if (result == null) {
|
||||
isPushing.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Return created poll to caller of this bottom sheet
|
||||
Navigator.of(context).pop(result);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 24),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _buildPollSubtitle(SnPollWithStats poll) {
|
||||
try {
|
||||
final List<SnPollQuestion> options = poll.questions;
|
||||
if (options.isEmpty) return null;
|
||||
final preview = options.take(3).map((e) => e.title).join(' · ');
|
||||
if (preview.trim().isEmpty) return null;
|
||||
return Text(preview);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
141
lib/posts/posts_widgets/post/compose_recorder.dart
Normal file
141
lib/posts/posts_widgets/post/compose_recorder.dart
Normal file
@@ -0,0 +1,141 @@
|
||||
import 'dart:async';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/foundation.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/core/services/time.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:record/record.dart' hide Amplitude;
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:waveform_flutter/waveform_flutter.dart';
|
||||
|
||||
class ComposeRecorder extends HookConsumerWidget {
|
||||
const ComposeRecorder({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final recording = useState(false);
|
||||
final recordingStartAt = useState<DateTime?>(null);
|
||||
final recordingDuration = useState<Duration>(Duration(seconds: 0));
|
||||
|
||||
StreamSubscription? originalAmplitude;
|
||||
StreamController<Amplitude> amplitudeStream = StreamController();
|
||||
var record = AudioRecorder();
|
||||
|
||||
final resultPath = useState<String?>(null);
|
||||
|
||||
Future<void> startRecord() async {
|
||||
recording.value = true;
|
||||
|
||||
// Check and request permission if needed
|
||||
final tempPath = !kIsWeb ? (await getTemporaryDirectory()).path : 'temp';
|
||||
final uuid = const Uuid().v4().substring(0, 8);
|
||||
if (!await record.hasPermission()) return;
|
||||
|
||||
const recordConfig = RecordConfig(
|
||||
encoder: AudioEncoder.pcm16bits,
|
||||
autoGain: true,
|
||||
echoCancel: true,
|
||||
noiseSuppress: true,
|
||||
);
|
||||
resultPath.value = '$tempPath/solar-network-record-$uuid.m4a';
|
||||
await record.start(recordConfig, path: resultPath.value!);
|
||||
|
||||
recordingStartAt.value = DateTime.now();
|
||||
originalAmplitude = record
|
||||
.onAmplitudeChanged(const Duration(milliseconds: 100))
|
||||
.listen((value) async {
|
||||
amplitudeStream.add(
|
||||
Amplitude(current: value.current, max: value.max),
|
||||
);
|
||||
recordingDuration.value = DateTime.now().difference(
|
||||
recordingStartAt.value!,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
return () {
|
||||
// Called when widget is unmounted
|
||||
talker.info('[Recorder] Clean up!');
|
||||
originalAmplitude?.cancel();
|
||||
amplitudeStream.close();
|
||||
record.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
Future<void> stopRecord() async {
|
||||
recording.value = false;
|
||||
await record.pause();
|
||||
final newResult = await record.stop();
|
||||
await record.cancel();
|
||||
if (newResult != null) resultPath.value = newResult;
|
||||
|
||||
if (context.mounted) Navigator.of(context).pop(resultPath.value);
|
||||
}
|
||||
|
||||
Future<void> addExistingAudio() async {
|
||||
var result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['mp3', 'm4a', 'wav', 'aac', 'flac', 'ogg', 'opus'],
|
||||
onFileLoading: (status) {
|
||||
if (!context.mounted) return;
|
||||
if (status == FilePickerStatus.picking) {
|
||||
showLoadingModal(context);
|
||||
} else {
|
||||
hideLoadingModal(context);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (result == null || result.count == 0) return;
|
||||
if (context.mounted) Navigator.of(context).pop(result.files.first.path);
|
||||
}
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: "recordAudio".tr(),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: addExistingAudio,
|
||||
icon: const Icon(Symbols.upload),
|
||||
),
|
||||
],
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Gap(32),
|
||||
Text(
|
||||
recordingDuration.value.formatShortDuration(),
|
||||
).fontSize(20).bold().padding(bottom: 8),
|
||||
SizedBox(
|
||||
height: 120,
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Card(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: AnimatedWaveList(stream: amplitudeStream.stream),
|
||||
),
|
||||
),
|
||||
),
|
||||
).padding(horizontal: 24),
|
||||
const Gap(12),
|
||||
IconButton.filled(
|
||||
onPressed: recording.value ? stopRecord : startRecord,
|
||||
iconSize: 32,
|
||||
icon: recording.value
|
||||
? const Icon(Symbols.stop, fill: 1, color: Colors.white)
|
||||
: const Icon(Symbols.play_arrow, fill: 1, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
489
lib/posts/posts_widgets/post/compose_settings_sheet.dart
Normal file
489
lib/posts/posts_widgets/post/compose_settings_sheet.dart
Normal file
@@ -0,0 +1,489 @@
|
||||
import 'package:dropdown_button2/dropdown_button2.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/posts/post/post_categories.dart';
|
||||
import 'package:island/posts/posts_models/post_category.dart';
|
||||
import 'package:island/posts/posts_models/post_tag.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_shared.dart';
|
||||
import 'package:island/realms/realm/realms.dart';
|
||||
import 'package:island/realms/realms_models/realm.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class ComposeSettingsSheet extends HookConsumerWidget {
|
||||
final ComposeState state;
|
||||
|
||||
const ComposeSettingsSheet({super.key, required this.state});
|
||||
|
||||
Future<List<SnPostTag>> _fetchTagSuggestions(
|
||||
String query,
|
||||
WidgetRef ref,
|
||||
) async {
|
||||
if (query.isEmpty) return [];
|
||||
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final response = await client.get(
|
||||
'/sphere/posts/tags',
|
||||
queryParameters: {'query': query},
|
||||
);
|
||||
return response.data
|
||||
.map<SnPostTag>((json) => SnPostTag.fromJson(json))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
// Listen to visibility changes to trigger rebuilds
|
||||
final currentVisibility = useValueListenable(state.visibility);
|
||||
final currentCategories = useValueListenable(state.categories);
|
||||
final currentTags = useValueListenable(state.tags);
|
||||
final currentRealm = useValueListenable(state.realm);
|
||||
final postCategories = ref.watch(postCategoriesProvider);
|
||||
final userRealms = ref.watch(realmsJoinedProvider);
|
||||
|
||||
IconData getVisibilityIcon(int visibilityValue) {
|
||||
switch (visibilityValue) {
|
||||
case 1:
|
||||
return Symbols.group;
|
||||
case 2:
|
||||
return Symbols.link_off;
|
||||
case 3:
|
||||
return Symbols.lock;
|
||||
default:
|
||||
return Symbols.public;
|
||||
}
|
||||
}
|
||||
|
||||
String getVisibilityText(int visibilityValue) {
|
||||
switch (visibilityValue) {
|
||||
case 1:
|
||||
return 'postVisibilityFriends';
|
||||
case 2:
|
||||
return 'postVisibilityUnlisted';
|
||||
case 3:
|
||||
return 'postVisibilityPrivate';
|
||||
default:
|
||||
return 'postVisibilityPublic';
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildVisibilityOption(
|
||||
BuildContext context,
|
||||
int value,
|
||||
IconData icon,
|
||||
String textKey,
|
||||
) {
|
||||
return ListTile(
|
||||
leading: Icon(icon),
|
||||
title: Text(textKey.tr()),
|
||||
onTap: () {
|
||||
state.visibility.value = value;
|
||||
Navigator.pop(context);
|
||||
},
|
||||
selected: state.visibility.value == value,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
);
|
||||
}
|
||||
|
||||
void showVisibilitySheet() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => SheetScaffold(
|
||||
titleText: 'postVisibility'.tr(),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
buildVisibilityOption(
|
||||
context,
|
||||
0,
|
||||
Symbols.public,
|
||||
'postVisibilityPublic',
|
||||
),
|
||||
buildVisibilityOption(
|
||||
context,
|
||||
1,
|
||||
Symbols.group,
|
||||
'postVisibilityFriends',
|
||||
),
|
||||
buildVisibilityOption(
|
||||
context,
|
||||
2,
|
||||
Symbols.link_off,
|
||||
'postVisibilityUnlisted',
|
||||
),
|
||||
buildVisibilityOption(
|
||||
context,
|
||||
3,
|
||||
Symbols.lock,
|
||||
'postVisibilityPrivate',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final tagInputController = useTextEditingController();
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 16,
|
||||
children: [
|
||||
// Slug field
|
||||
TextField(
|
||||
controller: state.slugController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'postSlug'.tr(),
|
||||
hintText: 'postSlugHint'.tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 9,
|
||||
horizontal: 16,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
|
||||
// Tags field
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
Text(
|
||||
'tags'.tr(),
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
// Existing tags display
|
||||
if (currentTags.isNotEmpty)
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: currentTags.map((tag) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'#$tag',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const Gap(4),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
final newTags = List<String>.from(
|
||||
state.tags.value,
|
||||
)..remove(tag);
|
||||
state.tags.value = newTags;
|
||||
},
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
// Tag input with autocomplete
|
||||
TypeAheadField<SnPostTag>(
|
||||
controller: tagInputController,
|
||||
builder: (context, controller, focusNode) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'addTag'.tr(),
|
||||
border: InputBorder.none,
|
||||
isCollapsed: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
onSubmitted: (value) {
|
||||
state.tags.value = [...state.tags.value, value];
|
||||
controller.clear();
|
||||
},
|
||||
);
|
||||
},
|
||||
suggestionsCallback: (pattern) =>
|
||||
_fetchTagSuggestions(pattern, ref),
|
||||
itemBuilder: (context, suggestion) {
|
||||
return ListTile(
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
title: Text('#${suggestion.slug}'),
|
||||
subtitle: Text('${suggestion.usage} posts'),
|
||||
dense: true,
|
||||
);
|
||||
},
|
||||
onSelected: (suggestion) {
|
||||
if (!state.tags.value.contains(suggestion.slug)) {
|
||||
state.tags.value = [...state.tags.value, suggestion.slug];
|
||||
}
|
||||
tagInputController.clear();
|
||||
},
|
||||
direction: VerticalDirection.down,
|
||||
hideOnEmpty: true,
|
||||
hideOnLoading: true,
|
||||
debounceDuration: const Duration(milliseconds: 300),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Categories field
|
||||
DropdownButtonFormField2<SnPostCategory>(
|
||||
isExpanded: true,
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 9),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
hint: Text('categories'.tr(), style: TextStyle(fontSize: 15)),
|
||||
items: (postCategories.value?.items ?? <SnPostCategory>[]).map((
|
||||
item,
|
||||
) {
|
||||
return DropdownMenuItem(
|
||||
value: item,
|
||||
enabled: false,
|
||||
child: StatefulBuilder(
|
||||
builder: (context, menuSetState) {
|
||||
final isSelected = state.categories.value.contains(item);
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
isSelected
|
||||
? state.categories.value = state.categories.value
|
||||
.where((e) => e != item)
|
||||
.toList()
|
||||
: state.categories.value = [
|
||||
...state.categories.value,
|
||||
item,
|
||||
];
|
||||
menuSetState(() {});
|
||||
},
|
||||
child: Container(
|
||||
height: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
if (isSelected)
|
||||
const Icon(Icons.check_box_outlined)
|
||||
else
|
||||
const Icon(Icons.check_box_outline_blank),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.categoryDisplayTitle,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
value: currentCategories.isEmpty ? null : currentCategories.last,
|
||||
onChanged: (_) {},
|
||||
selectedItemBuilder: (context) {
|
||||
return (postCategories.value?.items ?? []).map((item) {
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final category in currentCategories)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
margin: const EdgeInsets.only(right: 4),
|
||||
child: Text(
|
||||
category.categoryDisplayTitle,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
},
|
||||
buttonStyleData: const ButtonStyleData(
|
||||
padding: EdgeInsets.only(left: 16, right: 8),
|
||||
height: 38,
|
||||
),
|
||||
menuItemStyleData: const MenuItemStyleData(
|
||||
height: 38,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
|
||||
// Realm selection
|
||||
DropdownButtonFormField2<SnRealm?>(
|
||||
isExpanded: true,
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 9),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
hint: Text('realm'.tr(), style: const TextStyle(fontSize: 15)),
|
||||
items: [
|
||||
DropdownMenuItem<SnRealm?>(
|
||||
value: null,
|
||||
child: Row(
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 16,
|
||||
child: Icon(Symbols.link_off, fill: 1),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text('postUnlinkRealm').tr(),
|
||||
],
|
||||
).padding(left: 16, right: 8),
|
||||
),
|
||||
// Include current realm if it's not null and not in joined realms
|
||||
if (currentRealm != null &&
|
||||
!(userRealms.value ?? []).any((r) => r.id == currentRealm.id))
|
||||
DropdownMenuItem<SnRealm?>(
|
||||
value: currentRealm,
|
||||
child: Row(
|
||||
children: [
|
||||
ProfilePictureWidget(
|
||||
file: currentRealm.picture,
|
||||
fallbackIcon: Symbols.workspaces,
|
||||
radius: 16,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(currentRealm.name),
|
||||
],
|
||||
).padding(left: 16, right: 8),
|
||||
),
|
||||
if (userRealms.hasValue)
|
||||
...(userRealms.value ?? []).map(
|
||||
(realm) => DropdownMenuItem<SnRealm?>(
|
||||
value: realm,
|
||||
child: Row(
|
||||
children: [
|
||||
ProfilePictureWidget(
|
||||
file: realm.picture,
|
||||
fallbackIcon: Symbols.workspaces,
|
||||
radius: 16,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(realm.name),
|
||||
],
|
||||
).padding(left: 16, right: 8),
|
||||
),
|
||||
),
|
||||
],
|
||||
value: currentRealm,
|
||||
onChanged: (value) {
|
||||
state.realm.value = value;
|
||||
},
|
||||
selectedItemBuilder: (context) {
|
||||
return (userRealms.value ?? []).map((_) {
|
||||
return Row(
|
||||
children: [
|
||||
if (currentRealm == null)
|
||||
const CircleAvatar(
|
||||
radius: 16,
|
||||
child: Icon(Symbols.link_off, fill: 1),
|
||||
)
|
||||
else
|
||||
ProfilePictureWidget(
|
||||
file: currentRealm.picture,
|
||||
fallbackIcon: Symbols.workspaces,
|
||||
radius: 16,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(currentRealm?.name ?? 'postUnlinkRealm'.tr()),
|
||||
],
|
||||
);
|
||||
}).toList();
|
||||
},
|
||||
buttonStyleData: const ButtonStyleData(
|
||||
padding: EdgeInsets.only(left: 16, right: 8),
|
||||
height: 40,
|
||||
),
|
||||
menuItemStyleData: const MenuItemStyleData(
|
||||
height: 56,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
|
||||
// Visibility setting
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: colorScheme.outline, width: 1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Icon(getVisibilityIcon(currentVisibility)),
|
||||
title: Text('postVisibility'.tr()),
|
||||
subtitle: Text(getVisibilityText(currentVisibility).tr()),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: showVisibilitySheet,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
897
lib/posts/posts_widgets/post/compose_shared.dart
Normal file
897
lib/posts/posts_widgets/post/compose_shared.dart
Normal file
@@ -0,0 +1,897 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:island/core/services/event_bus.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_fund.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_link_attachments.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_poll.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_recorder.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_settings_sheet.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:island/drive/drive_models/file.dart';
|
||||
import 'package:island/posts/posts_models/post.dart';
|
||||
import 'package:island/posts/posts_models/post_category.dart';
|
||||
import 'package:island/posts/posts_models/publisher.dart';
|
||||
import 'package:island/realms/realms_models/realm.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/drive/drive_service.dart';
|
||||
import 'package:island/posts/compose_storage_db.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/drive/drive/file_pool.dart';
|
||||
import 'package:pasteboard/pasteboard.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:island/core/services/analytics_service.dart';
|
||||
|
||||
class ComposeState {
|
||||
final TextEditingController titleController;
|
||||
final TextEditingController descriptionController;
|
||||
final TextEditingController contentController;
|
||||
final TextEditingController slugController;
|
||||
final ValueNotifier<int> visibility;
|
||||
final ValueNotifier<List<UniversalFile>> attachments;
|
||||
final ValueNotifier<Map<int, double?>> attachmentProgress;
|
||||
final ValueNotifier<SnPublisher?> currentPublisher;
|
||||
final ValueNotifier<bool> submitting;
|
||||
final ValueNotifier<List<SnPostCategory>> categories;
|
||||
final ValueNotifier<List<String>> tags;
|
||||
final ValueNotifier<SnRealm?> realm;
|
||||
final ValueNotifier<SnPostEmbedView?> embedView;
|
||||
final String draftId;
|
||||
int postType;
|
||||
// Linked poll id for this compose session (nullable)
|
||||
final ValueNotifier<String?> pollId;
|
||||
// Linked fund id for this compose session (nullable)
|
||||
final ValueNotifier<String?> fundId;
|
||||
// Thumbnail id for article type post (nullable)
|
||||
final ValueNotifier<String?> thumbnailId;
|
||||
Timer? _autoSaveTimer;
|
||||
|
||||
ComposeState({
|
||||
required this.titleController,
|
||||
required this.descriptionController,
|
||||
required this.contentController,
|
||||
required this.slugController,
|
||||
required this.visibility,
|
||||
required this.attachments,
|
||||
required this.attachmentProgress,
|
||||
required this.currentPublisher,
|
||||
required this.submitting,
|
||||
required this.tags,
|
||||
required this.categories,
|
||||
required this.realm,
|
||||
required this.embedView,
|
||||
required this.draftId,
|
||||
this.postType = 0,
|
||||
String? pollId,
|
||||
String? fundId,
|
||||
String? thumbnailId,
|
||||
}) : pollId = ValueNotifier<String?>(pollId),
|
||||
fundId = ValueNotifier<String?>(fundId),
|
||||
thumbnailId = ValueNotifier<String?>(thumbnailId);
|
||||
|
||||
void startAutoSave(WidgetRef ref) {
|
||||
_autoSaveTimer?.cancel();
|
||||
_autoSaveTimer = Timer.periodic(const Duration(seconds: 3), (_) {
|
||||
ComposeLogic.saveDraftWithoutUpload(ref, this);
|
||||
});
|
||||
}
|
||||
|
||||
void stopAutoSave() {
|
||||
_autoSaveTimer?.cancel();
|
||||
_autoSaveTimer = null;
|
||||
}
|
||||
|
||||
bool get isEmpty =>
|
||||
attachments.value.isEmpty && contentController.text.isEmpty;
|
||||
}
|
||||
|
||||
class ComposeLogic {
|
||||
static ComposeState createState({
|
||||
SnPost? originalPost,
|
||||
SnPost? forwardedPost,
|
||||
SnPost? repliedPost,
|
||||
String? draftId,
|
||||
int postType = 0,
|
||||
}) {
|
||||
final id = draftId ?? DateTime.now().millisecondsSinceEpoch.toString();
|
||||
|
||||
// Initialize tags from original post
|
||||
final tags =
|
||||
originalPost?.tags.map((tag) => tag.slug).toList() ?? <String>[];
|
||||
|
||||
// Initialize categories from original post
|
||||
final categories = originalPost?.categories ?? <SnPostCategory>[];
|
||||
|
||||
// Extract poll and fund IDs from embeds
|
||||
String? pollId;
|
||||
String? fundId;
|
||||
if (originalPost?.meta?['embeds'] is List) {
|
||||
final embeds = (originalPost!.meta!['embeds'] as List)
|
||||
.cast<Map<String, dynamic>>();
|
||||
try {
|
||||
final pollEmbed = embeds.firstWhere((e) => e['type'] == 'poll');
|
||||
pollId = pollEmbed['id'];
|
||||
} catch (_) {}
|
||||
try {
|
||||
final fundEmbed = embeds.firstWhere((e) => e['type'] == 'fund');
|
||||
fundId = fundEmbed['id'];
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Extract thumbnail ID from meta
|
||||
final thumbnailId = originalPost?.meta?['thumbnail'] as String?;
|
||||
|
||||
return ComposeState(
|
||||
attachments: ValueNotifier<List<UniversalFile>>(
|
||||
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() ??
|
||||
[],
|
||||
),
|
||||
titleController: TextEditingController(text: originalPost?.title),
|
||||
descriptionController: TextEditingController(
|
||||
text: originalPost?.description,
|
||||
),
|
||||
contentController: TextEditingController(text: originalPost?.content),
|
||||
slugController: TextEditingController(text: originalPost?.slug),
|
||||
visibility: ValueNotifier<int>(originalPost?.visibility ?? 0),
|
||||
submitting: ValueNotifier<bool>(false),
|
||||
attachmentProgress: ValueNotifier<Map<int, double?>>({}),
|
||||
currentPublisher: ValueNotifier<SnPublisher?>(originalPost?.publisher),
|
||||
tags: ValueNotifier<List<String>>(tags),
|
||||
categories: ValueNotifier<List<SnPostCategory>>(categories),
|
||||
realm: ValueNotifier(originalPost?.realm),
|
||||
embedView: ValueNotifier<SnPostEmbedView?>(originalPost?.embedView),
|
||||
draftId: id,
|
||||
postType: postType,
|
||||
pollId: pollId,
|
||||
fundId: fundId,
|
||||
thumbnailId: thumbnailId,
|
||||
);
|
||||
}
|
||||
|
||||
static ComposeState createStateFromDraft(SnPost draft, {int postType = 0}) {
|
||||
final tags = draft.tags.map((tag) => tag.slug).toList();
|
||||
final thumbnailId = draft.meta?['thumbnail'] as String?;
|
||||
|
||||
return ComposeState(
|
||||
attachments: ValueNotifier<List<UniversalFile>>(
|
||||
draft.attachments.map((e) => UniversalFile.fromAttachment(e)).toList(),
|
||||
),
|
||||
titleController: TextEditingController(text: draft.title),
|
||||
descriptionController: TextEditingController(text: draft.description),
|
||||
contentController: TextEditingController(text: draft.content),
|
||||
slugController: TextEditingController(text: draft.slug),
|
||||
visibility: ValueNotifier<int>(draft.visibility),
|
||||
submitting: ValueNotifier<bool>(false),
|
||||
attachmentProgress: ValueNotifier<Map<int, double?>>({}),
|
||||
currentPublisher: ValueNotifier<SnPublisher?>(null),
|
||||
tags: ValueNotifier<List<String>>(tags),
|
||||
categories: ValueNotifier<List<SnPostCategory>>(draft.categories),
|
||||
realm: ValueNotifier(draft.realm),
|
||||
embedView: ValueNotifier<SnPostEmbedView?>(draft.embedView),
|
||||
draftId: draft.id,
|
||||
postType: postType,
|
||||
pollId: null,
|
||||
// initialize without fund by default
|
||||
fundId: null,
|
||||
thumbnailId: thumbnailId,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> saveDraft(WidgetRef ref, ComposeState state) async {
|
||||
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) {
|
||||
return; // Don't save empty posts
|
||||
}
|
||||
|
||||
try {
|
||||
// Upload any local attachments first
|
||||
for (int i = 0; i < state.attachments.value.length; i++) {
|
||||
final attachment = state.attachments.value[i];
|
||||
if (attachment.data is! SnCloudFile) {
|
||||
try {
|
||||
final cloudFile = await FileUploader.createCloudFile(
|
||||
ref: ref,
|
||||
fileData: attachment,
|
||||
).future;
|
||||
if (cloudFile != null) {
|
||||
// Update attachments list with cloud file
|
||||
final clone = List.of(state.attachments.value);
|
||||
clone[i] = UniversalFile(data: cloudFile, type: attachment.type);
|
||||
state.attachments.value = clone;
|
||||
}
|
||||
} catch (err) {
|
||||
talker.error('[ComposeLogic] Failed to upload attachment: $err');
|
||||
// Continue with other attachments even if one fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final draft = SnPost(
|
||||
id: state.draftId,
|
||||
title: state.titleController.text,
|
||||
description: state.descriptionController.text,
|
||||
language: null,
|
||||
editedAt: null,
|
||||
publishedAt: DateTime.now(),
|
||||
visibility: state.visibility.value,
|
||||
content: state.contentController.text,
|
||||
type: state.postType,
|
||||
meta: state.postType == 1 && state.thumbnailId.value != null
|
||||
? {'thumbnail': state.thumbnailId.value}
|
||||
: null,
|
||||
viewsUnique: 0,
|
||||
viewsTotal: 0,
|
||||
upvotes: 0,
|
||||
downvotes: 0,
|
||||
repliesCount: 0,
|
||||
threadedPostId: null,
|
||||
threadedPost: null,
|
||||
repliedPostId: null,
|
||||
repliedPost: null,
|
||||
forwardedPostId: null,
|
||||
forwardedPost: null,
|
||||
attachments: state.attachments.value
|
||||
.map((e) => e.data)
|
||||
.whereType<SnCloudFile>()
|
||||
.toList(),
|
||||
publisher: SnPublisher(
|
||||
id: '',
|
||||
type: 0,
|
||||
name: '',
|
||||
nick: '',
|
||||
picture: null,
|
||||
background: null,
|
||||
account: null,
|
||||
accountId: null,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
deletedAt: null,
|
||||
realmId: null,
|
||||
verification: null,
|
||||
),
|
||||
reactions: [],
|
||||
tags: [],
|
||||
categories: [],
|
||||
collections: [],
|
||||
embedView: state.embedView.value,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
deletedAt: null,
|
||||
);
|
||||
|
||||
await ref.read(composeStorageProvider.notifier).saveDraft(draft);
|
||||
} catch (e) {
|
||||
talker.error('[ComposeLogic] Failed to save draft, error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> saveDraftWithoutUpload(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
) async {
|
||||
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) {
|
||||
return; // Don't save empty posts
|
||||
}
|
||||
|
||||
try {
|
||||
final draft = SnPost(
|
||||
id: state.draftId,
|
||||
title: state.titleController.text,
|
||||
description: state.descriptionController.text,
|
||||
language: null,
|
||||
editedAt: null,
|
||||
publishedAt: DateTime.now(),
|
||||
visibility: state.visibility.value,
|
||||
content: state.contentController.text,
|
||||
type: state.postType,
|
||||
meta: state.postType == 1 && state.thumbnailId.value != null
|
||||
? {'thumbnail': state.thumbnailId.value}
|
||||
: null,
|
||||
viewsUnique: 0,
|
||||
viewsTotal: 0,
|
||||
upvotes: 0,
|
||||
downvotes: 0,
|
||||
repliesCount: 0,
|
||||
threadedPostId: null,
|
||||
threadedPost: null,
|
||||
repliedPostId: null,
|
||||
repliedPost: null,
|
||||
forwardedPostId: null,
|
||||
forwardedPost: null,
|
||||
attachments: state.attachments.value
|
||||
.map((e) => e.data)
|
||||
.whereType<SnCloudFile>()
|
||||
.toList(),
|
||||
publisher: SnPublisher(
|
||||
id: '',
|
||||
type: 0,
|
||||
name: '',
|
||||
nick: '',
|
||||
picture: null,
|
||||
background: null,
|
||||
account: null,
|
||||
accountId: null,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
deletedAt: null,
|
||||
realmId: null,
|
||||
verification: null,
|
||||
),
|
||||
reactions: [],
|
||||
tags: [],
|
||||
categories: [],
|
||||
collections: [],
|
||||
embedView: state.embedView.value,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
deletedAt: null,
|
||||
);
|
||||
|
||||
await ref.read(composeStorageProvider.notifier).saveDraft(draft);
|
||||
} catch (e) {
|
||||
talker.error(
|
||||
'[ComposeLogic] Failed to save draft without upload, error: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> saveDraftManually(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
BuildContext context,
|
||||
) async {
|
||||
try {
|
||||
await saveDraft(ref, state);
|
||||
|
||||
if (context.mounted) {
|
||||
showSnackBar('draftSaved'.tr());
|
||||
}
|
||||
} catch (e) {
|
||||
talker.error('[ComposeLogic] Failed to save draft manually, error: $e');
|
||||
if (context.mounted) {
|
||||
showSnackBar('draftSaveFailed'.tr());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> deleteDraft(WidgetRef ref, String draftId) async {
|
||||
try {
|
||||
await ref.read(composeStorageProvider.notifier).deleteDraft(draftId);
|
||||
} catch (e) {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
static Future<SnPost?> loadDraft(WidgetRef ref, String draftId) async {
|
||||
try {
|
||||
return ref.read(composeStorageProvider.notifier).getDraft(draftId);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static String getMimeTypeFromFileType(UniversalFileType type) {
|
||||
return switch (type) {
|
||||
UniversalFileType.image => 'image/unknown',
|
||||
UniversalFileType.video => 'video/unknown',
|
||||
UniversalFileType.audio => 'audio/unknown',
|
||||
UniversalFileType.file => 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
|
||||
static Future<void> pickGeneralFile(WidgetRef ref, ComposeState state) async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.any,
|
||||
allowMultiple: true,
|
||||
);
|
||||
if (result == null || result.count == 0) return;
|
||||
|
||||
final newFiles = <UniversalFile>[];
|
||||
|
||||
for (final f in result.files) {
|
||||
if (f.path == null) continue;
|
||||
|
||||
final mimeType =
|
||||
lookupMimeType(f.path!, headerBytes: f.bytes) ??
|
||||
'application/octet-stream';
|
||||
final xfile = XFile(f.path!, name: f.name, mimeType: mimeType);
|
||||
|
||||
final uf = UniversalFile(data: xfile, type: UniversalFileType.file);
|
||||
newFiles.add(uf);
|
||||
}
|
||||
|
||||
state.attachments.value = [...state.attachments.value, ...newFiles];
|
||||
}
|
||||
|
||||
static Future<void> pickPhotoMedia(WidgetRef ref, ComposeState state) async {
|
||||
final ImagePicker picker = ImagePicker();
|
||||
final List<XFile> results = await picker.pickMultiImage();
|
||||
if (results.isEmpty) return;
|
||||
state.attachments.value = [
|
||||
...state.attachments.value,
|
||||
...results.map(
|
||||
(xfile) => UniversalFile(data: xfile, type: UniversalFileType.image),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static Future<void> pickVideoMedia(WidgetRef ref, ComposeState state) async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.video,
|
||||
allowMultiple: true,
|
||||
allowCompression: false,
|
||||
);
|
||||
if (result == null || result.count == 0) return;
|
||||
state.attachments.value = [
|
||||
...state.attachments.value,
|
||||
...result.files.map(
|
||||
(e) => UniversalFile(data: e.xFile, type: UniversalFileType.video),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static Future<void> recordAudioMedia(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
BuildContext context,
|
||||
) async {
|
||||
final audioPath = await showModalBottomSheet<String?>(
|
||||
context: context,
|
||||
builder: (context) => ComposeRecorder(),
|
||||
);
|
||||
if (audioPath == null) return;
|
||||
|
||||
state.attachments.value = [
|
||||
...state.attachments.value,
|
||||
UniversalFile(
|
||||
data: XFile(audioPath, mimeType: 'audio/m4a'),
|
||||
type: UniversalFileType.audio,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static Future<void> linkAttachment(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
BuildContext context,
|
||||
) async {
|
||||
final cloudFile = await showModalBottomSheet<SnCloudFile?>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ComposeLinkAttachment(),
|
||||
);
|
||||
if (cloudFile == null) return;
|
||||
|
||||
state.attachments.value = [
|
||||
...state.attachments.value,
|
||||
UniversalFile(
|
||||
data: cloudFile,
|
||||
type: switch (cloudFile.mimeType?.split('/').firstOrNull) {
|
||||
'image' => UniversalFileType.image,
|
||||
'video' => UniversalFileType.video,
|
||||
'audio' => UniversalFileType.audio,
|
||||
_ => UniversalFileType.file,
|
||||
},
|
||||
isLink: true,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static void updateAttachment(
|
||||
ComposeState state,
|
||||
UniversalFile value,
|
||||
int index,
|
||||
) {
|
||||
state.attachments.value = state.attachments.value.mapIndexed((idx, ele) {
|
||||
if (idx == index) return value;
|
||||
return ele;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
static Future<void> uploadAttachment(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
int index, {
|
||||
String? poolId,
|
||||
}) async {
|
||||
final attachment = state.attachments.value[index];
|
||||
if (attachment.isOnCloud) return;
|
||||
|
||||
try {
|
||||
state.attachmentProgress.value = {
|
||||
...state.attachmentProgress.value,
|
||||
index: 0.0,
|
||||
};
|
||||
|
||||
SnCloudFile? cloudFile;
|
||||
|
||||
final pools = await ref.read(poolsProvider.future);
|
||||
final selectedPoolId = resolveDefaultPoolId(ref, pools);
|
||||
|
||||
cloudFile = await FileUploader.createCloudFile(
|
||||
ref: ref,
|
||||
fileData: attachment,
|
||||
poolId: poolId ?? selectedPoolId,
|
||||
mode: attachment.type == UniversalFileType.file
|
||||
? FileUploadMode.generic
|
||||
: FileUploadMode.mediaSafe,
|
||||
onProgress: (progress, _) {
|
||||
state.attachmentProgress.value = {
|
||||
...state.attachmentProgress.value,
|
||||
index: progress ?? 0.0,
|
||||
};
|
||||
},
|
||||
).future;
|
||||
|
||||
if (cloudFile == null) {
|
||||
throw ArgumentError('Failed to upload the file...');
|
||||
}
|
||||
|
||||
final clone = List.of(state.attachments.value);
|
||||
clone[index] = UniversalFile(data: cloudFile, type: attachment.type);
|
||||
state.attachments.value = clone;
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
state.attachmentProgress.value = {...state.attachmentProgress.value}
|
||||
..remove(index);
|
||||
}
|
||||
}
|
||||
|
||||
static List<UniversalFile> moveAttachment(
|
||||
List<UniversalFile> attachments,
|
||||
int idx,
|
||||
int delta,
|
||||
) {
|
||||
if (idx + delta < 0 || idx + delta >= attachments.length) {
|
||||
return attachments;
|
||||
}
|
||||
final clone = List.of(attachments);
|
||||
clone.insert(idx + delta, clone.removeAt(idx));
|
||||
return clone;
|
||||
}
|
||||
|
||||
static Future<void> deleteAttachment(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
int index,
|
||||
) async {
|
||||
final attachment = state.attachments.value[index];
|
||||
if (attachment.isOnCloud && !attachment.isLink) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
await client.delete('/drive/files/${attachment.data.id}');
|
||||
}
|
||||
final clone = List.of(state.attachments.value);
|
||||
clone.removeAt(index);
|
||||
state.attachments.value = clone;
|
||||
}
|
||||
|
||||
static void insertAttachment(WidgetRef ref, ComposeState state, int index) {
|
||||
final attachment = state.attachments.value[index];
|
||||
if (!attachment.isOnCloud) {
|
||||
return;
|
||||
}
|
||||
final cloudFile = attachment.data as SnCloudFile;
|
||||
final markdown = '';
|
||||
final controller = state.contentController;
|
||||
final text = controller.text;
|
||||
final selection = controller.selection;
|
||||
final newText = text.replaceRange(selection.start, selection.end, markdown);
|
||||
controller.text = newText;
|
||||
controller.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: selection.start + markdown.length),
|
||||
);
|
||||
}
|
||||
|
||||
static void setEmbedView(ComposeState state, SnPostEmbedView embedView) {
|
||||
state.embedView.value = embedView;
|
||||
}
|
||||
|
||||
static void updateEmbedView(ComposeState state, SnPostEmbedView embedView) {
|
||||
state.embedView.value = embedView;
|
||||
}
|
||||
|
||||
static void deleteEmbedView(ComposeState state) {
|
||||
state.embedView.value = null;
|
||||
}
|
||||
|
||||
static void setThumbnail(ComposeState state, String? thumbnailId) {
|
||||
state.thumbnailId.value = thumbnailId;
|
||||
}
|
||||
|
||||
static Future<void> pickPoll(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
BuildContext context,
|
||||
) async {
|
||||
if (state.pollId.value != null) {
|
||||
state.pollId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
final poll = await showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ComposePollSheet(pub: state.currentPublisher.value),
|
||||
);
|
||||
|
||||
if (poll == null) return;
|
||||
state.pollId.value = poll.id;
|
||||
}
|
||||
|
||||
static Future<void> pickFund(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
BuildContext context,
|
||||
) async {
|
||||
if (state.fundId.value != null) {
|
||||
state.fundId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
final fund = await showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => const ComposeFundSheet(),
|
||||
);
|
||||
|
||||
if (fund == null) return;
|
||||
state.fundId.value = fund.id;
|
||||
}
|
||||
|
||||
/// Unified submit method that returns the created/updated post.
|
||||
static Future<SnPost> performSubmit(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
BuildContext context, {
|
||||
SnPost? originalPost,
|
||||
SnPost? repliedPost,
|
||||
SnPost? forwardedPost,
|
||||
required Function() onSuccess,
|
||||
}) async {
|
||||
if (state.submitting.value) {
|
||||
throw Exception('Already submitting');
|
||||
}
|
||||
|
||||
// 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) {
|
||||
showErrorAlert('postContentEmpty'.tr());
|
||||
throw Exception('Post content is empty'); // 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.tags.value,
|
||||
'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.fundId.value != null) 'fund_id': state.fundId.value,
|
||||
if (state.postType == 1 && state.thumbnailId.value != null)
|
||||
'thumbnail_id': state.thumbnailId.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'),
|
||||
);
|
||||
|
||||
// Parse the response into a SnPost
|
||||
final post = SnPost.fromJson(response.data);
|
||||
|
||||
// Call the success callback
|
||||
onSuccess();
|
||||
eventBus.fire(PostCreatedEvent());
|
||||
|
||||
final postTypeStr = state.postType == 0 ? 'regular' : 'article';
|
||||
final visibilityStr = state.visibility.value.toString();
|
||||
final publisherId = state.currentPublisher.value?.id ?? 'unknown';
|
||||
|
||||
AnalyticsService().logPostCreated(
|
||||
postTypeStr,
|
||||
visibilityStr,
|
||||
state.attachments.value.isNotEmpty,
|
||||
publisherId,
|
||||
);
|
||||
|
||||
return post;
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
rethrow;
|
||||
} finally {
|
||||
state.submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> performAction(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
BuildContext context, {
|
||||
SnPost? originalPost,
|
||||
SnPost? repliedPost,
|
||||
SnPost? forwardedPost,
|
||||
}) async {
|
||||
await ComposeLogic.performSubmit(
|
||||
ref,
|
||||
state,
|
||||
context,
|
||||
originalPost: originalPost,
|
||||
repliedPost: repliedPost,
|
||||
forwardedPost: forwardedPost,
|
||||
onSuccess: () async {
|
||||
// Delete draft after successful submission
|
||||
if (state.postType == 1) {
|
||||
// Delete article draft
|
||||
await ref
|
||||
.read(composeStorageProvider.notifier)
|
||||
.deleteDraft(state.draftId);
|
||||
} else {
|
||||
// Delete regular post draft
|
||||
await ref
|
||||
.read(composeStorageProvider.notifier)
|
||||
.deleteDraft(state.draftId);
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).maybePop(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows the settings sheet modal.
|
||||
static void showSettingsSheet(BuildContext context, ComposeState state) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ComposeSettingsSheet(state: state),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> handlePaste(ComposeState state) async {
|
||||
final clipboard = await Pasteboard.image;
|
||||
if (clipboard == null) return;
|
||||
|
||||
state.attachments.value = [
|
||||
...state.attachments.value,
|
||||
UniversalFile(
|
||||
displayName: 'image.jpeg',
|
||||
data: XFile.fromData(
|
||||
clipboard,
|
||||
mimeType: "image/jpeg",
|
||||
name: 'image.jpeg',
|
||||
),
|
||||
type: UniversalFileType.image,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static void handleKeyPress(
|
||||
KeyEvent event,
|
||||
ComposeState state,
|
||||
WidgetRef ref,
|
||||
BuildContext context, {
|
||||
SnPost? originalPost,
|
||||
SnPost? repliedPost,
|
||||
SnPost? forwardedPost,
|
||||
}) {
|
||||
if (event is! KeyDownEvent) return;
|
||||
|
||||
final isPaste = event.logicalKey == LogicalKeyboardKey.keyV;
|
||||
final isSave = event.logicalKey == LogicalKeyboardKey.keyS;
|
||||
final isModifierPressed =
|
||||
HardwareKeyboard.instance.isMetaPressed ||
|
||||
HardwareKeyboard.instance.isControlPressed;
|
||||
final isSubmit = event.logicalKey == LogicalKeyboardKey.enter;
|
||||
|
||||
if (isPaste && isModifierPressed) {
|
||||
handlePaste(state);
|
||||
} else if (isSave && isModifierPressed) {
|
||||
saveDraftManually(ref, state, context);
|
||||
} else if (isSubmit && isModifierPressed && !state.submitting.value) {
|
||||
performAction(
|
||||
ref,
|
||||
state,
|
||||
context,
|
||||
originalPost: originalPost,
|
||||
repliedPost: repliedPost,
|
||||
forwardedPost: forwardedPost,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static void dispose(ComposeState state) {
|
||||
state.stopAutoSave();
|
||||
state.titleController.dispose();
|
||||
state.descriptionController.dispose();
|
||||
state.contentController.dispose();
|
||||
state.attachments.dispose();
|
||||
state.visibility.dispose();
|
||||
state.submitting.dispose();
|
||||
state.attachmentProgress.dispose();
|
||||
state.currentPublisher.dispose();
|
||||
state.tags.dispose();
|
||||
state.categories.dispose();
|
||||
state.realm.dispose();
|
||||
state.embedView.dispose();
|
||||
state.pollId.dispose();
|
||||
state.fundId.dispose();
|
||||
state.thumbnailId.dispose();
|
||||
}
|
||||
}
|
||||
328
lib/posts/posts_widgets/post/compose_sheet.dart
Normal file
328
lib/posts/posts_widgets/post/compose_sheet.dart
Normal file
@@ -0,0 +1,328 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/drive/drive_models/file.dart';
|
||||
import 'package:island/posts/posts/post_detail.dart';
|
||||
import 'package:island/posts/posts_models/post.dart';
|
||||
import 'package:island/posts/compose.dart';
|
||||
import 'package:island/posts/compose_storage_db.dart';
|
||||
import 'package:island/core/services/responsive.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_card.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_shared.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_state_utils.dart';
|
||||
import 'package:material_symbols_icons/symbols.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 PostComposeSheet extends HookConsumerWidget {
|
||||
final SnPost? originalPost;
|
||||
final PostComposeInitialState? initialState;
|
||||
final bool isBottomSheet;
|
||||
|
||||
const PostComposeSheet({
|
||||
super.key,
|
||||
this.originalPost,
|
||||
this.initialState,
|
||||
this.isBottomSheet = false,
|
||||
});
|
||||
|
||||
static Future<bool?> show(
|
||||
BuildContext context, {
|
||||
SnPost? originalPost,
|
||||
PostComposeInitialState? initialState,
|
||||
}) {
|
||||
// Check if editing an article
|
||||
if (originalPost != null && originalPost.type == 1) {
|
||||
context.pushNamed('articleEdit', pathParameters: {'id': originalPost.id});
|
||||
return Future.value(true);
|
||||
}
|
||||
|
||||
return showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder: (context) => PostComposeSheet(
|
||||
originalPost: originalPost,
|
||||
initialState: initialState,
|
||||
isBottomSheet: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final drafts = ref.watch(composeStorageProvider);
|
||||
final restoredInitialState = useState<PostComposeInitialState?>(null);
|
||||
final prompted = useState(false);
|
||||
|
||||
// Fetch full post data if we're editing a post
|
||||
final fullPostData = originalPost != null
|
||||
? ref.watch(postProvider(originalPost!.id))
|
||||
: const AsyncValue.data(null);
|
||||
|
||||
// Use the full post data if available, otherwise fall back to originalPost
|
||||
final effectiveOriginalPost = fullPostData.when(
|
||||
data: (fullPost) => fullPost ?? originalPost,
|
||||
loading: () => originalPost,
|
||||
error: (_, _) => originalPost,
|
||||
);
|
||||
|
||||
final repliedPost =
|
||||
initialState?.replyingTo ?? effectiveOriginalPost?.repliedPost;
|
||||
final forwardedPost =
|
||||
initialState?.forwardingTo ?? effectiveOriginalPost?.forwardedPost;
|
||||
|
||||
// Create compose state
|
||||
final ComposeState state = useMemoized(
|
||||
() => ComposeLogic.createState(
|
||||
originalPost: effectiveOriginalPost,
|
||||
forwardedPost: forwardedPost,
|
||||
repliedPost: repliedPost,
|
||||
postType: 0,
|
||||
),
|
||||
[effectiveOriginalPost, 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);
|
||||
|
||||
// Use shared state management utilities
|
||||
ComposeStateUtils.usePublisherInitialization(ref, state);
|
||||
ComposeStateUtils.useInitialStateLoader(state, initialState);
|
||||
|
||||
useEffect(() {
|
||||
if (!prompted.value &&
|
||||
originalPost == null &&
|
||||
initialState?.replyingTo == null &&
|
||||
initialState?.forwardingTo == null &&
|
||||
drafts.isNotEmpty) {
|
||||
prompted.value = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_showRestoreDialog(ref, restoredInitialState);
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [drafts, prompted.value]);
|
||||
|
||||
// Dispose state when widget is disposed
|
||||
useEffect(
|
||||
() =>
|
||||
() => ComposeLogic.dispose(state),
|
||||
[],
|
||||
);
|
||||
|
||||
// Helper methods for actions
|
||||
void showSettingsSheet() {
|
||||
ComposeLogic.showSettingsSheet(context, state);
|
||||
}
|
||||
|
||||
Future<void> performSubmit() async {
|
||||
await ComposeLogic.performSubmit(
|
||||
ref,
|
||||
state,
|
||||
context,
|
||||
originalPost: effectiveOriginalPost,
|
||||
repliedPost: repliedPost,
|
||||
forwardedPost: forwardedPost,
|
||||
onSuccess: () {
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final actions = [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.settings),
|
||||
onPressed: showSettingsSheet,
|
||||
tooltip: 'postSettings'.tr(),
|
||||
),
|
||||
IconButton(
|
||||
onPressed:
|
||||
(state.submitting.value || state.currentPublisher.value == null)
|
||||
? null
|
||||
: performSubmit,
|
||||
icon: state.submitting.value
|
||||
? SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: const CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(
|
||||
effectiveOriginalPost != null ? Symbols.edit : Symbols.upload,
|
||||
),
|
||||
tooltip: effectiveOriginalPost != null
|
||||
? 'postUpdate'.tr()
|
||||
: 'postPublish'.tr(),
|
||||
),
|
||||
];
|
||||
|
||||
// Tablet will show a virtual keyboard, so we adjust the height factor accordingly
|
||||
final isTablet =
|
||||
isWideScreen(context) &&
|
||||
!kIsWeb &&
|
||||
(Platform.isAndroid || Platform.isIOS);
|
||||
|
||||
return SheetScaffold(
|
||||
heightFactor: isTablet ? 0.95 : 0.8,
|
||||
titleText: 'postCompose'.tr(),
|
||||
actions: actions,
|
||||
child: PostComposeCard(
|
||||
originalPost: effectiveOriginalPost,
|
||||
initialState: restoredInitialState.value ?? initialState,
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onSubmit: () {
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
isContained: true,
|
||||
showHeader: false,
|
||||
providedState: state,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showRestoreDialog(
|
||||
WidgetRef ref,
|
||||
ValueNotifier<PostComposeInitialState?> restoredInitialState,
|
||||
) async {
|
||||
final drafts = ref.read(composeStorageProvider);
|
||||
if (drafts.isNotEmpty) {
|
||||
final latestDraft = drafts.values.last;
|
||||
|
||||
final restore = await showDialog<bool>(
|
||||
context: ref.context,
|
||||
useRootNavigator: true,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('restoreDraftTitle'.tr()),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('restoreDraftMessage'.tr()),
|
||||
const SizedBox(height: 16),
|
||||
_buildCompactDraftPreview(context, latestDraft),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text('no'.tr()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text('yes'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (restore == true) {
|
||||
// Delete the old draft
|
||||
await ref
|
||||
.read(composeStorageProvider.notifier)
|
||||
.deleteDraft(latestDraft.id);
|
||||
restoredInitialState.value = PostComposeInitialState(
|
||||
title: latestDraft.title,
|
||||
description: latestDraft.description,
|
||||
content: latestDraft.content,
|
||||
visibility: latestDraft.visibility,
|
||||
attachments: latestDraft.attachments
|
||||
.map((e) => UniversalFile.fromAttachment(e))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildCompactDraftPreview(BuildContext context, SnPost draft) {
|
||||
return 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: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.description,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'draft'.tr(),
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (draft.title?.isNotEmpty ?? false)
|
||||
Text(
|
||||
draft.title!,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (draft.content?.isNotEmpty ?? false)
|
||||
Text(
|
||||
draft.content!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (draft.attachments.isNotEmpty)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.attach_file,
|
||||
size: 12,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${draft.attachments.length} attachment${draft.attachments.length > 1 ? 's' : ''}',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
193
lib/posts/posts_widgets/post/compose_state_utils.dart
Normal file
193
lib/posts/posts_widgets/post/compose_state_utils.dart
Normal file
@@ -0,0 +1,193 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/creators/creators/publishers_form.dart';
|
||||
import 'package:island/drive/drive_models/file.dart';
|
||||
import 'package:island/posts/posts_models/post.dart';
|
||||
import 'package:island/posts/compose.dart';
|
||||
import 'package:island/posts/compose_storage_db.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_shared.dart';
|
||||
|
||||
/// Utility class for common compose state management logic.
|
||||
class ComposeStateUtils {
|
||||
/// Initializes publisher when data becomes available.
|
||||
static void usePublisherInitialization(WidgetRef ref, ComposeState state) {
|
||||
final publishers = ref.watch(publishersManagedProvider);
|
||||
|
||||
useEffect(() {
|
||||
if (publishers.value?.isNotEmpty ?? false) {
|
||||
if (state.currentPublisher.value == null) {
|
||||
state.currentPublisher.value = publishers.value!.first;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [publishers]);
|
||||
}
|
||||
|
||||
/// Loads initial state from provided parameters.
|
||||
static void useInitialStateLoader(
|
||||
ComposeState state,
|
||||
PostComposeInitialState? initialState,
|
||||
) {
|
||||
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]);
|
||||
}
|
||||
|
||||
/// Loads draft if available (for new posts without initial state).
|
||||
static void useDraftLoader(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
SnPost? originalPost,
|
||||
SnPost? repliedPost,
|
||||
SnPost? forwardedPost,
|
||||
PostComposeInitialState? initialState,
|
||||
) {
|
||||
useEffect(() {
|
||||
if (originalPost == null &&
|
||||
forwardedPost == null &&
|
||||
repliedPost == null &&
|
||||
initialState == null) {
|
||||
// Try to load the most recent draft
|
||||
final drafts = ref.read(composeStorageProvider);
|
||||
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;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/// Handles auto-save functionality for new posts.
|
||||
static void useAutoSave(WidgetRef ref, ComposeState state, bool isNewPost) {
|
||||
useEffect(() {
|
||||
if (isNewPost) {
|
||||
state.startAutoSave(ref);
|
||||
}
|
||||
return () => state.stopAutoSave();
|
||||
}, [state]);
|
||||
}
|
||||
|
||||
/// Handles disposal and draft saving logic.
|
||||
static void useDisposalHandler(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
SnPost? originalPost,
|
||||
bool submitted,
|
||||
) {
|
||||
useEffect(() {
|
||||
return () {
|
||||
if (!submitted &&
|
||||
originalPost == null &&
|
||||
state.currentPublisher.value != null) {
|
||||
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) {
|
||||
final draft = SnPost(
|
||||
id: state.draftId,
|
||||
title: state.titleController.text,
|
||||
description: state.descriptionController.text,
|
||||
content: state.contentController.text,
|
||||
visibility: state.visibility.value,
|
||||
type: state.postType,
|
||||
attachments: state.attachments.value
|
||||
.where((e) => e.isOnCloud)
|
||||
.map((e) => e.data as SnCloudFile)
|
||||
.toList(),
|
||||
publisher: state.currentPublisher.value!,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
ref
|
||||
.read(composeStorageProvider.notifier)
|
||||
.saveDraft(draft)
|
||||
.catchError((e) => debugPrint('Failed to save draft: $e'));
|
||||
}
|
||||
}
|
||||
ComposeLogic.dispose(state);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
/// Creates and manages the state notifier for rebuilds.
|
||||
static Listenable useStateNotifier(ComposeState state) {
|
||||
return useMemoized(
|
||||
() => Listenable.merge([
|
||||
state.titleController,
|
||||
state.descriptionController,
|
||||
state.contentController,
|
||||
state.visibility,
|
||||
state.attachments,
|
||||
state.attachmentProgress,
|
||||
state.currentPublisher,
|
||||
state.submitting,
|
||||
]),
|
||||
[state],
|
||||
);
|
||||
}
|
||||
|
||||
/// Resets form to clean state for new composition.
|
||||
static void resetForm(ComposeState state) {
|
||||
// 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.tags.value = [];
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
205
lib/posts/posts_widgets/post/compose_toolbar.dart
Normal file
205
lib/posts/posts_widgets/post/compose_toolbar.dart
Normal file
@@ -0,0 +1,205 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/posts/posts_models/post.dart';
|
||||
import 'package:island/posts/compose_storage_db.dart';
|
||||
import 'package:island/core/widgets/shared/upload_menu.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_embed_sheet.dart';
|
||||
import 'package:island/posts/posts_widgets/post/compose_shared.dart';
|
||||
import 'package:island/posts/posts_widgets/post/draft_manager.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class ComposeToolbar extends HookConsumerWidget {
|
||||
final ComposeState state;
|
||||
final SnPost? originalPost;
|
||||
final bool useSafeArea;
|
||||
|
||||
const ComposeToolbar({
|
||||
super.key,
|
||||
required this.state,
|
||||
this.originalPost,
|
||||
this.useSafeArea = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
void pickPhotoMedia() {
|
||||
ComposeLogic.pickPhotoMedia(ref, state);
|
||||
}
|
||||
|
||||
void pickVideoMedia() {
|
||||
ComposeLogic.pickVideoMedia(ref, state);
|
||||
}
|
||||
|
||||
void pickGeneralFile() {
|
||||
ComposeLogic.pickGeneralFile(ref, state);
|
||||
}
|
||||
|
||||
void addAudio() {
|
||||
ComposeLogic.recordAudioMedia(ref, state, context);
|
||||
}
|
||||
|
||||
void linkAttachment() {
|
||||
ComposeLogic.linkAttachment(ref, state, context);
|
||||
}
|
||||
|
||||
void saveDraft() {
|
||||
ComposeLogic.saveDraftManually(ref, state, context);
|
||||
}
|
||||
|
||||
void pickPoll() {
|
||||
ComposeLogic.pickPoll(ref, state, context);
|
||||
}
|
||||
|
||||
void pickFund() {
|
||||
ComposeLogic.pickFund(ref, state, context);
|
||||
}
|
||||
|
||||
void showEmbedSheet() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder: (context) => ComposeEmbedSheet(state: state),
|
||||
);
|
||||
}
|
||||
|
||||
void showDraftManager() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder: (context) => DraftManagerSheet(
|
||||
onDraftSelected: (draftId) {
|
||||
final draft = ref.read(composeStorageProvider)[draftId];
|
||||
if (draft != null) {
|
||||
state.titleController.text = draft.title ?? '';
|
||||
state.descriptionController.text = draft.description ?? '';
|
||||
state.contentController.text = draft.content ?? '';
|
||||
state.visibility.value = draft.visibility;
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final uploadMenuItems = [
|
||||
UploadMenuItemData(Symbols.add_a_photo, 'addPhoto', pickPhotoMedia),
|
||||
UploadMenuItemData(Symbols.videocam, 'addVideo', pickVideoMedia),
|
||||
UploadMenuItemData(Symbols.mic, 'addAudio', addAudio),
|
||||
UploadMenuItemData(Symbols.file_upload, 'uploadFile', pickGeneralFile),
|
||||
];
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Material(
|
||||
elevation: 8,
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child:
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
UploadMenu(items: uploadMenuItems),
|
||||
IconButton(
|
||||
onPressed: linkAttachment,
|
||||
icon: const Icon(Symbols.attach_file),
|
||||
tooltip: 'linkAttachment'.tr(),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
// Poll button with visual state when a poll is linked
|
||||
ListenableBuilder(
|
||||
listenable: state.pollId,
|
||||
builder: (context, _) {
|
||||
return IconButton(
|
||||
onPressed: pickPoll,
|
||||
icon: const Icon(Symbols.how_to_vote),
|
||||
tooltip: 'poll'.tr(),
|
||||
color: colorScheme.primary,
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
state.pollId.value != null
|
||||
? colorScheme.primary.withOpacity(0.15)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Fund button with visual state when a fund is linked
|
||||
ListenableBuilder(
|
||||
listenable: state.fundId,
|
||||
builder: (context, _) {
|
||||
return IconButton(
|
||||
onPressed: pickFund,
|
||||
icon: const Icon(
|
||||
Symbols.account_balance_wallet,
|
||||
),
|
||||
tooltip: 'fund'.tr(),
|
||||
color: colorScheme.primary,
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
state.fundId.value != null
|
||||
? colorScheme.primary.withOpacity(0.15)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Embed button with visual state when embed is present
|
||||
ListenableBuilder(
|
||||
listenable: state.embedView,
|
||||
builder: (context, _) {
|
||||
return IconButton(
|
||||
onPressed: showEmbedSheet,
|
||||
icon: const Icon(Symbols.iframe),
|
||||
tooltip: 'embedView'.tr(),
|
||||
color: colorScheme.primary,
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
state.embedView.value != null
|
||||
? colorScheme.primary.withOpacity(0.15)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (originalPost == null && state.isEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.draft),
|
||||
color: colorScheme.primary,
|
||||
onPressed: showDraftManager,
|
||||
tooltip: 'drafts'.tr(),
|
||||
)
|
||||
else if (originalPost == null)
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.save),
|
||||
color: colorScheme.primary,
|
||||
onPressed: saveDraft,
|
||||
onLongPress: showDraftManager,
|
||||
tooltip: 'saveDraft'.tr(),
|
||||
),
|
||||
],
|
||||
).padding(
|
||||
bottom: MediaQuery.of(context).padding.bottom + 16,
|
||||
horizontal: 16,
|
||||
top: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
285
lib/posts/posts_widgets/post/draft_manager.dart
Normal file
285
lib/posts/posts_widgets/post/draft_manager.dart
Normal file
@@ -0,0 +1,285 @@
|
||||
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/posts/compose_storage_db.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
class DraftManagerSheet extends HookConsumerWidget {
|
||||
final Function(String draftId)? onDraftSelected;
|
||||
|
||||
const DraftManagerSheet({super.key, this.onDraftSelected});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final searchController = useTextEditingController();
|
||||
final searchQuery = useState('');
|
||||
|
||||
final drafts = ref.watch(composeStorageProvider);
|
||||
|
||||
// Search functionality
|
||||
final filteredDrafts = useMemoized(() {
|
||||
if (searchQuery.value.isEmpty) {
|
||||
return drafts.values.toList()
|
||||
..sort((a, b) => b.updatedAt!.compareTo(a.updatedAt!));
|
||||
}
|
||||
|
||||
final query = searchQuery.value.toLowerCase();
|
||||
return drafts.values.where((draft) {
|
||||
return (draft.title?.toLowerCase().contains(query) ?? false) ||
|
||||
(draft.description?.toLowerCase().contains(query) ?? false) ||
|
||||
(draft.content?.toLowerCase().contains(query) ?? false);
|
||||
}).toList()..sort((a, b) => b.updatedAt!.compareTo(a.updatedAt!));
|
||||
}, [drafts, searchQuery.value]);
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: 'drafts'.tr(),
|
||||
child: Column(
|
||||
children: [
|
||||
// Search bar
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
controller: searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'searchDrafts'.tr(),
|
||||
prefixIcon: const Icon(Symbols.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) => searchQuery.value = value,
|
||||
),
|
||||
),
|
||||
|
||||
// Drafts list
|
||||
if (filteredDrafts.isEmpty)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.draft,
|
||||
size: 64,
|
||||
color: colorScheme.onSurface.withOpacity(0.3),
|
||||
),
|
||||
const Gap(16),
|
||||
Text(
|
||||
searchQuery.value.isEmpty
|
||||
? 'noDrafts'.tr()
|
||||
: 'noSearchResults'.tr(),
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: filteredDrafts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final draft = filteredDrafts[index];
|
||||
return _DraftItem(
|
||||
draft: draft,
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
onDraftSelected?.call(draft.id);
|
||||
},
|
||||
onDelete: () async {
|
||||
await ref
|
||||
.read(composeStorageProvider.notifier)
|
||||
.deleteDraft(draft.id);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Clear all button
|
||||
if (filteredDrafts.isNotEmpty) ...[
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final confirmed = await showConfirmAlert(
|
||||
'clearAllDraftsConfirm'.tr(),
|
||||
'clearAllDrafts'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await ref
|
||||
.read(composeStorageProvider.notifier)
|
||||
.clearAllDrafts();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Symbols.delete_sweep),
|
||||
label: Text('clearAll'.tr()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DraftItem extends StatelessWidget {
|
||||
final dynamic draft;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
const _DraftItem({required this.draft, this.onTap, this.onDelete});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
final title = draft.title ?? 'untitled'.tr();
|
||||
final content = draft.content ?? (draft.description ?? 'noContent'.tr());
|
||||
final preview = content.length > 100
|
||||
? '${content.substring(0, 100)}...'
|
||||
: content;
|
||||
final timeAgo = _formatTimeAgo(draft.updatedAt!);
|
||||
final visibility = _parseVisibility(draft.visibility).tr();
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
draft.type == 1 ? Symbols.article : Symbols.post_add,
|
||||
size: 20,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onDelete,
|
||||
icon: const Icon(Symbols.delete),
|
||||
iconSize: 20,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (preview.isNotEmpty) ...[
|
||||
const Gap(8),
|
||||
Text(
|
||||
preview,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const Gap(8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.schedule,
|
||||
size: 16,
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
const Gap(4),
|
||||
Text(
|
||||
timeAgo,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
visibility,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTimeAgo(DateTime dateTime) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(dateTime);
|
||||
|
||||
if (difference.inMinutes < 1) {
|
||||
return 'justNow'.tr();
|
||||
} else if (difference.inHours < 1) {
|
||||
return 'minutesAgo'.tr(args: [difference.inMinutes.toString()]);
|
||||
} else if (difference.inDays < 1) {
|
||||
return 'hoursAgo'.tr(args: [difference.inHours.toString()]);
|
||||
} else if (difference.inDays < 7) {
|
||||
return 'daysAgo'.tr(args: [difference.inDays.toString()]);
|
||||
} else {
|
||||
return DateFormat('MMM dd, yyyy').format(dateTime);
|
||||
}
|
||||
}
|
||||
|
||||
String _parseVisibility(int visibility) {
|
||||
switch (visibility) {
|
||||
case 1:
|
||||
return 'postVisibilityFriends';
|
||||
case 2:
|
||||
return 'postVisibilityUnlisted';
|
||||
case 3:
|
||||
return 'postVisibilityPrivate';
|
||||
default:
|
||||
return 'postVisibilityPublic';
|
||||
}
|
||||
}
|
||||
}
|
||||
304
lib/posts/posts_widgets/post/embed_view_renderer.dart
Normal file
304
lib/posts/posts_widgets/post/embed_view_renderer.dart
Normal file
@@ -0,0 +1,304 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/posts/posts_models/post.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class EmbedViewRenderer extends HookConsumerWidget {
|
||||
final SnPostEmbedView embedView;
|
||||
final double? maxHeight;
|
||||
final BorderRadius? borderRadius;
|
||||
|
||||
const EmbedViewRenderer({
|
||||
super.key,
|
||||
required this.embedView,
|
||||
this.maxHeight = 400,
|
||||
this.borderRadius,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
// State management for lazy loading
|
||||
final shouldLoad = useState(false);
|
||||
final isLoading = useState(false);
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight ?? 400),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: borderRadius ?? BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colorScheme.outline.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
color: colorScheme.surfaceContainerLowest,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: borderRadius ?? BorderRadius.circular(12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header with embed info
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: colorScheme.outline.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
embedView.renderer == PostEmbedViewRenderer.webView
|
||||
? Symbols.globe
|
||||
: Symbols.iframe,
|
||||
size: 16,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_getUriDisplay(embedView.uri),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
child: Icon(
|
||||
Symbols.open_in_new,
|
||||
size: 16,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onTap: () async {
|
||||
final uri = Uri.parse(embedView.uri);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// WebView content with lazy loading
|
||||
AspectRatio(
|
||||
aspectRatio: embedView.aspectRatio ?? 1,
|
||||
child:
|
||||
shouldLoad.value
|
||||
? Stack(
|
||||
children: [
|
||||
InAppWebView(
|
||||
gestureRecognizers: {
|
||||
Factory<VerticalDragGestureRecognizer>(
|
||||
() => VerticalDragGestureRecognizer(),
|
||||
),
|
||||
Factory<HorizontalDragGestureRecognizer>(
|
||||
() => HorizontalDragGestureRecognizer(),
|
||||
),
|
||||
Factory<ScaleGestureRecognizer>(
|
||||
() => ScaleGestureRecognizer(),
|
||||
),
|
||||
Factory<TapGestureRecognizer>(
|
||||
() => TapGestureRecognizer(),
|
||||
),
|
||||
},
|
||||
initialUrlRequest: URLRequest(
|
||||
url: WebUri(embedView.uri),
|
||||
),
|
||||
initialSettings: InAppWebViewSettings(
|
||||
javaScriptEnabled: true,
|
||||
mediaPlaybackRequiresUserGesture: false,
|
||||
allowsInlineMediaPlayback: true,
|
||||
useShouldOverrideUrlLoading: true,
|
||||
useOnLoadResource: true,
|
||||
supportZoom: false,
|
||||
useWideViewPort: false,
|
||||
loadWithOverviewMode: true,
|
||||
builtInZoomControls: false,
|
||||
displayZoomControls: false,
|
||||
minimumFontSize: 12,
|
||||
preferredContentMode:
|
||||
UserPreferredContentMode.RECOMMENDED,
|
||||
allowsBackForwardNavigationGestures: false,
|
||||
allowsLinkPreview: false,
|
||||
isInspectable: false,
|
||||
),
|
||||
onWebViewCreated: (controller) {
|
||||
// Configure webview settings
|
||||
controller.addJavaScriptHandler(
|
||||
handlerName: 'onHeightChanged',
|
||||
callback: (args) {
|
||||
// Handle dynamic height changes if needed
|
||||
},
|
||||
);
|
||||
},
|
||||
onLoadStart: (controller, url) {
|
||||
isLoading.value = true;
|
||||
},
|
||||
onLoadStop: (controller, url) async {
|
||||
isLoading.value = false;
|
||||
// Inject CSS to improve mobile display and remove borders
|
||||
await controller.evaluateJavascript(
|
||||
source: '''
|
||||
// Remove unwanted elements
|
||||
var elements = document.querySelectorAll('nav, header, footer, .ads, .advertisement, .sidebar');
|
||||
for (var i = 0; i < elements.length; i++) {
|
||||
elements[i].style.display = 'none';
|
||||
}
|
||||
|
||||
// Remove borders from embedded content (YouTube, Vimeo, etc.)
|
||||
var iframes = document.querySelectorAll('iframe');
|
||||
for (var i = 0; i < iframes.length; i++) {
|
||||
iframes[i].style.border = 'none';
|
||||
iframes[i].style.borderRadius = '0';
|
||||
}
|
||||
|
||||
// Remove borders from video elements
|
||||
var videos = document.querySelectorAll('video');
|
||||
for (var i = 0; i < videos.length; i++) {
|
||||
videos[i].style.border = 'none';
|
||||
videos[i].style.borderRadius = '0';
|
||||
}
|
||||
|
||||
// Remove borders from any element that might have them
|
||||
var allElements = document.querySelectorAll('*');
|
||||
for (var i = 0; i < allElements.length; i++) {
|
||||
if (allElements[i].style.border) {
|
||||
allElements[i].style.border = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Improve readability
|
||||
var body = document.body;
|
||||
body.style.fontSize = '14px';
|
||||
body.style.lineHeight = '1.4';
|
||||
body.style.margin = '0';
|
||||
body.style.padding = '0';
|
||||
|
||||
// Handle dynamic content
|
||||
var observer = new MutationObserver(function(mutations) {
|
||||
// Remove borders from newly added elements
|
||||
var newIframes = document.querySelectorAll('iframe');
|
||||
for (var i = 0; i < newIframes.length; i++) {
|
||||
if (!newIframes[i].style.border || newIframes[i].style.border !== 'none') {
|
||||
newIframes[i].style.border = 'none';
|
||||
newIframes[i].style.borderRadius = '0';
|
||||
}
|
||||
}
|
||||
var newVideos = document.querySelectorAll('video');
|
||||
for (var i = 0; i < newVideos.length; i++) {
|
||||
if (!newVideos[i].style.border || newVideos[i].style.border !== 'none') {
|
||||
newVideos[i].style.border = 'none';
|
||||
newVideos[i].style.borderRadius = '0';
|
||||
}
|
||||
}
|
||||
window.flutter_inappwebview.callHandler('onHeightChanged', document.body.scrollHeight);
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
''',
|
||||
);
|
||||
},
|
||||
onLoadError: (controller, url, code, message) {
|
||||
isLoading.value = false;
|
||||
},
|
||||
onLoadHttpError: (
|
||||
controller,
|
||||
url,
|
||||
statusCode,
|
||||
description,
|
||||
) {
|
||||
isLoading.value = false;
|
||||
},
|
||||
shouldOverrideUrlLoading: (
|
||||
controller,
|
||||
navigationAction,
|
||||
) async {
|
||||
final uri = navigationAction.request.url;
|
||||
if (uri != null &&
|
||||
uri.toString() != embedView.uri) {
|
||||
// Open external links in browser
|
||||
// You might want to use url_launcher here
|
||||
return NavigationActionPolicy.CANCEL;
|
||||
}
|
||||
return NavigationActionPolicy.ALLOW;
|
||||
},
|
||||
onProgressChanged: (controller, progress) {
|
||||
// Handle progress changes if needed
|
||||
},
|
||||
onConsoleMessage: (controller, consoleMessage) {
|
||||
// Handle console messages for debugging
|
||||
debugPrint(
|
||||
'WebView Console: ${consoleMessage.message}',
|
||||
);
|
||||
},
|
||||
),
|
||||
if (isLoading.value)
|
||||
Container(
|
||||
color: colorScheme.surfaceContainerLowest,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: GestureDetector(
|
||||
onTap: () {
|
||||
shouldLoad.value = true;
|
||||
},
|
||||
child: Container(
|
||||
color: colorScheme.surfaceContainerLowest,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.play_arrow,
|
||||
fill: 1,
|
||||
size: 48,
|
||||
color: colorScheme.onSurfaceVariant.withOpacity(
|
||||
0.6,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'viewEmbedLoadHint'.tr(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant
|
||||
.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getUriDisplay(String uri) {
|
||||
try {
|
||||
final parsedUri = Uri.parse(uri);
|
||||
return parsedUri.host.isNotEmpty ? parsedUri.host : uri;
|
||||
} catch (e) {
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
312
lib/posts/posts_widgets/post/filters/post_filter.dart
Normal file
312
lib/posts/posts_widgets/post/filters/post_filter.dart
Normal file
@@ -0,0 +1,312 @@
|
||||
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/posts/post/post_list.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
class PostFilterWidget extends HookConsumerWidget {
|
||||
final TabController categoryTabController;
|
||||
final PostListQuery initialQuery;
|
||||
final ValueChanged<PostListQuery> onQueryChanged;
|
||||
final bool hideSearch;
|
||||
|
||||
const PostFilterWidget({
|
||||
super.key,
|
||||
required this.categoryTabController,
|
||||
required this.initialQuery,
|
||||
required this.onQueryChanged,
|
||||
this.hideSearch = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final includeReplies = useState<bool?>(initialQuery.includeReplies);
|
||||
final mediaOnly = useState<bool>(initialQuery.mediaOnly ?? false);
|
||||
final queryTerm = useState<String?>(initialQuery.queryTerm);
|
||||
final order = useState<String?>(initialQuery.order);
|
||||
final orderDesc = useState<bool>(initialQuery.orderDesc);
|
||||
final periodStart = useState<int?>(initialQuery.periodStart);
|
||||
final periodEnd = useState<int?>(initialQuery.periodEnd);
|
||||
final type = useState<int?>(initialQuery.type);
|
||||
final showAdvancedFilters = useState<bool>(false);
|
||||
final searchController = useTextEditingController(
|
||||
text: initialQuery.queryTerm,
|
||||
);
|
||||
|
||||
void updateQuery() {
|
||||
final newQuery = initialQuery.copyWith(
|
||||
includeReplies: includeReplies.value,
|
||||
mediaOnly: mediaOnly.value,
|
||||
queryTerm: queryTerm.value,
|
||||
order: order.value,
|
||||
periodStart: periodStart.value,
|
||||
periodEnd: periodEnd.value,
|
||||
orderDesc: orderDesc.value,
|
||||
type: type.value,
|
||||
);
|
||||
onQueryChanged(newQuery);
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
void onTabChanged() {
|
||||
final tabIndex = categoryTabController.index;
|
||||
type.value = switch (tabIndex) {
|
||||
1 => 0,
|
||||
2 => 1,
|
||||
_ => null,
|
||||
};
|
||||
updateQuery();
|
||||
}
|
||||
|
||||
categoryTabController.addListener(onTabChanged);
|
||||
return () => categoryTabController.removeListener(onTabChanged);
|
||||
}, [categoryTabController]);
|
||||
|
||||
return Card(
|
||||
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Column(
|
||||
children: [
|
||||
TabBar(
|
||||
controller: categoryTabController,
|
||||
dividerColor: Colors.transparent,
|
||||
splashBorderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
tabs: [
|
||||
Tab(text: 'all'.tr()),
|
||||
Tab(text: 'postTypePost'.tr()),
|
||||
Tab(text: 'postArticle'.tr()),
|
||||
],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CheckboxListTile(
|
||||
title: Text('reply'.tr()),
|
||||
value: includeReplies.value,
|
||||
tristate: true,
|
||||
onChanged: (value) {
|
||||
final current = includeReplies.value;
|
||||
if (current == null) {
|
||||
includeReplies.value = false;
|
||||
} else if (current == false) {
|
||||
includeReplies.value = true;
|
||||
} else {
|
||||
includeReplies.value = null;
|
||||
}
|
||||
updateQuery();
|
||||
},
|
||||
dense: true,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
secondary: const Icon(Symbols.reply),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: CheckboxListTile(
|
||||
title: Text('attachments'.tr()),
|
||||
value: mediaOnly.value,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
mediaOnly.value = value;
|
||||
}
|
||||
updateQuery();
|
||||
},
|
||||
dense: true,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
secondary: const Icon(Symbols.attachment),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: Text('descendingOrder'.tr()),
|
||||
value: orderDesc.value,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
orderDesc.value = value;
|
||||
}
|
||||
updateQuery();
|
||||
},
|
||||
dense: true,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
secondary: const Icon(Symbols.sort),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
title: Text('advancedFilters'.tr()),
|
||||
leading: const Icon(Symbols.filter_list),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(const Radius.circular(8)),
|
||||
),
|
||||
trailing: Icon(
|
||||
showAdvancedFilters.value
|
||||
? Symbols.expand_less
|
||||
: Symbols.expand_more,
|
||||
),
|
||||
onTap: () {
|
||||
showAdvancedFilters.value = !showAdvancedFilters.value;
|
||||
},
|
||||
),
|
||||
if (showAdvancedFilters.value) ...[
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!hideSearch)
|
||||
TextField(
|
||||
controller: searchController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'search'.tr(),
|
||||
hintText: 'searchPosts'.tr(),
|
||||
prefixIcon: const Icon(Symbols.search),
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
queryTerm.value = value.isEmpty ? null : value;
|
||||
updateQuery();
|
||||
},
|
||||
),
|
||||
if (!hideSearch) const Gap(12),
|
||||
DropdownButtonFormField<String>(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'sortBy'.tr(),
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
value: order.value,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'date', child: Text('date'.tr())),
|
||||
DropdownMenuItem(
|
||||
value: 'popularity',
|
||||
child: Text('popularity'.tr()),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
order.value = value;
|
||||
updateQuery();
|
||||
},
|
||||
),
|
||||
const Gap(12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
final pickedDate = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: periodStart.value != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
periodStart.value! * 1000,
|
||||
)
|
||||
: DateTime.now(),
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime.now().add(
|
||||
const Duration(days: 365),
|
||||
),
|
||||
);
|
||||
if (pickedDate != null) {
|
||||
periodStart.value =
|
||||
pickedDate.millisecondsSinceEpoch ~/ 1000;
|
||||
updateQuery();
|
||||
}
|
||||
},
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fromDate'.tr(),
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(12),
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
suffixIcon: const Icon(Symbols.calendar_today),
|
||||
),
|
||||
child: Text(
|
||||
periodStart.value != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
periodStart.value! * 1000,
|
||||
).toString().split(' ')[0]
|
||||
: 'selectDate'.tr(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
final pickedDate = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: periodEnd.value != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
periodEnd.value! * 1000,
|
||||
)
|
||||
: DateTime.now(),
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime.now().add(
|
||||
const Duration(days: 365),
|
||||
),
|
||||
);
|
||||
if (pickedDate != null) {
|
||||
periodEnd.value =
|
||||
pickedDate.millisecondsSinceEpoch ~/ 1000;
|
||||
updateQuery();
|
||||
}
|
||||
},
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'toDate'.tr(),
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(12),
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
suffixIcon: const Icon(Symbols.calendar_today),
|
||||
),
|
||||
child: Text(
|
||||
periodEnd.value != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
periodEnd.value! * 1000,
|
||||
).toString().split(' ')[0]
|
||||
: 'selectDate'.tr(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
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/posts/posts_models/post.dart';
|
||||
import 'package:island/posts/posts_models/post_category.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
part 'post_subscription_filter.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<List<SnPublisherSubscription>> publishersSubscriptions(Ref ref) async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
|
||||
final response = await client.get('/sphere/publishers/subscriptions');
|
||||
|
||||
return response.data
|
||||
.map((json) => SnPublisherSubscription.fromJson(json))
|
||||
.cast<SnPublisherSubscription>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<List<SnCategorySubscription>> categoriesSubscriptions(Ref ref) async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
|
||||
final response = await client.get('/sphere/categories/subscriptions');
|
||||
|
||||
return response.data
|
||||
.map((json) => SnCategorySubscription.fromJson(json))
|
||||
.cast<SnCategorySubscription>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
class PostSubscriptionFilterWidget extends HookConsumerWidget {
|
||||
final List<String> initialSelectedPublishers;
|
||||
final List<String> initialSelectedCategories;
|
||||
final List<String> initialSelectedTags;
|
||||
final ValueChanged<List<String>> onSelectedPublishersChanged;
|
||||
final ValueChanged<List<String>> onSelectedCategoriesChanged;
|
||||
final ValueChanged<List<String>> onSelectedTagsChanged;
|
||||
final bool hideSearch;
|
||||
|
||||
const PostSubscriptionFilterWidget({
|
||||
super.key,
|
||||
required this.initialSelectedPublishers,
|
||||
required this.initialSelectedCategories,
|
||||
required this.initialSelectedTags,
|
||||
required this.onSelectedPublishersChanged,
|
||||
required this.onSelectedCategoriesChanged,
|
||||
required this.onSelectedTagsChanged,
|
||||
this.hideSearch = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedPublishers = useState<List<String>>(
|
||||
initialSelectedPublishers,
|
||||
);
|
||||
final selectedCategories = useState<List<String>>(
|
||||
initialSelectedCategories,
|
||||
);
|
||||
final selectedTags = useState<List<String>>(initialSelectedTags);
|
||||
|
||||
final publishersAsync = ref.watch(publishersSubscriptionsProvider);
|
||||
final categoriesAsync = ref.watch(categoriesSubscriptionsProvider);
|
||||
|
||||
void updateSelection() {
|
||||
onSelectedPublishersChanged(selectedPublishers.value);
|
||||
onSelectedCategoriesChanged(selectedCategories.value);
|
||||
onSelectedTagsChanged(selectedTags.value);
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
spacing: 16,
|
||||
children: [
|
||||
const Icon(Symbols.subscriptions, size: 20),
|
||||
Text(
|
||||
'exploreFilterSubscriptions'.tr(),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 16, top: 12),
|
||||
const Gap(12),
|
||||
|
||||
// Publishers Section
|
||||
publishersAsync.when(
|
||||
data: (subscriptions) {
|
||||
if (subscriptions.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text('noSubscriptions'.tr()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'publishers'.tr(),
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).padding(bottom: 8, horizontal: 16),
|
||||
...subscriptions.map((subscription) {
|
||||
final isSelected = selectedPublishers.value.contains(
|
||||
subscription.publisher.name,
|
||||
);
|
||||
final publisher = subscription.publisher;
|
||||
|
||||
return CheckboxListTile(
|
||||
controlAffinity: ListTileControlAffinity.trailing,
|
||||
title: Text(publisher.nick),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
value: isSelected,
|
||||
onChanged: (value) {
|
||||
if (value == true) {
|
||||
selectedPublishers.value = [
|
||||
...selectedPublishers.value,
|
||||
subscription.publisher.name,
|
||||
];
|
||||
} else {
|
||||
selectedPublishers.value = selectedPublishers.value
|
||||
.where(
|
||||
(name) => name != subscription.publisher.name,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
updateSelection();
|
||||
},
|
||||
dense: true,
|
||||
secondary: ProfilePictureWidget(
|
||||
file: subscription.publisher.picture,
|
||||
radius: 12,
|
||||
),
|
||||
contentPadding: const EdgeInsets.only(
|
||||
left: 15,
|
||||
right: 16,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
error: (error, stack) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text('errorLoadingSubscriptions'.tr()),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (publishersAsync.value?.isNotEmpty ?? false)
|
||||
const Divider(height: 1).padding(vertical: 8),
|
||||
|
||||
// Categories Section
|
||||
categoriesAsync.when(
|
||||
data: (subscriptions) {
|
||||
if (subscriptions.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'categoriesAndTags'.tr(),
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).padding(bottom: 8, horizontal: 16),
|
||||
...subscriptions.map((subscription) {
|
||||
final category = subscription.category;
|
||||
final tag = subscription.tag;
|
||||
final slug = category?.slug ?? tag?.slug;
|
||||
final displayTitle =
|
||||
category?.categoryDisplayTitle ??
|
||||
tag?.name ??
|
||||
slug ??
|
||||
'';
|
||||
final isCategorySelected = selectedCategories.value
|
||||
.contains(slug);
|
||||
final isTagSelected = selectedTags.value.contains(slug);
|
||||
|
||||
return CheckboxListTile(
|
||||
controlAffinity: ListTileControlAffinity.trailing,
|
||||
title: Text(displayTitle),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
secondary: category != null
|
||||
? Icon(Symbols.category)
|
||||
: Icon(Symbols.tag),
|
||||
value: category != null
|
||||
? isCategorySelected
|
||||
: isTagSelected,
|
||||
onChanged: (value) {
|
||||
if (value == true) {
|
||||
if (category != null) {
|
||||
selectedCategories.value = [
|
||||
...selectedCategories.value,
|
||||
slug!,
|
||||
];
|
||||
} else if (tag != null) {
|
||||
selectedTags.value = [...selectedTags.value, slug!];
|
||||
}
|
||||
} else {
|
||||
if (category != null) {
|
||||
selectedCategories.value = selectedCategories.value
|
||||
.where((id) => id != slug)
|
||||
.toList();
|
||||
} else if (tag != null) {
|
||||
selectedTags.value = selectedTags.value
|
||||
.where((id) => id != slug)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
updateSelection();
|
||||
},
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (error, stack) => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'post_subscription_filter.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(publishersSubscriptions)
|
||||
final publishersSubscriptionsProvider = PublishersSubscriptionsProvider._();
|
||||
|
||||
final class PublishersSubscriptionsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<SnPublisherSubscription>>,
|
||||
List<SnPublisherSubscription>,
|
||||
FutureOr<List<SnPublisherSubscription>>
|
||||
>
|
||||
with
|
||||
$FutureModifier<List<SnPublisherSubscription>>,
|
||||
$FutureProvider<List<SnPublisherSubscription>> {
|
||||
PublishersSubscriptionsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'publishersSubscriptionsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$publishersSubscriptionsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<SnPublisherSubscription>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<SnPublisherSubscription>> create(Ref ref) {
|
||||
return publishersSubscriptions(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$publishersSubscriptionsHash() =>
|
||||
r'208463c1f879a3ddab4092112e312a0cd27ebc2f';
|
||||
|
||||
@ProviderFor(categoriesSubscriptions)
|
||||
final categoriesSubscriptionsProvider = CategoriesSubscriptionsProvider._();
|
||||
|
||||
final class CategoriesSubscriptionsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<SnCategorySubscription>>,
|
||||
List<SnCategorySubscription>,
|
||||
FutureOr<List<SnCategorySubscription>>
|
||||
>
|
||||
with
|
||||
$FutureModifier<List<SnCategorySubscription>>,
|
||||
$FutureProvider<List<SnCategorySubscription>> {
|
||||
CategoriesSubscriptionsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'categoriesSubscriptionsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$categoriesSubscriptionsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<SnCategorySubscription>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<SnCategorySubscription>> create(Ref ref) {
|
||||
return categoriesSubscriptions(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$categoriesSubscriptionsHash() =>
|
||||
r'14a8f04d258d1a10aae20ca959495926840c9386';
|
||||
144
lib/posts/posts_widgets/post/post_award_history_sheet.dart
Normal file
144
lib/posts/posts_widgets/post/post_award_history_sheet.dart
Normal file
@@ -0,0 +1,144 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pagination/pagination.dart';
|
||||
import 'package:island/posts/posts_models/post.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:island/shared/widgets/pagination_list.dart';
|
||||
|
||||
final postAwardListNotifierProvider = AsyncNotifierProvider.autoDispose.family(
|
||||
PostAwardListNotifier.new,
|
||||
);
|
||||
|
||||
class PostAwardListNotifier extends AsyncNotifier<PaginationState<SnPostAward>>
|
||||
with AsyncPaginationController<SnPostAward> {
|
||||
static const int pageSize = 20;
|
||||
|
||||
final String arg;
|
||||
PostAwardListNotifier(this.arg);
|
||||
|
||||
@override
|
||||
Future<List<SnPostAward>> fetch() async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
|
||||
final queryParams = {'offset': fetchedCount, 'take': pageSize};
|
||||
|
||||
final response = await client.get(
|
||||
'/sphere/posts/$arg/awards',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
|
||||
final List<dynamic> data = response.data;
|
||||
return data.map((json) => SnPostAward.fromJson(json)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
class PostAwardHistorySheet extends HookConsumerWidget {
|
||||
final String postId;
|
||||
|
||||
const PostAwardHistorySheet({super.key, required this.postId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final provider = postAwardListNotifierProvider(postId);
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: 'Award History',
|
||||
child: PaginationList(
|
||||
provider: provider,
|
||||
notifier: provider.notifier,
|
||||
itemBuilder: (context, index, award) {
|
||||
return Column(
|
||||
children: [
|
||||
PostAwardItem(award: award),
|
||||
if (index < (ref.read(provider).value?.items.length ?? 0) - 1)
|
||||
const Divider(height: 1),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PostAwardItem extends StatelessWidget {
|
||||
final SnPostAward award;
|
||||
|
||||
const PostAwardItem({super.key, required this.award});
|
||||
|
||||
String _getAttitudeText(int attitude) {
|
||||
switch (attitude) {
|
||||
case 0:
|
||||
return 'Positive';
|
||||
case 2:
|
||||
return 'Negative';
|
||||
default:
|
||||
return 'Neutral';
|
||||
}
|
||||
}
|
||||
|
||||
Color _getAttitudeColor(int attitude, BuildContext context) {
|
||||
switch (attitude) {
|
||||
case 0:
|
||||
return Colors.green;
|
||||
case 2:
|
||||
return Colors.red;
|
||||
default:
|
||||
return Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: _getAttitudeColor(
|
||||
award.attitude,
|
||||
context,
|
||||
).withOpacity(0.1),
|
||||
child: Icon(
|
||||
award.attitude == 0
|
||||
? Icons.thumb_up
|
||||
: award.attitude == 2
|
||||
? Icons.thumb_down
|
||||
: Icons.thumbs_up_down,
|
||||
color: _getAttitudeColor(award.attitude, context),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
'${award.amount} pts',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_getAttitudeText(award.attitude),
|
||||
style: TextStyle(
|
||||
color: _getAttitudeColor(award.attitude, context),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (award.message != null && award.message!.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(award.message!, style: Theme.of(context).textTheme.bodyMedium),
|
||||
],
|
||||
const SizedBox(height: 2),
|
||||
if (award.createdAt != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
award.createdAt!.toLocal().toString().split('.')[0],
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
isThreeLine: award.message != null && award.message!.isNotEmpty,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
);
|
||||
}
|
||||
}
|
||||
339
lib/posts/posts_widgets/post/post_award_sheet.dart
Normal file
339
lib/posts/posts_widgets/post/post_award_sheet.dart
Normal file
@@ -0,0 +1,339 @@
|
||||
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/posts/posts_models/post.dart';
|
||||
import 'package:island/wallet/wallet_models/wallet.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:island/core/widgets/payment/payment_overlay.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
class PostAwardSheet extends HookConsumerWidget {
|
||||
final SnPost post;
|
||||
const PostAwardSheet({super.key, required this.post});
|
||||
|
||||
Widget _buildProfilePicture(BuildContext context, {double radius = 16}) {
|
||||
// Handle publisher case
|
||||
if (post.publisher != null) {
|
||||
return ProfilePictureWidget(
|
||||
file:
|
||||
post.publisher!.picture ?? post.publisher!.account?.profile.picture,
|
||||
radius: radius,
|
||||
);
|
||||
}
|
||||
// Handle actor case
|
||||
if (post.actor != null) {
|
||||
final avatarUrl = post.actor!.avatarUrl;
|
||||
if (avatarUrl != null) {
|
||||
return Container(
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
child: Image.network(
|
||||
avatarUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Icon(
|
||||
Symbols.account_circle,
|
||||
size: radius,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Fallback
|
||||
return ProfilePictureWidget(file: null, radius: radius);
|
||||
}
|
||||
|
||||
String _getPublisherName() {
|
||||
// Handle publisher case
|
||||
if (post.publisher != null) {
|
||||
return post.publisher!.name;
|
||||
}
|
||||
// Handle actor case
|
||||
if (post.actor != null) {
|
||||
return post.actor!.username ?? 'Unknown';
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final messageController = useTextEditingController();
|
||||
final amountController = useTextEditingController();
|
||||
final selectedAttitude = useState<int>(0); // 0 for positive, 2 for negative
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: 'awardPost'.tr(),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Post Preview Section
|
||||
_buildPostPreview(context),
|
||||
const Gap(20),
|
||||
|
||||
// Award Result Explanation
|
||||
_buildAwardResultExplanation(context),
|
||||
const Gap(20),
|
||||
|
||||
Text(
|
||||
'awardMessage'.tr(),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Gap(8),
|
||||
TextField(
|
||||
controller: messageController,
|
||||
maxLines: 3,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'awardMessageHint'.tr(),
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(16)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
Text(
|
||||
'awardAttitude'.tr(),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Gap(8),
|
||||
SegmentedButton<int>(
|
||||
segments: [
|
||||
ButtonSegment<int>(
|
||||
value: 0,
|
||||
label: Text('awardAttitudePositive'.tr()),
|
||||
icon: const Icon(Symbols.thumb_up),
|
||||
),
|
||||
ButtonSegment<int>(
|
||||
value: 2,
|
||||
label: Text('awardAttitudeNegative'.tr()),
|
||||
icon: const Icon(Symbols.thumb_down),
|
||||
),
|
||||
],
|
||||
selected: {selectedAttitude.value},
|
||||
onSelectionChanged: (Set<int> selection) {
|
||||
selectedAttitude.value = selection.first;
|
||||
},
|
||||
),
|
||||
const Gap(16),
|
||||
Text(
|
||||
'awardAmount'.tr(),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Gap(8),
|
||||
TextField(
|
||||
controller: amountController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'awardAmountHint'.tr(),
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(16)),
|
||||
),
|
||||
suffixText: 'NSP',
|
||||
),
|
||||
),
|
||||
const Gap(24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => _submitAward(
|
||||
context,
|
||||
ref,
|
||||
messageController,
|
||||
amountController,
|
||||
selectedAttitude.value,
|
||||
),
|
||||
icon: const Icon(Symbols.star),
|
||||
label: Text('awardSubmit'.tr()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPostPreview(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.article,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'awardPostPreview'.tr(),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
post.content ?? 'awardNoContent'.tr(),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
...[
|
||||
const Gap(4),
|
||||
Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Text(
|
||||
'awardByPublisher'.tr(args: ['@${_getPublisherName()}']),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
_buildProfilePicture(context, radius: 8),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAwardResultExplanation(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.info,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'awardBenefits'.tr(),
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'awardBenefitsDescription'.tr(),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submitAward(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
TextEditingController messageController,
|
||||
TextEditingController amountController,
|
||||
int selectedAttitude,
|
||||
) async {
|
||||
// Get values from controllers
|
||||
final message = messageController.text.trim();
|
||||
final amountText = amountController.text.trim();
|
||||
|
||||
// Validate inputs
|
||||
if (amountText.isEmpty) {
|
||||
showSnackBar('awardAmountRequired'.tr());
|
||||
return;
|
||||
}
|
||||
|
||||
final amount = double.tryParse(amountText);
|
||||
if (amount == null || amount <= 0) {
|
||||
showSnackBar('awardAmountInvalid'.tr());
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.length > 4096) {
|
||||
showSnackBar('awardMessageTooLong'.tr());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showLoadingModal(context);
|
||||
|
||||
final client = ref.read(apiClientProvider);
|
||||
|
||||
// Send award request
|
||||
final awardResponse = await client.post(
|
||||
'/sphere/posts/${post.id}/awards',
|
||||
data: {
|
||||
'amount': amount,
|
||||
'attitude': selectedAttitude,
|
||||
if (message.isNotEmpty) 'message': message,
|
||||
},
|
||||
);
|
||||
|
||||
final orderId = awardResponse.data['order_id'] as String;
|
||||
|
||||
// Fetch order details
|
||||
final orderResponse = await client.get('/wallet/orders/$orderId');
|
||||
final order = SnWalletOrder.fromJson(orderResponse.data);
|
||||
|
||||
if (context.mounted) {
|
||||
hideLoadingModal(context);
|
||||
|
||||
// Show payment overlay
|
||||
final paidOrder = await PaymentOverlay.show(
|
||||
context: context,
|
||||
order: order,
|
||||
enableBiometric: true,
|
||||
);
|
||||
|
||||
if (paidOrder != null && context.mounted) {
|
||||
showSnackBar('awardSuccess'.tr());
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (context.mounted) {
|
||||
hideLoadingModal(context);
|
||||
showErrorAlert(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
206
lib/posts/posts_widgets/post/post_featured.dart
Normal file
206
lib/posts/posts_widgets/post/post_featured.dart
Normal file
@@ -0,0 +1,206 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/posts/posts_models/post.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/posts/posts_widgets/post/post_item.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:island/core/config.dart'; // Import config.dart for shared preferences keys and provider
|
||||
|
||||
part 'post_featured.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<List<SnPost>> featuredPosts(Ref ref) async {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
final resp = await apiClient.get('/sphere/posts/featured');
|
||||
return resp.data.map((e) => SnPost.fromJson(e)).cast<SnPost>().toList();
|
||||
}
|
||||
|
||||
class PostFeaturedList extends HookConsumerWidget {
|
||||
final bool collapsable;
|
||||
final double? maxHeight;
|
||||
const PostFeaturedList({super.key, this.collapsable = true, this.maxHeight});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final featuredPostsAsync = ref.watch(featuredPostsProvider);
|
||||
|
||||
final pageViewController = usePageController();
|
||||
final prefs = ref.watch(sharedPreferencesProvider);
|
||||
final pageViewCurrent = useState(0);
|
||||
final previousFirstPostId = useState<String?>(null);
|
||||
final storedCollapsedId = useState<String?>(
|
||||
prefs.getString(kFeaturedPostsCollapsedId),
|
||||
);
|
||||
final isCollapsed = useState(false);
|
||||
|
||||
useEffect(() {
|
||||
pageViewController.addListener(() {
|
||||
pageViewCurrent.value = pageViewController.page?.round() ?? 0;
|
||||
});
|
||||
return null;
|
||||
}, [pageViewController]);
|
||||
|
||||
// Log isCollapsed state changes
|
||||
useEffect(() {
|
||||
talker.info('isCollapsed changed to ${isCollapsed.value}');
|
||||
return null;
|
||||
}, [isCollapsed]);
|
||||
|
||||
useEffect(() {
|
||||
if (featuredPostsAsync.hasValue && featuredPostsAsync.value!.isNotEmpty) {
|
||||
final currentFirstPostId = featuredPostsAsync.value!.first.id;
|
||||
talker.info('Current first post ID: $currentFirstPostId');
|
||||
talker.info('Previous first post ID: ${previousFirstPostId.value}');
|
||||
talker.info('Stored collapsed ID: ${storedCollapsedId.value}');
|
||||
|
||||
if (previousFirstPostId.value == null) {
|
||||
// Initial load
|
||||
previousFirstPostId.value = currentFirstPostId;
|
||||
isCollapsed.value = (storedCollapsedId.value == currentFirstPostId);
|
||||
talker.info('Initial load. isCollapsed set to ${isCollapsed.value}');
|
||||
} else if (previousFirstPostId.value != currentFirstPostId) {
|
||||
// First post changed, expand by default
|
||||
previousFirstPostId.value = currentFirstPostId;
|
||||
isCollapsed.value = false;
|
||||
prefs.remove(
|
||||
kFeaturedPostsCollapsedId,
|
||||
); // Clear stored ID if post changes
|
||||
talker.info('First post changed. isCollapsed set to false.');
|
||||
} else {
|
||||
// Same first post, maintain current collapse state
|
||||
// No change needed for isCollapsed.value unless manually toggled
|
||||
talker.info('Same first post. Maintaining current collapse state.');
|
||||
}
|
||||
} else {
|
||||
talker.info('featuredPostsAsync has no value or is empty.');
|
||||
}
|
||||
return null;
|
||||
}, [featuredPostsAsync]);
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: Card(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.highlight,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'highlightPost'.tr(),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () {
|
||||
pageViewController.animateToPage(
|
||||
pageViewCurrent.value - 1,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Symbols.arrow_left),
|
||||
),
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () {
|
||||
pageViewController.animateToPage(
|
||||
pageViewCurrent.value + 1,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Symbols.arrow_right),
|
||||
),
|
||||
if (collapsable)
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () {
|
||||
isCollapsed.value = !isCollapsed.value;
|
||||
talker.info(
|
||||
'Manual toggle. isCollapsed set to ${isCollapsed.value}',
|
||||
);
|
||||
if (isCollapsed.value &&
|
||||
featuredPostsAsync.hasValue &&
|
||||
featuredPostsAsync.value!.isNotEmpty) {
|
||||
prefs.setString(
|
||||
kFeaturedPostsCollapsedId,
|
||||
featuredPostsAsync.value!.first.id,
|
||||
);
|
||||
talker.info(
|
||||
'Stored collapsed ID: ${featuredPostsAsync.value!.first.id}',
|
||||
);
|
||||
} else {
|
||||
prefs.remove(kFeaturedPostsCollapsedId);
|
||||
talker.info('Removed stored collapsed ID.');
|
||||
}
|
||||
},
|
||||
icon: Icon(
|
||||
isCollapsed.value
|
||||
? Symbols.expand_more
|
||||
: Symbols.expand_less,
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 8),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
child: Visibility(
|
||||
visible: collapsable ? !isCollapsed.value : true,
|
||||
child: featuredPostsAsync.when(
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => Center(child: Text('Error: $error')),
|
||||
data: (posts) {
|
||||
return SizedBox(
|
||||
height: maxHeight == null ? 344 : (maxHeight! - 48),
|
||||
child: PageView.builder(
|
||||
controller: pageViewController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: posts.length,
|
||||
itemBuilder: (context, index) {
|
||||
return SingleChildScrollView(
|
||||
child: PostActionableItem(
|
||||
item: posts[index],
|
||||
borderRadius: 8,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
49
lib/posts/posts_widgets/post/post_featured.g.dart
Normal file
49
lib/posts/posts_widgets/post/post_featured.g.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'post_featured.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(featuredPosts)
|
||||
final featuredPostsProvider = FeaturedPostsProvider._();
|
||||
|
||||
final class FeaturedPostsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<SnPost>>,
|
||||
List<SnPost>,
|
||||
FutureOr<List<SnPost>>
|
||||
>
|
||||
with $FutureModifier<List<SnPost>>, $FutureProvider<List<SnPost>> {
|
||||
FeaturedPostsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'featuredPostsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$featuredPostsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<SnPost>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<SnPost>> create(Ref ref) {
|
||||
return featuredPosts(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$featuredPostsHash() => r'4b7fffb02eac72f5861b02af1b1e5da36b571698';
|
||||
710
lib/posts/posts_widgets/post/post_item.dart
Normal file
710
lib/posts/posts_widgets/post/post_item.dart
Normal file
@@ -0,0 +1,710 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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/posts/posts_models/post.dart';
|
||||
import 'package:island/core/config.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/core/translate.dart';
|
||||
import 'package:island/accounts/accounts_pod.dart';
|
||||
import 'package:island/posts/compose.dart';
|
||||
import 'package:island/core/utils/share_utils.dart';
|
||||
import 'package:island/posts/posts_widgets/post/embed_view_renderer.dart';
|
||||
import 'package:island/posts/posts_widgets/post/post_award_sheet.dart';
|
||||
import 'package:island/posts/posts_widgets/post/post_pin_sheet.dart';
|
||||
import 'package:island/posts/posts_widgets/post/post_reaction_sheet.dart';
|
||||
import 'package:island/posts/posts_widgets/post/post_shared.dart';
|
||||
import 'package:island/reports/reports_widgets/safety/abuse_report_helper.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/core/widgets/content/markdown.dart';
|
||||
import 'package:island/core/widgets/content/image.dart';
|
||||
import 'package:island/core/widgets/share/share_sheet.dart';
|
||||
import 'package:island/posts/posts_widgets/compose_sheet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:super_context_menu/super_context_menu.dart';
|
||||
import 'package:island/core/services/analytics_service.dart';
|
||||
|
||||
const kAvailableStickers = {
|
||||
'angry',
|
||||
'clap',
|
||||
'confuse',
|
||||
'pray',
|
||||
'thumb_up',
|
||||
'party',
|
||||
};
|
||||
|
||||
bool _getReactionImageAvailable(String symbol) {
|
||||
return kAvailableStickers.contains(symbol);
|
||||
}
|
||||
|
||||
Widget _buildReactionIcon(String symbol, double size, {double iconSize = 24}) {
|
||||
if (_getReactionImageAvailable(symbol)) {
|
||||
return Image.asset(
|
||||
'assets/images/stickers/$symbol.png',
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.bottomCenter,
|
||||
);
|
||||
} else {
|
||||
return Text(
|
||||
kReactionTemplates[symbol]?.icon ?? '',
|
||||
style: TextStyle(fontSize: iconSize),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PostActionableItem extends HookConsumerWidget {
|
||||
final SnPost item;
|
||||
final EdgeInsets? padding;
|
||||
final bool isFullPost;
|
||||
final bool isShowReference;
|
||||
final bool isEmbedReply;
|
||||
final bool isEmbedOpenable;
|
||||
final bool isCompact;
|
||||
final bool hideAttachments;
|
||||
final double? borderRadius;
|
||||
final VoidCallback? onRefresh;
|
||||
final Function(SnPost)? onUpdate;
|
||||
final VoidCallback? onOpen;
|
||||
const PostActionableItem({
|
||||
super.key,
|
||||
required this.item,
|
||||
this.padding,
|
||||
this.isFullPost = false,
|
||||
this.isShowReference = true,
|
||||
this.isEmbedReply = true,
|
||||
this.isEmbedOpenable = false,
|
||||
this.isCompact = false,
|
||||
this.hideAttachments = false,
|
||||
this.borderRadius,
|
||||
this.onRefresh,
|
||||
this.onUpdate,
|
||||
this.onOpen,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final user = ref.watch(userInfoProvider);
|
||||
final isAuthor = useMemoized(
|
||||
() => user.value != null && item.publisher?.accountId == user.value?.id,
|
||||
[user],
|
||||
);
|
||||
|
||||
final config = ref.watch(appSettingsProvider);
|
||||
|
||||
final widgetItem = InkWell(
|
||||
borderRadius: borderRadius != null
|
||||
? BorderRadius.all(Radius.circular(borderRadius!))
|
||||
: null,
|
||||
child: PostItem(
|
||||
key: key,
|
||||
item: item,
|
||||
padding: padding,
|
||||
isFullPost: isFullPost,
|
||||
isShowReference: isShowReference,
|
||||
isEmbedReply: isEmbedReply,
|
||||
isEmbedOpenable: isEmbedOpenable,
|
||||
isTextSelectable: false,
|
||||
isCompact: isCompact,
|
||||
hideAttachments: hideAttachments,
|
||||
onRefresh: onRefresh,
|
||||
onUpdate: onUpdate,
|
||||
onOpen: onOpen,
|
||||
),
|
||||
onTap: () {
|
||||
onOpen?.call();
|
||||
context.pushNamed('postDetail', pathParameters: {'id': item.id});
|
||||
},
|
||||
);
|
||||
|
||||
return ContextMenuWidget(
|
||||
menuProvider: (_) {
|
||||
return Menu(
|
||||
children: [
|
||||
if (isAuthor)
|
||||
MenuAction(
|
||||
title: 'edit'.tr(),
|
||||
image: MenuImage.icon(Symbols.edit),
|
||||
callback: () async {
|
||||
final result = await PostComposeSheet.show(
|
||||
context,
|
||||
originalPost: item,
|
||||
);
|
||||
if (result != null) {
|
||||
onRefresh?.call();
|
||||
}
|
||||
},
|
||||
),
|
||||
if (isAuthor)
|
||||
MenuAction(
|
||||
title: 'delete'.tr(),
|
||||
image: MenuImage.icon(Symbols.delete),
|
||||
callback: () {
|
||||
showConfirmAlert(
|
||||
'deletePostHint'.tr(),
|
||||
'deletePost'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
client
|
||||
.delete('/sphere/posts/${item.id}')
|
||||
.catchError((err) {
|
||||
showErrorAlert(err);
|
||||
return err;
|
||||
})
|
||||
.then((_) {
|
||||
onRefresh?.call();
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
if (isAuthor) MenuSeparator(),
|
||||
MenuAction(
|
||||
title: 'copyLink'.tr(),
|
||||
image: MenuImage.icon(Symbols.link),
|
||||
callback: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: 'https://solian.app/posts/${item.id}'),
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuAction(
|
||||
title: 'reply'.tr(),
|
||||
image: MenuImage.icon(Symbols.reply),
|
||||
callback: () async {
|
||||
final result = await PostComposeSheet.show(
|
||||
context,
|
||||
initialState: PostComposeInitialState(replyingTo: item),
|
||||
);
|
||||
if (result != null) {
|
||||
onRefresh?.call();
|
||||
}
|
||||
},
|
||||
),
|
||||
MenuAction(
|
||||
title: 'forward'.tr(),
|
||||
image: MenuImage.icon(Symbols.forward),
|
||||
callback: () async {
|
||||
final result = await PostComposeSheet.show(
|
||||
context,
|
||||
initialState: PostComposeInitialState(forwardingTo: item),
|
||||
);
|
||||
if (result != null) {
|
||||
onRefresh?.call();
|
||||
}
|
||||
},
|
||||
),
|
||||
if (isAuthor && item.pinMode == null)
|
||||
MenuAction(
|
||||
title: 'pinPost'.tr(),
|
||||
image: MenuImage.icon(Symbols.keep),
|
||||
callback: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => PostPinSheet(post: item),
|
||||
).then((value) {
|
||||
if (value is int) {
|
||||
onUpdate?.call(item.copyWith(pinMode: value));
|
||||
}
|
||||
});
|
||||
},
|
||||
)
|
||||
else if (isAuthor && item.pinMode != null)
|
||||
MenuAction(
|
||||
title: 'unpinPost'.tr(),
|
||||
image: MenuImage.icon(Symbols.keep_off),
|
||||
callback: () {
|
||||
showConfirmAlert('unpinPostHint'.tr(), 'unpinPost'.tr()).then(
|
||||
(confirm) async {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
try {
|
||||
if (context.mounted) showLoadingModal(context);
|
||||
await client.delete('/sphere/posts/${item.id}/pin');
|
||||
onUpdate?.call(item.copyWith(pinMode: null));
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuAction(
|
||||
title: 'award'.tr(),
|
||||
image: MenuImage.icon(Symbols.star),
|
||||
callback: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder: (context) => PostAwardSheet(post: item),
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuSeparator(),
|
||||
MenuAction(
|
||||
title: 'share'.tr(),
|
||||
image: MenuImage.icon(Symbols.share),
|
||||
callback: () {
|
||||
showShareSheetLink(
|
||||
context: context,
|
||||
link: 'https://solian.app/posts/${item.id}',
|
||||
title: 'sharePost'.tr(),
|
||||
toSystem: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!kIsWeb)
|
||||
MenuAction(
|
||||
title: 'sharePostPhoto'.tr(),
|
||||
image: MenuImage.icon(Symbols.share_reviews),
|
||||
callback: () {
|
||||
sharePostAsScreenshot(context, ref, item);
|
||||
},
|
||||
),
|
||||
MenuSeparator(),
|
||||
MenuAction(
|
||||
title: 'abuseReport'.tr(),
|
||||
image: MenuImage.icon(Symbols.flag),
|
||||
callback: () {
|
||||
showAbuseReportSheet(
|
||||
context,
|
||||
resourceIdentifier: 'post:${item.id}',
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: Material(
|
||||
color: config.cardTransparency < 1
|
||||
? Colors.transparent
|
||||
: Theme.of(context).cardTheme.color,
|
||||
borderRadius: borderRadius != null
|
||||
? BorderRadius.all(Radius.circular(borderRadius!))
|
||||
: null,
|
||||
child: widgetItem,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PostItem extends HookConsumerWidget {
|
||||
final SnPost item;
|
||||
final EdgeInsets? padding;
|
||||
final bool isFullPost;
|
||||
final bool isShowReference;
|
||||
final bool isEmbedReply;
|
||||
final bool isEmbedOpenable;
|
||||
final bool isTextSelectable;
|
||||
final bool isTranslatable;
|
||||
final bool isCompact;
|
||||
final bool hideAttachments;
|
||||
final double? textScale;
|
||||
final VoidCallback? onRefresh;
|
||||
final Function(SnPost)? onUpdate;
|
||||
final VoidCallback? onOpen;
|
||||
const PostItem({
|
||||
super.key,
|
||||
required this.item,
|
||||
this.padding,
|
||||
this.isFullPost = false,
|
||||
this.isShowReference = true,
|
||||
this.isEmbedReply = true,
|
||||
this.isEmbedOpenable = false,
|
||||
this.isTextSelectable = true,
|
||||
this.isTranslatable = true,
|
||||
this.isCompact = false,
|
||||
this.hideAttachments = false,
|
||||
this.textScale,
|
||||
this.onRefresh,
|
||||
this.onUpdate,
|
||||
this.onOpen,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final renderingPadding =
|
||||
padding ?? const EdgeInsets.symmetric(horizontal: 8, vertical: 8);
|
||||
|
||||
final reacting = useState(false);
|
||||
|
||||
Future<void> reactPost(String symbol, int attitude) async {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
reacting.value = true;
|
||||
await client
|
||||
.post(
|
||||
'/sphere/posts/${item.id}/reactions',
|
||||
data: {'symbol': symbol, 'attitude': attitude},
|
||||
)
|
||||
.catchError((err) {
|
||||
showErrorAlert(err);
|
||||
return err;
|
||||
})
|
||||
.then((resp) {
|
||||
final isRemoving = resp.statusCode == 204;
|
||||
final delta = isRemoving ? -1 : 1;
|
||||
final reactionsCount = Map<String, int>.from(item.reactionsCount);
|
||||
reactionsCount[symbol] = (reactionsCount[symbol] ?? 0) + delta;
|
||||
final reactionsMade = Map<String, bool>.from(item.reactionsMade);
|
||||
reactionsMade[symbol] = delta == 1 ? true : false;
|
||||
onUpdate?.call(
|
||||
item.copyWith(
|
||||
reactionsCount: reactionsCount,
|
||||
reactionsMade: reactionsMade,
|
||||
),
|
||||
);
|
||||
HapticFeedback.heavyImpact();
|
||||
|
||||
AnalyticsService().logPostReacted(
|
||||
item.id,
|
||||
symbol,
|
||||
attitude,
|
||||
isRemoving,
|
||||
);
|
||||
});
|
||||
|
||||
reacting.value = false;
|
||||
}
|
||||
|
||||
final mostReaction = item.reactionsCount.isEmpty
|
||||
? null
|
||||
: item.reactionsCount.entries
|
||||
.sortedBy((e) => e.value)
|
||||
.map((e) => e.key)
|
||||
.last;
|
||||
|
||||
final postLanguage = item.content != null && isTranslatable
|
||||
? ref.watch(detectStringLanguageProvider(item.content!))
|
||||
: null;
|
||||
|
||||
final currentLanguage = isTranslatable ? context.locale.toString() : null;
|
||||
final translatableLanguage = postLanguage != null && isTranslatable
|
||||
? postLanguage.substring(0, 2) != currentLanguage!.substring(0, 2)
|
||||
: false;
|
||||
|
||||
final translating = useState(false);
|
||||
final translatedText = useState<String?>(null);
|
||||
|
||||
Future<void> translate() async {
|
||||
if (!isTranslatable) return;
|
||||
if (translatedText.value != null) {
|
||||
translatedText.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (translating.value) return;
|
||||
if (item.content == null) return;
|
||||
translating.value = true;
|
||||
try {
|
||||
final text = await ref.watch(
|
||||
translateStringProvider(
|
||||
TranslateQuery(
|
||||
text: item.content!,
|
||||
lang: currentLanguage!.substring(0, 2),
|
||||
),
|
||||
).future,
|
||||
);
|
||||
translatedText.value = text;
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
translating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
final translatedWidget = (translatedText.value?.isNotEmpty ?? false)
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(child: Divider()),
|
||||
const Gap(8),
|
||||
const Text('translated').tr().fontSize(11).opacity(0.75),
|
||||
],
|
||||
),
|
||||
MarkdownTextContent(
|
||||
textStyle: TextStyle(
|
||||
fontSize:
|
||||
Theme.of(context).textTheme.bodyMedium!.fontSize! *
|
||||
(textScale ?? 1),
|
||||
),
|
||||
content: translatedText.value!,
|
||||
isSelectable: isTextSelectable,
|
||||
attachments: item.attachments,
|
||||
noMentionChip: item.fediverseUri != null,
|
||||
),
|
||||
],
|
||||
)
|
||||
: null;
|
||||
|
||||
final translatableWidget = (isTranslatable && translatableLanguage)
|
||||
? Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: translating.value ? null : translate,
|
||||
style: ButtonStyle(
|
||||
padding: const WidgetStatePropertyAll(
|
||||
EdgeInsets.symmetric(horizontal: 2),
|
||||
),
|
||||
visualDensity: const VisualDensity(horizontal: 0, vertical: -4),
|
||||
foregroundColor: WidgetStatePropertyAll(
|
||||
translatedText.value == null ? null : Colors.grey,
|
||||
),
|
||||
),
|
||||
icon: const Icon(Symbols.translate),
|
||||
label: translatedText.value != null
|
||||
? const Text('translated').tr()
|
||||
: translating.value
|
||||
? const Text('translating').tr()
|
||||
: const Text('translate').tr(),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
final translationSection = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [?translatedWidget, ?translatableWidget],
|
||||
);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Gap(renderingPadding.vertical),
|
||||
PostHeader(
|
||||
item: item,
|
||||
isFullPost: isFullPost,
|
||||
isCompact: isCompact,
|
||||
renderingPadding: renderingPadding,
|
||||
trailing: isCompact
|
||||
? null
|
||||
: SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
icon: mostReaction == null
|
||||
? const Icon(Symbols.add_reaction)
|
||||
: Badge(
|
||||
label: Center(
|
||||
child: Text(
|
||||
'x${item.reactionsCount[mostReaction]}',
|
||||
style: const TextStyle(fontSize: 11),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
offset: const Offset(4, 20),
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withOpacity(0.75),
|
||||
textColor: Theme.of(context).colorScheme.onPrimary,
|
||||
child: mostReaction.contains('+')
|
||||
? HookConsumer(
|
||||
builder: (context, ref, child) {
|
||||
final baseUrl = ref.watch(
|
||||
serverUrlProvider,
|
||||
);
|
||||
final stickerUri =
|
||||
'$baseUrl/sphere/stickers/lookup/$mostReaction/open';
|
||||
return SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: UniversalImage(
|
||||
uri: stickerUri,
|
||||
width: 28,
|
||||
height: 28,
|
||||
fit: BoxFit.contain,
|
||||
).center(),
|
||||
);
|
||||
},
|
||||
)
|
||||
: _buildReactionIcon(mostReaction, 32).padding(
|
||||
bottom:
|
||||
_getReactionImageAvailable(mostReaction)
|
||||
? 2
|
||||
: 0,
|
||||
),
|
||||
),
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
(item.reactionsMade[mostReaction] ?? false)
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withOpacity(0.5)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
builder: (BuildContext context) {
|
||||
return PostReactionSheet(
|
||||
reactionsCount: item.reactionsCount,
|
||||
reactionsMade: item.reactionsMade,
|
||||
onReact: (symbol, attitude) {
|
||||
reactPost(symbol, attitude);
|
||||
},
|
||||
postId: item.id,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -3,
|
||||
vertical: -3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
PostBody(
|
||||
item: item,
|
||||
textScale: textScale,
|
||||
isFullPost: isFullPost,
|
||||
isTextSelectable: isTextSelectable,
|
||||
translationSection: translationSection,
|
||||
renderingPadding: renderingPadding,
|
||||
hideAttachments: hideAttachments,
|
||||
),
|
||||
if (item.embedView != null)
|
||||
EmbedViewRenderer(
|
||||
embedView: item.embedView!,
|
||||
maxHeight: 400,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
).padding(horizontal: renderingPadding.horizontal, vertical: 8),
|
||||
if (isShowReference)
|
||||
ReferencedPostWidget(item: item, renderingPadding: renderingPadding),
|
||||
if (item.repliesCount > 0 && isEmbedReply)
|
||||
PostReplyPreview(
|
||||
parent: item,
|
||||
isOpenable: isEmbedOpenable,
|
||||
onOpen: onOpen,
|
||||
).padding(horizontal: renderingPadding.horizontal, top: 8),
|
||||
Gap(renderingPadding.vertical),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PostReactionList extends HookConsumerWidget {
|
||||
final String parentId;
|
||||
final Map<String, int> reactions;
|
||||
final Map<String, bool> reactionsMade;
|
||||
final Function(String symbol, int attitude, int delta)? onReact;
|
||||
final EdgeInsets? padding;
|
||||
const PostReactionList({
|
||||
super.key,
|
||||
required this.parentId,
|
||||
required this.reactions,
|
||||
required this.reactionsMade,
|
||||
this.padding,
|
||||
this.onReact,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final submitting = useState(false);
|
||||
|
||||
Future<void> reactPost(String symbol, int attitude) async {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
submitting.value = true;
|
||||
await client
|
||||
.post(
|
||||
'/sphere/posts/$parentId/reactions',
|
||||
data: {'symbol': symbol, 'attitude': attitude},
|
||||
)
|
||||
.catchError((err) {
|
||||
showErrorAlert(err);
|
||||
return err;
|
||||
})
|
||||
.then((resp) {
|
||||
var isRemoving = resp.statusCode == 204;
|
||||
onReact?.call(symbol, attitude, isRemoving ? -1 : 1);
|
||||
HapticFeedback.heavyImpact();
|
||||
});
|
||||
submitting.value = false;
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: 40,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: padding ?? EdgeInsets.zero,
|
||||
children: [
|
||||
if (onReact != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ActionChip(
|
||||
avatar: const Icon(Symbols.add_reaction),
|
||||
label: const Text('react').tr(),
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: VisualDensity.minimumDensity,
|
||||
vertical: VisualDensity.minimumDensity,
|
||||
),
|
||||
onPressed: submitting.value
|
||||
? null
|
||||
: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (BuildContext context) {
|
||||
return PostReactionSheet(
|
||||
reactionsCount: reactions,
|
||||
reactionsMade: reactionsMade,
|
||||
onReact: (symbol, attitude) {
|
||||
reactPost(symbol, attitude);
|
||||
},
|
||||
postId: parentId,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
for (final symbol in reactions.keys)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ActionChip(
|
||||
avatar: _buildReactionIcon(symbol, 24),
|
||||
label: Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
Text(symbol),
|
||||
Text('x${reactions[symbol]}').bold(),
|
||||
],
|
||||
),
|
||||
onPressed: submitting.value
|
||||
? null
|
||||
: () {
|
||||
reactPost(
|
||||
symbol,
|
||||
kReactionTemplates[symbol]?.attitude ?? 0,
|
||||
);
|
||||
},
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: VisualDensity.minimumDensity,
|
||||
vertical: VisualDensity.minimumDensity,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
321
lib/posts/posts_widgets/post/post_item_creator.dart
Normal file
321
lib/posts/posts_widgets/post/post_item_creator.dart
Normal file
@@ -0,0 +1,321 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/posts/posts_models/post.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/core/services/time.dart';
|
||||
import 'package:island/posts/posts_widgets/post/post_item.dart';
|
||||
import 'package:island/posts/posts_widgets/post/post_shared.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/posts/posts_widgets/compose_sheet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:super_context_menu/super_context_menu.dart';
|
||||
|
||||
class PostItemCreator extends HookConsumerWidget {
|
||||
final Color? backgroundColor;
|
||||
final SnPost item;
|
||||
final EdgeInsets? padding;
|
||||
final bool isOpenable;
|
||||
final Function? onRefresh;
|
||||
final Function(SnPost)? onUpdate;
|
||||
|
||||
const PostItemCreator({
|
||||
super.key,
|
||||
required this.item,
|
||||
this.backgroundColor,
|
||||
this.padding,
|
||||
this.isOpenable = true,
|
||||
this.onRefresh,
|
||||
this.onUpdate,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final renderingPadding =
|
||||
padding ?? const EdgeInsets.symmetric(horizontal: 8, vertical: 8);
|
||||
|
||||
return ContextMenuWidget(
|
||||
menuProvider: (_) {
|
||||
return Menu(
|
||||
children: [
|
||||
MenuAction(
|
||||
title: 'edit'.tr(),
|
||||
image: MenuImage.icon(Symbols.edit),
|
||||
callback: () {
|
||||
if (item.type == 1) {
|
||||
context
|
||||
.pushNamed('articleEdit', pathParameters: {'id': item.id})
|
||||
.then((value) {
|
||||
if (value != null) {
|
||||
onRefresh?.call();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
PostComposeSheet.show(context, originalPost: item).then((
|
||||
value,
|
||||
) {
|
||||
if (value == true) {
|
||||
onRefresh?.call();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
MenuAction(
|
||||
title: 'delete'.tr(),
|
||||
image: MenuImage.icon(Symbols.delete),
|
||||
callback: () {
|
||||
showConfirmAlert(
|
||||
'deletePostHint'.tr(),
|
||||
'deletePost'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
client
|
||||
.delete('/sphere/posts/${item.id}')
|
||||
.catchError((err) {
|
||||
showErrorAlert(err);
|
||||
return err;
|
||||
})
|
||||
.then((_) {
|
||||
onRefresh?.call();
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
MenuSeparator(),
|
||||
MenuAction(
|
||||
title: 'copyLink'.tr(),
|
||||
image: MenuImage.icon(Symbols.link),
|
||||
callback: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: 'https://solian.app/posts/${item.id}'),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: Material(
|
||||
color: backgroundColor ?? Theme.of(context).colorScheme.surface,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () {
|
||||
if (isOpenable) {
|
||||
context.pushNamed('postDetail', pathParameters: {'id': item.id});
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Gap(renderingPadding.vertical),
|
||||
PostHeader(item: item, renderingPadding: renderingPadding),
|
||||
PostBody(item: item, renderingPadding: renderingPadding),
|
||||
ReferencedPostWidget(
|
||||
item: item,
|
||||
renderingPadding: renderingPadding,
|
||||
),
|
||||
const Gap(16),
|
||||
_buildAnalyticsSection(
|
||||
context,
|
||||
).padding(horizontal: renderingPadding.horizontal),
|
||||
Gap(renderingPadding.vertical),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAnalyticsSection(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Analytics', style: Theme.of(context).textTheme.titleSmall),
|
||||
const Gap(8),
|
||||
Card(
|
||||
elevation: 1,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildMetricItem(
|
||||
context,
|
||||
Symbols.visibility,
|
||||
'Views',
|
||||
'${item.viewsUnique} / ${item.viewsTotal}',
|
||||
'Unique / Total',
|
||||
),
|
||||
_buildMetricItem(
|
||||
context,
|
||||
Symbols.thumb_up,
|
||||
'Upvotes',
|
||||
'${item.upvotes}',
|
||||
null,
|
||||
),
|
||||
_buildMetricItem(
|
||||
context,
|
||||
Symbols.thumb_down,
|
||||
'Downvotes',
|
||||
'${item.downvotes}',
|
||||
null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
if (item.reactionsCount.isNotEmpty) _buildReactionsSection(context),
|
||||
if (item.meta != null && item.meta!.isNotEmpty)
|
||||
_buildMetadataSection(context),
|
||||
const Gap(16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Created: ${item.createdAt?.formatSystem() ?? ''}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
if (item.editedAt != null)
|
||||
Text(
|
||||
'Edited: ${item.editedAt!.formatSystem()}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetricItem(
|
||||
BuildContext context,
|
||||
IconData icon,
|
||||
String label,
|
||||
String value,
|
||||
String? subtitle,
|
||||
) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
|
||||
const Gap(4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (subtitle != null)
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReactionsSection(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'reactions'.plural(
|
||||
item.reactionsCount.isNotEmpty
|
||||
? item.reactionsCount.values.reduce((a, b) => a + b)
|
||||
: 0,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
PostReactionList(
|
||||
parentId: item.id,
|
||||
reactions: item.reactionsCount,
|
||||
reactionsMade: item.reactionsMade,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
const Gap(16),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetadataSection(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Gap(16),
|
||||
Text('Metadata', style: Theme.of(context).textTheme.titleSmall),
|
||||
const Gap(8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceVariant.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final entry in item.meta!.entries)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${entry.key}: ',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${entry.value}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
282
lib/posts/posts_widgets/post/post_item_screenshot.dart
Normal file
282
lib/posts/posts_widgets/post/post_item_screenshot.dart
Normal file
@@ -0,0 +1,282 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/posts/posts_models/post.dart';
|
||||
import 'package:island/core/config.dart';
|
||||
import 'package:island/core/widgets/content/image.dart';
|
||||
import 'package:island/core/widgets/content/markdown.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:island/posts/posts_widgets/post/post_shared.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
const kAvailableStickers = {
|
||||
'angry',
|
||||
'clap',
|
||||
'confuse',
|
||||
'pray',
|
||||
'thumb_up',
|
||||
'party',
|
||||
};
|
||||
|
||||
bool _getReactionImageAvailable(String symbol) {
|
||||
return kAvailableStickers.contains(symbol);
|
||||
}
|
||||
|
||||
Widget _buildReactionIcon(String symbol, double size, {double iconSize = 24}) {
|
||||
if (_getReactionImageAvailable(symbol)) {
|
||||
return Image.asset(
|
||||
'assets/images/stickers/$symbol.png',
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.bottomCenter,
|
||||
);
|
||||
} else {
|
||||
return Text(
|
||||
kReactionTemplates[symbol]?.icon ?? '',
|
||||
style: TextStyle(fontSize: iconSize),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PostItemScreenshot extends ConsumerWidget {
|
||||
final SnPost item;
|
||||
final EdgeInsets? padding;
|
||||
final bool isFullPost;
|
||||
final bool isShowReference;
|
||||
const PostItemScreenshot({
|
||||
super.key,
|
||||
required this.item,
|
||||
this.padding,
|
||||
this.isFullPost = false,
|
||||
this.isShowReference = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final renderingPadding =
|
||||
padding ?? const EdgeInsets.symmetric(horizontal: 8, vertical: 8);
|
||||
|
||||
final mostReaction = item.reactionsCount.isEmpty
|
||||
? null
|
||||
: item.reactionsCount.entries
|
||||
.sortedBy((e) => e.value)
|
||||
.map((e) => e.key)
|
||||
.last;
|
||||
|
||||
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
||||
|
||||
return Material(
|
||||
elevation: 0,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Gap(renderingPadding.vertical),
|
||||
PostHeader(
|
||||
hideOverlay: true,
|
||||
item: item,
|
||||
isFullPost: isFullPost,
|
||||
isInteractive: false,
|
||||
renderingPadding: renderingPadding,
|
||||
isRelativeTime: false,
|
||||
trailing: mostReaction != null
|
||||
? Badge(
|
||||
label: Center(
|
||||
child: Text(
|
||||
'x${item.reactionsCount[mostReaction]}',
|
||||
style: const TextStyle(fontSize: 11),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
offset: const Offset(4, 20),
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withOpacity(0.75),
|
||||
textColor: Theme.of(context).colorScheme.onPrimary,
|
||||
child: mostReaction.contains('+')
|
||||
? Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final baseUrl = ref.watch(serverUrlProvider);
|
||||
final stickerUri =
|
||||
'$baseUrl/sphere/stickers/lookup/$mostReaction/open';
|
||||
return SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: UniversalImage(
|
||||
uri: stickerUri,
|
||||
width: 28,
|
||||
height: 28,
|
||||
fit: BoxFit.contain,
|
||||
).center(),
|
||||
);
|
||||
},
|
||||
)
|
||||
: _buildReactionIcon(mostReaction, 32).padding(
|
||||
bottom: _getReactionImageAvailable(mostReaction)
|
||||
? 2
|
||||
: 0,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
PostBody(
|
||||
item: item,
|
||||
renderingPadding: renderingPadding,
|
||||
isFullPost: isFullPost,
|
||||
isRelativeTime: false,
|
||||
isTextSelectable: false,
|
||||
isInteractive: false,
|
||||
hideOverlay: true,
|
||||
),
|
||||
if (isShowReference)
|
||||
ReferencedPostWidget(
|
||||
item: item,
|
||||
isInteractive: false,
|
||||
renderingPadding: renderingPadding,
|
||||
),
|
||||
if (item.repliesCount > 0)
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final repliesState = ref.watch(repliesProvider(item.id));
|
||||
final posts = repliesState.posts;
|
||||
|
||||
return Container(
|
||||
margin: EdgeInsets.only(
|
||||
left: renderingPadding.horizontal,
|
||||
right: renderingPadding.horizontal,
|
||||
top: 8,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor.withOpacity(0.5),
|
||||
),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 4,
|
||||
children: [
|
||||
Text(
|
||||
'repliesCount',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).plural(item.repliesCount).padding(horizontal: 5),
|
||||
if (posts.isEmpty && repliesState.loading)
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const Gap(8),
|
||||
const Text('loading').tr(),
|
||||
],
|
||||
).padding(horizontal: 5),
|
||||
if (posts.isNotEmpty)
|
||||
...posts.map(
|
||||
(post) => ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
ProfilePictureWidget(
|
||||
file:
|
||||
post.publisher?.picture ??
|
||||
post.publisher?.account?.profile.picture,
|
||||
radius: 12,
|
||||
).padding(top: 4),
|
||||
if (post.content?.isNotEmpty ?? false)
|
||||
Expanded(
|
||||
child: MarkdownTextContent(
|
||||
content: post.content!,
|
||||
attachments: post.attachments,
|
||||
noMentionChip: item.fediverseUri != null,
|
||||
).padding(top: 2),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child:
|
||||
Text(
|
||||
'postHasAttachments',
|
||||
style: const TextStyle(height: 2),
|
||||
)
|
||||
.plural(post.attachments.length)
|
||||
.padding(top: 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
margin: const EdgeInsets.only(top: 8),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: renderingPadding.horizontal,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: Image.asset(
|
||||
'assets/icons/icon${isDark ? '-dark' : ''}.png',
|
||||
width: 40,
|
||||
height: 40,
|
||||
),
|
||||
).padding(vertical: 8, right: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Solar Network',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'sharePostSlogan',
|
||||
style: TextStyle(fontSize: 12),
|
||||
).tr().opacity(0.9),
|
||||
],
|
||||
),
|
||||
),
|
||||
QrImageView(
|
||||
data: 'https://solian.app/posts/${item.id}',
|
||||
version: QrVersions.auto,
|
||||
size: 60,
|
||||
errorCorrectionLevel: QrErrorCorrectLevel.M,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
||||
padding: const EdgeInsets.all(8),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
439
lib/posts/posts_widgets/post/post_item_skeleton.dart
Normal file
439
lib/posts/posts_widgets/post/post_item_skeleton.dart
Normal file
@@ -0,0 +1,439 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class PostItemSkeleton extends StatelessWidget {
|
||||
final EdgeInsets? padding;
|
||||
final bool isFullPost;
|
||||
final bool isShowReference;
|
||||
final bool isEmbedReply;
|
||||
final bool isCompact;
|
||||
final double? borderRadius;
|
||||
final double maxWidth;
|
||||
|
||||
const PostItemSkeleton({
|
||||
super.key,
|
||||
this.padding,
|
||||
this.isFullPost = false,
|
||||
this.isShowReference = false,
|
||||
this.isEmbedReply = false,
|
||||
this.isCompact = false,
|
||||
this.borderRadius,
|
||||
this.maxWidth = 640,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final renderingPadding =
|
||||
padding ?? const EdgeInsets.symmetric(horizontal: 8, vertical: 8);
|
||||
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: Card(
|
||||
margin: EdgeInsets.only(bottom: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Gap(renderingPadding.vertical),
|
||||
_PostHeaderSkeleton(
|
||||
isFullPost: isFullPost,
|
||||
isCompact: isCompact,
|
||||
renderingPadding: renderingPadding,
|
||||
),
|
||||
_PostBodySkeleton(
|
||||
isFullPost: isFullPost,
|
||||
renderingPadding: renderingPadding,
|
||||
),
|
||||
if (isShowReference)
|
||||
_ReferencedPostWidgetSkeleton(
|
||||
renderingPadding: renderingPadding,
|
||||
),
|
||||
if (isEmbedReply)
|
||||
_PostReplyPreviewSkeleton(
|
||||
renderingPadding: renderingPadding,
|
||||
).padding(horizontal: renderingPadding.horizontal, top: 8),
|
||||
Gap(renderingPadding.vertical),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PostHeaderSkeleton extends StatelessWidget {
|
||||
final bool isFullPost;
|
||||
final bool isCompact;
|
||||
final EdgeInsets renderingPadding;
|
||||
|
||||
const _PostHeaderSkeleton({
|
||||
required this.isFullPost,
|
||||
required this.isCompact,
|
||||
required this.renderingPadding,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Skeletonizer(
|
||||
enabled: true,
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
spacing: 12,
|
||||
children: [
|
||||
// Profile picture skeleton
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
spacing: 4,
|
||||
children: [
|
||||
// Name skeleton
|
||||
Container(
|
||||
height: 16,
|
||||
width: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
if (!isCompact)
|
||||
Container(
|
||||
height: 12,
|
||||
width: 80,
|
||||
margin: const EdgeInsets.only(left: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(4),
|
||||
// Timestamp skeleton
|
||||
Container(
|
||||
height: 12,
|
||||
width: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Reaction button skeleton
|
||||
if (!isCompact)
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
).padding(horizontal: renderingPadding.horizontal, bottom: 4),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PostBodySkeleton extends StatelessWidget {
|
||||
final bool isFullPost;
|
||||
final EdgeInsets renderingPadding;
|
||||
|
||||
const _PostBodySkeleton({
|
||||
required this.isFullPost,
|
||||
required this.renderingPadding,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Skeletonizer(
|
||||
enabled: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Title skeleton (if applicable)
|
||||
if (isFullPost)
|
||||
Container(
|
||||
height: 20,
|
||||
width: 200,
|
||||
margin: EdgeInsets.only(
|
||||
left: renderingPadding.horizontal,
|
||||
right: renderingPadding.horizontal,
|
||||
bottom: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
// Content skeleton
|
||||
Container(
|
||||
height: 16,
|
||||
margin: EdgeInsets.only(
|
||||
left: renderingPadding.horizontal,
|
||||
right: renderingPadding.horizontal,
|
||||
bottom: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 16,
|
||||
width: 250,
|
||||
margin: EdgeInsets.only(
|
||||
left: renderingPadding.horizontal,
|
||||
right: renderingPadding.horizontal,
|
||||
bottom: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 16,
|
||||
width: 180,
|
||||
margin: EdgeInsets.only(
|
||||
left: renderingPadding.horizontal,
|
||||
right: renderingPadding.horizontal,
|
||||
bottom: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
// Metadata skeleton
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 12,
|
||||
width: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: renderingPadding.horizontal + 4, top: 4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReferencedPostWidgetSkeleton extends StatelessWidget {
|
||||
final EdgeInsets renderingPadding;
|
||||
|
||||
const _ReferencedPostWidgetSkeleton({required this.renderingPadding});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Skeletonizer(
|
||||
enabled: true,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: renderingPadding.horizontal,
|
||||
vertical: 8,
|
||||
),
|
||||
margin: EdgeInsets.only(
|
||||
top: 8,
|
||||
left: renderingPadding.vertical,
|
||||
right: renderingPadding.vertical,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
height: 12,
|
||||
width: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 14,
|
||||
width: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
height: 12,
|
||||
width: 150,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
height: 12,
|
||||
width: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PostReplyPreviewSkeleton extends StatelessWidget {
|
||||
final EdgeInsets renderingPadding;
|
||||
|
||||
const _PostReplyPreviewSkeleton({required this.renderingPadding});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Skeletonizer(
|
||||
enabled: true,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor.withOpacity(0.5),
|
||||
),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
spacing: 4,
|
||||
children: [
|
||||
Container(
|
||||
height: 14,
|
||||
width: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 12,
|
||||
width: 150,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
height: 10,
|
||||
width: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
108
lib/posts/posts_widgets/post/post_list.dart
Normal file
108
lib/posts/posts_widgets/post/post_list.dart
Normal file
@@ -0,0 +1,108 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/posts/post/post_list.dart';
|
||||
import 'package:island/posts/posts_models/post.dart';
|
||||
import 'package:island/posts/posts_widgets/post/post_item.dart';
|
||||
import 'package:island/posts/posts_widgets/post/post_item_creator.dart';
|
||||
import 'package:island/posts/posts_widgets/post/post_item_skeleton.dart';
|
||||
import 'package:island/shared/widgets/pagination_list.dart';
|
||||
|
||||
/// Defines which post item widget to use in the list
|
||||
enum PostItemType {
|
||||
/// Regular post item with user information
|
||||
regular,
|
||||
|
||||
/// Creator view with analytics and metadata
|
||||
creator,
|
||||
}
|
||||
|
||||
class SliverPostList extends HookConsumerWidget {
|
||||
final PostListQuery? query;
|
||||
final PostItemType itemType;
|
||||
final Color? backgroundColor;
|
||||
final EdgeInsets? padding;
|
||||
final EdgeInsets? itemPadding;
|
||||
final bool isOpenable;
|
||||
final Function? onRefresh;
|
||||
final Function(SnPost)? onUpdate;
|
||||
final double? maxWidth;
|
||||
final String? queryKey;
|
||||
|
||||
const SliverPostList({
|
||||
super.key,
|
||||
this.query,
|
||||
this.itemType = PostItemType.regular,
|
||||
this.backgroundColor,
|
||||
this.padding,
|
||||
this.itemPadding,
|
||||
this.isOpenable = true,
|
||||
this.onRefresh,
|
||||
this.onUpdate,
|
||||
this.maxWidth,
|
||||
this.queryKey,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final provider = postListProvider(
|
||||
PostListQueryConfig(
|
||||
id: queryKey,
|
||||
initialFilter: query ?? PostListQuery(),
|
||||
),
|
||||
);
|
||||
final notifier = ref.watch(provider.notifier);
|
||||
|
||||
final currentFilter = useState(query ?? PostListQuery());
|
||||
|
||||
useEffect(() {
|
||||
if (currentFilter.value != query) {
|
||||
notifier.applyFilter(query ?? PostListQuery());
|
||||
}
|
||||
return null;
|
||||
}, [query, queryKey]);
|
||||
|
||||
return PaginationList(
|
||||
provider: provider,
|
||||
notifier: provider.notifier,
|
||||
isRefreshable: false,
|
||||
isSliver: true,
|
||||
footerSkeletonChild: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: PostItemSkeleton(maxWidth: maxWidth ?? double.infinity),
|
||||
),
|
||||
itemBuilder: (context, index, post) {
|
||||
if (maxWidth != null) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth!),
|
||||
child: _buildPostItem(post, itemPadding),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildPostItem(post, itemPadding);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPostItem(SnPost post, EdgeInsets? padding) {
|
||||
switch (itemType) {
|
||||
case PostItemType.creator:
|
||||
return PostItemCreator(
|
||||
item: post,
|
||||
backgroundColor: backgroundColor,
|
||||
padding: padding,
|
||||
isOpenable: isOpenable,
|
||||
onRefresh: onRefresh,
|
||||
onUpdate: onUpdate,
|
||||
);
|
||||
case PostItemType.regular:
|
||||
return Card(
|
||||
margin:
|
||||
itemPadding ?? EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: PostActionableItem(item: post, borderRadius: 8),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
121
lib/posts/posts_widgets/post/post_pin_sheet.dart
Normal file
121
lib/posts/posts_widgets/post/post_pin_sheet.dart
Normal file
@@ -0,0 +1,121 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/posts/posts_models/post.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class PostPinSheet extends HookConsumerWidget {
|
||||
final SnPost post;
|
||||
const PostPinSheet({super.key, required this.post});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mode = useState(0);
|
||||
|
||||
Future<void> pinPost() async {
|
||||
try {
|
||||
showLoadingModal(context);
|
||||
final client = ref.watch(apiClientProvider);
|
||||
await client.post(
|
||||
'/sphere/posts/${post.id}/pin',
|
||||
data: {'mode': mode.value},
|
||||
);
|
||||
|
||||
if (context.mounted) Navigator.of(context).pop(mode.value);
|
||||
} catch (e) {
|
||||
showErrorAlert(e);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
}
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: 'pinPost'.tr(),
|
||||
heightFactor: 0.6,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Publisher page pin option (always available)
|
||||
ListTile(
|
||||
leading: Radio<int>(
|
||||
value: 0,
|
||||
groupValue: mode.value,
|
||||
onChanged: (value) {
|
||||
mode.value = value!;
|
||||
},
|
||||
),
|
||||
title: Text('publisherPage'.tr()),
|
||||
subtitle: Text('pinPostPublisherHint'.tr()),
|
||||
onTap: () {
|
||||
mode.value = 0;
|
||||
},
|
||||
),
|
||||
|
||||
// Realm page pin option (show always, but disabled when not available)
|
||||
ListTile(
|
||||
leading: Radio<int>(
|
||||
value: 1,
|
||||
groupValue: mode.value,
|
||||
onChanged: post.realmId != null && post.realmId!.isNotEmpty
|
||||
? (value) {
|
||||
mode.value = value!;
|
||||
}
|
||||
: null,
|
||||
),
|
||||
title: Text('realmPage'.tr()),
|
||||
subtitle: post.realmId != null && post.realmId!.isNotEmpty
|
||||
? Text('pinPostRealmHint'.tr())
|
||||
: Text('pinPostRealmDisabledHint'.tr()),
|
||||
onTap: post.realmId != null && post.realmId!.isNotEmpty
|
||||
? () {
|
||||
mode.value = 1;
|
||||
}
|
||||
: null,
|
||||
enabled: post.realmId != null && post.realmId!.isNotEmpty,
|
||||
),
|
||||
|
||||
// Reply page pin option (show always, but disabled when not available)
|
||||
// Disabled for now because im being lazy
|
||||
// ListTile(
|
||||
// leading: Radio<int>(
|
||||
// value: 2,
|
||||
// groupValue: mode.value,
|
||||
// onChanged:
|
||||
// post.repliedPostId != null && post.repliedPostId!.isNotEmpty
|
||||
// ? (value) {
|
||||
// mode.value = value!;
|
||||
// }
|
||||
// : null,
|
||||
// ),
|
||||
// title: Text('replyPage'.tr()),
|
||||
// subtitle:
|
||||
// post.repliedPostId != null && post.repliedPostId!.isNotEmpty
|
||||
// ? Text('pinPostReplyHint'.tr())
|
||||
// : Text('pinPostReplyDisabledHint'.tr()),
|
||||
// onTap:
|
||||
// post.repliedPostId != null && post.repliedPostId!.isNotEmpty
|
||||
// ? () {
|
||||
// mode.value = 2;
|
||||
// }
|
||||
// : null,
|
||||
// enabled:
|
||||
// post.repliedPostId != null && post.repliedPostId!.isNotEmpty,
|
||||
// ),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Pin button
|
||||
FilledButton.icon(
|
||||
onPressed: pinPost,
|
||||
icon: const Icon(Symbols.keep),
|
||||
label: Text('pin'.tr()),
|
||||
).padding(horizontal: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
168
lib/posts/posts_widgets/post/post_quick_reply.dart
Normal file
168
lib/posts/posts_widgets/post/post_quick_reply.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
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/creators/creators/publishers_form.dart';
|
||||
import 'package:island/posts/posts_models/post.dart';
|
||||
import 'package:island/posts/posts_models/publisher.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/posts/compose.dart';
|
||||
import 'package:island/core/services/event_bus.dart';
|
||||
import 'package:island/posts/posts_widgets/post/publishers_modal.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:island/posts/posts_widgets/compose_sheet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class PostQuickReply extends HookConsumerWidget {
|
||||
final SnPost parent;
|
||||
final VoidCallback? onPosted;
|
||||
final VoidCallback? onLaunch;
|
||||
const PostQuickReply({
|
||||
super.key,
|
||||
required this.parent,
|
||||
this.onPosted,
|
||||
this.onLaunch,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final publishers = ref.watch(publishersManagedProvider);
|
||||
|
||||
final currentPublisher = useState<SnPublisher?>(null);
|
||||
|
||||
useEffect(() {
|
||||
if (publishers.value?.isNotEmpty ?? false) {
|
||||
currentPublisher.value = publishers.value!.first;
|
||||
}
|
||||
return null;
|
||||
}, [publishers]);
|
||||
|
||||
final submitting = useState(false);
|
||||
|
||||
final contentController = useTextEditingController();
|
||||
|
||||
Future<void> performAction() async {
|
||||
if (!contentController.text.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
await client.post(
|
||||
'/sphere/posts',
|
||||
data: {
|
||||
'content': contentController.text,
|
||||
'replied_post_id': parent.id,
|
||||
},
|
||||
queryParameters: {'pub': currentPublisher.value?.name},
|
||||
);
|
||||
contentController.clear();
|
||||
onPosted?.call();
|
||||
eventBus.fire(PostCreatedEvent());
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const kInputChipHeight = 54.0;
|
||||
|
||||
return publishers.when(
|
||||
data: (data) => Material(
|
||||
elevation: 2,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
child: Container(
|
||||
constraints: BoxConstraints(minHeight: kInputChipHeight),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
child: ProfilePictureWidget(
|
||||
file: currentPublisher.value?.picture,
|
||||
radius: (kInputChipHeight * 0.5) - 6,
|
||||
),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => PublisherModal(),
|
||||
).then((value) {
|
||||
if (value is SnPublisher) {
|
||||
currentPublisher.value = value;
|
||||
}
|
||||
});
|
||||
},
|
||||
).padding(right: 12),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: contentController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'postReplyPlaceholder'.tr(),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
isCollapsed: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 14,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
style: TextStyle(fontSize: 14),
|
||||
minLines: 1,
|
||||
maxLines: 5,
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
onLaunch?.call();
|
||||
final value = await PostComposeSheet.show(
|
||||
context,
|
||||
initialState: PostComposeInitialState(
|
||||
content: contentController.text,
|
||||
replyingTo: parent,
|
||||
),
|
||||
);
|
||||
if (value != null) onPosted?.call();
|
||||
},
|
||||
icon: const Icon(Symbols.launch, size: 20),
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: kInputChipHeight - 6,
|
||||
minHeight: kInputChipHeight - 6,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: submitting.value
|
||||
? SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CircularProgressIndicator(strokeWidth: 3),
|
||||
)
|
||||
: Icon(Symbols.send, size: 20),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
onPressed: submitting.value ? null : performAction,
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: kInputChipHeight - 6,
|
||||
minHeight: kInputChipHeight - 6,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (e, _) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
734
lib/posts/posts_widgets/post/post_reaction_sheet.dart
Normal file
734
lib/posts/posts_widgets/post/post_reaction_sheet.dart
Normal file
@@ -0,0 +1,734 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_popup_card/flutter_popup_card.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/accounts/accounts_widgets/account/account_pfc.dart';
|
||||
import 'package:island/accounts/accounts_widgets/activitypub/actor_profile.dart';
|
||||
import 'package:island/pagination/pagination.dart';
|
||||
import 'package:island/posts/posts_models/post.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/core/services/time.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:island/shared/widgets/pagination_list.dart';
|
||||
import 'package:island/stickers/stickers_widgets/stickers/sticker_picker.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:island/core/config.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'post_reaction_sheet.freezed.dart';
|
||||
|
||||
@freezed
|
||||
sealed class ReactionListQuery with _$ReactionListQuery {
|
||||
const factory ReactionListQuery({
|
||||
required String symbol,
|
||||
required String postId,
|
||||
}) = _ReactionListQuery;
|
||||
}
|
||||
|
||||
final reactionListNotifierProvider = AsyncNotifierProvider.autoDispose.family(
|
||||
ReactionListNotifier.new,
|
||||
);
|
||||
|
||||
class ReactionListNotifier
|
||||
extends AsyncNotifier<PaginationState<SnPostReaction>>
|
||||
with AsyncPaginationController<SnPostReaction> {
|
||||
static const int pageSize = 20;
|
||||
|
||||
final ReactionListQuery arg;
|
||||
ReactionListNotifier(this.arg);
|
||||
|
||||
@override
|
||||
Future<List<SnPostReaction>> fetch() async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
|
||||
final response = await client.get(
|
||||
'/sphere/posts/${arg.postId}/reactions',
|
||||
queryParameters: {
|
||||
'symbol': arg.symbol,
|
||||
'offset': fetchedCount,
|
||||
'take': pageSize,
|
||||
},
|
||||
);
|
||||
|
||||
totalCount = int.tryParse(response.headers.value('x-total') ?? '0') ?? 0;
|
||||
|
||||
final List<dynamic> data = response.data;
|
||||
return data.map((json) => SnPostReaction.fromJson(json)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
const kAvailableStickers = {
|
||||
'angry',
|
||||
'clap',
|
||||
'confuse',
|
||||
'pray',
|
||||
'thumb_up',
|
||||
'party',
|
||||
};
|
||||
|
||||
bool _getReactionImageAvailable(String symbol) {
|
||||
return kAvailableStickers.contains(symbol);
|
||||
}
|
||||
|
||||
Widget _buildReactionIcon(String symbol, double size, {double iconSize = 24}) {
|
||||
if (_getReactionImageAvailable(symbol)) {
|
||||
return Image.asset(
|
||||
'assets/images/stickers/$symbol.png',
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.bottomCenter,
|
||||
);
|
||||
} else {
|
||||
return Text(
|
||||
kReactionTemplates[symbol]?.icon ?? '',
|
||||
style: TextStyle(fontSize: iconSize),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PostReactionSheet extends StatelessWidget {
|
||||
final Map<String, int> reactionsCount;
|
||||
final Map<String, bool> reactionsMade;
|
||||
final Function(String symbol, int attitude) onReact;
|
||||
final String postId;
|
||||
const PostReactionSheet({
|
||||
super.key,
|
||||
required this.reactionsCount,
|
||||
required this.reactionsMade,
|
||||
required this.onReact,
|
||||
required this.postId,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: SheetScaffold(
|
||||
heightFactor: 0.75,
|
||||
titleText: 'reactions'.plural(
|
||||
reactionsCount.isNotEmpty
|
||||
? reactionsCount.values.reduce((a, b) => a + b)
|
||||
: 0,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'overview'.tr()),
|
||||
Tab(text: 'custom'.tr()),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
ListView(
|
||||
children: [
|
||||
_buildCustomReactionSection(context),
|
||||
_buildReactionSection(
|
||||
context,
|
||||
Symbols.mood,
|
||||
'reactionPositive'.tr(),
|
||||
0,
|
||||
),
|
||||
_buildReactionSection(
|
||||
context,
|
||||
Symbols.sentiment_neutral,
|
||||
'reactionNeutral'.tr(),
|
||||
1,
|
||||
),
|
||||
_buildReactionSection(
|
||||
context,
|
||||
Symbols.mood_bad,
|
||||
'reactionNegative'.tr(),
|
||||
2,
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
CustomReactionForm(
|
||||
postId: postId,
|
||||
onReact: (s, a) => onReact(s.replaceAll(':', ''), a),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCustomReactionSection(BuildContext context) {
|
||||
final customReactions = reactionsCount.entries
|
||||
.where((entry) => entry.key.contains('+'))
|
||||
.map((entry) => entry.key)
|
||||
.toList();
|
||||
|
||||
if (customReactions.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return HookConsumer(
|
||||
builder: (context, ref, child) {
|
||||
final baseUrl = ref.watch(serverUrlProvider);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
spacing: 8,
|
||||
children: [
|
||||
const Icon(Symbols.emoji_symbols),
|
||||
Text('customReactions'.tr()).fontSize(17).bold(),
|
||||
],
|
||||
).padding(horizontal: 24, top: 16, bottom: 6),
|
||||
SizedBox(
|
||||
height: 120,
|
||||
child: GridView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 1,
|
||||
mainAxisExtent: 120,
|
||||
mainAxisSpacing: 8.0,
|
||||
crossAxisSpacing: 8.0,
|
||||
childAspectRatio: 1.0,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: customReactions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final symbol = customReactions[index];
|
||||
final count = reactionsCount[symbol] ?? 0;
|
||||
final stickerUri =
|
||||
'$baseUrl/sphere/stickers/lookup/$symbol/open';
|
||||
|
||||
return GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
if (count > 0) {
|
||||
showReactionDetailsPopup(
|
||||
context,
|
||||
symbol,
|
||||
details.localPosition,
|
||||
postId,
|
||||
reactionsCount[symbol] ?? 0,
|
||||
);
|
||||
}
|
||||
},
|
||||
onSecondaryTapUp: (details) {
|
||||
if (count > 0) {
|
||||
showReactionDetailsPopup(
|
||||
context,
|
||||
symbol,
|
||||
details.localPosition,
|
||||
postId,
|
||||
reactionsCount[symbol] ?? 0,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Badge(
|
||||
label: Text('x$count'),
|
||||
isLabelVisible: count > 0,
|
||||
textColor: Theme.of(context).colorScheme.onPrimary,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
offset: Offset(0, 0),
|
||||
child: Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerLowest,
|
||||
child: InkWell(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
onTap: () {
|
||||
onReact(
|
||||
symbol,
|
||||
1,
|
||||
); // Custom reactions use neutral attitude
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(width: double.infinity),
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(stickerUri),
|
||||
fit: BoxFit.contain,
|
||||
colorFilter:
|
||||
(reactionsMade[symbol] ?? false)
|
||||
? ColorFilter.mode(
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer
|
||||
.withOpacity(0.7),
|
||||
BlendMode.srcATop,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
symbol,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
blurRadius: 4,
|
||||
offset: Offset(0.5, 0.5),
|
||||
color: Colors.black,
|
||||
),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReactionSection(
|
||||
BuildContext context,
|
||||
IconData icon,
|
||||
String title,
|
||||
int attitude,
|
||||
) {
|
||||
final allReactions = kReactionTemplates.entries
|
||||
.where((entry) => entry.value.attitude == attitude)
|
||||
.map((entry) => entry.key)
|
||||
.toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
spacing: 8,
|
||||
children: [Icon(icon), Text(title).fontSize(17).bold()],
|
||||
).padding(horizontal: 24, top: 16, bottom: 6),
|
||||
SizedBox(
|
||||
height: 120,
|
||||
child: GridView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 1,
|
||||
mainAxisExtent: 120,
|
||||
mainAxisSpacing: 8.0,
|
||||
crossAxisSpacing: 8.0,
|
||||
childAspectRatio: 1.0,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: allReactions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final symbol = allReactions[index];
|
||||
final count = reactionsCount[symbol] ?? 0;
|
||||
final hasImage = _getReactionImageAvailable(symbol);
|
||||
return GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
if (count > 0) {
|
||||
showReactionDetailsPopup(
|
||||
context,
|
||||
symbol,
|
||||
details.localPosition,
|
||||
postId,
|
||||
reactionsCount[symbol] ?? 0,
|
||||
);
|
||||
}
|
||||
},
|
||||
onSecondaryTapUp: (details) {
|
||||
if (count > 0) {
|
||||
showReactionDetailsPopup(
|
||||
context,
|
||||
symbol,
|
||||
details.localPosition,
|
||||
postId,
|
||||
reactionsCount[symbol] ?? 0,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Badge(
|
||||
label: Text('x$count'),
|
||||
isLabelVisible: count > 0,
|
||||
textColor: Theme.of(context).colorScheme.onPrimary,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
offset: Offset(0, 0),
|
||||
child: Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLowest,
|
||||
child: InkWell(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
onTap: () {
|
||||
onReact(symbol, attitude);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Container(
|
||||
decoration: hasImage
|
||||
? BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: DecorationImage(
|
||||
image: AssetImage(
|
||||
'assets/images/stickers/$symbol.png',
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
colorFilter: (reactionsMade[symbol] ?? false)
|
||||
? ColorFilter.mode(
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer
|
||||
.withOpacity(0.7),
|
||||
BlendMode.srcATop,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (hasImage)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.withOpacity(0.7),
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: [0.0, 0.3],
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisAlignment: hasImage
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (!hasImage) _buildReactionIcon(symbol, 36),
|
||||
Text(
|
||||
ReactInfo.getTranslationKey(symbol),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: hasImage ? Colors.white : null,
|
||||
shadows: hasImage
|
||||
? [
|
||||
const Shadow(
|
||||
blurRadius: 4,
|
||||
offset: Offset(0.5, 0.5),
|
||||
color: Colors.black,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
).tr(),
|
||||
if (hasImage) const Gap(4),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ReactionDetailsPopup extends HookConsumerWidget {
|
||||
final String symbol;
|
||||
final String postId;
|
||||
final int totalCount;
|
||||
const ReactionDetailsPopup({
|
||||
super.key,
|
||||
required this.symbol,
|
||||
required this.postId,
|
||||
required this.totalCount,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final params = ReactionListQuery(symbol: symbol, postId: postId);
|
||||
final provider = reactionListNotifierProvider(params);
|
||||
|
||||
final width = math.min(MediaQuery.of(context).size.width * 0.8, 480.0);
|
||||
return PopupCard(
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: 400,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildReactionIcon(symbol, 24),
|
||||
const Gap(8),
|
||||
Text(
|
||||
ReactInfo.getTranslationKey(symbol),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
).tr(),
|
||||
const Spacer(),
|
||||
Text('reactions'.plural(totalCount)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: PaginationList(
|
||||
provider: provider,
|
||||
notifier: provider.notifier,
|
||||
padding: EdgeInsets.zero,
|
||||
itemBuilder: (context, index, reaction) {
|
||||
return ListTile(
|
||||
leading: AccountPfcRegion(
|
||||
uname: reaction.account?.name,
|
||||
child: reaction.actor != null
|
||||
? ActorPictureWidget(
|
||||
actor: reaction.actor!,
|
||||
radius: 20,
|
||||
)
|
||||
: ProfilePictureWidget(
|
||||
file: reaction.account?.profile.picture,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
reaction.actor?.displayName ??
|
||||
reaction.account?.nick ??
|
||||
'unknown'.tr(),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${reaction.createdAt.formatRelative(context)} · ${reaction.createdAt.formatSystem()}',
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CustomReactionForm extends HookConsumerWidget {
|
||||
final String postId;
|
||||
final Function(String symbol, int attitude) onReact;
|
||||
|
||||
const CustomReactionForm({
|
||||
super.key,
|
||||
required this.postId,
|
||||
required this.onReact,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final attitude = useState<int>(1);
|
||||
final symbol = useState<String>('');
|
||||
|
||||
Future<void> submitCustomReaction() async {
|
||||
if (symbol.value.isEmpty) return;
|
||||
onReact(symbol.value, attitude.value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.info,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'customReaction'.tr(),
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'customReactionHint'.tr(),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
TextField(
|
||||
readOnly: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'stickerPlaceholder'.tr(),
|
||||
hintText: 'prefix+slug',
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
suffixIcon: InkWell(
|
||||
onTapDown: (details) async {
|
||||
final screenSize = MediaQuery.sizeOf(context);
|
||||
const popoverWidth = 500.0;
|
||||
const popoverHeight = 500.0;
|
||||
const padding = 20.0;
|
||||
|
||||
// Calculate safe horizontal position (centered, but within bounds)
|
||||
final maxHorizontalOffset = math.max(
|
||||
padding,
|
||||
screenSize.width - popoverWidth - padding,
|
||||
);
|
||||
final horizontalOffset =
|
||||
((screenSize.width - popoverWidth) / 2).clamp(
|
||||
padding,
|
||||
maxHorizontalOffset,
|
||||
);
|
||||
|
||||
// Calculate safe vertical position (bottom-aligned, but within bounds)
|
||||
final maxVerticalOffset = math.max(
|
||||
padding,
|
||||
screenSize.height - popoverHeight - padding,
|
||||
);
|
||||
final verticalOffset =
|
||||
(screenSize.height - popoverHeight - padding).clamp(
|
||||
padding,
|
||||
maxVerticalOffset,
|
||||
);
|
||||
|
||||
await showStickerPickerPopover(
|
||||
context,
|
||||
Offset(horizontalOffset, verticalOffset),
|
||||
alignment: Alignment.topLeft,
|
||||
onPick: (placeholder) {
|
||||
// Remove the surrounding : from the placeholder
|
||||
symbol.value = placeholder.substring(
|
||||
1,
|
||||
placeholder.length - 1,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
child: const Icon(Symbols.sticky_note_2),
|
||||
),
|
||||
),
|
||||
controller: TextEditingController(text: symbol.value),
|
||||
onChanged: (value) => symbol.value = value,
|
||||
),
|
||||
const Gap(24),
|
||||
Text(
|
||||
'reactionAttitude'.tr(),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Gap(8),
|
||||
SegmentedButton(
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: 0,
|
||||
icon: const Icon(Symbols.sentiment_satisfied),
|
||||
label: Text('attitudePositive'.tr()),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: 1,
|
||||
icon: const Icon(Symbols.sentiment_stressed),
|
||||
label: Text('attitudeNeutral'.tr()),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: 2,
|
||||
icon: const Icon(Symbols.sentiment_sad),
|
||||
label: Text('attitudeNegative'.tr()),
|
||||
),
|
||||
],
|
||||
selected: {attitude.value},
|
||||
onSelectionChanged: (Set<int> newSelection) {
|
||||
attitude.value = newSelection.first;
|
||||
},
|
||||
),
|
||||
const Gap(32),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FilledButton.icon(
|
||||
onPressed: symbol.value.isEmpty ? null : submitCustomReaction,
|
||||
icon: const Icon(Symbols.send),
|
||||
label: Text('addReaction'.tr()),
|
||||
),
|
||||
),
|
||||
Gap(MediaQuery.of(context).padding.bottom + 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showReactionDetailsPopup(
|
||||
BuildContext context,
|
||||
String symbol,
|
||||
Offset offset,
|
||||
String postId,
|
||||
int totalCount,
|
||||
) async {
|
||||
await showPopupCard<void>(
|
||||
offset: offset,
|
||||
context: context,
|
||||
builder: (context) => ReactionDetailsPopup(
|
||||
symbol: symbol,
|
||||
postId: postId,
|
||||
totalCount: totalCount,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
dimBackground: true,
|
||||
);
|
||||
}
|
||||
268
lib/posts/posts_widgets/post/post_reaction_sheet.freezed.dart
Normal file
268
lib/posts/posts_widgets/post/post_reaction_sheet.freezed.dart
Normal file
@@ -0,0 +1,268 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'post_reaction_sheet.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$ReactionListQuery {
|
||||
|
||||
String get symbol; String get postId;
|
||||
/// Create a copy of ReactionListQuery
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$ReactionListQueryCopyWith<ReactionListQuery> get copyWith => _$ReactionListQueryCopyWithImpl<ReactionListQuery>(this as ReactionListQuery, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ReactionListQuery&&(identical(other.symbol, symbol) || other.symbol == symbol)&&(identical(other.postId, postId) || other.postId == postId));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,symbol,postId);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ReactionListQuery(symbol: $symbol, postId: $postId)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $ReactionListQueryCopyWith<$Res> {
|
||||
factory $ReactionListQueryCopyWith(ReactionListQuery value, $Res Function(ReactionListQuery) _then) = _$ReactionListQueryCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String symbol, String postId
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$ReactionListQueryCopyWithImpl<$Res>
|
||||
implements $ReactionListQueryCopyWith<$Res> {
|
||||
_$ReactionListQueryCopyWithImpl(this._self, this._then);
|
||||
|
||||
final ReactionListQuery _self;
|
||||
final $Res Function(ReactionListQuery) _then;
|
||||
|
||||
/// Create a copy of ReactionListQuery
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? symbol = null,Object? postId = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
symbol: null == symbol ? _self.symbol : symbol // ignore: cast_nullable_to_non_nullable
|
||||
as String,postId: null == postId ? _self.postId : postId // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [ReactionListQuery].
|
||||
extension ReactionListQueryPatterns on ReactionListQuery {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ReactionListQuery value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ReactionListQuery() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ReactionListQuery value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ReactionListQuery():
|
||||
return $default(_that);}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ReactionListQuery value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ReactionListQuery() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String symbol, String postId)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ReactionListQuery() when $default != null:
|
||||
return $default(_that.symbol,_that.postId);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String symbol, String postId) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ReactionListQuery():
|
||||
return $default(_that.symbol,_that.postId);}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String symbol, String postId)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ReactionListQuery() when $default != null:
|
||||
return $default(_that.symbol,_that.postId);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _ReactionListQuery implements ReactionListQuery {
|
||||
const _ReactionListQuery({required this.symbol, required this.postId});
|
||||
|
||||
|
||||
@override final String symbol;
|
||||
@override final String postId;
|
||||
|
||||
/// Create a copy of ReactionListQuery
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$ReactionListQueryCopyWith<_ReactionListQuery> get copyWith => __$ReactionListQueryCopyWithImpl<_ReactionListQuery>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ReactionListQuery&&(identical(other.symbol, symbol) || other.symbol == symbol)&&(identical(other.postId, postId) || other.postId == postId));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,symbol,postId);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ReactionListQuery(symbol: $symbol, postId: $postId)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$ReactionListQueryCopyWith<$Res> implements $ReactionListQueryCopyWith<$Res> {
|
||||
factory _$ReactionListQueryCopyWith(_ReactionListQuery value, $Res Function(_ReactionListQuery) _then) = __$ReactionListQueryCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String symbol, String postId
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$ReactionListQueryCopyWithImpl<$Res>
|
||||
implements _$ReactionListQueryCopyWith<$Res> {
|
||||
__$ReactionListQueryCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _ReactionListQuery _self;
|
||||
final $Res Function(_ReactionListQuery) _then;
|
||||
|
||||
/// Create a copy of ReactionListQuery
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? symbol = null,Object? postId = null,}) {
|
||||
return _then(_ReactionListQuery(
|
||||
symbol: null == symbol ? _self.symbol : symbol // ignore: cast_nullable_to_non_nullable
|
||||
as String,postId: null == postId ? _self.postId : postId // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
93
lib/posts/posts_widgets/post/post_replies.dart
Normal file
93
lib/posts/posts_widgets/post/post_replies.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pagination/pagination.dart';
|
||||
import 'package:island/posts/posts_models/post.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/posts/posts_widgets/post/post_item.dart';
|
||||
import 'package:island/posts/posts_widgets/post/post_item_skeleton.dart';
|
||||
import 'package:island/shared/widgets/pagination_list.dart';
|
||||
|
||||
final postRepliesProvider = AsyncNotifierProvider.autoDispose.family(
|
||||
PostRepliesNotifier.new,
|
||||
);
|
||||
|
||||
class PostRepliesNotifier extends AsyncNotifier<PaginationState<SnPost>>
|
||||
with AsyncPaginationController<SnPost> {
|
||||
static const int pageSize = 20;
|
||||
|
||||
final String arg;
|
||||
PostRepliesNotifier(this.arg);
|
||||
|
||||
@override
|
||||
Future<List<SnPost>> fetch() async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
|
||||
final response = await client.get(
|
||||
'/sphere/posts/$arg/replies',
|
||||
queryParameters: {'offset': fetchedCount, 'take': pageSize},
|
||||
);
|
||||
|
||||
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
|
||||
final List<dynamic> data = response.data;
|
||||
return data.map((json) => SnPost.fromJson(json)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
class PostRepliesList extends HookConsumerWidget {
|
||||
final String postId;
|
||||
final double? maxWidth;
|
||||
final VoidCallback? onOpen;
|
||||
const PostRepliesList({
|
||||
super.key,
|
||||
required this.postId,
|
||||
this.maxWidth,
|
||||
this.onOpen,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final provider = postRepliesProvider(postId);
|
||||
|
||||
final skeletonItem = Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: PostItemSkeleton(maxWidth: maxWidth ?? double.infinity),
|
||||
);
|
||||
|
||||
return PaginationList(
|
||||
provider: provider,
|
||||
notifier: provider.notifier,
|
||||
isRefreshable: false,
|
||||
isSliver: true,
|
||||
footerSkeletonChild: maxWidth == null
|
||||
? skeletonItem
|
||||
: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth!),
|
||||
child: skeletonItem,
|
||||
),
|
||||
),
|
||||
itemBuilder: (context, index, item) {
|
||||
final contentWidget = Card(
|
||||
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: PostActionableItem(
|
||||
borderRadius: 8,
|
||||
item: item,
|
||||
isShowReference: false,
|
||||
isEmbedOpenable: true,
|
||||
onOpen: onOpen,
|
||||
onUpdate: (newPost) {},
|
||||
),
|
||||
);
|
||||
|
||||
if (maxWidth == null) return contentWidget;
|
||||
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth!),
|
||||
child: contentWidget,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
60
lib/posts/posts_widgets/post/post_replies_sheet.dart
Normal file
60
lib/posts/posts_widgets/post/post_replies_sheet.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/posts/posts_models/post.dart';
|
||||
import 'package:island/accounts/accounts_pod.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:island/posts/posts_widgets/post/post_quick_reply.dart';
|
||||
import 'package:island/posts/posts_widgets/post/post_replies.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class PostRepliesSheet extends HookConsumerWidget {
|
||||
final SnPost post;
|
||||
|
||||
const PostRepliesSheet({super.key, required this.post});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final user = ref.watch(userInfoProvider);
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: 'repliesCount'.plural(post.repliesCount),
|
||||
child: Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
PostRepliesList(
|
||||
postId: post.id.toString(),
|
||||
onOpen: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
SliverGap(80),
|
||||
],
|
||||
),
|
||||
if (user.value != null)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child:
|
||||
PostQuickReply(
|
||||
parent: post,
|
||||
onPosted: () {
|
||||
ref.invalidate(postRepliesProvider(post.id));
|
||||
},
|
||||
onLaunch: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
).padding(
|
||||
bottom: MediaQuery.of(context).padding.bottom + 16,
|
||||
top: 8,
|
||||
horizontal: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1186
lib/posts/posts_widgets/post/post_shared.dart
Normal file
1186
lib/posts/posts_widgets/post/post_shared.dart
Normal file
File diff suppressed because it is too large
Load Diff
176
lib/posts/posts_widgets/post/post_shared.g.dart
Normal file
176
lib/posts/posts_widgets/post/post_shared.g.dart
Normal file
@@ -0,0 +1,176 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'post_shared.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(RepliesNotifier)
|
||||
final repliesProvider = RepliesNotifierFamily._();
|
||||
|
||||
final class RepliesNotifierProvider
|
||||
extends $NotifierProvider<RepliesNotifier, RepliesState> {
|
||||
RepliesNotifierProvider._({
|
||||
required RepliesNotifierFamily super.from,
|
||||
required String super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'repliesProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$repliesNotifierHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'repliesProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
RepliesNotifier create() => RepliesNotifier();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(RepliesState value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<RepliesState>(value),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is RepliesNotifierProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$repliesNotifierHash() => r'fcaea9b502b1d713a8084da022a03e86d67acc1a';
|
||||
|
||||
final class RepliesNotifierFamily extends $Family
|
||||
with
|
||||
$ClassFamilyOverride<
|
||||
RepliesNotifier,
|
||||
RepliesState,
|
||||
RepliesState,
|
||||
RepliesState,
|
||||
String
|
||||
> {
|
||||
RepliesNotifierFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'repliesProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
RepliesNotifierProvider call(String parentId) =>
|
||||
RepliesNotifierProvider._(argument: parentId, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'repliesProvider';
|
||||
}
|
||||
|
||||
abstract class _$RepliesNotifier extends $Notifier<RepliesState> {
|
||||
late final _$args = ref.$arg as String;
|
||||
String get parentId => _$args;
|
||||
|
||||
RepliesState build(String parentId);
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<RepliesState, RepliesState>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<RepliesState, RepliesState>,
|
||||
RepliesState,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, () => build(_$args));
|
||||
}
|
||||
}
|
||||
|
||||
@ProviderFor(postFeaturedReply)
|
||||
final postFeaturedReplyProvider = PostFeaturedReplyFamily._();
|
||||
|
||||
final class PostFeaturedReplyProvider
|
||||
extends $FunctionalProvider<AsyncValue<SnPost?>, SnPost?, FutureOr<SnPost?>>
|
||||
with $FutureModifier<SnPost?>, $FutureProvider<SnPost?> {
|
||||
PostFeaturedReplyProvider._({
|
||||
required PostFeaturedReplyFamily super.from,
|
||||
required String super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'postFeaturedReplyProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$postFeaturedReplyHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'postFeaturedReplyProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<SnPost?> $createElement($ProviderPointer pointer) =>
|
||||
$FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<SnPost?> create(Ref ref) {
|
||||
final argument = this.argument as String;
|
||||
return postFeaturedReply(ref, argument);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is PostFeaturedReplyProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$postFeaturedReplyHash() => r'3f0ac0d51ad21f8754a63dd94109eb8ac4812293';
|
||||
|
||||
final class PostFeaturedReplyFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<SnPost?>, String> {
|
||||
PostFeaturedReplyFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'postFeaturedReplyProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
PostFeaturedReplyProvider call(String id) =>
|
||||
PostFeaturedReplyProvider._(argument: id, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'postFeaturedReplyProvider';
|
||||
}
|
||||
196
lib/posts/posts_widgets/post/post_shuffle.dart
Normal file
196
lib/posts/posts_widgets/post/post_shuffle.dart
Normal file
@@ -0,0 +1,196 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/posts/post/post_list.dart';
|
||||
import 'package:island/posts/posts_widgets/post/post_item.dart';
|
||||
import 'package:island/shared/widgets/app_scaffold.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
const kShufflePostListId = 'shuffle';
|
||||
|
||||
class _ShufflePageNotifier extends Notifier<int> {
|
||||
@override
|
||||
int build() => 0;
|
||||
|
||||
void updatePage(int page) => state = page;
|
||||
}
|
||||
|
||||
final _shufflePageProvider = NotifierProvider<_ShufflePageNotifier, int>(
|
||||
_ShufflePageNotifier.new,
|
||||
);
|
||||
|
||||
class PostShuffleScreen extends HookConsumerWidget {
|
||||
const PostShuffleScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
const query = PostListQuery(shuffle: true);
|
||||
final cfg = PostListQueryConfig(
|
||||
id: kShufflePostListId,
|
||||
initialFilter: query,
|
||||
);
|
||||
final postListState = ref.watch(postListProvider(cfg));
|
||||
final postListNotifier = ref.watch(postListProvider(cfg).notifier);
|
||||
|
||||
final savedPage = ref.watch(_shufflePageProvider);
|
||||
final pageNotifier = ref.watch(_shufflePageProvider.notifier);
|
||||
|
||||
final pageController = usePageController(initialPage: savedPage);
|
||||
|
||||
useEffect(() {
|
||||
return pageController.dispose;
|
||||
}, []);
|
||||
|
||||
final items = postListState.value?.items ?? [];
|
||||
|
||||
useEffect(() {
|
||||
void listener() {
|
||||
if (!pageController.hasClients) return;
|
||||
final page = pageController.page?.round() ?? 0;
|
||||
if (page != savedPage) {
|
||||
pageNotifier.updatePage(page);
|
||||
}
|
||||
if (page >= items.length - 3 && !postListNotifier.fetchedAll) {
|
||||
postListNotifier.fetchFurther();
|
||||
}
|
||||
}
|
||||
|
||||
pageController.addListener(listener);
|
||||
return () => pageController.removeListener(listener);
|
||||
}, [items.length, postListNotifier.fetchedAll]);
|
||||
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(title: const Text('postShuffle').tr()),
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
if (items.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
PageView.builder(
|
||||
controller: pageController,
|
||||
scrollDirection: Axis.vertical,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
return SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 640),
|
||||
child: PostActionableItem(
|
||||
item: items[index],
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
).center(),
|
||||
);
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withOpacity(0.3),
|
||||
],
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 12,
|
||||
bottom: 16,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
final currentPage = pageController.page?.round() ?? 0;
|
||||
if (currentPage > 0) {
|
||||
pageController.animateToPage(
|
||||
currentPage - 1,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.keyboard_double_arrow_up,
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
size: 20,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 2),
|
||||
blurRadius: 4,
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'swipeToExplore'.tr(),
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 2),
|
||||
blurRadius: 4,
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
final currentPage = pageController.page?.round() ?? 0;
|
||||
if (currentPage < items.length - 1) {
|
||||
pageController.animateToPage(
|
||||
currentPage + 1,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.keyboard_double_arrow_down,
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
size: 20,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 2),
|
||||
blurRadius: 4,
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
83
lib/posts/posts_widgets/post/publishers_modal.dart
Normal file
83
lib/posts/posts_widgets/post/publishers_modal.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/creators/creators/publishers_form.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class PublisherModal extends HookConsumerWidget {
|
||||
const PublisherModal({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final publishers = ref.watch(publishersManagedProvider);
|
||||
|
||||
return SizedBox(
|
||||
height: math.min(MediaQuery.of(context).size.height * 0.4, 480),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: publishers.when(
|
||||
data: (value) => value.isEmpty
|
||||
? ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 280),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'publishersEmpty',
|
||||
textAlign: TextAlign.center,
|
||||
).tr().fontSize(17).bold(),
|
||||
Text(
|
||||
'publishersEmptyDescription',
|
||||
textAlign: TextAlign.center,
|
||||
).tr(),
|
||||
const Gap(12),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) =>
|
||||
const NewPublisherScreen(),
|
||||
).then((value) {
|
||||
if (value != null) {
|
||||
ref.invalidate(publishersManagedProvider);
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Text('createPublisher').tr(),
|
||||
),
|
||||
],
|
||||
).center(),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
for (final publisher in value)
|
||||
ListTile(
|
||||
leading: ProfilePictureWidget(
|
||||
file: publisher.picture,
|
||||
),
|
||||
title: Text(publisher.nick),
|
||||
subtitle: Text('@${publisher.name}'),
|
||||
onTap: () {
|
||||
Navigator.pop(context, publisher);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Text('Error: $e'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
108
lib/posts/posts_widgets/publisher/publisher_card.dart
Normal file
108
lib/posts/posts_widgets/publisher/publisher_card.dart
Normal file
@@ -0,0 +1,108 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/posts/posts_models/publisher.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
|
||||
class PublisherDiscoveryCard extends ConsumerWidget {
|
||||
final SnPublisher publisher;
|
||||
final double? maxWidth;
|
||||
|
||||
const PublisherDiscoveryCard({
|
||||
super.key,
|
||||
required this.publisher,
|
||||
this.maxWidth,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Widget imageWidget;
|
||||
if (publisher.picture != null) {
|
||||
imageWidget = CloudImageWidget(
|
||||
file: publisher.background,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
} else {
|
||||
imageWidget = ColoredBox(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
);
|
||||
}
|
||||
|
||||
Widget card = Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
margin: EdgeInsets.zero,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
context.pushNamed(
|
||||
'publisherProfile',
|
||||
pathParameters: {'name': publisher.name},
|
||||
);
|
||||
},
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 7,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
imageWidget,
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.7),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ProfilePictureWidget(
|
||||
file: publisher.picture,
|
||||
radius: 12,
|
||||
),
|
||||
),
|
||||
const Gap(2),
|
||||
Text(
|
||||
publisher.nick,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user