Files
prosappco/lib/screens/profile/profile_screen.dart
T
Lizandro GuarnizoandClaude Opus 5 8631e6f729 fix: port 7 web features and repair the endless-loading screens
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>
2026-08-24 16:45:42 -05:00

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,
);
}
}