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? _userTier;
|
||||||
String? _district;
|
String? _district;
|
||||||
DateTime? _districtChangedAt;
|
DateTime? _districtChangedAt;
|
||||||
|
String? _firstName;
|
||||||
|
String? _lastName;
|
||||||
|
String? _phone;
|
||||||
|
String? _place;
|
||||||
|
String? _pincode;
|
||||||
|
String? _state;
|
||||||
|
String? _country;
|
||||||
final ImagePicker _picker = ImagePicker();
|
final ImagePicker _picker = ImagePicker();
|
||||||
|
|
||||||
// Share rank
|
// Share rank
|
||||||
@@ -206,6 +213,15 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
_districtChangedAt = DateTime.tryParse(districtChangedStr);
|
_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);
|
await _loadEventsForProfile(prefs);
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
}
|
}
|
||||||
@@ -241,10 +257,31 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
if (changedAtStr.isNotEmpty) {
|
if (changedAtStr.isNotEmpty) {
|
||||||
await prefs.setString('district_changed_at', changedAtStr);
|
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) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
if (districtFromServer.isNotEmpty) _district = districtFromServer;
|
if (districtFromServer.isNotEmpty) _district = districtFromServer;
|
||||||
if (changedAtStr.isNotEmpty) _districtChangedAt = DateTime.tryParse(changedAtStr);
|
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 (_) {
|
} catch (_) {
|
||||||
@@ -658,119 +695,130 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openEditDialog() async {
|
Future<void> _openEditDialog() async {
|
||||||
final nameCtl = TextEditingController(text: _username);
|
final firstNameCtl = TextEditingController(text: _firstName ?? '');
|
||||||
final emailCtl = TextEditingController(text: _email);
|
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(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
// nameCtl and emailCtl are disposed via .then() below
|
|
||||||
final theme = Theme.of(ctx);
|
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(
|
return DraggableScrollableSheet(
|
||||||
expand: false,
|
expand: false,
|
||||||
initialChildSize: 0.44,
|
initialChildSize: 0.85,
|
||||||
minChildSize: 0.28,
|
minChildSize: 0.5,
|
||||||
maxChildSize: 0.92,
|
maxChildSize: 0.95,
|
||||||
builder: (context, scrollController) {
|
builder: (context, scrollController) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 18),
|
padding: const EdgeInsets.fromLTRB(18, 14, 18, 32),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.cardColor,
|
color: theme.cardColor,
|
||||||
borderRadius:
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
const BorderRadius.vertical(top: Radius.circular(20)),
|
|
||||||
),
|
),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
// Handle bar
|
||||||
width: 48,
|
Center(
|
||||||
height: 6,
|
child: Container(
|
||||||
|
width: 48, height: 6,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.dividerColor,
|
color: theme.dividerColor,
|
||||||
borderRadius: BorderRadius.circular(6))),
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Header row
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text('Edit profile',
|
Text('Personal Info',
|
||||||
style: theme.textTheme.titleMedium
|
style: theme.textTheme.titleMedium
|
||||||
?.copyWith(fontWeight: FontWeight.bold)),
|
?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.photo_camera),
|
icon: const Icon(Icons.photo_camera),
|
||||||
|
tooltip: 'Change photo',
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
await _pickFromGallery();
|
await _pickFromGallery();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.link),
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
_enterAssetPathDialog();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
|
||||||
controller: nameCtl,
|
// ── Personal info ────────────────────────────────────
|
||||||
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),
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Spacer(),
|
Expanded(
|
||||||
TextButton(
|
child: TextField(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
controller: firstNameCtl,
|
||||||
child: const Text('Cancel')),
|
textCapitalization: TextCapitalization.words,
|
||||||
const SizedBox(width: 8),
|
decoration: inputDecoration.copyWith(labelText: 'First Name *'),
|
||||||
ElevatedButton(
|
),
|
||||||
onPressed: () async {
|
),
|
||||||
final newName = nameCtl.text.trim().isEmpty
|
const SizedBox(width: 10),
|
||||||
? _email
|
Expanded(
|
||||||
: nameCtl.text.trim();
|
child: TextField(
|
||||||
final newEmail = emailCtl.text.trim().isEmpty
|
controller: lastNameCtl,
|
||||||
? _email
|
textCapitalization: TextCapitalization.words,
|
||||||
: emailCtl.text.trim();
|
decoration: inputDecoration.copyWith(labelText: 'Last Name'),
|
||||||
await _saveProfile(newName, newEmail, _profileImage);
|
),
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
child: const Text('Save'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 10),
|
||||||
Text(
|
|
||||||
'Tip: tap the camera icon to pick from gallery (mobile). Or tap the link icon to paste an asset path or URL.',
|
TextField(
|
||||||
style: theme.textTheme.bodySmall
|
controller: emailCtl,
|
||||||
?.copyWith(color: theme.hintColor),
|
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),
|
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(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
_showDistrictPicker();
|
_showDistrictPicker();
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.cardColor,
|
color: theme.cardColor,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@@ -778,12 +826,27 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.location_on_outlined, size: 18),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Column(
|
||||||
_district ?? 'Tap to set district',
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: theme.textTheme.bodyMedium,
|
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(
|
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((_) {
|
).then((_) {
|
||||||
nameCtl.dispose();
|
firstNameCtl.dispose();
|
||||||
|
lastNameCtl.dispose();
|
||||||
emailCtl.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 Icon(Icons.location_on_outlined, color: Colors.white, size: 18),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
_district ?? 'Tap to set district',
|
p?.district ?? _district ?? 'Tap to set district',
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w400),
|
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w400),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
|
|||||||
Reference in New Issue
Block a user