feat(profile): full personal info form in edit profile sheet
Adds all fields to the edit profile bottom sheet: - First Name / Last Name (side by side), Email, Phone - Location section: Home District (locked with "Next change" date), Place, Pincode, State, Country - Saves all fields via update-profile API and persists to prefs - Loads existing values from prefs on open; refreshes from status API on every profile open so fields stay in sync with server Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,13 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
String? _userTier;
|
||||
String? _district;
|
||||
DateTime? _districtChangedAt;
|
||||
String? _firstName;
|
||||
String? _lastName;
|
||||
String? _phone;
|
||||
String? _place;
|
||||
String? _pincode;
|
||||
String? _state;
|
||||
String? _country;
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
|
||||
// Share rank
|
||||
@@ -206,6 +213,15 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
_districtChangedAt = DateTime.tryParse(districtChangedStr);
|
||||
}
|
||||
|
||||
// Personal info fields
|
||||
_firstName = prefs.getString('first_name');
|
||||
_lastName = prefs.getString('last_name');
|
||||
_phone = prefs.getString('phone_number');
|
||||
_place = prefs.getString('place');
|
||||
_pincode = prefs.getString('pincode');
|
||||
_state = prefs.getString('state');
|
||||
_country = prefs.getString('country');
|
||||
|
||||
await _loadEventsForProfile(prefs);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
@@ -241,10 +257,31 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
if (changedAtStr.isNotEmpty) {
|
||||
await prefs.setString('district_changed_at', changedAtStr);
|
||||
}
|
||||
// Personal info fields from status response
|
||||
final fields = <String, String>{
|
||||
'first_name': res['first_name']?.toString() ?? '',
|
||||
'last_name': res['last_name']?.toString() ?? '',
|
||||
'phone_number': res['phone_number']?.toString() ?? '',
|
||||
'place': res['place']?.toString() ?? '',
|
||||
'pincode': res['pincode']?.toString() ?? '',
|
||||
'state': res['state']?.toString() ?? '',
|
||||
'country': res['country']?.toString() ?? '',
|
||||
};
|
||||
for (final e in fields.entries) {
|
||||
if (e.value.isNotEmpty) await prefs.setString(e.key, e.value);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (districtFromServer.isNotEmpty) _district = districtFromServer;
|
||||
if (changedAtStr.isNotEmpty) _districtChangedAt = DateTime.tryParse(changedAtStr);
|
||||
if (fields['first_name']!.isNotEmpty) _firstName = fields['first_name'];
|
||||
if (fields['last_name']!.isNotEmpty) _lastName = fields['last_name'];
|
||||
if (fields['phone_number']!.isNotEmpty) _phone = fields['phone_number'];
|
||||
if (fields['place']!.isNotEmpty) _place = fields['place'];
|
||||
if (fields['pincode']!.isNotEmpty) _pincode = fields['pincode'];
|
||||
if (fields['state']!.isNotEmpty) _state = fields['state'];
|
||||
if (fields['country']!.isNotEmpty) _country = fields['country'];
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
@@ -658,119 +695,130 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
}
|
||||
|
||||
Future<void> _openEditDialog() async {
|
||||
final nameCtl = TextEditingController(text: _username);
|
||||
final emailCtl = TextEditingController(text: _email);
|
||||
final firstNameCtl = TextEditingController(text: _firstName ?? '');
|
||||
final lastNameCtl = TextEditingController(text: _lastName ?? '');
|
||||
final emailCtl = TextEditingController(text: _email == 'not provided' ? '' : _email);
|
||||
final phoneCtl = TextEditingController(text: _phone ?? '');
|
||||
final placeCtl = TextEditingController(text: _place ?? '');
|
||||
final pincodeCtl = TextEditingController(text: _pincode ?? '');
|
||||
final stateCtl = TextEditingController(text: _state ?? '');
|
||||
final countryCtl = TextEditingController(text: _country ?? '');
|
||||
final gamDistrict = context.read<GamificationProvider>().profile?.district;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (ctx) {
|
||||
// nameCtl and emailCtl are disposed via .then() below
|
||||
final theme = Theme.of(ctx);
|
||||
final inputDecoration = InputDecoration(
|
||||
filled: true,
|
||||
fillColor: theme.cardColor,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
);
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.44,
|
||||
minChildSize: 0.28,
|
||||
maxChildSize: 0.92,
|
||||
initialChildSize: 0.85,
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.95,
|
||||
builder: (context, scrollController) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 18),
|
||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 32),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 6,
|
||||
// Handle bar
|
||||
Center(
|
||||
child: Container(
|
||||
width: 48, height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.dividerColor,
|
||||
borderRadius: BorderRadius.circular(6))),
|
||||
color: theme.dividerColor,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Header row
|
||||
Row(
|
||||
children: [
|
||||
Text('Edit profile',
|
||||
Text('Personal Info',
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.photo_camera),
|
||||
tooltip: 'Change photo',
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
await _pickFromGallery();
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.link),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_enterAssetPathDialog();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: nameCtl,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Name',
|
||||
filled: true,
|
||||
fillColor: theme.cardColor,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8)))),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: emailCtl,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Email',
|
||||
filled: true,
|
||||
fillColor: theme.cardColor,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8)))),
|
||||
const SizedBox(height: 14),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Personal info ────────────────────────────────────
|
||||
Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel')),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final newName = nameCtl.text.trim().isEmpty
|
||||
? _email
|
||||
: nameCtl.text.trim();
|
||||
final newEmail = emailCtl.text.trim().isEmpty
|
||||
? _email
|
||||
: emailCtl.text.trim();
|
||||
await _saveProfile(newName, newEmail, _profileImage);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Save'),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: firstNameCtl,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: inputDecoration.copyWith(labelText: 'First Name *'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: lastNameCtl,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: inputDecoration.copyWith(labelText: 'Last Name'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tip: tap the camera icon to pick from gallery (mobile). Or tap the link icon to paste an asset path or URL.',
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: theme.hintColor),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
TextField(
|
||||
controller: emailCtl,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: inputDecoration.copyWith(labelText: 'Email *'),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
TextField(
|
||||
controller: phoneCtl,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: inputDecoration.copyWith(labelText: 'Phone'),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// PROF-002: District — tap to open dedicated picker sheet
|
||||
// ── Location ─────────────────────────────────────────
|
||||
Text('Location',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.hintColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.8,
|
||||
)),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// District — tap to open picker (locked with cooldown)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
_showDistrictPicker();
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -778,12 +826,27 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.location_on_outlined, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_district ?? 'Tap to set district',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Home District',
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: theme.hintColor)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
gamDistrict ?? _district ?? 'Tap to set district',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
if (_districtLocked) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Next change: $_districtNextChange',
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: Colors.amber[700]),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
@@ -795,7 +858,122 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
TextField(
|
||||
controller: placeCtl,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: inputDecoration.copyWith(labelText: 'Place'),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
TextField(
|
||||
controller: pincodeCtl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: inputDecoration.copyWith(labelText: 'Pincode'),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
TextField(
|
||||
controller: stateCtl,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: inputDecoration.copyWith(labelText: 'State'),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
TextField(
|
||||
controller: countryCtl,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: inputDecoration.copyWith(labelText: 'Country'),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Save / Cancel ─────────────────────────────────────
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final firstName = firstNameCtl.text.trim();
|
||||
final lastName = lastNameCtl.text.trim();
|
||||
final email = emailCtl.text.trim();
|
||||
final phone = phoneCtl.text.trim();
|
||||
final place = placeCtl.text.trim();
|
||||
final pincode = pincodeCtl.text.trim();
|
||||
final stateVal = stateCtl.text.trim();
|
||||
final country = countryCtl.text.trim();
|
||||
|
||||
Navigator.of(context).pop();
|
||||
|
||||
try {
|
||||
// Build display name from first + last
|
||||
final displayName = [firstName, lastName]
|
||||
.where((s) => s.isNotEmpty)
|
||||
.join(' ');
|
||||
|
||||
// API call
|
||||
await _apiClient.post(
|
||||
ApiEndpoints.updateProfile,
|
||||
body: {
|
||||
if (firstName.isNotEmpty) 'first_name': firstName,
|
||||
if (lastName.isNotEmpty) 'last_name': lastName,
|
||||
if (email.isNotEmpty) 'email': email,
|
||||
if (phone.isNotEmpty) 'phone_number': phone,
|
||||
if (place.isNotEmpty) 'place': place,
|
||||
if (pincode.isNotEmpty) 'pincode': pincode,
|
||||
if (stateVal.isNotEmpty) 'state': stateVal,
|
||||
if (country.isNotEmpty) 'country': country,
|
||||
},
|
||||
);
|
||||
|
||||
// Persist to prefs
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (firstName.isNotEmpty) await prefs.setString('first_name', firstName);
|
||||
if (lastName.isNotEmpty) await prefs.setString('last_name', lastName);
|
||||
if (phone.isNotEmpty) await prefs.setString('phone_number', phone);
|
||||
if (place.isNotEmpty) await prefs.setString('place', place);
|
||||
if (pincode.isNotEmpty) await prefs.setString('pincode', pincode);
|
||||
if (stateVal.isNotEmpty) await prefs.setString('state', stateVal);
|
||||
if (country.isNotEmpty) await prefs.setString('country', country);
|
||||
|
||||
// Update display name if first name provided
|
||||
if (displayName.isNotEmpty) {
|
||||
await _saveProfile(displayName, email.isNotEmpty ? email : _email, _profileImage);
|
||||
} else if (email.isNotEmpty) {
|
||||
await _saveProfile(_username, email, _profileImage);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (firstName.isNotEmpty) _firstName = firstName;
|
||||
if (lastName.isNotEmpty) _lastName = lastName;
|
||||
if (phone.isNotEmpty) _phone = phone;
|
||||
if (place.isNotEmpty) _place = place;
|
||||
if (pincode.isNotEmpty) _pincode = pincode;
|
||||
if (stateVal.isNotEmpty) _state = stateVal;
|
||||
if (country.isNotEmpty) _country = country;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Profile updated')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(userFriendlyError(e))),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -804,8 +982,14 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
);
|
||||
},
|
||||
).then((_) {
|
||||
nameCtl.dispose();
|
||||
firstNameCtl.dispose();
|
||||
lastNameCtl.dispose();
|
||||
emailCtl.dispose();
|
||||
phoneCtl.dispose();
|
||||
placeCtl.dispose();
|
||||
pincodeCtl.dispose();
|
||||
stateCtl.dispose();
|
||||
countryCtl.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1084,7 +1268,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
const Icon(Icons.location_on_outlined, color: Colors.white, size: 18),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_district ?? 'Tap to set district',
|
||||
p?.district ?? _district ?? 'Tap to set district',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w400),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
|
||||
Reference in New Issue
Block a user