[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: character limits

+146 -25
+14
lib/src/core/design_system/components/molecules/input_field.dart
··· 9 9 final VoidCallback? onSendMessage; 10 10 final ValueChanged<String>? onSubmitted; 11 11 final TextInputAction? textInputAction; 12 + final int? maxLines; 13 + final int? minLines; 12 14 13 15 const InputField._({ 14 16 required this.controller, ··· 18 20 required this.onSendMessage, 19 21 required this.onSubmitted, 20 22 required this.textInputAction, 23 + required this.maxLines, 24 + required this.minLines, 21 25 super.key, 22 26 }); 23 27 ··· 36 40 onSendMessage: null, 37 41 onSubmitted: null, 38 42 textInputAction: null, 43 + maxLines: null, 44 + minLines: null, 39 45 ); 40 46 41 47 const InputField.chat({ ··· 53 59 actionWidgets: null, 54 60 onSubmitted: null, 55 61 textInputAction: null, 62 + maxLines: null, 63 + minLines: null, 56 64 ); 57 65 58 66 const InputField.search({ ··· 63 71 List<Widget>? actionWidgets, 64 72 ValueChanged<String>? onSubmitted, 65 73 TextInputAction? textInputAction, 74 + int? maxLines, 75 + int? minLines, 66 76 }) : this._( 67 77 key: key, 68 78 controller: controller, ··· 72 82 onSendMessage: null, 73 83 onSubmitted: onSubmitted, 74 84 textInputAction: textInputAction, 85 + maxLines: maxLines, 86 + minLines: minLines, 75 87 ); 76 88 77 89 @override ··· 108 120 controller: controller, 109 121 onSubmitted: onSubmitted, 110 122 textInputAction: textInputAction, 123 + maxLines: maxLines, 124 + minLines: minLines, 111 125 decoration: InputDecoration( 112 126 hintText: hintText, 113 127 prefixIcon: leading,
+26 -9
lib/src/core/design_system/templates/image_review_page_template.dart
··· 42 42 this.onMentionsChanged, 43 43 this.showCrossPostWarning = false, 44 44 this.backgroundColor, 45 + this.isOverLimit = false, 45 46 }); 46 47 47 48 final String title; ··· 68 69 final VoidCallback? onPost; 69 70 final bool isPosting; 70 71 final Color? backgroundColor; 72 + final bool isOverLimit; 71 73 72 74 @override 73 75 Widget build(BuildContext context) { ··· 155 157 ), 156 158 ), 157 159 ) 158 - : LongButton(label: postLabel, onPressed: onPost), 160 + : LongButton( 161 + label: postLabel, 162 + onPressed: isOverLimit ? null : onPost, 163 + ), 159 164 ), 160 165 ), 161 166 ], ··· 363 368 Widget build(BuildContext context) { 364 369 final textController = mentionController?.textController ?? controller; 365 370 final count = textController?.text.runes.length ?? 0; 371 + final showCounter = count >= (maxChars * 0.8); 372 + final isNearLimit = count >= maxChars * 0.9; 373 + final isOverLimit = count > maxChars; 374 + 366 375 return Column( 367 376 crossAxisAlignment: CrossAxisAlignment.start, 368 377 children: [ ··· 375 384 InputField.search( 376 385 controller: controller!, 377 386 hintText: 'Add a description... (optional)', 387 + maxLines: 5, 388 + minLines: 1, 378 389 ), 379 - const SizedBox(height: 8), 380 - Align( 381 - alignment: Alignment.centerRight, 382 - child: Text( 383 - '$count/$maxChars', 384 - style: AppTypography.textSmallMedium.copyWith( 385 - color: Colors.white.withAlpha(160), 390 + if (showCounter) ...[ 391 + const SizedBox(height: 8), 392 + Align( 393 + alignment: Alignment.centerRight, 394 + child: Text( 395 + '$count/$maxChars', 396 + style: AppTypography.textSmallMedium.copyWith( 397 + color: isOverLimit 398 + ? AppColors.red300 399 + : isNearLimit 400 + ? AppColors.rajah500 401 + : Colors.white.withAlpha(160), 402 + ), 386 403 ), 387 404 ), 388 - ), 405 + ], 389 406 ], 390 407 ); 391 408 }
+26 -9
lib/src/core/design_system/templates/video_review_page_template.dart
··· 30 30 this.showCrossPost = true, 31 31 this.aspectRatio = 1.0, 32 32 this.backgroundColor, 33 + this.isOverLimit = false, 33 34 super.key, 34 35 }); 35 36 ··· 50 51 final bool isPosting; 51 52 final double aspectRatio; 52 53 final Color? backgroundColor; 54 + final bool isOverLimit; 53 55 54 56 @override 55 57 Widget build(BuildContext context) { ··· 123 125 ), 124 126 ), 125 127 ) 126 - : LongButton(label: postLabel, onPressed: onPost), 128 + : LongButton( 129 + label: postLabel, 130 + onPressed: isOverLimit ? null : onPost, 131 + ), 127 132 ), 128 133 ), 129 134 ], ··· 216 221 Widget build(BuildContext context) { 217 222 final textController = mentionController?.textController ?? controller; 218 223 final count = textController?.text.runes.length ?? 0; 224 + final showCounter = count >= (maxChars * 0.8); 225 + final isNearLimit = count >= maxChars * 0.9; 226 + final isOverLimit = count > maxChars; 227 + 219 228 return Column( 220 229 crossAxisAlignment: CrossAxisAlignment.start, 221 230 children: [ ··· 228 237 InputField.search( 229 238 controller: controller!, 230 239 hintText: 'Add a description... (optional)', 240 + maxLines: 5, 241 + minLines: 1, 231 242 ), 232 - const SizedBox(height: 8), 233 - Align( 234 - alignment: Alignment.centerRight, 235 - child: Text( 236 - '$count/$maxChars', 237 - style: AppTypography.textSmallMedium.copyWith( 238 - color: Colors.white.withAlpha(160), 243 + if (showCounter) ...[ 244 + const SizedBox(height: 8), 245 + Align( 246 + alignment: Alignment.centerRight, 247 + child: Text( 248 + '$count/$maxChars', 249 + style: AppTypography.textSmallMedium.copyWith( 250 + color: isOverLimit 251 + ? AppColors.red300 252 + : isNearLimit 253 + ? AppColors.rajah500 254 + : Colors.white.withAlpha(160), 255 + ), 239 256 ), 240 257 ), 241 - ), 258 + ], 242 259 ], 243 260 ); 244 261 }
+4
lib/src/core/design_system/tokens/constants.dart
··· 40 40 static const int blur5 = 52; 41 41 static const int blur6 = 62; 42 42 static const int blurGlass = 5; 43 + 44 + // Character limits 45 + static const int postDescriptionMaxChars = 300; 46 + static const int replyMaxChars = 300; 43 47 }
+58 -4
lib/src/features/comments/ui/widgets/comment_input.dart
··· 1 - import 'dart:io'; // Import for File 1 + import 'dart:io'; 2 2 3 3 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 4 4 import 'package:flutter/material.dart'; 5 5 import 'package:flutter/services.dart'; 6 6 import 'package:flutter_riverpod/flutter_riverpod.dart'; 7 - import 'package:image_picker/image_picker.dart'; // Import image_picker 7 + import 'package:image_picker/image_picker.dart'; 8 + import 'package:spark/src/core/design_system/tokens/colors.dart'; 9 + import 'package:spark/src/core/design_system/tokens/constants.dart'; 10 + import 'package:spark/src/core/design_system/tokens/typography.dart'; 8 11 import 'package:spark/src/core/ui/widgets/alt_text_editor_dialog.dart'; 9 12 import 'package:spark/src/core/ui/widgets/user_avatar.dart'; 10 13 import 'package:spark/src/features/auth/providers/auth_providers.dart'; ··· 41 44 class _CommentInputState extends ConsumerState<CommentInputWidget> { 42 45 final textController = TextEditingController(); 43 46 final imagePicker = ImagePicker(); 47 + static const int _maxChars = AppConstants.replyMaxChars; 48 + 44 49 @override 45 50 void initState() { 46 51 super.initState(); 52 + textController.addListener(() { 53 + if (mounted) setState(() {}); 54 + }); 55 + } 56 + 57 + @override 58 + void dispose() { 59 + textController.dispose(); 60 + super.dispose(); 47 61 } 48 62 49 63 @override ··· 111 125 placeholderColor: Theme.of( 112 126 context, 113 127 ).colorScheme.onSurface.withValues(alpha: 128), 128 + isOverLimit: textController.text.runes.length > _maxChars, 114 129 ), 115 130 ), 116 131 ], ··· 123 138 padding: const EdgeInsets.only(top: 8), 124 139 child: _SelectedImagesPreview(state: state, notifier: notifier), 125 140 ), 141 + 142 + // Character counter (show when approaching limit) 143 + _CharacterCounter(controller: textController, maxChars: _maxChars), 126 144 ], 127 145 ), 128 146 ); 129 147 } 130 148 } 131 149 150 + class _CharacterCounter extends StatelessWidget { 151 + const _CharacterCounter({required this.controller, required this.maxChars}); 152 + 153 + final TextEditingController controller; 154 + final int maxChars; 155 + 156 + @override 157 + Widget build(BuildContext context) { 158 + final count = controller.text.runes.length; 159 + final showCounter = count >= (maxChars * 0.8); 160 + final isNearLimit = count >= maxChars * 0.9; 161 + final isOverLimit = count > maxChars; 162 + 163 + if (!showCounter) return const SizedBox.shrink(); 164 + 165 + return Padding( 166 + padding: const EdgeInsets.only(top: 4), 167 + child: Align( 168 + alignment: Alignment.centerRight, 169 + child: Text( 170 + '$count/$maxChars', 171 + style: AppTypography.textExtraSmallMedium.copyWith( 172 + color: isOverLimit 173 + ? AppColors.red300 174 + : isNearLimit 175 + ? AppColors.rajah500 176 + : Theme.of(context).colorScheme.onSurface.withAlpha(160), 177 + ), 178 + ), 179 + ), 180 + ); 181 + } 182 + } 183 + 132 184 class _TextField extends StatelessWidget { 133 185 const _TextField({ 134 186 required this.widget, ··· 137 189 required this.notifier, 138 190 required this.textColor, 139 191 required this.placeholderColor, 192 + required this.isOverLimit, 140 193 }); 141 194 142 195 final CommentInputWidget widget; ··· 145 198 final CommentInput notifier; 146 199 final Color textColor; 147 200 final Color placeholderColor; 201 + final bool isOverLimit; 148 202 149 203 @override 150 204 Widget build(BuildContext context) { ··· 178 232 icon: Icon( 179 233 FluentIcons.send_24_filled, 180 234 size: 20, 181 - color: state.canSubmit 235 + color: state.canSubmit && !isOverLimit 182 236 ? Theme.of(context).colorScheme.primary 183 237 : placeholderColor, 184 238 ), 185 239 onPressed: () { 186 - if (state.canSubmit) { 240 + if (state.canSubmit && !isOverLimit) { 187 241 HapticFeedback.mediumImpact(); 188 242 // Use reply info if available, otherwise use main post info 189 243 final parentCid = widget.postCid;
+5 -1
lib/src/features/posting/ui/pages/image_review_page.dart
··· 6 6 import 'package:get_it/get_it.dart'; 7 7 import 'package:image_picker/image_picker.dart'; 8 8 import 'package:spark/src/core/design_system/templates/image_review_page_template.dart'; 9 + import 'package:spark/src/core/design_system/tokens/constants.dart'; 9 10 import 'package:spark/src/core/network/atproto/atproto.dart'; 10 11 import 'package:spark/src/core/pro_video_editor/pro_video_editor_repository.dart'; 11 12 import 'package:spark/src/core/routing/app_router.dart'; ··· 159 160 Widget build(BuildContext context) { 160 161 final canPickMore = _imageFiles.length < _maxImages; 161 162 final showCrossPostWarning = _crosspostToBsky && _imageFiles.length > 4; 163 + final textLength = _descriptionController.text.runes.length; 164 + final isOverLimit = textLength > AppConstants.postDescriptionMaxChars; 162 165 163 166 return ImageReviewPageTemplate( 164 167 title: 'Review Image Post', ··· 186 189 onMentionsChanged: (mentions) { 187 190 // Mentions are automatically tracked in the controller 188 191 }, 189 - descriptionMaxChars: 300, 192 + descriptionMaxChars: AppConstants.postDescriptionMaxChars, 190 193 crossPostValue: _crosspostToBsky, 191 194 onCrossPostChanged: (v) => setState(() => _crosspostToBsky = v), 192 195 showCrossPostWarning: showCrossPostWarning, 193 196 postLabel: 'Post', 194 197 isPosting: _isPosting, 198 + isOverLimit: isOverLimit, 195 199 onPost: _isPosting 196 200 ? null 197 201 : () async {
+5 -1
lib/src/features/posting/ui/pages/video_review_page.dart
··· 7 7 import 'package:flutter_riverpod/flutter_riverpod.dart'; 8 8 import 'package:image_picker/image_picker.dart'; 9 9 import 'package:spark/src/core/design_system/templates/video_review_page_template.dart'; 10 + import 'package:spark/src/core/design_system/tokens/constants.dart'; 10 11 import 'package:spark/src/core/routing/app_router.dart'; 11 12 import 'package:spark/src/core/ui/widgets/alt_text_editor_dialog.dart'; 12 13 import 'package:spark/src/features/auth/providers/auth_providers.dart'; ··· 147 148 final ar = rawAspectRatio != null && rawAspectRatio > 0 148 149 ? rawAspectRatio 149 150 : 1.0; 151 + final textLength = _descriptionController.text.runes.length; 152 + final isOverLimit = textLength > AppConstants.postDescriptionMaxChars; 150 153 151 154 return VideoReviewPageTemplate( 152 155 title: 'Review Video', ··· 160 163 onMentionsChanged: (mentions) { 161 164 // Mentions are automatically tracked in the controller 162 165 }, 163 - descriptionMaxChars: 300, 166 + descriptionMaxChars: AppConstants.postDescriptionMaxChars, 164 167 showCrossPost: !widget.storyMode, 165 168 crossPostValue: _crosspostToBsky, 166 169 onCrossPostChanged: (v) => setState(() => _crosspostToBsky = v), 167 170 postLabel: 'Post', 168 171 isPosting: _isPosting, 172 + isOverLimit: isOverLimit, 169 173 onPost: _isPosting 170 174 ? null 171 175 : () async {
+8 -1
lib/src/features/posting/ui/widgets/mention_input_field.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 3 import 'package:spark/src/core/design_system/components/molecules/input_field.dart'; 4 + import 'package:spark/src/core/design_system/tokens/constants.dart'; 4 5 import 'package:spark/src/core/utils/text_formatter.dart'; 5 6 import 'package:spark/src/features/posting/models/mention.dart'; 6 7 import 'package:spark/src/features/posting/models/mention_controller.dart'; ··· 11 12 required this.controller, 12 13 required this.onMentionsChanged, 13 14 this.hintText = 'Add a description... (optional)', 14 - this.maxChars = 300, 15 + this.maxChars = AppConstants.postDescriptionMaxChars, 16 + this.maxLines = 5, 17 + this.minLines = 1, 15 18 super.key, 16 19 }); 17 20 ··· 19 22 final ValueChanged<List<Mention>> onMentionsChanged; 20 23 final String hintText; 21 24 final int maxChars; 25 + final int maxLines; 26 + final int minLines; 22 27 23 28 @override 24 29 ConsumerState<MentionInputField> createState() => _MentionInputFieldState(); ··· 296 301 InputField.search( 297 302 controller: widget.controller.textController, 298 303 hintText: widget.hintText, 304 + maxLines: widget.maxLines, 305 + minLines: widget.minLines, 299 306 ), 300 307 if (_showSuggestions && typeaheadState.results.isNotEmpty) 301 308 Container(