this repo has no description

feat: refactor gallery and profile editing UI to use Cupertino components; enhance user experience with improved layout and functionality

+353 -280
+1 -5
lib/screens/gallery_page.dart
··· 112 112 icon: const Icon(Icons.edit), 113 113 tooltip: 'Edit Gallery', 114 114 onPressed: () async { 115 - await showModalBottomSheet( 116 - context: context, 117 - isScrollControlled: true, 118 - builder: (context) => CreateGalleryPage(gallery: gallery), 119 - ); 115 + await showCreateGallerySheet(context, gallery: gallery); 120 116 _maybeFetchGallery(); 121 117 }, 122 118 ),
+17 -20
lib/screens/profile_page.dart
··· 211 211 size: AppButtonSize.small, 212 212 variant: AppButtonVariant.secondary, 213 213 onPressed: () async { 214 - final bottomSheetContext = context; 215 - await showModalBottomSheet<Map<String, dynamic>>( 216 - context: bottomSheetContext, 217 - isScrollControlled: true, 218 - backgroundColor: Colors.transparent, 219 - builder: (sheetContext) => EditProfileSheet( 220 - initialDisplayName: profile.displayName, 221 - initialDescription: profile.description, 222 - initialAvatarUrl: profile.avatar, 223 - onSave: 224 - (displayName, description, avatarFile) async { 225 - await _handleProfileSave( 226 - profile.did, 227 - displayName, 228 - description, 229 - avatarFile, 230 - ); 231 - }, 232 - onCancel: () => Navigator.of(sheetContext).pop(), 233 - ), 214 + showEditProfileSheet( 215 + context, 216 + initialDisplayName: profile.displayName, 217 + initialDescription: profile.description, 218 + initialAvatarUrl: profile.avatar, 219 + onSave: 220 + (displayName, description, avatarFile) async { 221 + await _handleProfileSave( 222 + profile.did, 223 + displayName, 224 + description, 225 + avatarFile, 226 + ); 227 + }, 228 + onCancel: () { 229 + Navigator.of(context).maybePop(); 230 + }, 234 231 ); 235 232 }, 236 233 label: 'Edit profile',
+179 -116
lib/widgets/edit_profile_sheet.dart
··· 1 1 import 'dart:io'; 2 2 3 + import 'package:flutter/cupertino.dart'; 3 4 import 'package:flutter/material.dart'; 5 + import 'package:flutter/services.dart'; 4 6 import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 5 - import 'package:grain/widgets/app_button.dart'; 6 7 import 'package:grain/widgets/plain_text_field.dart'; 7 8 import 'package:image_picker/image_picker.dart'; 8 9 10 + Future<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 + 9 39 class EditProfileSheet extends StatefulWidget { 10 40 final String? initialDisplayName; 11 41 final String? initialDescription; ··· 31 61 late TextEditingController _descriptionController; 32 62 XFile? _selectedAvatar; 33 63 bool _saving = false; 64 + bool _hasChanged = false; 34 65 35 66 @override 36 67 void initState() { 37 68 super.initState(); 38 69 _displayNameController = TextEditingController(text: widget.initialDisplayName ?? ''); 39 70 _descriptionController = TextEditingController(text: widget.initialDescription ?? ''); 71 + _displayNameController.addListener(_onInputChanged); 72 + _descriptionController.addListener(_onInputChanged); 73 + } 74 + 75 + 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 + } 40 90 } 41 91 42 92 @override 43 93 void dispose() { 94 + _displayNameController.removeListener(_onInputChanged); 95 + _descriptionController.removeListener(_onInputChanged); 44 96 _displayNameController.dispose(); 45 97 _descriptionController.dispose(); 46 98 super.dispose(); ··· 52 104 if (picked != null) { 53 105 setState(() { 54 106 _selectedAvatar = picked; 107 + _onInputChanged(); 55 108 }); 56 109 } 57 110 } 58 111 59 - void _onSave() async { 60 - if (_saving) return; 61 - setState(() => _saving = true); 62 - if (widget.onSave != null) { 63 - await widget.onSave!( 64 - _displayNameController.text.trim(), 65 - _descriptionController.text.trim(), 66 - _selectedAvatar, 67 - ); 68 - } 69 - if (mounted) setState(() => _saving = false); 70 - } 71 - 72 112 @override 73 113 Widget build(BuildContext context) { 74 114 final theme = Theme.of(context); 75 115 final avatarRadius = 44.0; 76 - final double maxHeight = 77 - MediaQuery.of(context).size.height - kToolbarHeight - MediaQuery.of(context).padding.top; 78 - 79 - return ConstrainedBox( 80 - constraints: BoxConstraints(maxHeight: maxHeight), 81 - child: ClipRRect( 82 - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), 83 - child: Container( 84 - color: theme.colorScheme.surface, 85 - child: LayoutBuilder( 86 - builder: (context, constraints) { 87 - return Padding( 88 - padding: EdgeInsets.fromLTRB(16, 16, 16, MediaQuery.of(context).viewInsets.bottom), 89 - child: Column( 90 - mainAxisSize: MainAxisSize.max, 116 + return CupertinoPageScaffold( 117 + backgroundColor: theme.colorScheme.surface, 118 + navigationBar: CupertinoNavigationBar( 119 + backgroundColor: theme.colorScheme.surface, 120 + border: Border(bottom: BorderSide(color: theme.dividerColor, width: 1)), 121 + middle: Text( 122 + 'Edit profile', 123 + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), 124 + ), 125 + leading: CupertinoButton( 126 + padding: EdgeInsets.zero, 127 + onPressed: _saving ? null : widget.onCancel, 128 + child: Text( 129 + 'Cancel', 130 + style: TextStyle(color: theme.colorScheme.primary, fontWeight: FontWeight.w600), 131 + ), 132 + ), 133 + trailing: CupertinoButton( 134 + padding: EdgeInsets.zero, 135 + onPressed: (!_hasChanged || _saving) 136 + ? null 137 + : () async { 138 + if (widget.onSave != null) { 139 + setState(() { 140 + _saving = true; 141 + }); 142 + await widget.onSave!( 143 + _displayNameController.text.trim(), 144 + _descriptionController.text.trim(), 145 + _selectedAvatar, 146 + ); 147 + setState(() { 148 + _saving = false; 149 + }); 150 + } 151 + }, 152 + child: Row( 153 + mainAxisSize: MainAxisSize.min, 154 + children: [ 155 + Text( 156 + 'Save', 157 + style: TextStyle( 158 + color: (!_hasChanged || _saving) 159 + ? theme.disabledColor 160 + : theme.colorScheme.primary, 161 + fontWeight: FontWeight.w600, 162 + ), 163 + ), 164 + if (_saving) ...[ 165 + const SizedBox(width: 8), 166 + SizedBox( 167 + width: 16, 168 + height: 16, 169 + child: CircularProgressIndicator( 170 + strokeWidth: 2, 171 + valueColor: AlwaysStoppedAnimation<Color>(theme.colorScheme.primary), 172 + semanticsLabel: 'Saving', 173 + ), 174 + ), 175 + ], 176 + ], 177 + ), 178 + ), 179 + ), 180 + child: SafeArea( 181 + bottom: false, 182 + child: Padding( 183 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), 184 + child: Column( 185 + children: [ 186 + const SizedBox(height: 8), 187 + GestureDetector( 188 + onTap: _pickAvatar, 189 + child: Stack( 190 + alignment: Alignment.center, 91 191 children: [ 92 - Row( 93 - mainAxisAlignment: MainAxisAlignment.spaceBetween, 94 - children: [ 95 - AppButton( 96 - label: 'Cancel', 97 - size: AppButtonSize.small, 98 - variant: AppButtonVariant.text, 99 - disabled: _saving, 100 - onPressed: widget.onCancel ?? () => Navigator.of(context).maybePop(), 101 - ), 102 - Text( 103 - 'Edit profile', 104 - style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), 105 - ), 106 - AppButton( 107 - label: 'Save', 108 - variant: AppButtonVariant.primary, 109 - loading: _saving, 110 - onPressed: _saving ? null : _onSave, 111 - height: 36, 112 - fontSize: 15, 113 - borderRadius: 22, 114 - padding: const EdgeInsets.symmetric(horizontal: 18), 115 - ), 116 - ], 192 + CircleAvatar( 193 + radius: avatarRadius, 194 + backgroundColor: theme.colorScheme.surfaceVariant, 195 + backgroundImage: _selectedAvatar != null 196 + ? FileImage(File(_selectedAvatar!.path)) 197 + : (widget.initialAvatarUrl != null && widget.initialAvatarUrl!.isNotEmpty) 198 + ? NetworkImage(widget.initialAvatarUrl!) 199 + : null as ImageProvider<Object>?, 200 + child: 201 + (_selectedAvatar == null && 202 + (widget.initialAvatarUrl == null || widget.initialAvatarUrl!.isEmpty)) 203 + ? Icon( 204 + Icons.account_circle, 205 + size: avatarRadius * 2, 206 + color: theme.colorScheme.onSurfaceVariant, 207 + ) 208 + : null, 117 209 ), 118 - const SizedBox(height: 8), 119 - GestureDetector( 120 - onTap: _pickAvatar, 121 - child: Stack( 122 - alignment: Alignment.center, 123 - children: [ 124 - CircleAvatar( 125 - radius: avatarRadius, 126 - backgroundColor: theme.colorScheme.surfaceVariant, 127 - backgroundImage: _selectedAvatar != null 128 - ? FileImage(File(_selectedAvatar!.path)) 129 - : (widget.initialAvatarUrl != null && 130 - widget.initialAvatarUrl!.isNotEmpty) 131 - ? NetworkImage(widget.initialAvatarUrl!) 132 - : null as ImageProvider<Object>?, 133 - child: 134 - (_selectedAvatar == null && 135 - (widget.initialAvatarUrl == null || 136 - widget.initialAvatarUrl!.isEmpty)) 137 - ? Icon( 138 - Icons.account_circle, 139 - size: avatarRadius * 2, 140 - color: theme.colorScheme.onSurfaceVariant, 141 - ) 142 - : null, 143 - ), 144 - Positioned( 145 - bottom: 0, 146 - right: 0, 147 - child: Container( 148 - decoration: BoxDecoration( 149 - color: theme.colorScheme.primary, 150 - shape: BoxShape.circle, 151 - ), 152 - padding: const EdgeInsets.all(6), 153 - child: Icon(FontAwesomeIcons.camera, color: Colors.white, size: 12), 154 - ), 155 - ), 156 - ], 157 - ), 158 - ), 159 - const SizedBox(height: 16), 160 - Expanded( 161 - child: SingleChildScrollView( 162 - padding: EdgeInsets.zero, 163 - child: Column( 164 - children: [ 165 - PlainTextField( 166 - label: 'Display Name', 167 - controller: _displayNameController, 168 - maxLines: 1, 169 - ), 170 - const SizedBox(height: 12), 171 - PlainTextField( 172 - label: 'Description', 173 - controller: _descriptionController, 174 - maxLines: 6, 175 - ), 176 - ], 210 + Positioned( 211 + bottom: 0, 212 + right: 0, 213 + child: Container( 214 + decoration: BoxDecoration( 215 + color: theme.colorScheme.primary, 216 + shape: BoxShape.circle, 177 217 ), 218 + padding: const EdgeInsets.all(6), 219 + child: Icon(FontAwesomeIcons.camera, color: Colors.white, size: 12), 178 220 ), 179 221 ), 180 - const SizedBox(height: 24), 181 222 ], 182 223 ), 183 - ); 184 - }, 224 + ), 225 + const SizedBox(height: 16), 226 + Expanded( 227 + child: SingleChildScrollView( 228 + padding: EdgeInsets.zero, 229 + child: Column( 230 + children: [ 231 + PlainTextField( 232 + label: 'Display Name', 233 + controller: _displayNameController, 234 + maxLines: 1, 235 + ), 236 + const SizedBox(height: 12), 237 + PlainTextField( 238 + label: 'Description', 239 + controller: _descriptionController, 240 + maxLines: 6, 241 + ), 242 + ], 243 + ), 244 + ), 245 + ), 246 + const SizedBox(height: 24), 247 + ], 185 248 ), 186 249 ), 187 250 ),