🎨 Use feature based folder structure

This commit is contained in:
2026-02-06 00:37:02 +08:00
parent 62a3ea26e3
commit 862e3b451b
539 changed files with 8406 additions and 5056 deletions

View 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,
),
),
],
),
],
),
);
}
}

View 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;
}),
),
);
}
}

View 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,
),
),
);
}
}

View 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,
),
),
],
),
),
);
}
}

View 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,
),
),
],
),
],
),
);
}
}

View 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()),
),
),
],
],
),
),
],
),
),
],
),
);
}
}

View 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(),
),
),
],
),
),
);
}
}

View 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;
}
}
}

View 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,
),
],
),
),
);
}
}

View 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),
),
],
),
),
],
),
),
);
}
}

View 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;
}
}
}

View 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),
),
],
),
);
}
}

View 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,
),
),
),
],
),
);
}
}

View 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 = '![${cloudFile.name}](solian://files/${cloudFile.id})';
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();
}
}

View 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,
),
),
],
),
],
),
);
}
}

View 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
}
}

View 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,
),
),
),
);
}
}

View 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';
}
}
}

View 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;
}
}
}

View 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(),
),
),
),
),
],
),
],
),
),
],
],
),
);
}
}

View File

@@ -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(),
),
],
),
);
}
}

View File

@@ -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';

View 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),
);
}
}

View 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);
}
}
}
}

View 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,
),
);
},
),
);
},
),
),
),
],
),
),
);
}
}

View 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';

View 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,
),
),
),
],
),
);
}
}

View 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),
),
),
],
),
),
],
),
),
],
);
}
}

View 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),
),
],
),
),
],
),
);
}
}

View 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),
),
),
],
),
),
],
),
],
),
),
);
}
}

View 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),
);
}
}
}

View 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),
],
),
);
}
}

View 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(),
);
}
}

View 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,
);
}

View 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

View 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,
),
);
},
);
}
}

View 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,
),
),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

View 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';
}

View 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(),
),
],
),
),
),
],
);
},
),
);
}
}

View 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'),
),
),
],
),
);
}
}

View 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,
);
}
}