diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 954c9cc5..9bb285eb 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -1594,5 +1594,8 @@ "other": "{} tasks" }, "setAsThumbnail": "Set as thumbnail", - "unsetAsThumbnail": "Unset as thumbnail" + "unsetAsThumbnail": "Unset as thumbnail", + "sidebar": "Sidebar", + "dropFilesHere": "Drop your files here", + "dragAndDropToAttach": "Drag your files here to attach it" } diff --git a/lib/screens/dashboard/dash.dart b/lib/screens/dashboard/dash.dart index 62deb771..e19f8232 100644 --- a/lib/screens/dashboard/dash.dart +++ b/lib/screens/dashboard/dash.dart @@ -1,5 +1,6 @@ import 'dart:math' as math; +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'; @@ -26,9 +27,11 @@ import 'package:island/models/activity.dart'; import 'package:island/screens/notification.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:slide_countdown/slide_countdown.dart'; -import 'package:styled_widget/styled_widget.dart'; +import 'package:island/widgets/share/share_sheet.dart'; import 'dart:async'; +import 'package:styled_widget/styled_widget.dart'; + class DashboardScreen extends HookConsumerWidget { const DashboardScreen({super.key}); @@ -51,77 +54,129 @@ class DashboardGrid extends HookConsumerWidget { final userInfo = ref.watch(userInfoProvider); - return Container( - constraints: BoxConstraints( - maxHeight: isWide - ? math.min(640, MediaQuery.sizeOf(context).height * 0.65) - : MediaQuery.sizeOf(context).height, - ), - padding: isWide - ? EdgeInsets.only(top: devicePadding.top) - : EdgeInsets.only(top: 24 + devicePadding.top), - child: Column( - spacing: 16, - mainAxisAlignment: MainAxisAlignment.center, + final dragging = useState(false); + + return DropTarget( + onDragDone: (detail) { + dragging.value = false; + if (detail.files.isNotEmpty) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: true, + builder: (context) => ShareSheet.files(files: detail.files), + ); + } + }, + onDragEntered: (_) => dragging.value = true, + onDragExited: (_) => dragging.value = false, + child: Stack( children: [ - // Clock card spans full width - if (isWide) - ClockCard().padding(horizontal: 24) - else - Row( + Container( + constraints: BoxConstraints( + maxHeight: isWide + ? math.min(640, MediaQuery.sizeOf(context).height * 0.65) + : MediaQuery.sizeOf(context).height, + ), + padding: isWide + ? EdgeInsets.only(top: devicePadding.top) + : EdgeInsets.only(top: 24 + devicePadding.top), + child: Column( + spacing: 16, mainAxisAlignment: MainAxisAlignment.center, children: [ - const Gap(8), - Expanded(child: ClockCard(compact: true)), - IconButton( - onPressed: () { - eventBus.fire(CommandPaletteTriggerEvent()); - }, - icon: const Icon(Symbols.search), - tooltip: 'searchAnything'.tr(), - ), + // Clock card spans full width + if (isWide) + ClockCard().padding(horizontal: 24) + else + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Gap(8), + Expanded(child: ClockCard(compact: true)), + IconButton( + onPressed: () { + eventBus.fire(CommandPaletteTriggerEvent()); + }, + icon: const Icon(Symbols.search), + tooltip: 'searchAnything'.tr(), + ), + ], + ).padding(horizontal: 24), + // Row with two cards side by side + if (isWide) + Padding( + padding: EdgeInsets.symmetric(horizontal: isWide ? 24 : 16), + child: SearchBar( + hintText: 'searchAnything'.tr(), + constraints: const BoxConstraints(minHeight: 56), + leading: const Icon( + Symbols.search, + ).padding(horizontal: 24), + readOnly: true, + onTap: () { + eventBus.fire(CommandPaletteTriggerEvent()); + }, + ), + ), + if (userInfo.value != null) + Expanded( + child: + SingleChildScrollView( + padding: isWide + ? const EdgeInsets.symmetric(horizontal: 24) + : EdgeInsets.only( + bottom: 64 + devicePadding.bottom, + ), + scrollDirection: isWide + ? Axis.horizontal + : Axis.vertical, + child: isWide + ? _DashboardGridWide() + : _DashboardGridNarrow(), + ) + .clipRRect( + topLeft: isWide ? 0 : 12, + topRight: isWide ? 0 : 12, + ) + .padding(horizontal: isWide ? 0 : 16), + ) + else + Center( + child: _UnauthorizedCard(isWide: isWide), + ).padding(horizontal: isWide ? 24 : 16), ], - ).padding(horizontal: 24), - // Row with two cards side by side - if (isWide) - Padding( - padding: EdgeInsets.symmetric(horizontal: isWide ? 24 : 16), - child: SearchBar( - hintText: 'searchAnything'.tr(), - constraints: const BoxConstraints(minHeight: 56), - leading: const Icon(Symbols.search).padding(horizontal: 24), - readOnly: true, - onTap: () { - eventBus.fire(CommandPaletteTriggerEvent()); - }, + ), + ), + if (dragging.value) + Positioned.fill( + child: Container( + color: Theme.of( + context, + ).colorScheme.primaryContainer.withOpacity(0.9), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Symbols.upload_file, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + const Gap(16), + Text( + 'dropToShare'.tr(), + style: Theme.of(context).textTheme.headlineMedium + ?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), ), ), - if (userInfo.value != null) - Expanded( - child: - SingleChildScrollView( - padding: isWide - ? const EdgeInsets.symmetric(horizontal: 24) - : EdgeInsets.only( - bottom: 64 + devicePadding.bottom, - ), - scrollDirection: isWide - ? Axis.horizontal - : Axis.vertical, - child: isWide - ? _DashboardGridWide() - : _DashboardGridNarrow(), - ) - .clipRRect( - topLeft: isWide ? 0 : 12, - topRight: isWide ? 0 : 12, - ) - .padding(horizontal: isWide ? 0 : 16), - ) - else - Center( - child: _UnauthorizedCard(isWide: isWide), - ).padding(horizontal: isWide ? 24 : 16), ], ), ); diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index 0af1b2ff..2d724605 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -1,4 +1,3 @@ -import 'package:desktop_drop/desktop_drop.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -29,10 +28,9 @@ import 'package:island/widgets/realm/realm_card.dart'; import 'package:island/widgets/publisher/publisher_card.dart'; import 'package:island/widgets/web_article_card.dart'; import 'package:island/services/event_bus.dart'; -import 'package:island/widgets/share/share_sheet.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:super_sliver_list/super_sliver_list.dart'; -import 'package:island/widgets/posts/post_subscription_filter.dart'; +import 'package:island/widgets/post/filters/post_subscription_filter.dart'; import 'package:island/pods/post/post_list.dart'; class ExploreScreen extends HookConsumerWidget { @@ -193,118 +191,66 @@ class ExploreScreen extends HookConsumerWidget { hasSubscriptionsSelected, ); - final dragging = useState(false); - - return DropTarget( - onDragDone: (detail) { - dragging.value = false; - if (detail.files.isNotEmpty) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - useRootNavigator: true, - builder: (context) => ShareSheet.files(files: detail.files), - ); - } - }, - onDragEntered: (_) => dragging.value = true, - onDragExited: (_) => dragging.value = false, - child: Stack( - children: [ - AppScaffold( - isNoBackground: false, - appBar: appBar, - floatingActionButton: userInfo.value != null - ? FloatingActionButton( - child: const Icon(Symbols.create), - onPressed: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - useRootNavigator: true, - builder: (context) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Gap(40), - ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 24, - ), - leading: const Icon(Symbols.post_add_rounded), - title: Text('postCompose').tr(), - onTap: () async { - Navigator.of(context).pop(); - await PostComposeSheet.show(context); - }, - ), - ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 24, - ), - leading: const Icon(Symbols.article), - title: Text('articleCompose').tr(), - onTap: () async { - Navigator.of(context).pop(); - GoRouter.of( - context, - ).pushNamed('articleCompose'); - }, - ), - const Gap(16), - ], - ), - ); - }, - ).padding(bottom: MediaQuery.of(context).padding.bottom) - : null, - body: isWide - ? _buildWideBody( - context, - ref, - filterBar, - user, - notificationCount, - query, - events, - selectedDay, - currentFilter.value, - selectedPublisherNames, - selectedCategoryIds, - selectedTagIds, - ) - : _buildNarrowBody(context, ref, currentFilter.value), - ), - if (dragging.value) - Positioned.fill( - child: Container( - color: Theme.of( - context, - ).colorScheme.primaryContainer.withOpacity(0.9), - child: Center( - child: Column( + return AppScaffold( + isNoBackground: false, + appBar: appBar, + floatingActionButton: userInfo.value != null + ? FloatingActionButton( + child: const Icon(Symbols.create), + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: true, + builder: (context) => Column( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Symbols.upload_file, - size: 64, - color: Theme.of(context).colorScheme.primary, + const Gap(40), + ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 24, + ), + leading: const Icon(Symbols.post_add_rounded), + title: Text('postCompose').tr(), + onTap: () async { + Navigator.of(context).pop(); + await PostComposeSheet.show(context); + }, + ), + ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 24, + ), + leading: const Icon(Symbols.article), + title: Text('articleCompose').tr(), + onTap: () async { + Navigator.of(context).pop(); + GoRouter.of(context).pushNamed('articleCompose'); + }, ), const Gap(16), - Text( - 'dropToShare'.tr(), - style: Theme.of(context).textTheme.headlineMedium - ?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), ], ), - ), - ), - ), - ], - ), + ); + }, + ).padding(bottom: MediaQuery.of(context).padding.bottom) + : null, + body: isWide + ? _buildWideBody( + context, + ref, + filterBar, + user, + notificationCount, + query, + events, + selectedDay, + currentFilter.value, + selectedPublisherNames, + selectedCategoryIds, + selectedTagIds, + ) + : _buildNarrowBody(context, ref, currentFilter.value), ); } diff --git a/lib/screens/posts/compose_article.dart b/lib/screens/posts/compose_article.dart index fa1e6f6a..a1755e8f 100644 --- a/lib/screens/posts/compose_article.dart +++ b/lib/screens/posts/compose_article.dart @@ -15,6 +15,7 @@ import 'package:island/services/responsive.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/markdown.dart'; +import 'package:island/widgets/common/responsive_sidebar.dart'; import 'package:island/widgets/post/compose_form_fields.dart'; import 'package:island/widgets/post/compose_shared.dart'; import 'package:island/widgets/post/compose_attachments.dart'; @@ -87,6 +88,7 @@ class ArticleComposeScreen extends HookConsumerWidget { }, [state]); final showPreview = useState(false); + final showSidebar = useState(false); // Initialize publisher once when data is available useEffect(() { @@ -140,17 +142,9 @@ class ArticleComposeScreen extends HookConsumerWidget { }, []); // Helper methods - void showSettingsSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (context) => ComposeSettingsSheet(state: state), - ); - } - Widget buildPreviewPane() { final widgetItem = SingleChildScrollView( - padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8), + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 24), child: ValueListenableBuilder( valueListenable: state.titleController, builder: (context, titleValue, _) { @@ -170,7 +164,7 @@ class ArticleComposeScreen extends HookConsumerWidget { fontWeight: FontWeight.bold, ), ), - const Gap(16), + const Gap(20), ], if (descriptionValue.text.isNotEmpty) ...[ Text( @@ -179,7 +173,7 @@ class ArticleComposeScreen extends HookConsumerWidget { color: colorScheme.onSurface.withOpacity(0.7), ), ), - const Gap(16), + const Gap(20), ], if (contentValue.text.isNotEmpty) MarkdownTextContent( @@ -233,7 +227,7 @@ class ArticleComposeScreen extends HookConsumerWidget { ), Expanded( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), + padding: const EdgeInsets.symmetric(horizontal: 16), child: widgetItem, ), ), @@ -251,6 +245,7 @@ class ArticleComposeScreen extends HookConsumerWidget { children: [ Expanded( child: SingleChildScrollView( + padding: const EdgeInsets.all(24), child: ComposeFormFields( state: state, showPublisherAvatar: false, @@ -265,12 +260,9 @@ class ArticleComposeScreen extends HookConsumerWidget { } }); }, - ).padding(top: 16), + ), ), ), - - // Attachments preview - ArticleComposeAttachments(state: state), ], ), ), @@ -290,8 +282,15 @@ class ArticleComposeScreen extends HookConsumerWidget { title: ValueListenableBuilder( valueListenable: state.titleController, builder: (context, titleValue, _) { - return Text( - titleValue.text.isEmpty ? 'postTitle'.tr() : titleValue.text, + return AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + switchInCurve: Curves.fastEaseInToSlowEaseOut, + switchOutCurve: Curves.fastEaseInToSlowEaseOut, + child: Text( + titleValue.text.isEmpty ? 'postTitle'.tr() : titleValue.text, + key: ValueKey(titleValue.text), + overflow: TextOverflow.ellipsis, + ), ); }, ), @@ -319,9 +318,9 @@ class ArticleComposeScreen extends HookConsumerWidget { }, ), IconButton( - icon: const Icon(Symbols.settings), - onPressed: showSettingsSheet, - tooltip: 'postSettings'.tr(), + icon: const Icon(Symbols.tune), + onPressed: () => showSidebar.value = !showSidebar.value, + tooltip: 'sidebar'.tr(), ), Tooltip( message: 'togglePreview'.tr(), @@ -333,17 +332,26 @@ class ArticleComposeScreen extends HookConsumerWidget { ValueListenableBuilder( valueListenable: state.submitting, builder: (context, submitting, _) { - return IconButton( - onPressed: submitting - ? null - : () => ComposeLogic.performAction( - ref, - state, - context, - originalPost: originalPost, - ), - icon: submitting + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + transitionBuilder: + (Widget child, Animation animation) { + return FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: Tween( + begin: 0.8, + end: 1.0, + ).animate(animation), + child: child, + ), + ); + }, + child: submitting ? SizedBox( + key: const ValueKey('submitting'), width: 28, height: 28, child: const CircularProgressIndicator( @@ -351,8 +359,19 @@ class ArticleComposeScreen extends HookConsumerWidget { strokeWidth: 2.5, ), ).center() - : Icon( - originalPost != null ? Symbols.edit : Symbols.upload, + : IconButton( + key: const ValueKey('icon'), + onPressed: () => ComposeLogic.performAction( + ref, + state, + context, + originalPost: originalPost, + ), + icon: Icon( + originalPost != null + ? Symbols.edit + : Symbols.upload, + ), ), ); }, @@ -363,24 +382,53 @@ class ArticleComposeScreen extends HookConsumerWidget { body: Column( children: [ Expanded( - child: Padding( - padding: const EdgeInsets.only(left: 16, right: 16), - child: isWideScreen(context) - ? Row( - spacing: 16, - children: [ - Expanded( - flex: showPreview.value ? 1 : 2, - child: buildEditorPane(), + child: ResponsiveSidebar( + sidebarWidth: 480, + attachmentsContent: ArticleComposeAttachments(state: state), + settingsContent: ComposeSettingsSheet(state: state), + showSidebar: showSidebar, + mainContent: Padding( + padding: const EdgeInsets.only(left: 16, right: 16), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + transitionBuilder: + (Widget child, Animation animation) { + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: + Tween( + begin: const Offset(0, 0.05), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ), + ), + child: child, + ), + ); + }, + child: isWideScreen(context) + ? Row( + spacing: 16, + children: [ + Expanded(child: buildEditorPane()), + if (showPreview.value) + Expanded(child: buildPreviewPane()), + ], + ) + : Container( + key: ValueKey('narrow-${showPreview.value}'), + child: showPreview.value + ? buildPreviewPane() + : buildEditorPane(), ), - if (showPreview.value) const VerticalDivider(), - if (showPreview.value) - Expanded(child: buildPreviewPane()), - ], - ) - : showPreview.value - ? buildPreviewPane() - : buildEditorPane(), + ), + ), ), ), @@ -391,4 +439,4 @@ class ArticleComposeScreen extends HookConsumerWidget { ), ); } -} +} \ No newline at end of file diff --git a/lib/screens/posts/publisher_profile.dart b/lib/screens/posts/publisher_profile.dart index 7d06ec67..3ce5af45 100644 --- a/lib/screens/posts/publisher_profile.dart +++ b/lib/screens/posts/publisher_profile.dart @@ -25,7 +25,7 @@ import 'package:island/widgets/content/markdown.dart'; import 'package:island/widgets/post/post_list.dart'; import 'package:island/widgets/post/post_item.dart'; import 'package:island/widgets/activity_heatmap.dart'; -import 'package:island/widgets/posts/post_filter.dart'; +import 'package:island/widgets/post/filters/post_filter.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:island/services/color_extraction.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; diff --git a/lib/screens/search.dart b/lib/screens/search.dart index 3c52dcbb..ae50472b 100644 --- a/lib/screens/search.dart +++ b/lib/screens/search.dart @@ -15,7 +15,7 @@ import 'package:island/widgets/extended_refresh_indicator.dart'; import 'package:island/widgets/paging/pagination_list.dart'; import 'package:island/widgets/post/post_item.dart'; import 'package:island/widgets/post/post_item_skeleton.dart'; -import 'package:island/widgets/posts/post_filter.dart'; +import 'package:island/widgets/post/filters/post_filter.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; diff --git a/lib/widgets/common/responsive_sidebar.dart b/lib/widgets/common/responsive_sidebar.dart new file mode 100644 index 00000000..de30df3e --- /dev/null +++ b/lib/widgets/common/responsive_sidebar.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/services/responsive.dart'; +import 'package:island/widgets/post/article_sidebar_panel.dart'; + +class ResponsiveSidebar extends HookConsumerWidget { + final Widget attachmentsContent; + final Widget settingsContent; + final Widget mainContent; + final double sidebarWidth; + final ValueNotifier showSidebar; + + const ResponsiveSidebar({ + super.key, + required this.attachmentsContent, + required this.settingsContent, + required this.mainContent, + this.sidebarWidth = 480, + required this.showSidebar, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isWide = isWideScreen(context); + final animationController = useAnimationController( + duration: const Duration(milliseconds: 300), + ); + final animation = useMemoized( + () => Tween(begin: 0, end: 1).animate( + CurvedAnimation(parent: animationController, curve: Curves.easeInOut), + ), + [animationController], + ); + + final showDrawer = useState(false); + final scaffoldKey = useMemoized(() => GlobalKey()); + + useEffect(() { + void listener() { + final currentIsWide = isWideScreen(context); + if (currentIsWide) { + if (showSidebar.value && !showDrawer.value) { + showDrawer.value = true; + animationController.forward(); + } else if (!showSidebar.value && showDrawer.value) { + showDrawer.value = false; + animationController.reverse(); + } + } else { + if (showSidebar.value) { + scaffoldKey.currentState?.openEndDrawer(); + } else { + Navigator.of(context).pop(); + } + } + } + + showSidebar.addListener(listener); + // Set initial state after first frame + WidgetsBinding.instance.addPostFrameCallback((_) => listener()); + + return () => showSidebar.removeListener(listener); + }, []); + + useEffect(() { + void listener() { + if (!animationController.isAnimating && + animationController.value == 0) { + showDrawer.value = false; + } + } + + animationController.addListener(listener); + return () => animationController.removeListener(listener); + }, [animationController]); + + void closeSidebar() { + showSidebar.value = false; + } + + if (isWide) { + return LayoutBuilder( + builder: (context, constraints) { + return AnimatedBuilder( + animation: animation, + builder: (context, child) { + return Stack( + children: [ + _buildWideScreenContent( + context, + constraints, + animation, + mainContent, + ), + if (showDrawer.value) + Positioned( + right: 0, + top: 0, + bottom: 0, + width: sidebarWidth, + child: _buildWideScreenSidebar( + context, + animation, + attachmentsContent, + settingsContent, + closeSidebar, + ), + ), + ], + ); + }, + ); + }, + ); + } else { + return Scaffold( + key: scaffoldKey, + endDrawer: Drawer( + width: sidebarWidth, + child: ArticleSidebarPanelWidget( + attachmentsContent: attachmentsContent, + settingsContent: settingsContent, + onClose: () { + showSidebar.value = false; + Navigator.of(context).pop(); + }, + isWide: false, + width: sidebarWidth, + ), + ), + body: mainContent, + ); + } + } + + Widget _buildWideScreenContent( + BuildContext context, + BoxConstraints constraints, + Animation animation, + Widget mainContent, + ) { + return Positioned( + left: 0, + top: 0, + bottom: 0, + width: constraints.maxWidth - animation.value * sidebarWidth, + child: mainContent, + ); + } + + Widget _buildWideScreenSidebar( + BuildContext context, + Animation animation, + Widget attachmentsContent, + Widget settingsContent, + VoidCallback onClose, + ) { + return Transform.translate( + offset: Offset((1 - animation.value) * sidebarWidth, 0), + child: SizedBox( + width: sidebarWidth, + child: Material( + elevation: 8, + color: Theme.of(context).colorScheme.surfaceContainer, + child: ArticleSidebarPanelWidget( + attachmentsContent: attachmentsContent, + settingsContent: settingsContent, + onClose: onClose, + isWide: true, + width: sidebarWidth, + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/content/attachment_preview.dart b/lib/widgets/content/attachment_preview.dart index ce635d3e..3223d1d7 100644 --- a/lib/widgets/content/attachment_preview.dart +++ b/lib/widgets/content/attachment_preview.dart @@ -95,6 +95,7 @@ class AttachmentPreview extends HookConsumerWidget { final bool isCompact; final String? thumbnailId; final Function(String?)? onSetThumbnail; + final bool bordered; const AttachmentPreview({ super.key, @@ -109,6 +110,7 @@ class AttachmentPreview extends HookConsumerWidget { this.isCompact = false, this.thumbnailId, this.onSetThumbnail, + this.bordered = false, }); // GlobalKey for selector @@ -475,7 +477,7 @@ class AttachmentPreview extends HookConsumerWidget { item.isOnCloud && (item.data as SnCloudFile).id == thumbnailId) Positioned( - top: 8, + bottom: 8, right: 8, child: Container( padding: const EdgeInsets.all(6), @@ -493,10 +495,19 @@ class AttachmentPreview extends HookConsumerWidget { ], ); - final contentWidget = ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Container( + final contentWidget = Container( + decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainer, + border: bordered + ? Border.all( + color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + width: 1, + ) + : null, + borderRadius: BorderRadius.circular(8), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), child: Stack( children: [ if (ratio != null) diff --git a/lib/widgets/debug_sheet.dart b/lib/widgets/debug_sheet.dart index 1564d3ee..780961f4 100644 --- a/lib/widgets/debug_sheet.dart +++ b/lib/widgets/debug_sheet.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/pods/audio.dart'; import 'package:island/pods/message.dart'; import 'package:island/pods/network.dart'; import 'package:island/services/update_service.dart'; @@ -77,7 +78,7 @@ class DebugSheet extends HookConsumerWidget { minTileHeight: 48, leading: const Icon(Symbols.update), trailing: const Icon(Symbols.chevron_right), - title: Text('Force Update'), + title: Text('Force update'), contentPadding: const EdgeInsets.symmetric(horizontal: 24), onTap: () async { // Fetch latest release and show the unified sheet @@ -102,7 +103,7 @@ class DebugSheet extends HookConsumerWidget { minTileHeight: 48, leading: const Icon(Symbols.wifi), trailing: const Icon(Symbols.chevron_right), - title: Text('Connection Status'), + title: Text('Connection status'), contentPadding: EdgeInsets.symmetric(horizontal: 24), onTap: () { showModalBottomSheet( @@ -128,6 +129,23 @@ class DebugSheet extends HookConsumerWidget { }, ), const Divider(height: 8), + ListTile( + minTileHeight: 48, + leading: const Icon(Symbols.play_arrow), + trailing: const Icon(Symbols.chevron_right), + contentPadding: EdgeInsets.symmetric(horizontal: 24), + title: Text('Play untitled'), + onTap: () async { + final synth = MiniSampleSynth( + sampleAsset: 'assets/audio/messages.mp3', + baseNote: 60, + ); + await synth.playMidiAsset( + 'assets/midi/never-gonna-give-you-up.mid', + ); + }, + ), + const Divider(height: 8), ListTile( minTileHeight: 48, leading: const Icon(Symbols.copy_all), diff --git a/lib/widgets/post/article_sidebar_panel.dart b/lib/widgets/post/article_sidebar_panel.dart new file mode 100644 index 00000000..b17c5535 --- /dev/null +++ b/lib/widgets/post/article_sidebar_panel.dart @@ -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.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 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 activePanel, + ColorScheme colorScheme, + ThemeData theme, + ) { + return SegmentedButton( + 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 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; + }), + ), + ); + } +} diff --git a/lib/widgets/post/compose_attachments.dart b/lib/widgets/post/compose_attachments.dart index 7fcd6ca2..f4be87e8 100644 --- a/lib/widgets/post/compose_attachments.dart +++ b/lib/widgets/post/compose_attachments.dart @@ -1,11 +1,15 @@ +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/models/file.dart'; import 'package:island/services/responsive.dart'; +import 'package:island/services/file_uploader.dart'; import 'package:island/widgets/attachment_uploader.dart'; import 'package:island/widgets/content/attachment_preview.dart'; import 'package:island/widgets/post/compose_shared.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. @@ -103,111 +107,256 @@ class ComposeAttachments extends ConsumerWidget { } } -/// A specialized attachment widget for article compose with expansion tile. -class ArticleComposeAttachments extends ConsumerWidget { +class ArticleComposeAttachments extends HookConsumerWidget { final ComposeState state; + final EdgeInsets? padding; - const ArticleComposeAttachments({super.key, required this.state}); + const ArticleComposeAttachments({ + super.key, + required this.state, + this.padding, + }); + + Future _handleDroppedFiles(DropDoneDetails details, ComposeState state) async { + final newFiles = []; + + 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 ValueListenableBuilder( - valueListenable: state.thumbnailId, - builder: (context, thumbnailId, _) { - return ValueListenableBuilder>( - valueListenable: state.attachments, - builder: (context, attachments, _) { - if (attachments.isEmpty) return const SizedBox.shrink(); - return Theme( - data: Theme.of( - context, - ).copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - initiallyExpanded: true, - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('attachments').tr(), - Text( - 'articleAttachmentHint'.tr(), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + return Padding( + padding: padding ?? EdgeInsets.all(16), + child: ValueListenableBuilder( + valueListenable: state.thumbnailId, + builder: (context, thumbnailId, _) { + return ValueListenableBuilder>( + 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>( + 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), + ), + ], + ); + }, + ), ), ), - ], - ), - children: [ - ValueListenableBuilder>( - valueListenable: state.attachmentProgress, - builder: (context, progressMap, _) { - return Wrap( - runSpacing: 8, - spacing: 8, - children: [ - for (var idx = 0; idx < attachments.length; idx++) - SizedBox( - width: 180, - height: 180, - child: AttachmentPreview( - isCompact: true, - 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, - 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, - ), - ), - ), - ], - ); - }, - ), - const SizedBox(height: 16), - ], - ), - ); - }, - ); - }, + ); + }, + ); + }, + ); + }, + ), ); } } + +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(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: animationController, curve: Curves.easeOut), + ); + + final slideAnimation = + Tween(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, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/post/compose_settings_sheet.dart b/lib/widgets/post/compose_settings_sheet.dart index 244a6728..573a76a7 100644 --- a/lib/widgets/post/compose_settings_sheet.dart +++ b/lib/widgets/post/compose_settings_sheet.dart @@ -139,364 +139,350 @@ class ComposeSettingsSheet extends HookConsumerWidget { final tagInputController = useTextEditingController(); - return SheetScaffold( - titleText: 'postSettings'.tr(), - heightFactor: 0.6, - child: 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), - ), + 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, ), - onTapOutside: (_) => - FocusManager.instance.primaryFocus?.unfocus(), - ), - - // Tags field - Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), + border: OutlineInputBorder( 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, + ), + 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.from( + state.tags.value, + )..remove(tag); + state.tags.value = newTags; + }, + child: Icon( + Icons.close, + size: 16, + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + ], + ), + ); + }).toList(), ), - // Existing tags display - if (currentTags.isNotEmpty) - Wrap( - spacing: 8, - runSpacing: 8, - children: currentTags.map((tag) { - return Container( + // Tag input with autocomplete + TypeAheadField( + 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( + 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 ?? []).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, - borderRadius: BorderRadius.circular(16), ), padding: const EdgeInsets.symmetric( horizontal: 12, - vertical: 6, + vertical: 4, ), - 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.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( - 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( - 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 ?? []).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), - ), - ), - ], + margin: const EdgeInsets.only(right: 4), + child: Text( + category.categoryDisplayTitle, + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, + fontSize: 13, + ), ), ), - ); - }, + ], ), ); - }).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, + }).toList(); + }, + buttonStyleData: const ButtonStyleData( + padding: EdgeInsets.only(left: 16, right: 8), + height: 38, + ), + menuItemStyleData: const MenuItemStyleData( + height: 38, + padding: EdgeInsets.zero, + ), + ), + + // Realm selection + DropdownButtonFormField2( + isExpanded: true, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(vertical: 9), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), ), ), - - // Realm selection - DropdownButtonFormField2( - 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( + 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), ), - hint: Text('realm'.tr(), style: const TextStyle(fontSize: 15)), - items: [ + // 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( - value: null, + value: currentRealm, child: Row( children: [ - const CircleAvatar( + ProfilePictureWidget( + file: currentRealm.picture, + fallbackIcon: Symbols.workspaces, radius: 16, - child: Icon(Symbols.link_off, fill: 1), ), const SizedBox(width: 12), - Text('postUnlinkRealm').tr(), + Text(currentRealm.name), ], ).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( - value: currentRealm, + if (userRealms.hasValue) + ...(userRealms.value ?? []).map( + (realm) => DropdownMenuItem( + value: realm, child: Row( children: [ ProfilePictureWidget( - file: currentRealm.picture, + file: realm.picture, fallbackIcon: Symbols.workspaces, radius: 16, ), const SizedBox(width: 12), - Text(currentRealm.name), + Text(realm.name), ], ).padding(left: 16, right: 8), ), - if (userRealms.hasValue) - ...(userRealms.value ?? []).map( - (realm) => DropdownMenuItem( - 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, - ), + ), + ], + 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), + // 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), ), - 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, - ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, ), ), - ], - ), + ), + ], ), ); } diff --git a/lib/widgets/posts/post_filter.dart b/lib/widgets/post/filters/post_filter.dart similarity index 100% rename from lib/widgets/posts/post_filter.dart rename to lib/widgets/post/filters/post_filter.dart diff --git a/lib/widgets/posts/post_subscription_filter.dart b/lib/widgets/post/filters/post_subscription_filter.dart similarity index 100% rename from lib/widgets/posts/post_subscription_filter.dart rename to lib/widgets/post/filters/post_subscription_filter.dart diff --git a/lib/widgets/posts/post_subscription_filter.g.dart b/lib/widgets/post/filters/post_subscription_filter.g.dart similarity index 100% rename from lib/widgets/posts/post_subscription_filter.g.dart rename to lib/widgets/post/filters/post_subscription_filter.g.dart