[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401import 'dart:io';
import 'package:flutter/services.dart';import 'package:flutter_riverpod/flutter_riverpod.dart';import 'package:material_ui/material_ui.dart';import 'package:image_picker/image_picker.dart';import 'package:spark/src/core/design_system/components/atoms/icons.dart';import 'package:spark/src/core/design_system/tokens/colors.dart';import 'package:spark/src/core/l10n/app_localizations.dart';import 'package:spark/src/core/design_system/tokens/constants.dart';import 'package:spark/src/core/design_system/tokens/typography.dart';import 'package:spark/src/core/ui/widgets/alt_text_editor_dialog.dart';import 'package:spark/src/core/design_system/components/atoms/user_avatar.dart';import 'package:spark/src/features/auth/providers/auth_providers.dart';import 'package:spark/src/features/comments/providers/comment_input_provider.dart';import 'package:spark/src/features/comments/providers/comment_input_state.dart';import 'package:spark/src/features/comments/ui/widgets/emoji_picker.dart';import 'package:spark/src/features/posting/ui/widgets/mention_input_field.dart';import 'package:spark/src/features/profile/providers/profile_provider.dart';
class CommentInputWidget extends ConsumerStatefulWidget { const CommentInputWidget({ required this.videoId, required this.postCid, required this.postUri, required this.isSprk, super.key, this.focusNode, this.rootCid, this.rootUri, }); final String videoId; // Video post info final String postCid; final String postUri; // For replies, parent may be different than the main post final String? rootCid; final String? rootUri; final FocusNode? focusNode; final bool isSprk;
@override ConsumerState<CommentInputWidget> createState() => _CommentInputState();}
class _CommentInputState extends ConsumerState<CommentInputWidget> { final imagePicker = ImagePicker(); static const int _maxChars = AppConstants.replyMaxChars;
@override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final state = ref.watch(commentInputProvider(imagePicker)); final notifier = ref.read(commentInputProvider(imagePicker).notifier); final authState = ref.watch(authProvider); final userDid = authState.did ?? ''; final userHandle = authState.handle ?? ''; final inputController = state.mentionController.textController; return Container( padding: const EdgeInsets.only(left: 16, right: 16, top: 8, bottom: 16),
child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ // Emoji Picker is always displayed at the top EmojiPicker( onEmojiSelected: notifier.insertEmoji, isDarkMode: Theme.of(context).colorScheme.brightness == Brightness.dark, ),
const SizedBox(height: 8),
// Updated input row with centered alignment Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(32), ), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), child: Row( children: [ UserAvatar( imageUrl: ref .read(profileProvider(did: userDid)) .when( data: (profileData) => profileData.profile?.avatar?.toString() ?? '', error: (error, stackTrace) => '', loading: () => '', ), username: userHandle, size: 28, ), const SizedBox(width: 8), Expanded( child: MentionInputField( controller: state.mentionController, onMentionsChanged: (_) {}, hintText: l10n.hintAddComment, maxChars: _maxChars, maxLines: 5, minLines: 1, focusNode: widget.focusNode, enabled: !state.isPosting, ), ), const SizedBox(width: 5), _AttachmentButton(state: state, notifier: notifier), ValueListenableBuilder<TextEditingValue>( valueListenable: inputController, builder: (context, value, child) { final showSendButton = state.canSubmit || state.isPosting; if (!showSendButton) { return const SizedBox.shrink(); }
return Padding( padding: const EdgeInsets.only(left: 4), child: _SendButton( state: state, widget: widget, notifier: notifier, isOverLimit: value.text.runes.length > _maxChars, ), ); }, ), ], ), ),
// Selected Images Preview (only show if images are selected) if (state.selectedImages.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 8), child: _SelectedImagesPreview(state: state, notifier: notifier), ),
// Character counter (show when approaching limit) ValueListenableBuilder<TextEditingValue>( valueListenable: inputController, builder: (context, _, child) => _CharacterCounter( controller: inputController, maxChars: _maxChars, ), ), ], ), ); }}
class _CharacterCounter extends StatelessWidget { const _CharacterCounter({required this.controller, required this.maxChars});
final TextEditingController controller; final int maxChars;
@override Widget build(BuildContext context) { final count = controller.text.runes.length; final showCounter = count >= (maxChars * 0.8); final isNearLimit = count >= maxChars * 0.9; final isOverLimit = count > maxChars;
if (!showCounter) return const SizedBox.shrink();
return Padding( padding: const EdgeInsets.only(top: 4), child: Align( alignment: Alignment.centerRight, child: Text( '$count/$maxChars', style: AppTypography.textExtraSmallMedium.copyWith( color: isOverLimit ? AppColors.red300 : isNearLimit ? AppColors.rajah500 : Theme.of(context).colorScheme.onSurface.withAlpha(160), ), ), ), ); }}
class _SendButton extends StatelessWidget { const _SendButton({ required this.state, required this.widget, required this.notifier, required this.isOverLimit, });
final CommentInputState state; final CommentInputWidget widget; final CommentInput notifier; final bool isOverLimit;
@override Widget build(BuildContext context) { final canSend = state.canSubmit && !isOverLimit && !state.isPosting; final placeholderColor = Theme.of(context).colorScheme.onSurface .withValues(alpha: 128);
if (state.isPosting) { return const Padding( padding: EdgeInsets.all(8), child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ), ); }
return IconButton( icon: AppIcon( AppIconData.send, size: 20, color: canSend ? Theme.of(context).colorScheme.primary : placeholderColor, ), onPressed: canSend ? () { HapticFeedback.mediumImpact(); // Use reply info if available, otherwise use main post info final parentCid = widget.postCid; final parentUri = widget.postUri; final rootCid = widget.rootCid; final rootUri = widget.rootUri;
notifier.submitComment( parentCid: parentCid, parentUri: parentUri, isSprk: widget.isSprk, rootCid: rootCid, rootUri: rootUri, ); } : null, ); }}
class _AttachmentButton extends StatelessWidget { const _AttachmentButton({required this.state, required this.notifier});
final CommentInputState state; final CommentInput notifier;
@override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final canAddMoreImages = state.selectedImages.isEmpty; final enabled = !state.isPosting && canAddMoreImages;
return IconButton( padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, constraints: const BoxConstraints(minWidth: 16, minHeight: 16), onPressed: enabled ? () => notifier.pickImages(context) : null, tooltip: enabled ? l10n.hintAddImage : (state.isPosting ? l10n.messagePostingStory : l10n.messageMaximumImagesReached), icon: AppIcon( AppIconData.gallery, size: 24, color: Theme.of(context).colorScheme.primary, ), ); }}
class _SelectedImagesPreview extends StatelessWidget { const _SelectedImagesPreview({required this.state, required this.notifier});
final CommentInputState state; final CommentInput notifier;
@override Widget build(BuildContext context) { return SizedBox( height: 72, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: state.selectedImages.length, itemBuilder: (context, index) { final imageFile = state.selectedImages[index]; final alt = state.altTexts[imageFile.path]; return Padding( padding: const EdgeInsets.only(right: 8), child: Stack( alignment: Alignment.bottomRight, children: [ // Image Thumbnail with rounded corners and shadow Container( width: 72, height: 72, decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), border: Border.all( color: Theme.of(context).colorScheme.outline, width: 0.5, ), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 26), blurRadius: 4, offset: const Offset(0, 2), ), ], image: DecorationImage( image: FileImage(File(imageFile.path)), fit: BoxFit.cover, ), ), ), // ALT Button (bottom right) Positioned( bottom: 4, right: 4, child: Material( color: Colors.black.withValues(alpha: 128), borderRadius: BorderRadius.circular(8), child: InkWell( onTap: () async { final result = await showDialog<String>( context: context, builder: (context) => AltTextEditorDialog( imageFile: imageFile.path, initialAltText: alt ?? '', ), ); if (result != null) { notifier.updateAltText(imageFile.path, result.trim()); } }, borderRadius: BorderRadius.circular(8), child: const Padding( padding: EdgeInsets.symmetric( horizontal: 8, vertical: 4, ), child: Row( children: [ AppIcon( AppIconData.typo, color: Colors.white, size: 14, ), SizedBox(width: 2), Text( 'ALT', style: TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold, ), ), ], ), ), ), ), ), // Remove Button (top right) Positioned( top: 4, right: 4, child: Material( color: Colors.black.withValues(alpha: 128), shape: const CircleBorder(), child: InkWell( onTap: () => notifier.removeImage(index), customBorder: const CircleBorder(), child: Container( padding: const EdgeInsets.all(2), child: const AppIcon( AppIconData.cancel, color: Colors.white, size: 12, ), ), ), ), ), ], ), ); }, ), ); }}