Files
2026-04-10 12:05:40 +02:00

612 lines
24 KiB
Dart

import 'dart:io';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../core/utils/validators.dart';
import '../../../core/utils/unit_conversion_utils.dart';
import '../application/profile_controller.dart';
class ProfileSetupScreen extends ConsumerStatefulWidget {
const ProfileSetupScreen({super.key});
@override
ConsumerState<ProfileSetupScreen> createState() => _ProfileSetupScreenState();
}
class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _bioController = TextEditingController();
final _twitterController = TextEditingController();
final _instagramController = TextEditingController();
final _tiktokController = TextEditingController();
final _websiteController = TextEditingController();
final _heightController = TextEditingController();
final _weightController = TextEditingController();
dynamic _avatarFile;
String? _avatarUrl;
bool _isLoading = false;
bool _isCheckingUsername = false;
bool _isUsernameAvailable = true;
String? _usernameError;
Gender? _selectedGender;
DateTime? _selectedBirthDate;
HeightUnit _selectedHeightUnit = HeightUnit.metric;
WeightUnit _selectedWeightUnit = WeightUnit.metric;
final ImagePicker _imagePicker = ImagePicker();
Future<void> _pickAvatar() async {
try {
final XFile? image = await _imagePicker.pickImage(
source: ImageSource.gallery,
maxWidth: 512,
maxHeight: 512,
imageQuality: 85,
);
if (image != null) {
setState(() {
if (kIsWeb) {
_avatarFile = image;
} else {
_avatarFile = File(image.path);
}
});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to pick image: $e')),
);
}
}
}
Future<String?> _uploadAvatar() async {
if (_avatarFile == null) return null;
try {
final client = supabase.Supabase.instance.client;
final userId = client.auth.currentUser?.id;
if (userId == null) return null;
String fileExt;
String fileName;
String filePath;
if (kIsWeb && _avatarFile is XFile) {
fileExt = (_avatarFile as XFile).path.split('.').last;
fileName = '$userId/avatar.$fileExt';
filePath = 'avatars/$fileName';
final bytes = await (_avatarFile as XFile).readAsBytes();
await client.storage.from('avatars').uploadBinary(
filePath,
bytes,
fileOptions: supabase.FileOptions(contentType: 'image/$fileExt'),
);
} else {
fileExt = (_avatarFile as File).path.split('.').last;
fileName = '$userId/avatar.$fileExt';
filePath = 'avatars/$fileName';
await client.storage.from('avatars').upload(filePath, _avatarFile!);
}
final response = client.storage.from('avatars').getPublicUrl(filePath);
return response;
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to upload avatar: $e')),
);
}
return null;
}
}
Future<void> _checkUsernameAvailability(String username) async {
if (username.length < 3) return;
setState(() {
_isCheckingUsername = true;
_usernameError = null;
});
try {
final client = supabase.Supabase.instance.client;
final response = await client
.from('users')
.select('id')
.eq('username', username)
.maybeSingle();
setState(() {
_isUsernameAvailable = response == null;
_isCheckingUsername = false;
if (!_isUsernameAvailable) {
_usernameError = 'Username is already taken';
}
});
} catch (e) {
setState(() {
_isCheckingUsername = false;
});
}
}
Future<void> _handleCompleteSetup() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final client = supabase.Supabase.instance.client;
final userId = client.auth.currentUser?.id;
if (userId == null) {
throw Exception('User not authenticated');
}
String? uploadedAvatarUrl;
if (_avatarFile != null) {
uploadedAvatarUrl = await _uploadAvatar();
}
// Parse height and weight values
double? heightCm;
double? weightKg;
if (_heightController.text.isNotEmpty) {
heightCm = UnitConversionUtils.parseHeight(_heightController.text, _selectedHeightUnit);
}
if (_weightController.text.isNotEmpty) {
weightKg = UnitConversionUtils.parseWeight(_weightController.text, _selectedWeightUnit);
}
await ref.read(profileControllerProvider.notifier).completeProfileSetup(
userId: userId,
username: _usernameController.text.trim(),
bio: _bioController.text.trim().isEmpty ? null : _bioController.text.trim(),
avatarUrl: uploadedAvatarUrl ?? _avatarUrl,
twitterHandle: _twitterController.text.trim().isEmpty
? null
: _twitterController.text.trim(),
instagramHandle: _instagramController.text.trim().isEmpty
? null
: _instagramController.text.trim(),
tiktokHandle: _tiktokController.text.trim().isEmpty
? null
: _tiktokController.text.trim(),
websiteUrl: _websiteController.text.trim().isEmpty
? null
: _websiteController.text.trim(),
gender: _selectedGender,
birthDate: _selectedBirthDate,
heightCm: heightCm,
weightKg: weightKg,
heightUnit: _selectedHeightUnit,
weightUnit: _selectedWeightUnit,
);
if (mounted) {
context.go('/home');
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to complete setup: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
void dispose() {
_usernameController.dispose();
_bioController.dispose();
_twitterController.dispose();
_instagramController.dispose();
_tiktokController.dispose();
_websiteController.dispose();
_heightController.dispose();
_weightController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
title: 'Complete Your Profile',
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 16),
Center(
child: GestureDetector(
onTap: _isLoading ? null : _pickAvatar,
child: Stack(
children: [
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 2,
),
),
child: _avatarFile != null
? ClipOval(
child: Image.file(
_avatarFile!,
fit: BoxFit.cover,
),
)
: _avatarUrl != null
? ClipOval(
child: Image.network(
_avatarUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Icon(
Icons.person,
size: 60,
color: Theme.of(context).colorScheme.onSurfaceVariant,
);
},
),
)
: Icon(
Icons.person,
size: 60,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
if (!_isLoading)
Positioned(
bottom: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(
Icons.camera_alt,
size: 20,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
),
],
),
),
),
const SizedBox(height: 8),
Center(
child: Text(
'Tap to add a photo',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(height: 32),
TextFormField(
controller: _usernameController,
textCapitalization: TextCapitalization.none,
decoration: InputDecoration(
labelText: 'Username',
prefixIcon: const Icon(Icons.alternate_email),
suffixIcon: _isCheckingUsername
? const SizedBox(
width: 20,
height: 20,
child: Padding(
padding: EdgeInsets.all(12.0),
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: _usernameController.text.isNotEmpty && !_isCheckingUsername
? Icon(
_isUsernameAvailable ? Icons.check_circle : Icons.cancel,
color: _isUsernameAvailable ? Colors.green : Colors.red,
)
: null,
border: const OutlineInputBorder(),
),
validator: Validators.validateUsername,
enabled: !_isLoading,
onChanged: (value) {
if (value.length >= 3) {
_checkUsernameAvailability(value.trim());
}
},
),
if (_usernameError != null) ...[
const SizedBox(height: 4),
Text(
_usernameError!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
],
const SizedBox(height: 16),
TextFormField(
controller: _bioController,
maxLines: 3,
maxLength: 150,
decoration: const InputDecoration(
labelText: 'Bio (optional)',
prefixIcon: Icon(Icons.info_outline),
border: OutlineInputBorder(),
helperText: 'Tell others a bit about yourself',
),
enabled: !_isLoading,
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 16),
Text(
'Biometric Information (Optional)',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 16),
// Gender Field
DropdownButtonFormField<Gender>(
initialValue: _selectedGender,
decoration: const InputDecoration(
labelText: 'Gender',
prefixIcon: Icon(Icons.person_outline),
border: OutlineInputBorder(),
),
items: Gender.values.map((gender) {
return DropdownMenuItem(
value: gender,
child: Row(
children: [
Text(gender.emoji),
const SizedBox(width: 8),
Text(gender.displayName),
],
),
);
}).toList(),
onChanged: !_isLoading ? (Gender? value) {
setState(() {
_selectedGender = value;
});
} : null,
),
const SizedBox(height: 16),
// Birth Date Field
InkWell(
onTap: !_isLoading ? () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedBirthDate ?? DateTime.now().subtract(const Duration(days: 365 * 25)),
firstDate: DateTime.now().subtract(const Duration(days: 365 * 120)),
lastDate: DateTime.now().subtract(const Duration(days: 365 * 13)),
);
if (picked != null) {
setState(() {
_selectedBirthDate = picked;
});
}
} : null,
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Birth Date',
prefixIcon: Icon(Icons.cake_outlined),
border: OutlineInputBorder(),
),
child: Text(
_selectedBirthDate != null
? '${_selectedBirthDate!.day}/${_selectedBirthDate!.month}/${_selectedBirthDate!.year}'
: 'Select your birth date',
style: TextStyle(
color: _selectedBirthDate != null
? null
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
),
),
const SizedBox(height: 16),
// Height and Weight Row
Row(
children: [
Expanded(
child: TextFormField(
controller: _heightController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Height (${_selectedHeightUnit == HeightUnit.metric ? 'cm' : 'ft/in'})',
prefixIcon: const Icon(Icons.height_outlined),
border: const OutlineInputBorder(),
suffixIcon: PopupMenuButton<HeightUnit>(
icon: const Icon(Icons.tune),
onSelected: (HeightUnit unit) {
setState(() {
_selectedHeightUnit = unit;
// Convert existing value if needed
if (_heightController.text.isNotEmpty) {
final currentValue = double.tryParse(_heightController.text);
if (currentValue != null) {
double convertedValue;
if (unit == HeightUnit.imperial && _selectedHeightUnit == HeightUnit.metric) {
convertedValue = UnitConversionUtils.cmToInches(currentValue);
_heightController.text = convertedValue.toStringAsFixed(1);
} else if (unit == HeightUnit.metric && _selectedHeightUnit == HeightUnit.imperial) {
convertedValue = UnitConversionUtils.inchesToCm(currentValue);
_heightController.text = convertedValue.toStringAsFixed(1);
}
}
}
});
},
itemBuilder: (context) => [
const PopupMenuItem(value: HeightUnit.metric, child: Text('Metric (cm)')),
const PopupMenuItem(value: HeightUnit.imperial, child: Text('Imperial (ft/in)')),
],
),
),
enabled: !_isLoading,
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _weightController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Weight (${_selectedWeightUnit == WeightUnit.metric ? 'kg' : 'lbs'})',
prefixIcon: const Icon(Icons.monitor_weight_outlined),
border: const OutlineInputBorder(),
suffixIcon: PopupMenuButton<WeightUnit>(
icon: const Icon(Icons.tune),
onSelected: (WeightUnit unit) {
setState(() {
_selectedWeightUnit = unit;
// Convert existing value if needed
if (_weightController.text.isNotEmpty) {
final currentValue = double.tryParse(_weightController.text);
if (currentValue != null) {
double convertedValue;
if (unit == WeightUnit.imperial && _selectedWeightUnit == WeightUnit.metric) {
convertedValue = UnitConversionUtils.kgToLbs(currentValue);
_weightController.text = convertedValue.toStringAsFixed(1);
} else if (unit == WeightUnit.metric && _selectedWeightUnit == WeightUnit.imperial) {
convertedValue = UnitConversionUtils.lbsToKg(currentValue);
_weightController.text = convertedValue.toStringAsFixed(1);
}
}
}
});
},
itemBuilder: (context) => [
const PopupMenuItem(value: WeightUnit.metric, child: Text('Metric (kg)')),
const PopupMenuItem(value: WeightUnit.imperial, child: Text('Imperial (lbs)')),
],
),
),
enabled: !_isLoading,
),
),
],
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 16),
Text(
'Social Links (Optional)',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 16),
TextFormField(
controller: _twitterController,
decoration: const InputDecoration(
labelText: 'Twitter (optional)',
prefixIcon: Icon(Icons.alternate_email),
border: OutlineInputBorder(),
),
enabled: !_isLoading,
),
const SizedBox(height: 12),
TextFormField(
controller: _instagramController,
decoration: const InputDecoration(
labelText: 'Instagram (optional)',
prefixIcon: Icon(Icons.camera_alt_outlined),
border: OutlineInputBorder(),
),
enabled: !_isLoading,
),
const SizedBox(height: 12),
TextFormField(
controller: _tiktokController,
decoration: const InputDecoration(
labelText: 'TikTok (optional)',
prefixIcon: Icon(Icons.music_note_outlined),
border: OutlineInputBorder(),
),
enabled: !_isLoading,
),
const SizedBox(height: 12),
TextFormField(
controller: _websiteController,
decoration: const InputDecoration(
labelText: 'Website (optional)',
prefixIcon: Icon(Icons.link),
border: OutlineInputBorder(),
),
enabled: !_isLoading,
),
const SizedBox(height: 32),
PrimaryButton(
onPressed: () {
if (!_isLoading) {
_handleCompleteSetup();
}
},
text: _isLoading ? 'Saving...' : 'Continue',
isLoading: _isLoading,
),
const SizedBox(height: 16),
TextButton(
onPressed: _isLoading
? null
: () async {
try {
await supabase.Supabase.instance.client.auth.signOut();
if (!context.mounted) return;
context.go('/');
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Sign out failed: $e')),
);
}
},
child: const Text('Sign out'),
),
],
),
),
),
),
);
}
}