Files
prosappco/lib/screens/user/user_view_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

280 lines
9.4 KiB
Dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:prosappco/blocs/professional_list_bloc/professional_list_bloc.dart';
const _kPrimary = Color(0xFF1565C0);
extension _Th on BuildContext {
ThemeData get _t => Theme.of(this);
Color get bg => _t.scaffoldBackgroundColor;
Color get card => _t.cardColor;
Color get onSurface => _t.colorScheme.onSurface;
Color get muted => _t.colorScheme.onSurface.withOpacity(0.55);
Color get subtle => _t.colorScheme.onSurface.withOpacity(0.35);
bool get isDark => _t.brightness == Brightness.dark;
Color get shadowSm =>
isDark ? Colors.transparent : Colors.black.withOpacity(0.05);
}
class UserViewProfileScreen extends StatelessWidget {
final UserProfessional userProfessional;
const UserViewProfileScreen({
super.key,
required this.userProfessional,
});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: context.bg,
body: CustomScrollView(
slivers: [
_SliverHeader(userProfessional: userProfessional),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
sliver: SliverList(
delegate: SliverChildListDelegate([
_nameSection(context),
const SizedBox(height: 16),
if (userProfessional
.professionalInfo.specializations.isNotEmpty)
_sectionCard(
context,
icon: Icons.stars_outlined,
title: 'Especialidades',
child: Wrap(
spacing: 8,
runSpacing: 8,
children: userProfessional
.professionalInfo.specializations
.map((s) => _Chip(label: s))
.toList(),
),
),
if (userProfessional.professionalInfo.specializations.isNotEmpty)
const SizedBox(height: 12),
if (userProfessional.professionalInfo.rate.isNotEmpty)
_sectionCard(
context,
icon: Icons.payments_outlined,
title: 'Tarifa de consulta',
child: Row(
children: [
Text(
_formatCurrency(
int.tryParse(userProfessional
.professionalInfo.rate) ??
0),
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: context.onSurface),
),
const SizedBox(width: 6),
Text('por consulta',
style: TextStyle(
fontSize: 12, color: context.muted)),
],
),
),
if (userProfessional.professionalInfo.rate.isNotEmpty)
const SizedBox(height: 12),
if (userProfessional.professionalInfo.paymentMethods.datafono ||
userProfessional.professionalInfo.paymentMethods.nequi ||
userProfessional
.professionalInfo.paymentMethods.transferencia)
_sectionCard(
context,
icon: Icons.credit_card_outlined,
title: 'Métodos de pago',
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (userProfessional
.professionalInfo.paymentMethods.datafono)
_Chip(label: 'Datafono', icon: Icons.point_of_sale_outlined),
if (userProfessional
.professionalInfo.paymentMethods.nequi)
_Chip(label: 'Nequi', icon: Icons.phone_android_outlined),
if (userProfessional
.professionalInfo.paymentMethods.transferencia)
_Chip(
label: 'Transferencia',
icon: Icons.account_balance_outlined),
],
),
),
]),
),
),
],
),
);
}
Widget _nameSection(BuildContext context) {
return Column(
children: [
Text(
userProfessional.myUser.name ?? '',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: context.onSurface),
),
const SizedBox(height: 4),
Text(
'${userProfessional.professionalInfo.profession} · ${userProfessional.myUser.city ?? ''}',
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 14, color: context.muted),
),
],
);
}
Widget _sectionCard(BuildContext context,
{required IconData icon,
required String title,
required Widget child}) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.card,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(color: context.shadowSm, blurRadius: 10)
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 16, color: _kPrimary),
const SizedBox(width: 7),
Text(title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: _kPrimary)),
],
),
const SizedBox(height: 10),
child,
],
),
);
}
String _formatCurrency(int n) {
final formatter =
NumberFormat.currency(locale: 'es_CO', decimalDigits: 0, symbol: '');
return '\$${formatter.format(n)}';
}
}
class _SliverHeader extends StatelessWidget {
final UserProfessional userProfessional;
const _SliverHeader({required this.userProfessional});
@override
Widget build(BuildContext context) {
return SliverAppBar(
expandedHeight: 220,
pinned: true,
backgroundColor: _kPrimary,
foregroundColor: Colors.white,
elevation: 0,
flexibleSpace: FlexibleSpaceBar(
collapseMode: CollapseMode.parallax,
background: Stack(
fit: StackFit.expand,
children: [
// Banner
Image.network(
userProfessional.professionalInfo.bannerPicture,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Container(color: _kPrimary.withOpacity(0.6)),
),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.45),
],
),
),
),
// Avatar centered at bottom
Positioned(
bottom: 12,
left: 0,
right: 0,
child: Center(
child: Container(
width: 82,
height: 82,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
color: Colors.grey.shade300,
image: userProfessional.myUser.picture == null
? null
: DecorationImage(
image: NetworkImage(
userProfessional.myUser.picture!),
fit: BoxFit.cover),
),
child: userProfessional.myUser.picture == null
? Icon(CupertinoIcons.person,
color: Colors.grey.shade400, size: 40)
: null,
),
),
),
],
),
),
);
}
}
class _Chip extends StatelessWidget {
final String label;
final IconData? icon;
const _Chip({required this.label, this.icon});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: _kPrimary.withOpacity(0.07),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: _kPrimary.withOpacity(0.2)),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
if (icon != null) ...[
Icon(icon, size: 13, color: _kPrimary),
const SizedBox(width: 4),
],
Text(label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _kPrimary)),
]),
);
}
}