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>
522 lines
21 KiB
Dart
522 lines
21 KiB
Dart
import 'package:flutter/cupertino.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:flutter_rating_bar/flutter_rating_bar.dart';
|
|
import 'package:injector/injector.dart';
|
|
import 'package:prosappco/blocs/my_user_bloc/my_user_bloc.dart';
|
|
import 'package:prosappco/blocs/professional_bloc/professional_bloc.dart';
|
|
import 'package:prosappco/blocs/score_bloc/score_bloc.dart';
|
|
import 'package:prosappco/components/drawer_reputation.dart';
|
|
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/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';
|
|
import 'package:prosappco/screens/lists/user_score_list_screen.dart';
|
|
import 'package:prosappco/screens/lists/user_service_history_list_screen.dart';
|
|
import 'package:prosappco/screens/lists/user_service_list_screen.dart';
|
|
import 'package:prosappco/screens/professional/professional_calendar_screen.dart';
|
|
import 'package:prosappco/screens/professional/professional_denied_screen.dart';
|
|
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/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 uri = Uri.parse(url);
|
|
if (await canLaunchUrl(uri)) {
|
|
await launchUrl(uri);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocProvider(
|
|
create: (context) => Injector.appInstance.get<ScoreBloc>(),
|
|
child: BlocBuilder<MyUserBloc, MyUserState>(
|
|
builder: (context, userState) {
|
|
return BlocBuilder<ProfessionalBloc, ProfessionalState>(
|
|
builder: (context, professionalState) {
|
|
final isProMode = _isProModeActive(professionalState);
|
|
return Drawer(
|
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
const GeneralDrawerHeader(),
|
|
Divider(
|
|
color: Theme.of(context)
|
|
.colorScheme
|
|
.onSurface
|
|
.withOpacity(0.1),
|
|
thickness: 0.5,
|
|
height: 1,
|
|
),
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
children: [
|
|
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: () => isProMode
|
|
? Navigator.push(
|
|
context,
|
|
CupertinoPageRoute(
|
|
builder: (_) =>
|
|
const ProfessionalServiceListScreen()),
|
|
)
|
|
: Navigator.push(
|
|
context,
|
|
CupertinoPageRoute(
|
|
builder: (_) =>
|
|
const UserServiceListScreen()),
|
|
),
|
|
),
|
|
GeneralDrawerItem(
|
|
leading: Icons.access_time_outlined,
|
|
label: 'Historial',
|
|
onTap: () => isProMode
|
|
? Navigator.push(
|
|
context,
|
|
CupertinoPageRoute(
|
|
builder: (_) =>
|
|
const ProfessionalServiceHistoryListScreen()),
|
|
)
|
|
: Navigator.push(
|
|
context,
|
|
CupertinoPageRoute(
|
|
builder: (_) =>
|
|
const UserServiceHistoryListScreen()),
|
|
),
|
|
),
|
|
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: (_) =>
|
|
const ConfigurationScreen()),
|
|
),
|
|
),
|
|
GeneralDrawerItem(
|
|
leading: Icons.help_outline,
|
|
label: 'Soporte',
|
|
onTap: () => Navigator.push(
|
|
context,
|
|
CupertinoPageRoute(
|
|
builder: (_) =>
|
|
const ConfigurationSupportScreen()),
|
|
),
|
|
),
|
|
GeneralDrawerItem(
|
|
leading: Icons.campaign_outlined,
|
|
label: 'Sugerencias',
|
|
onTap: () {
|
|
if (kIsWeb) {
|
|
_irSugerencias();
|
|
} else {
|
|
Navigator.push(
|
|
context,
|
|
CupertinoPageRoute(
|
|
builder: (_) => const SuggestionScreen(),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
// CTA item — go to main action
|
|
_CtaItem(
|
|
isProMode: isProMode,
|
|
professionalState: professionalState,
|
|
),
|
|
// Rating row
|
|
DrawerReputation(builder: (reputation) {
|
|
final total = isProMode
|
|
? reputation.totalPro
|
|
: reputation.total;
|
|
final average = isProMode
|
|
? reputation.averagePro
|
|
: reputation.average;
|
|
|
|
return _RatingRow(
|
|
average: average,
|
|
total: total,
|
|
isProMode: isProMode,
|
|
professionalState: professionalState,
|
|
);
|
|
}),
|
|
const Divider(height: 1, thickness: 0.5),
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 10, bottom: 4),
|
|
child: Text(
|
|
'Prosapp ® todos los derechos reservados',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: Theme.of(context)
|
|
.colorScheme
|
|
.onSurface
|
|
.withOpacity(0.4),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Divider(
|
|
color: Theme.of(context)
|
|
.colorScheme
|
|
.onSurface
|
|
.withOpacity(0.1),
|
|
thickness: 0.5,
|
|
height: 1,
|
|
),
|
|
const SizedBox(height: 15),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
child: _ModeButton(
|
|
professionalState: professionalState,
|
|
userState: userState,
|
|
isProMode: isProMode,
|
|
),
|
|
),
|
|
const SizedBox(height: 15),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
bool _isProModeActive(ProfessionalState state) =>
|
|
(state is LoadedModeProState) ? state.isProModeActive : false;
|
|
}
|
|
|
|
// CTA tile navigating to solicitudes / solicitar servicio
|
|
class _CtaItem extends StatelessWidget {
|
|
final bool isProMode;
|
|
final ProfessionalState professionalState;
|
|
_CtaItem(
|
|
{required this.isProMode, required this.professionalState});
|
|
|
|
@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}');
|
|
}
|
|
|
|
@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: double.infinity,
|
|
child: ElevatedButton.icon(
|
|
onPressed: () {
|
|
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());
|
|
break;
|
|
case ProState.inactive:
|
|
Navigator.push(
|
|
context,
|
|
CupertinoPageRoute(
|
|
builder: (_) => const ProfessionalFormScreen()),
|
|
);
|
|
break;
|
|
case ProState.pending:
|
|
Navigator.push(
|
|
context,
|
|
CupertinoPageRoute(
|
|
builder: (_) =>
|
|
const ProfessionalPendingScreen()),
|
|
);
|
|
break;
|
|
case ProState.denied:
|
|
Navigator.push(
|
|
context,
|
|
CupertinoPageRoute(
|
|
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(
|
|
SnackBar(
|
|
content: Text(
|
|
'Completa tu perfil: falta ${missing.join(', ')}')),
|
|
);
|
|
Navigator.push(
|
|
context,
|
|
CupertinoPageRoute(
|
|
builder: (_) => const ProfileScreen()),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
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(12),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
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),
|
|
),
|
|
child: Text(
|
|
snapshot.data! > 9 ? '+9' : snapshot.data!.toString(),
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|