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:
2026-04-08 21:22:25 +05:30
parent 9f1de2bead
commit d0762668d6

View File

@@ -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),