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>
This commit is contained in:
Lizandro Guarnizo
2026-08-24 16:45:42 -05:00
co-authored by Claude Opus 5
parent 06a89df690
commit 8631e6f729
86 changed files with 5470 additions and 5291 deletions
@@ -195,6 +195,7 @@ class UserCalendarScreenState extends State<UserCalendarScreen> {
List<TimeOfDay> ranges = TimeOfDayUtils.genRanges(
schedule.range1Hour1!,
schedule.range2Hour2!,
stepMinutes: widget.userProfessional.professionalInfo.slotDurationMinutes,
);
return rangesItemList(ranges, _services, today);
@@ -215,10 +216,12 @@ class UserCalendarScreenState extends State<UserCalendarScreen> {
List<TimeOfDay> ranges1 = TimeOfDayUtils.genRanges(
schedule.range1Hour1!,
schedule.range1Hour2!,
stepMinutes: widget.userProfessional.professionalInfo.slotDurationMinutes,
);
List<TimeOfDay> ranges2 = TimeOfDayUtils.genRanges(
schedule.range2Hour1!,
schedule.range2Hour2!,
stepMinutes: widget.userProfessional.professionalInfo.slotDurationMinutes,
);
return [
+51 -10
View File
@@ -19,8 +19,11 @@ import 'package:prosappco/blocs/service_bloc/service_bloc.dart';
import 'package:prosappco/constansts.dart';
import 'package:prosappco/local_notifications/local_notifications.dart';
import 'package:prosappco/screens/lists/professional_list_screen.dart';
import 'package:prosappco/screens/profile/profile_screen.dart';
import 'package:prosappco/screens/user/user_service_screen.dart';
import 'package:prosappco/utils/nominatim_geocoder.dart';
import 'package:prosappco/utils/time_of_day_extension.dart';
import 'package:prosappco/utils/version_utils.dart';
import 'package:service_repository/service_repository.dart';
import 'package:setting_repository/setting_repository.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -42,7 +45,7 @@ class _UserMapScreenState extends State<UserMapScreen> {
final settingRepository = Injector.appInstance.get<SettingRepository>();
SettingEntity? settings;
late String appVersion;
String appVersion = '';
final DateFormat formatter = DateFormat('dd/MM/yyyy');
@@ -125,7 +128,7 @@ class _UserMapScreenState extends State<UserMapScreen> {
void _checkForUpdate(SettingEntity settings) {
if (Platform.isAndroid) {
if (appVersion != settings.versionAndroid) {
if (isUpdateRequired(appVersion, settings.versionAndroid)) {
showDialog(
context: context,
builder: (context) {
@@ -165,7 +168,7 @@ class _UserMapScreenState extends State<UserMapScreen> {
}
}
if (Platform.isIOS) {
if (appVersion != settings.versionIos) {
if (isUpdateRequired(appVersion, settings.versionIos)) {
showDialog(
context: context,
builder: (context) {
@@ -377,9 +380,16 @@ class _UserMapScreenState extends State<UserMapScreen> {
state.user?.phone == null) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Por favor completa tu perfil y agrega tu celular'),
SnackBar(
content: const Text('Por favor completa tu perfil y agrega tu celular'),
action: SnackBarAction(
label: 'Ir al perfil',
onPressed: () {
Navigator.push(context, CupertinoPageRoute(
builder: (context) => const ProfileScreen(),
));
},
),
),
);
return;
@@ -552,7 +562,10 @@ class _UserMapScreenState extends State<UserMapScreen> {
description: _observationController.text,
range1Hour1: horaSeleccionada!,
range1Hour2:
horaSeleccionada!.add(hour: 2),
horaSeleccionada!.add(
minute: profesionalSeleccionado!
.professionalInfo
.slotDurationMinutes),
rate: profesionalSeleccionado!
.professionalInfo.rate,
location: serviceLocationPreference!,
@@ -577,7 +590,10 @@ class _UserMapScreenState extends State<UserMapScreen> {
description: _observationController.text,
range1Hour1: horaSeleccionada!,
range1Hour2:
horaSeleccionada!.add(hour: 2),
horaSeleccionada!.add(
minute: profesionalSeleccionado!
.professionalInfo
.slotDurationMinutes),
rate: '0',
location: serviceLocationPreference!,
),
@@ -600,7 +616,10 @@ class _UserMapScreenState extends State<UserMapScreen> {
description: _observationController.text,
range1Hour1: horaSeleccionada!,
range1Hour2:
horaSeleccionada!.add(hour: 2),
horaSeleccionada!.add(
minute: profesionalSeleccionado!
.professionalInfo
.slotDurationMinutes),
rate: profesionalSeleccionado!
.professionalInfo.rate,
location: serviceLocationPreference!,
@@ -620,7 +639,10 @@ class _UserMapScreenState extends State<UserMapScreen> {
description: _observationController.text,
range1Hour1: horaSeleccionada!,
range1Hour2:
horaSeleccionada!.add(hour: 2),
horaSeleccionada!.add(
minute: profesionalSeleccionado!
.professionalInfo
.slotDurationMinutes),
rate: '0',
location: serviceLocationPreference!,
),
@@ -745,6 +767,25 @@ class _UserMapScreenState extends State<UserMapScreen> {
);
});
_animateCameraToPosition(_currentP!);
} catch (e) {
log(e.toString());
await _locateByUserCity();
}
}
Future<void> _locateByUserCity() async {
try {
final city = Injector.appInstance.get<MyUserBloc>().state.user?.city;
if (city == null || city.isEmpty) return;
final coords = await NominatimGeocoder.forwardGeocodeCity(city);
if (coords == null) return;
setState(() {
_currentP = LatLng(coords.$1, coords.$2);
});
_animateCameraToPosition(_currentP!);
} catch (e) {
log(e.toString());
+239 -164
View File
@@ -1,204 +1,279 @@
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';
class UserViewProfileScreen extends StatelessWidget {
UserProfessional userProfessional;
const _kPrimary = Color(0xFF1565C0);
UserViewProfileScreen({
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,
});
final double bannerHeight = 200;
final double profileHeight = 160;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Perfil Profesional'),
),
body: ListView(
padding: EdgeInsets.zero,
children: [
buildTop(),
buildContent(),
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),
],
),
),
]),
),
),
],
),
);
}
Stack buildTop() {
final top = bannerHeight - profileHeight / 2;
final bottom = profileHeight / 2;
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
Container(
margin: EdgeInsets.only(bottom: bottom),
child: buildBannerImage(),
),
Positioned(
top: top,
child: buildProfileImage(),
),
],
);
}
buildContent() {
Widget _nameSection(BuildContext context) {
return Column(
children: [
Text(
userProfessional.myUser.name ?? '',
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: context.onSurface),
),
const SizedBox(height: 4),
Text(
'${userProfessional.professionalInfo.profession}, ${userProfessional.myUser.city ?? ''}',
'${userProfessional.professionalInfo.profession} · ${userProfessional.myUser.city ?? ''}',
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 18, color: Colors.black54),
style: TextStyle(fontSize: 14, color: context.muted),
),
const Divider(),
const Text(
'Especialidades:',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
userProfessional.professionalInfo.specializations.isEmpty
? const Text(
'No posee especialidades',
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 15, color: Colors.black45),
)
: Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: userProfessional.professionalInfo.specializations
.map((specialization) {
return Chip(
label: Text(specialization),
);
}).toList(),
),
Visibility(
visible: userProfessional.professionalInfo.rate.isNotEmpty,
child: const Divider(),
),
Visibility(
visible: userProfessional.professionalInfo.rate.isNotEmpty,
child: const Text(
'Tarifa:',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
),
Visibility(
visible: userProfessional.professionalInfo.rate.isNotEmpty,
child: Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
Chip(label: Text('\$${userProfessional.professionalInfo.rate}')),
],
),
),
const Divider(),
const Text(
'Metodos de pago recibidos:',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
userProfessional.professionalInfo.paymentMethods.datafono ||
userProfessional.professionalInfo.paymentMethods.nequi ||
userProfessional.professionalInfo.paymentMethods.transferencia
? Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
Visibility(
visible: userProfessional
.professionalInfo.paymentMethods.datafono,
child: const Chip(label: Text('Datafono')),
),
Visibility(
visible:
userProfessional.professionalInfo.paymentMethods.nequi,
child: const Chip(label: Text('Nequi')),
),
Visibility(
visible: userProfessional
.professionalInfo.paymentMethods.transferencia,
child: const Chip(label: Text('Transferencia Bancaria')),
),
],
)
: const Text(
'No hay metodos de pago registrados',
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 15, color: Colors.black45),
),
const Divider(),
],
);
}
Container buildProfileImage() {
Widget _sectionCard(BuildContext context,
{required IconData icon,
required String title,
required Widget child}) {
return Container(
width: 155,
height: 155,
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 5,
),
color: context.card,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(color: context.shadowSm, blurRadius: 10)
],
),
child: Container(
width: 150,
height: 150,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
border: Border.all(width: 0),
image: userProfessional.myUser.picture == null
? null
: DecorationImage(
image: NetworkImage(userProfessional.myUser.picture ?? ''),
fit: BoxFit.contain,
),
),
child: userProfessional.myUser.picture == null
? Icon(
CupertinoIcons.person,
color: Colors.grey.shade400,
size: 40,
)
: null,
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,
],
),
);
}
Container buildBannerImage() {
return Container(
color: Colors.grey,
child: Image.network(
userProfessional.professionalInfo.bannerPicture,
fit: BoxFit.cover,
width: double.infinity,
height: bannerHeight,
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)),
]),
);
}
}