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}