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
+336 -308
View File
@@ -12,7 +12,6 @@ import 'package:prosappco/components/general_drawer_header.dart';
import 'package:prosappco/components/general_drawer_item.dart';
import 'package:prosappco/screens/configuration/configuration_screen.dart';
import 'package:prosappco/screens/configuration/configuration_support_screen.dart';
import 'package:prosappco/screens/lists/dropwdon.dart';
import 'package:prosappco/screens/lists/professional_score_list_screen.dart';
import 'package:prosappco/screens/lists/professional_service_history_list_screen.dart';
import 'package:prosappco/screens/lists/professional_service_list_screen.dart';
@@ -25,23 +24,22 @@ import 'package:prosappco/screens/professional/professional_form_screen.dart';
import 'package:prosappco/screens/professional/professional_pending_screen.dart';
import 'package:prosappco/screens/professional/professional_profile_screen.dart';
import 'package:prosappco/screens/profile/profile_screen.dart';
import 'package:prosappco/screens/web/web_view_screen.dart';
import 'package:prosappco/screens/suggestions/suggestion_screen.dart';
import 'package:service_repository/service_repository.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:user_repository/user_repository.dart';
const _kPro = Color(0xFF0D9488);
const _kUser = Color(0xFF1565C0);
class GeneralDrawer extends StatelessWidget {
const GeneralDrawer({super.key});
Future<void> _irSugerencias() async {
const url = 'https://admin.prosapp.co/sugerencias';
final Uri _url = Uri.parse(url);
if (await canLaunchUrl(_url)) {
await launchUrl(_url);
} else {
throw 'No se pudo abrir la URL $url';
final Uri uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
}
}
@@ -53,8 +51,9 @@ class GeneralDrawer extends StatelessWidget {
builder: (context, userState) {
return BlocBuilder<ProfessionalBloc, ProfessionalState>(
builder: (context, professionalState) {
final isProMode = _isProModeActive(professionalState);
return Drawer(
backgroundColor: Theme.of(context).colorScheme.secondary,
backgroundColor: Theme.of(context).colorScheme.surface,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -71,111 +70,100 @@ class GeneralDrawer extends StatelessWidget {
child: SingleChildScrollView(
child: Column(
children: [
shouldProModeActive(context, professionalState)
? GeneralDrawerItem(
leading: Icons.person_outline_rounded,
label: 'Perfil profesional',
onTap: () {
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) =>
const ProfessionalProfileScreen(),
),
);
},
)
: const SizedBox(),
if (isProMode)
GeneralDrawerItem(
leading: Icons.person_outline_rounded,
label: 'Perfil profesional',
onTap: () => Navigator.push(
context,
CupertinoPageRoute(
builder: (_) =>
const ProfessionalProfileScreen(),
),
),
),
GeneralDrawerItem(
leading: Icons.checklist_outlined,
label: 'Mis servicios',
onTap: () {
shouldProModeActive(context, professionalState)
? Navigator.push(
context,
CupertinoPageRoute(
builder: (context) =>
const ProfessionalServiceListScreen(),
// const UserServicesScreen(),
),
)
: Navigator.push(
context,
CupertinoPageRoute(
builder: (context) =>
const UserServiceListScreen(),
// const UserServicesScreen(),
),
);
},
onTap: () => isProMode
? Navigator.push(
context,
CupertinoPageRoute(
builder: (_) =>
const ProfessionalServiceListScreen()),
)
: Navigator.push(
context,
CupertinoPageRoute(
builder: (_) =>
const UserServiceListScreen()),
),
),
GeneralDrawerItem(
leading: Icons.access_time_outlined,
label: 'Historial',
onTap: () {
shouldProModeActive(context, professionalState)
? Navigator.push(
context,
CupertinoPageRoute(
builder: (context) =>
const ProfessionalServiceHistoryListScreen(),
),
)
: Navigator.push(
context,
CupertinoPageRoute(
builder: (context) =>
const UserServiceHistoryListScreen(),
),
);
},
onTap: () => isProMode
? Navigator.push(
context,
CupertinoPageRoute(
builder: (_) =>
const ProfessionalServiceHistoryListScreen()),
)
: Navigator.push(
context,
CupertinoPageRoute(
builder: (_) =>
const UserServiceHistoryListScreen()),
),
),
shouldProModeActive(context, professionalState)
? GeneralDrawerItem(
onTap: () {
if (professionalState
is LoadedModeProState) {
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) =>
ProfessionalCalendarScreen(
userProfessional:
professionalState.proInfo!,
),
),
);
}
},
label: 'Calendario',
leading: Icons.calendar_month_outlined,
)
: const SizedBox(),
if (isProMode)
GeneralDrawerItem(
leading: Icons.calendar_month_outlined,
label: 'Calendario',
onTap: () {
if (professionalState
is LoadedModeProState &&
professionalState.proInfo != null) {
Navigator.push(
context,
CupertinoPageRoute(
builder: (_) =>
ProfessionalCalendarScreen(
userProfessional:
professionalState.proInfo!,
),
),
);
} else {
ScaffoldMessenger.of(context)
.clearSnackBars();
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(
content: Text(
'Cargando tu información de profesional, intenta de nuevo en un momento'),
));
}
},
),
GeneralDrawerItem(
leading: Icons.settings_outlined,
label: 'Configuración',
onTap: () {
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) =>
const ConfigurationScreen(),
),
);
},
onTap: () => Navigator.push(
context,
CupertinoPageRoute(
builder: (_) =>
const ConfigurationScreen()),
),
),
GeneralDrawerItem(
leading: Icons.help_outline,
label: 'Soporte',
onTap: () {
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) =>
const ConfigurationSupportScreen(),
),
);
},
onTap: () => Navigator.push(
context,
CupertinoPageRoute(
builder: (_) =>
const ConfigurationSupportScreen()),
),
),
GeneralDrawerItem(
leading: Icons.campaign_outlined,
@@ -187,137 +175,45 @@ class GeneralDrawer extends StatelessWidget {
Navigator.push(
context,
CupertinoPageRoute(
builder: (BuildContext context) {
return WebViewScreen(
label: 'Sugerencias',
link:
'https://admin.prosapp.co/sugerencias');
},
builder: (_) => const SuggestionScreen(),
),
);
}
},
),
Container(
color: const Color(0xFF2BA4EC),
child: ListTile(
onTap: () {
Navigator.pop(context);
},
trailing: shouldProModeActive(context, professionalState)
? FutureBuilder(builder: (context, AsyncSnapshot<int> snapshot) {
if (!snapshot.hasData || snapshot.data! < 1) {
return const SizedBox();
}
final int notificationCount = snapshot.data!;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(12),
),
child: Text(
notificationCount > 9
? '+9'
: notificationCount.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
);
},
future: Injector.appInstance
.get<ApiServiceRepository>()
.countPendingServicesForProfessional(
ApiUserRepository.currentUserId ?? ''),
)
: const Icon(
Icons.keyboard_arrow_right,
color: Colors.white,
size: 25,
),
title: Text(
shouldProModeActive(context, professionalState)
? 'Solicitudes'
: 'Solicitar servicio',
style: const TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
// CTA item — go to main action
_CtaItem(
isProMode: isProMode,
professionalState: professionalState,
),
// Rating row
DrawerReputation(builder: (reputation) {
final isProModeActive =
(professionalState is LoadedModeProState) &&
professionalState.isProModeActive;
final total = isProModeActive
final total = isProMode
? reputation.totalPro
: reputation.total;
final average = isProModeActive
final average = isProMode
? reputation.averagePro
: reputation.average;
return ListTile(
onTap: () {
final isProModeActive = (professionalState is LoadedModeProState) && professionalState.isProModeActive;
isProModeActive ? Navigator.push(context,
CupertinoPageRoute(
builder: (context) {
return const ProfessionalScoreListScreen();
}))
: Navigator.push(context,
CupertinoPageRoute(
builder: (context) {
return const UserScoreListScreen();
}));
},
trailing: const Icon(Icons.keyboard_arrow_right,
color: Colors.black),
title: Row(
children: [
RatingBar.builder(
initialRating: calculoRating(average),
minRating: 1,
direction: Axis.horizontal,
allowHalfRating: true,
itemCount: 5,
itemSize: 25,
maxRating: 5,
itemBuilder: (context, _) => const Icon(
Icons.star,
color: Color(0xFF2BA4EC),
),
onRatingUpdate: (rating) {},
ignoreGestures: true,
),
const SizedBox(width: 5),
Text(
'${average.toStringAsFixed(1)} (${total.toString()})',
),
],
),
return _RatingRow(
average: average,
total: total,
isProMode: isProMode,
professionalState: professionalState,
);
}),
const Divider(
height: 1,
thickness: 0.5,
),
const Divider(height: 1, thickness: 0.5),
Padding(
padding: const EdgeInsets.only(top: 10),
padding: const EdgeInsets.only(top: 10, bottom: 4),
child: Text(
'Prosapp ® todos los derechos reservados',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10,
color: Colors.grey[700],
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.4),
),
),
),
@@ -336,7 +232,11 @@ class GeneralDrawer extends StatelessWidget {
const SizedBox(height: 15),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: buttonOfState(context, professionalState),
child: _ModeButton(
professionalState: professionalState,
userState: userState,
isProMode: isProMode,
),
),
const SizedBox(height: 15),
],
@@ -349,145 +249,273 @@ class GeneralDrawer extends StatelessWidget {
);
}
double calculoRating(double average) {
String numeroString = average.toString();
List<String> partes = numeroString.split('.');
int parteEntera = int.parse(partes[0]);
int parteFraccionaria = partes.length > 1 ? int.parse(partes[1]) : 0;
bool _isProModeActive(ProfessionalState state) =>
(state is LoadedModeProState) ? state.isProModeActive : false;
}
if (parteFraccionaria >= 3) {
parteFraccionaria = 5;
} else {
parteFraccionaria = 0;
}
// CTA tile navigating to solicitudes / solicitar servicio
class _CtaItem extends StatelessWidget {
final bool isProMode;
final ProfessionalState professionalState;
_CtaItem(
{required this.isProMode, required this.professionalState});
// Unir la parte entera y fraccionaria y convertirlo nuevamente a double
double resultado = double.parse('$parteEntera.$parteFraccionaria');
return resultado;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isProMode
? _kPro.withOpacity(0.1)
: _kUser.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isProMode
? _kPro.withOpacity(0.25)
: _kUser.withOpacity(0.25)),
),
child: ListTile(
onTap: () => Navigator.pop(context),
leading: Icon(
isProMode ? Icons.inbox_outlined : Icons.search_rounded,
color: isProMode ? _kPro : _kUser,
),
title: Text(
isProMode ? 'Solicitudes' : 'Solicitar servicio',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: isProMode ? _kPro : _kUser,
),
),
trailing: isProMode
? FutureBuilder<int>(
future: Injector.appInstance
.get<ApiServiceRepository>()
.countPendingServicesForProfessional(
ApiUserRepository.currentUserId ?? ''),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data! < 1) {
return Icon(Icons.keyboard_arrow_right,
color: _kPro.withOpacity(0.5));
}
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(12),
),
child: Text(
snapshot.data! > 9
? '+9'
: snapshot.data!.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600),
),
);
},
)
: Icon(Icons.keyboard_arrow_right,
color: _kUser.withOpacity(0.5)),
),
);
}
}
class _RatingRow extends StatelessWidget {
final double average;
final int total;
final bool isProMode;
final ProfessionalState professionalState;
_RatingRow(
{required this.average,
required this.total,
required this.isProMode,
required this.professionalState});
double _calcRating(double avg) {
final parts = avg.toString().split('.');
final frac = parts.length > 1 ? int.parse(parts[1]) : 0;
return double.parse(
'${parts[0]}.${frac >= 3 ? 5 : 0}');
}
buttonOfState(BuildContext context, ProfessionalState state) {
@override
Widget build(BuildContext context) {
return ListTile(
onTap: () {
isProMode
? Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => const ProfessionalScoreListScreen()))
: Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => const UserScoreListScreen()));
},
leading: Icon(Icons.star_outline_rounded,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6)),
trailing: Icon(Icons.keyboard_arrow_right,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.4)),
title: Row(
children: [
RatingBar.builder(
initialRating: _calcRating(average),
minRating: 1,
direction: Axis.horizontal,
allowHalfRating: true,
itemCount: 5,
itemSize: 20,
maxRating: 5,
itemBuilder: (_, __) => const Icon(Icons.star,
color: Color(0xFF1565C0)),
onRatingUpdate: (_) {},
ignoreGestures: true,
),
const SizedBox(width: 6),
Text(
'${average.toStringAsFixed(1)} ($total)',
style: TextStyle(
fontSize: 13,
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.7)),
),
],
),
);
}
}
class _ModeButton extends StatelessWidget {
final ProfessionalState professionalState;
final MyUserState userState;
final bool isProMode;
_ModeButton({
required this.professionalState,
required this.userState,
required this.isProMode,
});
@override
Widget build(BuildContext context) {
return Stack(
children: [
SizedBox(
width: MediaQuery.of(context).size.width * 0.7,
child: ElevatedButton(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
final myUserState = context.read<MyUserBloc>().state;
if (myUserState.status == MyUserStatus.success) {
final user = myUserState.user!;
if (user.name != null && user.email != null && user.city != null && user.phone != null) {
if (userState.status == MyUserStatus.success) {
final user = userState.user!;
if (user.name != null &&
user.city != null &&
user.phone != null) {
switch (user.proState) {
case ProState.active: Navigator.pop(context);
context.read<ProfessionalBloc>().add(const SwitchProModeEvent());
case ProState.active:
Navigator.pop(context);
context
.read<ProfessionalBloc>()
.add(const SwitchProModeEvent());
break;
case ProState.inactive:
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => const ProfessionalFormScreen(),
),
builder: (_) => const ProfessionalFormScreen()),
);
break;
case ProState.pending:
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => const ProfessionalPendingScreen(),
),
builder: (_) =>
const ProfessionalPendingScreen()),
);
break;
case ProState.denied:
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => const ProfessionalDeniedScreen(),
),
builder: (_) =>
const ProfessionalDeniedScreen()),
);
break;
}
} else {
final missing = <String>[
if (user.name == null) 'nombre',
if (user.city == null) 'ciudad',
if (user.phone == null) 'teléfono',
];
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Por favor, completa tu perfil'),
));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Completa tu perfil: falta ${missing.join(', ')}')),
);
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => const ProfileScreen(),
),
builder: (_) => const ProfileScreen()),
);
}
} else {}
}
},
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
padding: const EdgeInsets.symmetric(vertical: 15),
icon: const Icon(Icons.swap_horiz, size: 20),
label: Text(
isProMode ? 'Cambiar a modo cliente' : 'Cambiar a modo profesional',
style: const TextStyle(fontSize: 15),
),
style: ElevatedButton.styleFrom(
backgroundColor:
isProMode ? _kPro : _kUser,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(12),
),
),
child: (state is LoadedModeProState)
? Text(
state.isProModeActive ? 'Modo cliente' : 'Modo profesional',
style: const TextStyle(color: Colors.white, fontSize: 18),
)
: const Text(
'Modo profesional',
style: TextStyle(color: Colors.white, fontSize: 18),
),
),
),
(state is LoadedModeProState)
? Visibility(
visible: !state.isProModeActive,
child: Positioned(
top: 0,
right: 0,
child: FutureBuilder(
builder: (context, AsyncSnapshot<int> snapshot) {
if (!snapshot.hasData || snapshot.data! < 1) {
return const SizedBox();
}
final int notificationCount = snapshot.data!;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(12),
),
child: Text(
notificationCount > 9
? '+9'
: notificationCount.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
);
},
future: Injector.appInstance
.get<ApiServiceRepository>()
.countPendingServicesForProfessional(
ApiUserRepository.currentUserId ?? ''),
if (!isProMode && professionalState is LoadedModeProState)
Positioned(
top: 0,
right: 0,
child: FutureBuilder<int>(
future: Injector.appInstance
.get<ApiServiceRepository>()
.countPendingServicesForProfessional(
ApiUserRepository.currentUserId ?? ''),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data! < 1) {
return const SizedBox();
}
return Container(
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(12),
),
),
)
: const SizedBox(),
child: Text(
snapshot.data! > 9 ? '+9' : snapshot.data!.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600),
),
);
},
),
),
],
);
}
shouldProModeActive(BuildContext context, ProfessionalState state) {
return (state is LoadedModeProState) ? state.isProModeActive : false;
}
}