Verified the real contracts against the backend before changing anything.
Chat (three defects, one root cause):
- every chat endpoint is keyed by the chat id, not the service id. The app
called POST /chat/start, threw away the id it returned and kept using the
service id, so every later request 404'd.
- messages arrive as {data, meta}; reading the body as a bare list threw and
surfaced as an empty conversation.
- the bloc created the chat and then never emitted ChatLoaded (the else hung
off `if (chat == null)`), leaving a permanent spinner. Sending a message
emitted nothing at all, so it vanished until reopening.
Messages now render optimistically and roll back if the send fails, and the
screen distinguishes "loading" from "could not open" with a retry.
Appointments:
- a null range1_hour2 parsed as 00:00, so new appointments were born
"Caducado" and every action was hidden. It now falls back to the start time.
- service requests validate the HTTP status and tolerate an empty body: a 4xx
was treated as success and a 204 as failure.
- creating a service with no id in the response no longer reports success and
navigates to a service that does not exist.
- dispatching LoadService from build() looped forever on failure; the three
detail screens now load once and offer a retry.
Ratings:
- both sides read userScored, so only one of the two could ever rate. The
client side now reads professionalScored.
- the screen closed before the request finished, killing the provider mid
flight while addComment swallowed every error. It now waits for confirmation.
- score/reputation parsing tolerates integers and numeric strings instead of
emptying the review list.
Also: guarded map lookups in ScoreBloc, a nullable name in the search list,
and error states with retry where a failure used to shimmer forever.
Verified against the backend: `accepted` is the status the API expects, so the
suspected spelling bug was a false alarm and was left alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
750 lines
26 KiB
Dart
750 lines
26 KiB
Dart
import 'dart:async';
|
|
import 'package:community_material_icon/community_material_icon.dart';
|
|
import 'package:flutter/cupertino.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:injector/injector.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:professional_repository/professional_repository.dart';
|
|
import 'package:prosappco/blocs/service_bloc/service_bloc.dart';
|
|
import 'package:prosappco/components/general_reputation.dart';
|
|
import 'package:prosappco/local_notifications/local_notifications.dart';
|
|
import 'package:prosappco/screens/chat/chat_screen.dart';
|
|
import 'package:prosappco/components/general_secondary_button.dart';
|
|
import 'package:prosappco/screens/score/score_screen.dart';
|
|
import 'package:score_repository/score_repository.dart';
|
|
import 'package:service_repository/service_repository.dart';
|
|
import 'package:setting_repository/setting_repository.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import 'package:user_repository/user_repository.dart';
|
|
|
|
const _kPrimary = Color(0xFF1565C0);
|
|
|
|
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 UserServiceScreen extends StatefulWidget {
|
|
final String serviceId;
|
|
const UserServiceScreen({super.key, required this.serviceId});
|
|
|
|
@override
|
|
State<UserServiceScreen> createState() => _UserServiceScreenState();
|
|
}
|
|
|
|
class _UserServiceScreenState extends State<UserServiceScreen> {
|
|
final settingRepository = Injector.appInstance.get<SettingRepository>();
|
|
SettingEntity? settings;
|
|
Timer? _timer;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadSettings();
|
|
_startTimer();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_timer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _startTimer() {
|
|
_timer = Timer.periodic(const Duration(minutes: 5), (_) {
|
|
setState(() {});
|
|
});
|
|
}
|
|
|
|
void _loadSettings() {
|
|
settingRepository.getSettings().then((v) => setState(() => settings = v));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocProvider<ServiceBloc>(
|
|
create: (_) => Injector.appInstance.get<ServiceBloc>()
|
|
..add(LoadService(widget.serviceId)),
|
|
child: Scaffold(
|
|
backgroundColor: context.bg,
|
|
appBar: AppBar(
|
|
title: const Text('Detalle del servicio'),
|
|
backgroundColor: _kPrimary,
|
|
foregroundColor: Colors.white,
|
|
elevation: 0,
|
|
),
|
|
body: BlocBuilder<ServiceBloc, ServiceState>(
|
|
builder: (context, state) {
|
|
if (state is ServiceLoaded) {
|
|
final service = state.service;
|
|
return FutureBuilder<List<dynamic>>(
|
|
future: _getUserAndProfessionalInfo(service),
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
if (snapshot.hasError) {
|
|
return Center(child: Text('Error: ${snapshot.error}'));
|
|
}
|
|
final userInfo = snapshot.data![0] as MyUser;
|
|
final professionalInfo =
|
|
snapshot.data![1] as ProfessionalEntity;
|
|
return Column(
|
|
children: [
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
children: [
|
|
_headerCard(context, service, userInfo,
|
|
professionalInfo),
|
|
_locationCard(context, service),
|
|
if (settings?.tarifas == true)
|
|
_rateCard(context, service, professionalInfo),
|
|
if (service.description.isNotEmpty)
|
|
_descriptionCard(context, service),
|
|
_actionButtons(context, service, userInfo),
|
|
_statusBanner(context, service),
|
|
const SizedBox(height: 24),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const Divider(height: 1, thickness: 0.5),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
vertical: 12, horizontal: 16),
|
|
child: _bottomButton(context, service, userInfo),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
} else if (state is CreateServiceFailure) {
|
|
return _loadErrorState(context);
|
|
} else {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Dispatching LoadService from build() turned any failure into an endless
|
|
/// request loop: fail -> rebuild -> request -> fail. Errors now get an
|
|
/// explicit retry instead of a permanent spinner.
|
|
Widget _loadErrorState(BuildContext context) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.error_outline, size: 40, color: Colors.grey),
|
|
const SizedBox(height: 12),
|
|
const Text('No se pudo cargar el servicio',
|
|
textAlign: TextAlign.center),
|
|
const SizedBox(height: 16),
|
|
OutlinedButton(
|
|
onPressed: () => BlocProvider.of<ServiceBloc>(context)
|
|
.add(LoadService(widget.serviceId)),
|
|
child: const Text('Reintentar'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _headerCard(BuildContext context, ServiceEntity service, MyUser user,
|
|
ProfessionalEntity professional) {
|
|
return Container(
|
|
width: double.infinity,
|
|
margin: const EdgeInsets.all(16),
|
|
padding: const EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
color: context.card,
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: context.shadowSm,
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 2))
|
|
],
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
Container(
|
|
width: 90,
|
|
height: 90,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
border:
|
|
Border.all(color: _kPrimary.withOpacity(0.25), width: 3),
|
|
color: Colors.grey.shade200,
|
|
image: user.picture != null && user.picture!.isNotEmpty
|
|
? DecorationImage(
|
|
image: NetworkImage(user.picture!),
|
|
fit: BoxFit.cover)
|
|
: null,
|
|
),
|
|
child: user.picture == null || user.picture!.isEmpty
|
|
? Icon(CupertinoIcons.person,
|
|
color: Colors.grey.shade500, size: 40)
|
|
: null,
|
|
),
|
|
GeneralReputation(
|
|
userId: service.professionalId,
|
|
builder: (_, ReputationEntity reputation) {
|
|
return Positioned(
|
|
top: 0,
|
|
right: MediaQuery.of(context).size.width * 0.18,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: context.card,
|
|
borderRadius: BorderRadius.circular(20),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: context.shadowSm,
|
|
blurRadius: 6,
|
|
spreadRadius: 1)
|
|
],
|
|
),
|
|
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
|
const Icon(Icons.star, color: Colors.amber, size: 14),
|
|
const SizedBox(width: 3),
|
|
Text(reputation.averagePro.toStringAsFixed(1),
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: context.onSurface)),
|
|
]),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
Text(user.name ?? '',
|
|
style: TextStyle(
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w700,
|
|
color: context.onSurface)),
|
|
const SizedBox(height: 2),
|
|
Text(professional.profession,
|
|
style: TextStyle(fontSize: 13, color: context.muted)),
|
|
const SizedBox(height: 4),
|
|
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
|
Icon(Icons.calendar_today_outlined, size: 13, color: context.muted),
|
|
const SizedBox(width: 5),
|
|
Text(
|
|
'${DateFormat('dd MMMM', 'es').format(DateTime.parse(service.day))} · ${ScheduleEntity.getFormatTime(service.range1Hour1) ?? ''}',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: context.muted,
|
|
fontWeight: FontWeight.w500),
|
|
),
|
|
]),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _locationCard(BuildContext context, ServiceEntity service) {
|
|
final isOffice =
|
|
service.location == ServiceLocationPreferences.office;
|
|
if (!isOffice &&
|
|
service.status != ServiceStatus.acepted &&
|
|
service.status != ServiceStatus.active) {
|
|
return const SizedBox();
|
|
}
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
color: _kPrimary.withOpacity(0.06),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: _kPrimary.withOpacity(0.15)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
isOffice ? Icons.business_outlined : Icons.home_outlined,
|
|
color: _kPrimary,
|
|
size: 20),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
isOffice
|
|
? 'Servicio en sitio / consultorio'
|
|
: 'Servicio a tu domicilio',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: _kPrimary)),
|
|
if (isOffice && service.address.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 2),
|
|
child: Text(service.address,
|
|
style: TextStyle(fontSize: 12, color: context.muted)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (isOffice &&
|
|
(service.status == ServiceStatus.acepted ||
|
|
service.status == ServiceStatus.active))
|
|
IconButton(
|
|
icon: Icon(Icons.near_me, color: _kPrimary),
|
|
onPressed: () async {
|
|
final url = Uri.parse(
|
|
'https://www.google.com/maps/search/?api=1&query=${service.latitude},${service.longitude}');
|
|
if (!await launchUrl(url)) {
|
|
throw Exception('No se pudo abrir mapa');
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _rateCard(BuildContext context, ServiceEntity service,
|
|
ProfessionalEntity professional) {
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: context.card,
|
|
borderRadius: BorderRadius.circular(12),
|
|
boxShadow: [
|
|
BoxShadow(color: context.shadowSm, blurRadius: 8)
|
|
],
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
formatCurrency(int.tryParse(service.rate) ?? 0),
|
|
style: TextStyle(
|
|
fontSize: 26,
|
|
fontWeight: FontWeight.w700,
|
|
color: context.onSurface),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text('tarifa de consulta',
|
|
style: TextStyle(fontSize: 12, color: context.muted)),
|
|
],
|
|
),
|
|
if (professional.paymentMethods.datafono ||
|
|
professional.paymentMethods.nequi ||
|
|
professional.paymentMethods.transferencia) ...[
|
|
const SizedBox(height: 10),
|
|
Text('Métodos de pago',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: context.onSurface)),
|
|
const SizedBox(height: 6),
|
|
Wrap(
|
|
spacing: 6,
|
|
runSpacing: 6,
|
|
alignment: WrapAlignment.center,
|
|
children: [
|
|
if (professional.paymentMethods.datafono)
|
|
_PayBadge('Datafono'),
|
|
if (professional.paymentMethods.nequi) _PayBadge('Nequi'),
|
|
if (professional.paymentMethods.transferencia)
|
|
_PayBadge('Transferencia'),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _descriptionCard(BuildContext context, ServiceEntity service) {
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: context.card,
|
|
borderRadius: BorderRadius.circular(12),
|
|
boxShadow: [BoxShadow(color: context.shadowSm, blurRadius: 8)],
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(Icons.format_quote_rounded,
|
|
color: context.subtle, size: 20),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
service.description.trim(),
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: context.muted,
|
|
fontStyle: FontStyle.italic,
|
|
height: 1.5),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _actionButtons(
|
|
BuildContext context, ServiceEntity service, MyUser user) {
|
|
final serviceDate = DateTime.parse(service.day);
|
|
final now = DateTime.now();
|
|
final serviceDateTime = DateTime(serviceDate.year, serviceDate.month,
|
|
serviceDate.day, service.range1Hour2.hour, service.range1Hour2.minute);
|
|
|
|
if ((service.status == ServiceStatus.acepted ||
|
|
service.status == ServiceStatus.active) &&
|
|
!now.isAfter(serviceDateTime)) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
if (user.phone != null)
|
|
_ActionBtn(
|
|
icon: Icons.phone_outlined,
|
|
label: 'Llamar',
|
|
onTap: () => launchUrl(Uri.parse('tel:${user.phone}')),
|
|
),
|
|
if (user.phone != null) const SizedBox(width: 10),
|
|
_ActionBtn(
|
|
icon: Icons.chat_bubble_outline_rounded,
|
|
label: 'Chat',
|
|
onTap: () {
|
|
if (service.id != null) {
|
|
Navigator.push(
|
|
context,
|
|
CupertinoPageRoute(
|
|
builder: (_) => ChatScreen(service: service)));
|
|
}
|
|
},
|
|
),
|
|
if (user.phone != null) const SizedBox(width: 10),
|
|
if (user.phone != null)
|
|
_ActionBtn(
|
|
icon: CommunityMaterialIcons.whatsapp,
|
|
label: 'WhatsApp',
|
|
onTap: () async {
|
|
final url =
|
|
'https://wa.me/${user.phone}?text=${Uri.encodeFull('Hola! me contactaste por Prossapp')}';
|
|
if (!await launchUrl(Uri.parse(url))) {
|
|
throw Exception('No se pudo abrir WhatsApp');
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
return const SizedBox();
|
|
}
|
|
|
|
Widget _statusBanner(BuildContext context, ServiceEntity service) {
|
|
final serviceDate = DateTime.parse(service.day);
|
|
final now = DateTime.now();
|
|
final serviceDateTime = DateTime(serviceDate.year, serviceDate.month,
|
|
serviceDate.day, service.range1Hour2.hour, service.range1Hour2.minute);
|
|
|
|
if (now.isAfter(serviceDateTime)) {
|
|
return _StatusCard(
|
|
color: const Color(0xFFDC2626),
|
|
icon: Icons.timer_off_outlined,
|
|
title: 'Caducado',
|
|
subtitle: 'El tiempo del servicio ha expirado',
|
|
);
|
|
}
|
|
|
|
switch (service.status) {
|
|
case ServiceStatus.cancelled:
|
|
return _StatusCard(
|
|
color: const Color(0xFF9CA3AF),
|
|
icon: Icons.remove_circle_outline,
|
|
title: 'Cancelado',
|
|
subtitle: 'El servicio fue cancelado',
|
|
);
|
|
case ServiceStatus.denied:
|
|
return _StatusCard(
|
|
color: const Color(0xFFDC2626),
|
|
icon: Icons.cancel_outlined,
|
|
title: 'Rechazado',
|
|
subtitle: 'El servicio fue rechazado',
|
|
);
|
|
case ServiceStatus.completed:
|
|
if (service.professionalScored == false) {
|
|
return _StatusCard(
|
|
color: const Color(0xFF16A34A),
|
|
icon: Icons.star_outline_rounded,
|
|
title: 'Completado',
|
|
subtitle: '¡Califica el servicio!',
|
|
action: _ScoreButton(service: service),
|
|
);
|
|
}
|
|
return _StatusCard(
|
|
color: const Color(0xFF16A34A),
|
|
icon: Icons.task_alt_outlined,
|
|
title: 'Completado',
|
|
subtitle: '¡Servicio finalizado exitosamente!',
|
|
);
|
|
default:
|
|
return const SizedBox();
|
|
}
|
|
}
|
|
|
|
Widget _bottomButton(
|
|
BuildContext context, ServiceEntity service, MyUser userInfo) {
|
|
final serviceDate = DateTime.parse(service.day);
|
|
final now = DateTime.now();
|
|
final serviceDateTime = DateTime(serviceDate.year, serviceDate.month,
|
|
serviceDate.day, service.range1Hour2.hour, service.range1Hour2.minute);
|
|
|
|
if (now.isAfter(serviceDateTime)) {
|
|
return GeneralSecondaryButton(
|
|
label: 'Volver', onPressed: () => Navigator.pop(context));
|
|
}
|
|
|
|
if (service.status == ServiceStatus.pending) {
|
|
return GeneralSecondaryButton(
|
|
label: 'Cancelar servicio',
|
|
color: Theme.of(context).colorScheme.error,
|
|
onPressed: () {
|
|
final s = context.read<ServiceBloc>().state;
|
|
if (s is ServiceLoaded) {
|
|
context.read<ServiceBloc>().add(
|
|
UpdateServiceStatus(widget.serviceId, ServiceStatus.cancelled));
|
|
}
|
|
if (userInfo.token != null) {
|
|
LocalNotifications.sendPushNotification(
|
|
userInfo.token!,
|
|
'Servicio cancelado',
|
|
'Servicio cancelado por ${userInfo.name}',
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
if (service.status == ServiceStatus.acepted) {
|
|
if (serviceDateTime.difference(now).inDays > 1) {
|
|
return GeneralSecondaryButton(
|
|
label: 'Cancelar servicio',
|
|
color: Theme.of(context).colorScheme.error,
|
|
onPressed: () {
|
|
final s = context.read<ServiceBloc>().state;
|
|
if (s is ServiceLoaded) {
|
|
context.read<ServiceBloc>().add(UpdateServiceStatus(
|
|
widget.serviceId, ServiceStatus.cancelled));
|
|
}
|
|
if (userInfo.token != null) {
|
|
LocalNotifications.sendPushNotification(
|
|
userInfo.token!,
|
|
'Servicio cancelado',
|
|
'Servicio cancelado por ${userInfo.name}',
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
if (service.status == ServiceStatus.active) {
|
|
return GeneralSecondaryButton(
|
|
label: 'Terminar servicio',
|
|
onPressed: () {
|
|
final s = context.read<ServiceBloc>().state;
|
|
if (s is ServiceLoaded) {
|
|
context.read<ServiceBloc>().add(UpdateServiceStatus(
|
|
widget.serviceId, ServiceStatus.completed));
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
return GeneralSecondaryButton(
|
|
label: 'Volver', onPressed: () => Navigator.pop(context));
|
|
}
|
|
|
|
String formatCurrency(int number) {
|
|
final formatter =
|
|
NumberFormat.currency(locale: 'es_CO', decimalDigits: 0, symbol: '');
|
|
return '\$${formatter.format(number)}';
|
|
}
|
|
|
|
Future<List<dynamic>> _getUserAndProfessionalInfo(
|
|
ServiceEntity service) async {
|
|
final userRepo = Injector.appInstance.get<UserRepository>();
|
|
final professionalRepo =
|
|
Injector.appInstance.get<ApiProfessionalRepository>();
|
|
final userInfo = await userRepo.getMyUser(service.professionalId);
|
|
final professionalInfo =
|
|
await professionalRepo.getProInfo(service.professionalId);
|
|
return [userInfo, professionalInfo];
|
|
}
|
|
}
|
|
|
|
// ── shared widgets ──────────────────────────────────────────────────────────
|
|
|
|
class _ActionBtn extends StatelessWidget {
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback onTap;
|
|
const _ActionBtn(
|
|
{required this.icon, required this.label, required this.onTap});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return OutlinedButton.icon(
|
|
onPressed: onTap,
|
|
icon: Icon(icon, size: 16),
|
|
label: Text(label, style: const TextStyle(fontSize: 12)),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: _kPrimary,
|
|
side: BorderSide(color: _kPrimary.withOpacity(0.5)),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PayBadge extends StatelessWidget {
|
|
final String label;
|
|
const _PayBadge(this.label);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
color: _kPrimary.withOpacity(0.08),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: _kPrimary.withOpacity(0.25)),
|
|
),
|
|
child: Text(label,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: _kPrimary)),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatusCard extends StatelessWidget {
|
|
final Color color;
|
|
final IconData icon;
|
|
final String title;
|
|
final String subtitle;
|
|
final Widget? action;
|
|
const _StatusCard({
|
|
required this.color,
|
|
required this.icon,
|
|
required this.title,
|
|
required this.subtitle,
|
|
this.action,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.08),
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: color.withOpacity(0.3)),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 44,
|
|
height: 44,
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.15), shape: BoxShape.circle),
|
|
child: Icon(icon, color: color, size: 24),
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(title,
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
color: color)),
|
|
Text(subtitle,
|
|
style: TextStyle(
|
|
fontSize: 12, color: color.withOpacity(0.75))),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (action != null) ...[
|
|
const SizedBox(height: 12),
|
|
action!,
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ScoreButton extends StatelessWidget {
|
|
final ServiceEntity service;
|
|
const _ScoreButton({required this.service});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton.icon(
|
|
onPressed: () => Navigator.push(
|
|
context,
|
|
CupertinoPageRoute(builder: (_) => ScoreScreen(service: service)),
|
|
),
|
|
icon: const Icon(Icons.star_outline_rounded, size: 18),
|
|
label: const Text('Calificar servicio'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: const Color(0xFF16A34A),
|
|
foregroundColor: Colors.white,
|
|
elevation: 0,
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
shape:
|
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|