Files
prosappco/lib/components/general_drawer.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

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),
),
);
},
),
),
],
);
}
}