Root cause behind most "stuck loading" reports: the backend changed shape (schedules became an array, location_preferences a string) while the mobile parser still hard-cast to Map/int. The TypeError was swallowed by a silent catch that returned null, and screens only handled the success state, so a parse failure rendered as a permanent spinner. Same class of bug appeared across service lists via non-null map lookups and a total absence of request timeouts. Ported from prosappweb: - in-app suggestions (POST /suggestions) - policies/terms from GET /settings/policies - configurable appointment length (slot_duration_minutes) - block/unblock calendar slots (POST /services/block) - GPS city detection on the profile (Nominatim) - server-side professional search with haversine distance - retry cooldown after a rejected professional application Reliability: - parse schedules array (day_of_week 0=Mon) and string location_preferences - read times as wall clock, so 08:00 stays 08:00 across timezones - carry minutes into hours in TimeOfDay.add; a minute-based step used to loop forever and freeze the calendar (covered by test/time_slots_test.dart) - semver update check instead of string equality, which blocked every build that did not exactly match the configured version - request timeouts across all repositories - surface HTTP >= 400 instead of reporting failed writes as success - error states with retry instead of an indefinite shimmer Includes pre-existing uncommitted work from the UI redesign. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
483 lines
17 KiB
Dart
483 lines
17 KiB
Dart
import 'dart:io';
|
|
import 'package:city_repository/city_repository.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:geolocator/geolocator.dart';
|
|
import 'package:injector/injector.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:intl_phone_field/helpers.dart';
|
|
import 'package:flutter/cupertino.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:prosappco/blocs/auth_bloc/auth_bloc.dart';
|
|
import 'package:prosappco/blocs/my_user_bloc/my_user_bloc.dart';
|
|
import 'package:prosappco/blocs/profile_bloc/profile_bloc.dart';
|
|
import 'package:prosappco/components/birthday_picker.dart';
|
|
import 'package:prosappco/components/gender_dropdown.dart';
|
|
import 'package:prosappco/screens/lists/city_list_screen.dart';
|
|
import 'package:prosappco/screens/profile/components/profile_item.dart';
|
|
import 'package:prosappco/screens/profile/profile_register_email_screen.dart';
|
|
import 'package:prosappco/screens/profile/profile_register_phone_screen.dart';
|
|
import 'package:prosappco/screens/profile/profile_update_password_screen.dart';
|
|
import 'package:prosappco/utils/nominatim_geocoder.dart';
|
|
|
|
const _kPrimary = Color(0xFF1565C0);
|
|
|
|
extension _Th on BuildContext {
|
|
ThemeData get _t => Theme.of(this);
|
|
Color get onSurface => _t.colorScheme.onSurface;
|
|
Color get muted => _t.colorScheme.onSurface.withOpacity(0.55);
|
|
Color get card => _t.cardColor;
|
|
bool get isDark => _t.brightness == Brightness.dark;
|
|
Color get shadowSm =>
|
|
isDark ? Colors.transparent : Colors.black.withOpacity(0.05);
|
|
}
|
|
|
|
class ProfileScreen extends StatefulWidget {
|
|
const ProfileScreen({super.key});
|
|
|
|
@override
|
|
State<ProfileScreen> createState() => _ProfileScreenState();
|
|
}
|
|
|
|
class _ProfileScreenState extends State<ProfileScreen> {
|
|
final TextEditingController _nameController = TextEditingController();
|
|
final TextEditingController _cityController = TextEditingController();
|
|
final TextEditingController _emailController = TextEditingController();
|
|
final TextEditingController _phoneController = TextEditingController();
|
|
final TextEditingController _birthdayController = TextEditingController();
|
|
final TextEditingController _genderController = TextEditingController();
|
|
|
|
XFile? _imageFile;
|
|
bool isLoading = false;
|
|
bool _isLocating = false;
|
|
|
|
late final AuthBloc authBloc;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
authBloc = Injector.appInstance.get<AuthBloc>();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameController.dispose();
|
|
_cityController.dispose();
|
|
_emailController.dispose();
|
|
_phoneController.dispose();
|
|
_birthdayController.dispose();
|
|
_genderController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _useMyLocation() async {
|
|
setState(() => _isLocating = true);
|
|
try {
|
|
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
|
if (!serviceEnabled) throw Exception('Location services disabled');
|
|
|
|
LocationPermission permission = await Geolocator.checkPermission();
|
|
if (permission == LocationPermission.denied) {
|
|
permission = await Geolocator.requestPermission();
|
|
}
|
|
if (permission == LocationPermission.denied ||
|
|
permission == LocationPermission.deniedForever) {
|
|
throw Exception('Location permission denied');
|
|
}
|
|
|
|
final position = await Geolocator.getCurrentPosition();
|
|
final cityName = await NominatimGeocoder.reverseGeocodeCity(
|
|
position.latitude, position.longitude);
|
|
|
|
if (cityName == null) throw Exception('City not resolved');
|
|
|
|
final cities =
|
|
await Injector.appInstance.get<CityRepository>().getCities();
|
|
final normalized = removeDiacritics(cityName.toLowerCase().trim());
|
|
|
|
CityUi? match;
|
|
for (final c in cities) {
|
|
if (removeDiacritics(c.cityName.toLowerCase().trim()) == normalized) {
|
|
match = c;
|
|
break;
|
|
}
|
|
}
|
|
if (match == null) {
|
|
for (final c in cities) {
|
|
final cName = removeDiacritics(c.cityName.toLowerCase());
|
|
if (cName.contains(normalized) || normalized.contains(cName)) {
|
|
match = c;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!mounted) return;
|
|
if (match != null) {
|
|
setState(() => _cityController.text = match!.cityName);
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
|
content: Text(
|
|
'No encontramos tu ciudad en la lista, selecciónala manualmente'),
|
|
));
|
|
}
|
|
} catch (_) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
|
content: Text('No se pudo obtener tu ubicación, selecciona tu ciudad manualmente'),
|
|
));
|
|
} finally {
|
|
if (mounted) setState(() => _isLocating = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocProvider<AuthBloc>(
|
|
create: (_) => authBloc,
|
|
child: BlocListener<ProfileBloc, ProfileState>(
|
|
listener: (context, state) {
|
|
if (state is UpdateUserInfoLoading) {
|
|
setState(() => isLoading = true);
|
|
} else if (state is UpdateUserInfoSuccess) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Información actualizada')));
|
|
setState(() => isLoading = false);
|
|
} else if (state is UpdateUserInfoFailure) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Error al actualizar')));
|
|
setState(() => isLoading = false);
|
|
}
|
|
},
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Mi perfil'),
|
|
backgroundColor: _kPrimary,
|
|
foregroundColor: Colors.white,
|
|
elevation: 0,
|
|
),
|
|
body: BlocBuilder<MyUserBloc, MyUserState>(
|
|
builder: (context, state) {
|
|
if (state.status == MyUserStatus.success) {
|
|
_nameController.text = state.user!.name ?? '';
|
|
_cityController.text = state.user!.city ?? '';
|
|
_emailController.text = state.user!.email ?? '';
|
|
_phoneController.text = state.user!.phone ?? '';
|
|
_birthdayController.text = state.user!.birthday ?? '';
|
|
_genderController.text = state.user!.gender ?? '';
|
|
|
|
return Column(
|
|
children: [
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_avatarSection(state, context),
|
|
const SizedBox(height: 8),
|
|
_formSection(state, context),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const Divider(height: 1, thickness: 0.5),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
vertical: 12, horizontal: 16),
|
|
child: _saveButton(state, context),
|
|
),
|
|
],
|
|
);
|
|
} else {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _avatarSection(MyUserState state, BuildContext context) {
|
|
return Container(
|
|
color: _kPrimary,
|
|
padding: const EdgeInsets.only(bottom: 28),
|
|
child: Center(
|
|
child: Stack(
|
|
children: [
|
|
GestureDetector(
|
|
onTap: () async {
|
|
final picker = ImagePicker();
|
|
final image = await picker.pickImage(
|
|
source: ImageSource.gallery,
|
|
maxHeight: 500,
|
|
maxWidth: 500,
|
|
imageQuality: 40,
|
|
);
|
|
if (image != null) setState(() => _imageFile = image);
|
|
},
|
|
child: Hero(
|
|
tag: 'picture-profile',
|
|
child: _avatarContainer(state),
|
|
),
|
|
),
|
|
Positioned(
|
|
bottom: 0,
|
|
right: 0,
|
|
child: Container(
|
|
width: 30,
|
|
height: 30,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
shape: BoxShape.circle,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.15), blurRadius: 4)
|
|
],
|
|
),
|
|
child:
|
|
const Icon(Icons.camera_alt_outlined, size: 16, color: _kPrimary),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _avatarContainer(MyUserState state) {
|
|
ImageProvider<Object>? imageProvider;
|
|
if (_imageFile?.path != null && _imageFile!.path.isNotEmpty) {
|
|
imageProvider = FileImage(File(_imageFile!.path));
|
|
} else if (state.user?.picture != null &&
|
|
state.user!.picture!.isNotEmpty) {
|
|
imageProvider = NetworkImage(state.user!.picture!);
|
|
}
|
|
|
|
return Container(
|
|
width: 96,
|
|
height: 96,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.2),
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: Colors.white, width: 3),
|
|
image: imageProvider == null
|
|
? null
|
|
: DecorationImage(image: imageProvider, fit: BoxFit.cover),
|
|
),
|
|
child: imageProvider == null
|
|
? const Icon(CupertinoIcons.person, color: Colors.white70, size: 44)
|
|
: null,
|
|
);
|
|
}
|
|
|
|
Widget _formSection(MyUserState state, BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
const SizedBox(height: 16),
|
|
_field(
|
|
controller: _nameController,
|
|
label: 'Nombre',
|
|
icon: Icons.person_outline_rounded,
|
|
validator: (v) =>
|
|
(v == null || v.isEmpty) ? 'Ingresa tu nombre' : null,
|
|
),
|
|
const SizedBox(height: 14),
|
|
TextFormField(
|
|
controller: _cityController,
|
|
readOnly: true,
|
|
onTap: () async {
|
|
final cityName = await Navigator.push<String>(
|
|
context,
|
|
CupertinoPageRoute(builder: (_) => const CityListScreen()),
|
|
);
|
|
if (cityName != null) _cityController.text = cityName;
|
|
},
|
|
decoration: _inputDeco(
|
|
'Ciudad',
|
|
Icons.location_city_outlined,
|
|
hint: 'Selecciona tu ciudad',
|
|
suffixIcon: _isLocating
|
|
? const Padding(
|
|
padding: EdgeInsets.all(12),
|
|
child: SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
)
|
|
: IconButton(
|
|
icon: const Icon(Icons.my_location_outlined),
|
|
tooltip: 'Usar mi ubicación',
|
|
onPressed: _useMyLocation,
|
|
),
|
|
),
|
|
),
|
|
if (_birthdayController.text.isEmpty &&
|
|
_genderController.text.isEmpty) ...[
|
|
const SizedBox(height: 14),
|
|
BirthdayPicker(
|
|
onDateSelected: (d) => _birthdayController.text =
|
|
DateFormat('dd/MM/yyyy').format(d),
|
|
controller: _birthdayController,
|
|
),
|
|
const SizedBox(height: 14),
|
|
GenderDropdown(controller: _genderController),
|
|
],
|
|
const SizedBox(height: 20),
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: context.card,
|
|
borderRadius: BorderRadius.circular(12),
|
|
boxShadow: [BoxShadow(color: context.shadowSm, blurRadius: 8)],
|
|
),
|
|
child: Column(
|
|
children: [
|
|
ProfileItem(
|
|
title: 'Inicio de sesión con correo',
|
|
subtitle: _emailController.text,
|
|
leading: Icons.email_outlined,
|
|
onTap: () {
|
|
if (!_validateProfile(state, context)) return;
|
|
Navigator.push(
|
|
context,
|
|
CupertinoPageRoute(
|
|
builder: (_) => _emailController.text.isEmpty
|
|
? ProfileRegisterEmailScreen()
|
|
: ProfileUpdatePasswordScreen(
|
|
email: _emailController.text),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
Divider(
|
|
height: 1,
|
|
thickness: 0.5,
|
|
color: context.onSurface.withOpacity(0.08)),
|
|
ProfileItem(
|
|
title: 'Inicio de sesión con celular',
|
|
subtitle: _phoneController.text,
|
|
leading: Icons.phone_iphone_rounded,
|
|
onTap: () {
|
|
if (!_validateProfile(state, context)) return;
|
|
if (_phoneController.text.isEmpty) {
|
|
Navigator.push(
|
|
context,
|
|
CupertinoPageRoute(
|
|
builder: (_) =>
|
|
const ProfileRegisterPhoneScreen()),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
bool _validateProfile(MyUserState state, BuildContext context) {
|
|
if (state.user!.name == null || state.user!.name == '') {
|
|
ScaffoldMessenger.of(context).clearSnackBars();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Por favor, ingresa tu nombre')));
|
|
return false;
|
|
}
|
|
if (state.user!.city == null || state.user!.city == '') {
|
|
ScaffoldMessenger.of(context).clearSnackBars();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Por favor, ingresa tu ciudad')));
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
Widget _saveButton(MyUserState state, BuildContext context) {
|
|
return ElevatedButton(
|
|
onPressed: () {
|
|
if (isLoading) return;
|
|
if (_nameController.text.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Por favor, ingresa tu nombre')));
|
|
return;
|
|
}
|
|
if (_cityController.text.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Por favor, ingresa tu ciudad')));
|
|
return;
|
|
}
|
|
final myUser = state.user!.copyWith(
|
|
name: _nameController.text,
|
|
city: _cityController.text,
|
|
nickname: _nameController.text.trim().toLowerCase(),
|
|
email: _emailController.text,
|
|
phone: _phoneController.text,
|
|
birthday: _birthdayController.text,
|
|
gender: _genderController.text,
|
|
);
|
|
context
|
|
.read<ProfileBloc>()
|
|
.add(UpdateUserInfo(myUser: myUser, filePicture: _imageFile?.path));
|
|
ScaffoldMessenger.of(context).clearSnackBars();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Actualizando información...')));
|
|
Navigator.pop(context);
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: _kPrimary,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(vertical: 15),
|
|
elevation: 0,
|
|
shape:
|
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
),
|
|
child: isLoading
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2, color: Colors.white))
|
|
: const Text('Guardar cambios',
|
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
|
);
|
|
}
|
|
|
|
static InputDecoration _inputDeco(String label, IconData icon,
|
|
{String? hint, Widget? suffixIcon}) {
|
|
return InputDecoration(
|
|
labelText: label,
|
|
hintText: hint,
|
|
prefixIcon: Icon(icon),
|
|
suffixIcon: suffixIcon,
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: const BorderSide(color: _kPrimary, width: 2),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _field({
|
|
required TextEditingController controller,
|
|
required String label,
|
|
required IconData icon,
|
|
String? Function(String?)? validator,
|
|
}) {
|
|
return TextFormField(
|
|
controller: controller,
|
|
decoration: _inputDeco(label, icon),
|
|
validator: validator,
|
|
);
|
|
}
|
|
}
|