feat: add character limit validation and display for display name and description in profile editing and on the gallery sheet

Changed files
+128 -23
lib
+65 -22
lib/widgets/edit_profile_sheet.dart
··· 61 61 late TextEditingController _descriptionController; 62 62 XFile? _selectedAvatar; 63 63 bool _saving = false; 64 - bool _hasChanged = false; 64 + static const int maxDisplayNameGraphemes = 64; 65 + static const int maxDescriptionGraphemes = 256; 65 66 66 67 @override 67 68 void initState() { 68 69 super.initState(); 69 70 _displayNameController = TextEditingController(text: widget.initialDisplayName ?? ''); 70 71 _descriptionController = TextEditingController(text: widget.initialDescription ?? ''); 72 + // No need to track changes 71 73 _displayNameController.addListener(_onInputChanged); 72 74 _descriptionController.addListener(_onInputChanged); 73 75 } 74 76 75 77 void _onInputChanged() { 76 - final displayName = _displayNameController.text.trim(); 77 - final initialDisplayName = widget.initialDisplayName ?? ''; 78 - final displayNameChanged = displayName != initialDisplayName; 79 - final descriptionChanged = 80 - _descriptionController.text.trim() != (widget.initialDescription ?? ''); 81 - final avatarChanged = _selectedAvatar != null; 82 - // Only allow Save if displayName is not empty and at least one field changed 83 - final changed = 84 - (displayNameChanged || descriptionChanged || avatarChanged) && displayName.isNotEmpty; 85 - if (_hasChanged != changed) { 86 - setState(() { 87 - _hasChanged = changed; 88 - }); 89 - } 78 + setState(() { 79 + // Trigger rebuild to update character counts 80 + }); 90 81 } 91 82 92 83 @override 93 84 void dispose() { 94 - _displayNameController.removeListener(_onInputChanged); 95 - _descriptionController.removeListener(_onInputChanged); 96 85 _displayNameController.dispose(); 97 86 _descriptionController.dispose(); 98 87 super.dispose(); ··· 104 93 if (picked != null) { 105 94 setState(() { 106 95 _selectedAvatar = picked; 107 - _onInputChanged(); 108 96 }); 109 97 } 110 98 } ··· 113 101 Widget build(BuildContext context) { 114 102 final theme = Theme.of(context); 115 103 final avatarRadius = 44.0; 104 + final displayNameGraphemes = _displayNameController.text.characters.length; 105 + final descriptionGraphemes = _descriptionController.text.characters.length; 116 106 return CupertinoPageScaffold( 117 107 backgroundColor: theme.colorScheme.surface, 118 108 navigationBar: CupertinoNavigationBar( ··· 132 122 ), 133 123 trailing: CupertinoButton( 134 124 padding: EdgeInsets.zero, 135 - onPressed: (!_hasChanged || _saving) 125 + onPressed: _saving 136 126 ? null 137 127 : () async { 128 + if (displayNameGraphemes > maxDisplayNameGraphemes || 129 + descriptionGraphemes > maxDescriptionGraphemes) { 130 + await showDialog( 131 + context: context, 132 + builder: (context) => AlertDialog( 133 + title: const Text('Character Limit Exceeded'), 134 + content: Text( 135 + displayNameGraphemes > maxDisplayNameGraphemes 136 + ? 'Display Name must be $maxDisplayNameGraphemes characters or fewer.' 137 + : 'Description must be $maxDescriptionGraphemes characters or fewer.', 138 + ), 139 + actions: [ 140 + TextButton( 141 + child: const Text('OK'), 142 + onPressed: () => Navigator.of(context).pop(), 143 + ), 144 + ], 145 + ), 146 + ); 147 + return; 148 + } 138 149 if (widget.onSave != null) { 139 150 setState(() { 140 151 _saving = true; ··· 155 166 Text( 156 167 'Save', 157 168 style: TextStyle( 158 - color: (!_hasChanged || _saving) 159 - ? theme.disabledColor 160 - : theme.colorScheme.primary, 169 + color: _saving ? theme.disabledColor : theme.colorScheme.primary, 161 170 fontWeight: FontWeight.w600, 162 171 ), 163 172 ), ··· 233 242 controller: _displayNameController, 234 243 maxLines: 1, 235 244 ), 245 + Padding( 246 + padding: const EdgeInsets.only(top: 4), 247 + child: Row( 248 + mainAxisAlignment: MainAxisAlignment.spaceBetween, 249 + children: [ 250 + const SizedBox(), 251 + Text( 252 + '$displayNameGraphemes/$maxDisplayNameGraphemes', 253 + style: theme.textTheme.bodySmall?.copyWith( 254 + color: displayNameGraphemes > maxDisplayNameGraphemes 255 + ? theme.colorScheme.error 256 + : theme.textTheme.bodySmall?.color, 257 + ), 258 + ), 259 + ], 260 + ), 261 + ), 236 262 const SizedBox(height: 12), 237 263 PlainTextField( 238 264 label: 'Description', 239 265 controller: _descriptionController, 240 266 maxLines: 6, 267 + ), 268 + Padding( 269 + padding: const EdgeInsets.only(top: 4), 270 + child: Row( 271 + mainAxisAlignment: MainAxisAlignment.spaceBetween, 272 + children: [ 273 + const SizedBox(), 274 + Text( 275 + '$descriptionGraphemes/$maxDescriptionGraphemes', 276 + style: theme.textTheme.bodySmall?.copyWith( 277 + color: descriptionGraphemes > maxDescriptionGraphemes 278 + ? theme.colorScheme.error 279 + : theme.textTheme.bodySmall?.color, 280 + ), 281 + ), 282 + ], 283 + ), 241 284 ), 242 285 ], 243 286 ),