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
@@ -1,12 +1,10 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:injector/injector.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:prosappco/components/general_drawer_item.dart';
import 'package:prosappco/screens/web/web_view_screen.dart';
import 'package:prosappco/screens/configuration/policy_text_screen.dart';
import 'package:setting_repository/setting_repository.dart';
import 'package:url_launcher/url_launcher.dart';
import 'dart:io' show Platform;
class ConfigurationAboutScreen extends StatefulWidget {
@@ -37,15 +35,24 @@ class _ConfigurationAboutScreenState extends State<ConfigurationAboutScreen> {
);
}
void _launchURL(String url) async {
if (await canLaunch(url)) {
await launch(url, forceSafariVC: false, forceWebView: false);
} else {
throw 'No se pudo abrir el enlace $url';
Future<void> _openPolicy(String title, String Function(PoliciesEntity) pick) async {
try {
final policies = await settingRepository.getPolicies();
if (!mounted) return;
Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => PolicyTextScreen(title: title, content: pick(policies)),
),
);
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No se pudo cargar el contenido')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -56,44 +63,18 @@ class _ConfigurationAboutScreenState extends State<ConfigurationAboutScreen> {
children: [
GeneralDrawerItem(
label: 'Políticas de privacidad',
onTap: () {
if (kIsWeb) {
_launchURL(settings?.politicasPrivacidad ?? '');
} else {
Navigator.push(
context,
CupertinoPageRoute(
builder: (BuildContext context) {
return WebViewScreen(
label: 'Políticas de privacidad',
link: settings?.politicasPrivacidad ?? '',
);
},
),
);
}
},
onTap: () => _openPolicy(
'Políticas de privacidad',
(p) => p.privacy,
),
trailing: true,
),
GeneralDrawerItem(
label: 'Términos y condiciones',
onTap: () {
if (kIsWeb) {
_launchURL(settings?.terminosCondiciones ?? '');
} else {
Navigator.push(
context,
CupertinoPageRoute(
builder: (BuildContext context) {
return WebViewScreen(
label: 'Términos y condiciones',
link: settings?.terminosCondiciones ?? '',
);
},
),
);
}
},
onTap: () => _openPolicy(
'Términos y condiciones',
(p) => p.terms,
),
trailing: true,
),
Platform.isIOS
@@ -1,130 +1,3 @@
// import 'package:cloud_firestore/cloud_firestore.dart';
// import 'package:firebase_auth/firebase_auth.dart';
// import 'package:flutter/cupertino.dart';
// import 'package:flutter/material.dart';
// import 'package:get/get.dart';
// import 'package:prosappco/src/components/pop_appbar.dart';
// import 'package:prosappco/src/presentation/screens/about.dart';
// class ConfigurationScreen extends StatefulWidget {
// const ConfigurationScreen({super.key});
// @override
// State<ConfigurationScreen> createState() => _ConfigurationScreenState();
// }
// class _ConfigurationScreenState extends State<ConfigurationScreen> {
// late final FirebaseAuth _auth;
// @override
// void initState() {
// super.initState();
// _auth = FirebaseAuth.instance;
// }
// Future<void> deleteAccount() async {
// try {
// final currentUser = _auth.currentUser;
// if (currentUser != null) {
// final uid = currentUser.uid;
// await FirebaseFirestore.instance.collection('users').doc(uid).delete();
// await currentUser.delete();
// await _auth.signOut();
// Get.snackbar(
// 'Cuenta Eliminada',
// 'Tu cuenta ha sido eliminada con éxito.',
// snackPosition: SnackPosition.BOTTOM,
// );
// }
// } catch (e) {
// Get.snackbar(
// 'Error al Eliminar Cuenta',
// 'Hubo un error al eliminar tu cuenta. Por favor, inténtalo de nuevo más tarde.',
// snackPosition: SnackPosition.BOTTOM,
// );
// }
// }
// Future<void> _showDeleteAccountConfirmationDialog(
// BuildContext context) async {
// return showDialog(
// context: context,
// builder: (BuildContext context) {
// return AlertDialog(
// title: const Text('Eliminar Cuenta'),
// content: const Text(
// '¿Estás seguro de que deseas eliminar tu cuenta? Esta acción no se puede deshacer.'),
// actions: [
// TextButton(
// onPressed: () {
// Navigator.of(context).pop();
// },
// child: const Text('Cancelar'),
// ),
// TextButton(
// onPressed: () {
// deleteAccount();
// Navigator.of(context).pop();
// },
// child: const Text(
// 'Eliminar',
// style:
// TextStyle(color: Colors.red, fontWeight: FontWeight.w600),
// ),
// ),
// ],
// );
// },
// );
// }
// @override
// Widget build(BuildContext context) {
// return Scaffold(
// appBar: PopAppbar(
// onPressed: () {
// Navigator.pop(context);
// },
// label: 'Configuración'),
// body: ListView(
// children: [
// ListTile(
// onTap: () {
// Navigator.push(
// context,
// CupertinoPageRoute(
// builder: (BuildContext context) {
// return const AboutScreen();
// },
// ),
// );
// },
// title: const Text('Acerca de la aplicación'),
// trailing: const Icon(
// Icons.keyboard_arrow_right,
// color: Colors.black,
// ),
// ),
// ListTile(
// onTap: () {
// _showDeleteAccountConfirmationDialog(context);
// },
// title: const Text(
// 'Eliminar cuenta',
// style: TextStyle(color: Colors.red),
// ),
// ),
// ],
// ),
// );
// }
// }
import 'dart:developer';
import 'package:flutter/cupertino.dart';
@@ -132,9 +5,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injector/injector.dart';
import 'package:prosappco/blocs/setting_bloc/setting_bloc.dart';
import 'package:prosappco/components/general_drawer_item.dart';
import 'package:prosappco/screens/configuration/configuration_about_screen.dart';
const _kPrimary = Color(0xFF1565C0);
class ConfigurationScreen extends StatelessWidget {
const ConfigurationScreen({super.key});
@@ -153,38 +27,41 @@ class ConfigurationScreen extends StatelessWidget {
child: Scaffold(
appBar: AppBar(
title: const Text('Configuración'),
backgroundColor: _kPrimary,
foregroundColor: Colors.white,
elevation: 0,
),
body: ListView(
children: [
GeneralDrawerItem(
const SizedBox(height: 8),
_ConfigItem(
icon: Icons.info_outline_rounded,
label: 'Acerca de la aplicación',
onTap: () {
Navigator.push(
context,
CupertinoPageRoute(
builder: (BuildContext context) {
return const ConfigurationAboutScreen();
},
),
);
},
trailing: true,
onTap: () => Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => const ConfigurationAboutScreen(),
),
),
),
GeneralDrawerItem(
_ConfigItem(
icon: Icons.logout_rounded,
label: 'Cerrar sesión',
onTap: () {
try {
settingBloc.add(SettingLogoutRequest());
} catch (e) {
log('xd conigura ${e.toString()}');
log('logout error: ${e.toString()}');
}
},
),
GeneralDrawerItem(
const Divider(height: 1),
_ConfigItem(
icon: Icons.delete_outline_rounded,
label: 'Eliminar cuenta',
onTap: () {},
color: Theme.of(context).colorScheme.error,
)
onTap: () {},
),
],
),
),
@@ -192,3 +69,39 @@ class ConfigurationScreen extends StatelessWidget {
);
}
}
class _ConfigItem extends StatelessWidget {
final IconData icon;
final String label;
final Color? color;
final VoidCallback onTap;
const _ConfigItem({
required this.icon,
required this.label,
required this.onTap,
this.color,
});
@override
Widget build(BuildContext context) {
final onSurface = Theme.of(context).colorScheme.onSurface;
final c = color ?? onSurface;
return ListTile(
onTap: onTap,
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: c.withOpacity(0.08),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: c, size: 20),
),
title: Text(label, style: TextStyle(color: c, fontWeight: FontWeight.w500)),
trailing: color == null
? Icon(Icons.keyboard_arrow_right, color: onSurface.withOpacity(0.35))
: null,
);
}
}
@@ -81,13 +81,17 @@ class _ConfigurationSupportScreenState
Padding(
padding: const EdgeInsets.only(top: 30, left: 35, right: 35),
child: Text(
'${settings?.tituloSoporte}',
settings == null
? 'Cargando...'
: (settings?.tituloSoporte ?? 'Soporte'),
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 30, horizontal: 35),
child: Text(settings?.parrafoSoporte ?? 'Cargando...'),
child: Text(
settings == null ? 'Cargando...' : (settings?.parrafoSoporte ?? ''),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
class PolicyTextScreen extends StatelessWidget {
final String title;
final String content;
const PolicyTextScreen({
super.key,
required this.title,
required this.content,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(title)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Text(
content,
style: const TextStyle(fontSize: 14, height: 1.5),
),
),
);
}
}