330 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			330 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
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/models/account.dart';
 | 
						|
import 'package:island/models/autocomplete_response.dart';
 | 
						|
import 'package:island/models/chat.dart';
 | 
						|
import 'package:island/models/publisher.dart';
 | 
						|
import 'package:island/models/realm.dart';
 | 
						|
import 'package:island/models/sticker.dart';
 | 
						|
import 'package:island/services/autocomplete_service.dart';
 | 
						|
import 'package:island/widgets/content/cloud_files.dart';
 | 
						|
import 'package:island/widgets/post/compose_shared.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(
 | 
						|
              fileId: state.currentPublisher.value?.picture?.id,
 | 
						|
              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(fileId: sticker.image.id),
 | 
						|
                        ),
 | 
						|
                      );
 | 
						|
                      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(),
 | 
						|
              ),
 | 
						|
            ),
 | 
						|
          ],
 | 
						|
        ),
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 |