531 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			531 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| 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/models/post_category.dart';
 | |
| import 'package:island/models/post_tag.dart';
 | |
| import 'package:island/models/realm.dart';
 | |
| import 'package:island/pods/network.dart';
 | |
| import 'package:island/screens/realm/realms.dart';
 | |
| import 'package:island/widgets/content/cloud_files.dart';
 | |
| import 'package:island/widgets/content/sheet.dart';
 | |
| import 'package:island/widgets/post/compose_shared.dart';
 | |
| import 'package:material_symbols_icons/symbols.dart';
 | |
| import 'package:riverpod_annotation/riverpod_annotation.dart';
 | |
| import 'package:styled_widget/styled_widget.dart';
 | |
| 
 | |
| part 'compose_settings_sheet.g.dart';
 | |
| 
 | |
| @riverpod
 | |
| Future<List<SnPostCategory>> postCategories(Ref ref) async {
 | |
|   final apiClient = ref.watch(apiClientProvider);
 | |
|   final resp = await apiClient.get('/sphere/posts/categories');
 | |
|   final categories =
 | |
|       resp.data
 | |
|           .map((e) => SnPostCategory.fromJson(e))
 | |
|           .cast<SnPostCategory>()
 | |
|           .toList();
 | |
|   // Remove duplicates based on id
 | |
|   final uniqueCategories = <String, SnPostCategory>{};
 | |
|   for (final category in categories) {
 | |
|     uniqueCategories[category.id] = category;
 | |
|   }
 | |
|   return uniqueCategories.values.toList();
 | |
| }
 | |
| 
 | |
| 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 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),
 | |
|                 ),
 | |
|               ),
 | |
|               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 ?? <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 ?? []).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(
 | |
|                           fileId: currentRealm.picture?.id,
 | |
|                           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(
 | |
|                             fileId: realm.picture?.id,
 | |
|                             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(
 | |
|                           fileId: currentRealm.picture?.id,
 | |
|                           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,
 | |
|                 ),
 | |
|               ),
 | |
|             ),
 | |
|           ],
 | |
|         ),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 |