this repo has no description
at main 296 lines 11 kB view raw
1import 'dart:io'; 2 3import 'package:flutter/cupertino.dart'; 4import 'package:flutter/material.dart'; 5import 'package:flutter/services.dart'; 6import 'package:grain/app_icons.dart'; 7import 'package:grain/widgets/plain_text_field.dart'; 8import 'package:image_picker/image_picker.dart'; 9 10Future<void> showEditProfileSheet( 11 BuildContext context, { 12 required String? initialDisplayName, 13 required String? initialDescription, 14 required String? initialAvatarUrl, 15 required Future<void> Function(String, String, dynamic) onSave, 16 required VoidCallback onCancel, 17}) async { 18 final theme = Theme.of(context); 19 await showCupertinoSheet( 20 context: context, 21 useNestedNavigation: false, 22 pageBuilder: (context) => Material( 23 type: MaterialType.transparency, 24 child: EditProfileSheet( 25 initialDisplayName: initialDisplayName, 26 initialDescription: initialDescription, 27 initialAvatarUrl: initialAvatarUrl, 28 onSave: onSave, 29 onCancel: onCancel, 30 ), 31 ), 32 ); 33 // Restore status bar style or any other cleanup 34 SystemChrome.setSystemUIOverlayStyle( 35 theme.brightness == Brightness.dark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark, 36 ); 37} 38 39class EditProfileSheet extends StatefulWidget { 40 final String? initialDisplayName; 41 final String? initialDescription; 42 final String? initialAvatarUrl; 43 final Future<void> Function(String displayName, String description, XFile? avatar)? onSave; 44 final VoidCallback? onCancel; 45 46 const EditProfileSheet({ 47 super.key, 48 this.initialDisplayName, 49 this.initialDescription, 50 this.initialAvatarUrl, 51 this.onSave, 52 this.onCancel, 53 }); 54 55 @override 56 State<EditProfileSheet> createState() => _EditProfileSheetState(); 57} 58 59class _EditProfileSheetState extends State<EditProfileSheet> { 60 late TextEditingController _displayNameController; 61 late TextEditingController _descriptionController; 62 XFile? _selectedAvatar; 63 bool _saving = false; 64 static const int maxDisplayNameGraphemes = 64; 65 static const int maxDescriptionGraphemes = 256; 66 67 @override 68 void initState() { 69 super.initState(); 70 _displayNameController = TextEditingController(text: widget.initialDisplayName ?? ''); 71 _descriptionController = TextEditingController(text: widget.initialDescription ?? ''); 72 // No need to track changes 73 _displayNameController.addListener(_onInputChanged); 74 _descriptionController.addListener(_onInputChanged); 75 } 76 77 void _onInputChanged() { 78 setState(() { 79 // Trigger rebuild to update character counts 80 }); 81 } 82 83 @override 84 void dispose() { 85 _displayNameController.dispose(); 86 _descriptionController.dispose(); 87 super.dispose(); 88 } 89 90 Future<void> _pickAvatar() async { 91 final picker = ImagePicker(); 92 final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 85); 93 if (picked != null) { 94 setState(() { 95 _selectedAvatar = picked; 96 }); 97 } 98 } 99 100 @override 101 Widget build(BuildContext context) { 102 final theme = Theme.of(context); 103 final avatarRadius = 44.0; 104 final displayNameGraphemes = _displayNameController.text.characters.length; 105 final descriptionGraphemes = _descriptionController.text.characters.length; 106 return CupertinoPageScaffold( 107 backgroundColor: theme.colorScheme.surface, 108 navigationBar: CupertinoNavigationBar( 109 backgroundColor: theme.colorScheme.surface, 110 border: Border(bottom: BorderSide(color: theme.dividerColor, width: 1)), 111 middle: Text( 112 'Edit profile', 113 style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), 114 ), 115 leading: CupertinoButton( 116 padding: EdgeInsets.zero, 117 onPressed: _saving ? null : widget.onCancel, 118 child: Text( 119 'Cancel', 120 style: TextStyle(color: theme.colorScheme.primary, fontWeight: FontWeight.w600), 121 ), 122 ), 123 trailing: CupertinoButton( 124 padding: EdgeInsets.zero, 125 onPressed: _saving 126 ? null 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 } 149 if (widget.onSave != null) { 150 setState(() { 151 _saving = true; 152 }); 153 await widget.onSave!( 154 _displayNameController.text.trim(), 155 _descriptionController.text.trim(), 156 _selectedAvatar, 157 ); 158 setState(() { 159 _saving = false; 160 }); 161 } 162 }, 163 child: Row( 164 mainAxisSize: MainAxisSize.min, 165 children: [ 166 Text( 167 'Save', 168 style: TextStyle( 169 color: _saving ? theme.disabledColor : theme.colorScheme.primary, 170 fontWeight: FontWeight.w600, 171 ), 172 ), 173 if (_saving) ...[ 174 const SizedBox(width: 8), 175 SizedBox( 176 width: 16, 177 height: 16, 178 child: CircularProgressIndicator( 179 strokeWidth: 2, 180 valueColor: AlwaysStoppedAnimation<Color>(theme.colorScheme.primary), 181 semanticsLabel: 'Saving', 182 ), 183 ), 184 ], 185 ], 186 ), 187 ), 188 ), 189 child: SafeArea( 190 bottom: false, 191 child: Padding( 192 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), 193 child: Column( 194 children: [ 195 const SizedBox(height: 8), 196 GestureDetector( 197 onTap: _pickAvatar, 198 child: Stack( 199 alignment: Alignment.center, 200 children: [ 201 CircleAvatar( 202 radius: avatarRadius, 203 backgroundColor: theme.colorScheme.surfaceVariant, 204 backgroundImage: _selectedAvatar != null 205 ? FileImage(File(_selectedAvatar!.path)) 206 : (widget.initialAvatarUrl != null && widget.initialAvatarUrl!.isNotEmpty) 207 ? NetworkImage(widget.initialAvatarUrl!) 208 : null as ImageProvider<Object>?, 209 child: 210 (_selectedAvatar == null && 211 (widget.initialAvatarUrl == null || widget.initialAvatarUrl!.isEmpty)) 212 ? Icon( 213 AppIcons.accountCircle, 214 size: avatarRadius * 2, 215 color: theme.colorScheme.onSurfaceVariant, 216 ) 217 : null, 218 ), 219 Positioned( 220 bottom: 0, 221 right: 0, 222 child: Container( 223 decoration: BoxDecoration( 224 color: theme.colorScheme.primary, 225 shape: BoxShape.circle, 226 ), 227 padding: const EdgeInsets.all(6), 228 child: Icon(AppIcons.camera, color: Colors.white, size: 12), 229 ), 230 ), 231 ], 232 ), 233 ), 234 const SizedBox(height: 16), 235 Expanded( 236 child: SingleChildScrollView( 237 padding: EdgeInsets.zero, 238 child: Column( 239 children: [ 240 PlainTextField( 241 label: 'Display Name', 242 controller: _displayNameController, 243 maxLines: 1, 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 ), 262 const SizedBox(height: 12), 263 PlainTextField( 264 label: 'Description', 265 controller: _descriptionController, 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 ), 284 ), 285 ], 286 ), 287 ), 288 ), 289 const SizedBox(height: 24), 290 ], 291 ), 292 ), 293 ), 294 ); 295 } 296}