Compare commits
36
Commits
2d5ff1581b
..
main
+1
-1
@@ -6,7 +6,7 @@ COPY pubspec.yaml pubspec.lock ./
|
|||||||
RUN flutter pub get
|
RUN flutter pub get
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN flutter build web --release --base-href /
|
RUN flutter build web --release --base-href / --pwa-strategy=none
|
||||||
|
|
||||||
# Stage 2: Serve with nginx
|
# Stage 2: Serve with nginx
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ class AppState extends StatelessWidget {
|
|||||||
ChangeNotifierProvider(create: (_) => ProfessionalFormProvider()),
|
ChangeNotifierProvider(create: (_) => ProfessionalFormProvider()),
|
||||||
ChangeNotifierProxyProvider<AuthProvider, ProfessionalsProvider>(
|
ChangeNotifierProxyProvider<AuthProvider, ProfessionalsProvider>(
|
||||||
create: (_) => ProfessionalsProvider(),
|
create: (_) => ProfessionalsProvider(),
|
||||||
update: (_, auth, prev) => prev!..updateCity(auth.user?.city),
|
update: (_, auth, prev) => prev!..updateCity(auth.user?.city, userId: auth.user?.id),
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider(create: (_) => ServicesProvider()),
|
ChangeNotifierProvider(create: (_) => ServicesProvider()),
|
||||||
ChangeNotifierProvider(create: (_) => CalendarServicesProvider()),
|
ChangeNotifierProvider(create: (_) => CalendarServicesProvider()),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class Profesional {
|
|||||||
final List<String> specializationsPictures;
|
final List<String> specializationsPictures;
|
||||||
final Schedules schedules;
|
final Schedules schedules;
|
||||||
final PaymentMethodEntity paymentMethods;
|
final PaymentMethodEntity paymentMethods;
|
||||||
|
final int slotDurationMinutes;
|
||||||
|
|
||||||
const Profesional({
|
const Profesional({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -44,6 +45,7 @@ class Profesional {
|
|||||||
required this.specializationsPictures,
|
required this.specializationsPictures,
|
||||||
required this.schedules,
|
required this.schedules,
|
||||||
required this.paymentMethods,
|
required this.paymentMethods,
|
||||||
|
this.slotDurationMinutes = 30,
|
||||||
});
|
});
|
||||||
|
|
||||||
static Profesional empty() => Profesional(
|
static Profesional empty() => Profesional(
|
||||||
@@ -132,20 +134,23 @@ class Profesional {
|
|||||||
rethusCode: (doc['rethus_code'] as String?) ?? '',
|
rethusCode: (doc['rethus_code'] as String?) ?? '',
|
||||||
rethusValidated: (doc['rethus_validated'] as bool?) ?? false,
|
rethusValidated: (doc['rethus_validated'] as bool?) ?? false,
|
||||||
address: (doc['address'] as String?) ?? '',
|
address: (doc['address'] as String?) ?? '',
|
||||||
aditionalAddress: (doc['aditional_address'] as String?) ?? '',
|
aditionalAddress: (doc['additional_address'] as String?) ?? '',
|
||||||
profession: (doc['profession'] as String?) ?? '',
|
profession: (doc['profession'] as String?) ?? '',
|
||||||
ratePreferences: (doc['rate_preferences'] as bool?) ?? false,
|
ratePreferences: doc['rate_preferences'] is bool
|
||||||
rate: (doc['rate'] as num?)?.toString() ?? '',
|
? doc['rate_preferences'] as bool
|
||||||
|
: doc['rate_preferences'] == 'true',
|
||||||
|
rate: doc['rate']?.toString() ?? '',
|
||||||
locationPreferences: locationPrefsFromValue(doc['location_preferences']),
|
locationPreferences: locationPrefsFromValue(doc['location_preferences']),
|
||||||
bannerPicture: (doc['banner_picture'] as String?) ?? '',
|
bannerPicture: (doc['banner_picture'] as String?) ?? '',
|
||||||
identificationPicture: (doc['identification_picture'] as String?) ?? '',
|
identificationPicture: (doc['identification_picture'] as String?) ?? '',
|
||||||
certificatePicture: (doc['certificate_picture'] as String?) ?? '',
|
certificatePicture: (doc['certificate_picture'] as String?) ?? '',
|
||||||
latitude: (doc['latitude'] as num?)?.toDouble() ?? 0.0,
|
latitude: double.tryParse(doc['latitude']?.toString() ?? '') ?? 0.0,
|
||||||
longitude: (doc['longitude'] as num?)?.toDouble() ?? 0.0,
|
longitude: double.tryParse(doc['longitude']?.toString() ?? '') ?? 0.0,
|
||||||
specializations: specs,
|
specializations: specs,
|
||||||
specializationsPictures: specPics,
|
specializationsPictures: specPics,
|
||||||
schedules: parsedSchedules,
|
schedules: parsedSchedules,
|
||||||
paymentMethods: pm,
|
paymentMethods: pm,
|
||||||
|
slotDurationMinutes: (doc['slot_duration_minutes'] as int?) ?? 30,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +174,7 @@ class Profesional {
|
|||||||
List<String>? specializationsPictures,
|
List<String>? specializationsPictures,
|
||||||
Schedules? schedules,
|
Schedules? schedules,
|
||||||
PaymentMethodEntity? paymentMethods,
|
PaymentMethodEntity? paymentMethods,
|
||||||
|
int? slotDurationMinutes,
|
||||||
}) {
|
}) {
|
||||||
return Profesional(
|
return Profesional(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -192,6 +198,7 @@ class Profesional {
|
|||||||
specializationsPictures ?? this.specializationsPictures,
|
specializationsPictures ?? this.specializationsPictures,
|
||||||
schedules: schedules ?? this.schedules,
|
schedules: schedules ?? this.schedules,
|
||||||
paymentMethods: paymentMethods ?? this.paymentMethods,
|
paymentMethods: paymentMethods ?? this.paymentMethods,
|
||||||
|
slotDurationMinutes: slotDurationMinutes ?? this.slotDurationMinutes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +209,7 @@ class Profesional {
|
|||||||
'rethus_code': rethusCode,
|
'rethus_code': rethusCode,
|
||||||
'rethus_validated': rethusValidated,
|
'rethus_validated': rethusValidated,
|
||||||
'address': address,
|
'address': address,
|
||||||
'aditional_address': aditionalAddress,
|
'additional_address': aditionalAddress,
|
||||||
'profession': profession,
|
'profession': profession,
|
||||||
'rate_preferences': ratePreferences,
|
'rate_preferences': ratePreferences,
|
||||||
'rate': double.tryParse(rate) ?? 0.0,
|
'rate': double.tryParse(rate) ?? 0.0,
|
||||||
|
|||||||
@@ -73,4 +73,18 @@ class Schedules {
|
|||||||
'sunday': sunday.toJson(),
|
'sunday': sunday.toJson(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Produces the array format expected by PATCH /professionals/me/schedules
|
||||||
|
// day_of_week: 0=Monday … 6=Sunday (matches backend DB convention)
|
||||||
|
List<Map<String, dynamic>> toSchedulesArray() {
|
||||||
|
return [
|
||||||
|
monday.toScheduleDto(0),
|
||||||
|
tuesday.toScheduleDto(1),
|
||||||
|
wednesday.toScheduleDto(2),
|
||||||
|
thursday.toScheduleDto(3),
|
||||||
|
friday.toScheduleDto(4),
|
||||||
|
saturday.toScheduleDto(5),
|
||||||
|
sunday.toScheduleDto(6),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,20 +73,35 @@ class ScheduleEntity {
|
|||||||
return {
|
return {
|
||||||
'habilitado': enabled,
|
'habilitado': enabled,
|
||||||
'continuous_day': continuousDay,
|
'continuous_day': continuousDay,
|
||||||
'range1Hour1': formatTimeOfDay(range1Hour1),
|
'range1Hour1': _formatTimePadded(range1Hour1),
|
||||||
'range1Hour2': formatTimeOfDay(range1Hour2),
|
'range1Hour2': _formatTimePadded(range1Hour2),
|
||||||
'range2Hour1': formatTimeOfDay(range2Hour1),
|
'range2Hour1': _formatTimePadded(range2Hour1),
|
||||||
'range2Hour2': formatTimeOfDay(range2Hour2),
|
'range2Hour2': _formatTimePadded(range2Hour2),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
String? formatTimeOfDay(TimeOfDay? time) {
|
// Produces { day_of_week, enabled, continuous_day, range*_hour* } for PATCH /professionals/me/schedules
|
||||||
if (time != null) {
|
Map<String, dynamic> toScheduleDto(int dayOfWeek) {
|
||||||
return "${time.hour.toString()}:${time.minute.toString()}";
|
return {
|
||||||
}
|
'day_of_week': dayOfWeek,
|
||||||
return null;
|
'enabled': enabled,
|
||||||
|
'continuous_day': continuousDay,
|
||||||
|
'range1_hour1': _formatTimePadded(range1Hour1),
|
||||||
|
'range1_hour2': _formatTimePadded(range1Hour2),
|
||||||
|
'range2_hour1': _formatTimePadded(range2Hour1),
|
||||||
|
'range2_hour2': _formatTimePadded(range2Hour2),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String? _formatTimePadded(TimeOfDay? time) {
|
||||||
|
if (time == null) return null;
|
||||||
|
final h = time.hour.toString().padLeft(2, '0');
|
||||||
|
final m = time.minute.toString().padLeft(2, '0');
|
||||||
|
return '$h:$m';
|
||||||
|
}
|
||||||
|
|
||||||
|
String? formatTimeOfDay(TimeOfDay? time) => _formatTimePadded(time);
|
||||||
|
|
||||||
static String? getFormatTime(TimeOfDay? time) {
|
static String? getFormatTime(TimeOfDay? time) {
|
||||||
if (time == null) {
|
if (time == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -65,9 +65,10 @@ class Service {
|
|||||||
userScored: doc['user_scored'] as bool? ?? false,
|
userScored: doc['user_scored'] as bool? ?? false,
|
||||||
address: doc['address'] as String? ?? '',
|
address: doc['address'] as String? ?? '',
|
||||||
aditionalAddress: (doc['additional_address'] ?? doc['aditional_address']) as String? ?? '',
|
aditionalAddress: (doc['additional_address'] ?? doc['aditional_address']) as String? ?? '',
|
||||||
latitude: (doc['latitude'] as num?)?.toDouble() ?? 0.0,
|
// Prisma Decimal fields serialize as JSON strings — same pattern as average_score
|
||||||
longitude: (doc['longitude'] as num?)?.toDouble() ?? 0.0,
|
latitude: double.tryParse(doc['latitude']?.toString() ?? '') ?? 0.0,
|
||||||
day: doc['day']?.toString() ?? '',
|
longitude: double.tryParse(doc['longitude']?.toString() ?? '') ?? 0.0,
|
||||||
|
day: (doc['day']?.toString() ?? '').split('T').first,
|
||||||
createdAt: doc['created_at']?.toString() ?? '',
|
createdAt: doc['created_at']?.toString() ?? '',
|
||||||
description: doc['description'] as String? ?? '',
|
description: doc['description'] as String? ?? '',
|
||||||
range1Hour1: _parseTime(doc['range1_hour1']?.toString() ?? '0:0'),
|
range1Hour1: _parseTime(doc['range1_hour1']?.toString() ?? '0:0'),
|
||||||
@@ -113,8 +114,10 @@ class Service {
|
|||||||
static TimeOfDay parseTimeOfDay(String timeString) => _parseTime(timeString);
|
static TimeOfDay parseTimeOfDay(String timeString) => _parseTime(timeString);
|
||||||
|
|
||||||
String? formatTimeOfDay(TimeOfDay? time) {
|
String? formatTimeOfDay(TimeOfDay? time) {
|
||||||
if (time != null) return '${time.hour}:${time.minute}';
|
if (time == null) return null;
|
||||||
return null;
|
final h = time.hour.toString().padLeft(2, '0');
|
||||||
|
final m = time.minute.toString().padLeft(2, '0');
|
||||||
|
return '$h:$m';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -10,10 +10,20 @@ enum ServiceStatus {
|
|||||||
selfBooked // 6
|
selfBooked // 6
|
||||||
}
|
}
|
||||||
|
|
||||||
int enumToIntService(ServiceStatus state) {
|
// Maps Flutter enum → backend string (matches ServiceStatus enum in the backend DTO)
|
||||||
return state.index;
|
const _serviceStatusStrings = {
|
||||||
}
|
ServiceStatus.pending: 'pending',
|
||||||
|
ServiceStatus.acepted: 'accepted',
|
||||||
|
ServiceStatus.denied: 'denied',
|
||||||
|
ServiceStatus.active: 'active',
|
||||||
|
ServiceStatus.cancelled: 'cancelled',
|
||||||
|
ServiceStatus.completed: 'completed',
|
||||||
|
ServiceStatus.selfBooked:'self_booked',
|
||||||
|
};
|
||||||
|
|
||||||
ServiceStatus intToEnumService(int value) {
|
String enumToStringService(ServiceStatus state) =>
|
||||||
return ServiceStatus.values[value];
|
_serviceStatusStrings[state] ?? 'pending';
|
||||||
}
|
|
||||||
|
int enumToIntService(ServiceStatus state) => state.index;
|
||||||
|
|
||||||
|
ServiceStatus intToEnumService(int value) => ServiceStatus.values[value];
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class UsuarioProfesional {
|
|||||||
return UsuarioProfesional(
|
return UsuarioProfesional(
|
||||||
user: Usuario.fromDocument(userDoc ?? {}),
|
user: Usuario.fromDocument(userDoc ?? {}),
|
||||||
professionalInfo: Profesional.fromDocument(proDoc),
|
professionalInfo: Profesional.fromDocument(proDoc),
|
||||||
averageScore: (doc['average_score'] as num?)?.toDouble() ?? 0.0,
|
averageScore: double.tryParse(doc['average_score']?.toString() ?? '') ?? 0.0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
|
import 'package:prosapp_web_app/models/pro_state.dart';
|
||||||
import 'package:prosapp_web_app/models/usuario.dart';
|
import 'package:prosapp_web_app/models/usuario.dart';
|
||||||
|
import 'package:prosapp_web_app/providers/professional_form_provider.dart';
|
||||||
import 'package:prosapp_web_app/providers/professional_provider.dart';
|
import 'package:prosapp_web_app/providers/professional_provider.dart';
|
||||||
import 'package:prosapp_web_app/providers/services_provider.dart';
|
import 'package:prosapp_web_app/providers/services_provider.dart';
|
||||||
import 'package:prosapp_web_app/router/router.dart';
|
import 'package:prosapp_web_app/router/router.dart';
|
||||||
import 'package:prosapp_web_app/services/api_service.dart';
|
import 'package:prosapp_web_app/services/api_service.dart';
|
||||||
|
import 'package:prosapp_web_app/services/local_storage.dart';
|
||||||
import 'package:prosapp_web_app/services/navigation_service.dart';
|
import 'package:prosapp_web_app/services/navigation_service.dart';
|
||||||
import 'package:prosapp_web_app/services/notifications_service.dart';
|
import 'package:prosapp_web_app/services/notifications_service.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -170,12 +174,18 @@ class AuthProvider extends ChangeNotifier {
|
|||||||
if (token == null) {
|
if (token == null) {
|
||||||
authStatus = AuthStatus.notAuthenticated;
|
authStatus = AuthStatus.notAuthenticated;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
_redirectToPhoneLogin();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final data = await _api.get('/auth/me');
|
final data = await _api.get('/auth/me');
|
||||||
user = Usuario.fromDocument(data as Map<String, dynamic>);
|
user = Usuario.fromDocument(data as Map<String, dynamic>);
|
||||||
userAverageScore = await _loadAverageScore(user!.id);
|
userAverageScore = await _loadAverageScore(user!.id);
|
||||||
|
// If the user is not an active professional, clear the persisted pro mode
|
||||||
|
// flag so a stale value doesn't leave them stuck in professional mode.
|
||||||
|
if (user!.proState != ProState.active) {
|
||||||
|
LocalStorage.prefs.setBool('isProModeActive', false);
|
||||||
|
}
|
||||||
authStatus = AuthStatus.authenticated;
|
authStatus = AuthStatus.authenticated;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return true;
|
return true;
|
||||||
@@ -183,10 +193,17 @@ class AuthProvider extends ChangeNotifier {
|
|||||||
await _api.deleteToken();
|
await _api.deleteToken();
|
||||||
authStatus = AuthStatus.notAuthenticated;
|
authStatus = AuthStatus.notAuthenticated;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
_redirectToPhoneLogin();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _redirectToPhoneLogin() {
|
||||||
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||||
|
NavigationService.replaceTo(Flurorouter.phoneLoginRoute);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
await _api.deleteToken();
|
await _api.deleteToken();
|
||||||
authStatus = AuthStatus.notAuthenticated;
|
authStatus = AuthStatus.notAuthenticated;
|
||||||
@@ -196,6 +213,7 @@ class AuthProvider extends ChangeNotifier {
|
|||||||
final ctx = NavigationService.navigatorKey.currentContext!;
|
final ctx = NavigationService.navigatorKey.currentContext!;
|
||||||
Provider.of<ServicesProvider>(ctx, listen: false).logout();
|
Provider.of<ServicesProvider>(ctx, listen: false).logout();
|
||||||
Provider.of<ProfessionalProvider>(ctx, listen: false).logout();
|
Provider.of<ProfessionalProvider>(ctx, listen: false).logout();
|
||||||
|
Provider.of<ProfessionalFormProvider>(ctx, listen: false).clear();
|
||||||
|
|
||||||
NavigationService.replaceTo(Flurorouter.phoneLoginRoute);
|
NavigationService.replaceTo(Flurorouter.phoneLoginRoute);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,21 @@ class CalendarServicesProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns services for a specific professional via the public endpoint (no auth needed).
|
||||||
|
// Used by CalendarView when a user browses a professional's availability.
|
||||||
|
Future<List<Service>> getPublicServicesForProfessional(String professionalId) async {
|
||||||
|
try {
|
||||||
|
final res = await _api.get('/services/public-calendar/$professionalId');
|
||||||
|
final rawServices = (res as Map<String, dynamic>)['services'] as List? ?? [];
|
||||||
|
return rawServices.map((e) {
|
||||||
|
final m = e as Map<String, dynamic>;
|
||||||
|
return Service.fromJson(m, m['id'] as String);
|
||||||
|
}).toList();
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getServicesForProfessional(String userId) async {
|
getServicesForProfessional(String userId) async {
|
||||||
try {
|
try {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class ProfessionalFormProvider with ChangeNotifier {
|
|||||||
List<String>? specializationsPictures,
|
List<String>? specializationsPictures,
|
||||||
Schedules? schedules,
|
Schedules? schedules,
|
||||||
PaymentMethodEntity? paymentMethods,
|
PaymentMethodEntity? paymentMethods,
|
||||||
|
int? slotDurationMinutes,
|
||||||
}) {
|
}) {
|
||||||
profesional = Profesional(
|
profesional = Profesional(
|
||||||
id: id ?? profesional!.id,
|
id: id ?? profesional!.id,
|
||||||
@@ -53,6 +54,7 @@ class ProfessionalFormProvider with ChangeNotifier {
|
|||||||
specializationsPictures: specializationsPictures ?? profesional!.specializationsPictures,
|
specializationsPictures: specializationsPictures ?? profesional!.specializationsPictures,
|
||||||
schedules: schedules ?? profesional!.schedules,
|
schedules: schedules ?? profesional!.schedules,
|
||||||
paymentMethods: paymentMethods ?? profesional!.paymentMethods,
|
paymentMethods: paymentMethods ?? profesional!.paymentMethods,
|
||||||
|
slotDurationMinutes: slotDurationMinutes ?? profesional!.slotDurationMinutes,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -60,6 +62,11 @@ class ProfessionalFormProvider with ChangeNotifier {
|
|||||||
bool _validForm() => formKey.currentState!.validate();
|
bool _validForm() => formKey.currentState!.validate();
|
||||||
bool _validProfileForm() => profileFormKey.currentState!.validate();
|
bool _validProfileForm() => profileFormKey.currentState!.validate();
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
profesional = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
setProfesional(Profesional p) {
|
setProfesional(Profesional p) {
|
||||||
profesional = p;
|
profesional = p;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -67,7 +74,12 @@ class ProfessionalFormProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Future<bool> updateProfesionalInfo(String userId) async {
|
Future<bool> updateProfesionalInfo(String userId) async {
|
||||||
if (!_validForm()) return false;
|
if (!_validForm()) return false;
|
||||||
await _api.patch('/professionals/me', profesional!.toDocument());
|
try {
|
||||||
|
await _api.patch('/professionals/me', profesional!.toDocument());
|
||||||
|
} catch (e) {
|
||||||
|
NotificationsService.showSnackbar('Error al guardar: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
NotificationsService.showSnackbar('Información actualizada');
|
NotificationsService.showSnackbar('Información actualizada');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -79,14 +91,31 @@ class ProfessionalFormProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Future<bool> updateProfesionalProfileInfo(String userId) async {
|
Future<bool> updateProfesionalProfileInfo(String userId) async {
|
||||||
if (!_validProfileForm()) return false;
|
if (!_validProfileForm()) return false;
|
||||||
await _api.patch('/professionals/me', profesional!.toDocument());
|
try {
|
||||||
|
await _api.patch('/professionals/me', profesional!.toDocument());
|
||||||
|
} catch (e) {
|
||||||
|
NotificationsService.showSnackbar('Error al guardar: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
NotificationsService.showSnackbar('Información actualizada');
|
NotificationsService.showSnackbar('Información actualizada');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> updateProfesionalProfileScheduleInfo(String userId) async {
|
Future<bool> updateProfesionalProfileScheduleInfo(String userId) async {
|
||||||
await _api.patch('/professionals/me', profesional!.toDocument());
|
try {
|
||||||
NotificationsService.showSnackbar('Información actualizada');
|
await Future.wait([
|
||||||
|
_api.patch('/professionals/me/schedules', {
|
||||||
|
'schedules': profesional!.schedules.toSchedulesArray(),
|
||||||
|
}),
|
||||||
|
_api.patch('/professionals/me', {
|
||||||
|
'slot_duration_minutes': profesional!.slotDurationMinutes,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
NotificationsService.showSnackbar('Error al guardar: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
NotificationsService.showSnackbar('Horario actualizado');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,19 +4,27 @@ import 'package:prosapp_web_app/models/payment_method_entity.dart';
|
|||||||
import 'package:prosapp_web_app/models/profesional.dart';
|
import 'package:prosapp_web_app/models/profesional.dart';
|
||||||
import 'package:prosapp_web_app/models/schedules.dart';
|
import 'package:prosapp_web_app/models/schedules.dart';
|
||||||
import 'package:prosapp_web_app/services/api_service.dart';
|
import 'package:prosapp_web_app/services/api_service.dart';
|
||||||
|
import 'package:prosapp_web_app/services/local_storage.dart';
|
||||||
|
|
||||||
class ProfessionalProvider extends ChangeNotifier {
|
class ProfessionalProvider extends ChangeNotifier {
|
||||||
Profesional? profesional;
|
Profesional? profesional;
|
||||||
bool _isProModeActive = false;
|
bool _isProModeActive = false;
|
||||||
final _api = ApiService.instance;
|
final _api = ApiService.instance;
|
||||||
|
|
||||||
|
static const _kProModeKey = 'isProModeActive';
|
||||||
|
|
||||||
|
ProfessionalProvider() {
|
||||||
|
_isProModeActive = LocalStorage.prefs.getBool(_kProModeKey) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
bool get isProModeActive => _isProModeActive;
|
bool get isProModeActive => _isProModeActive;
|
||||||
|
|
||||||
Future<Profesional> getProfessional(String uid) async {
|
Future<Profesional> getProfessional(String uid) async {
|
||||||
try {
|
try {
|
||||||
final data = await _api.get('/professionals/$uid');
|
final data = await _api.get('/professionals/me');
|
||||||
profesional = Profesional.fromDocument(data as Map<String, dynamic>);
|
profesional = Profesional.fromDocument(data as Map<String, dynamic>);
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
|
// Professional profile doesn't exist yet — return empty so the form shows blank
|
||||||
profesional = Profesional(
|
profesional = Profesional(
|
||||||
id: uid,
|
id: uid,
|
||||||
identification: '',
|
identification: '',
|
||||||
@@ -43,18 +51,34 @@ class ProfessionalProvider extends ChangeNotifier {
|
|||||||
return profesional!;
|
return profesional!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetches any professional by UUID or user_id via the public endpoint.
|
||||||
|
// Used by CalendarView to load the target professional (not the logged-in user).
|
||||||
|
Future<Profesional> getProfessionalById(String id) async {
|
||||||
|
try {
|
||||||
|
final data = await _api.get('/professionals/$id');
|
||||||
|
final prof = Profesional.fromDocument(data as Map<String, dynamic>);
|
||||||
|
notifyListeners();
|
||||||
|
return prof;
|
||||||
|
} catch (_) {
|
||||||
|
return Profesional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void logout() {
|
void logout() {
|
||||||
_isProModeActive = false;
|
_isProModeActive = false;
|
||||||
|
LocalStorage.prefs.setBool(_kProModeKey, false);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void toggleProMode() {
|
void toggleProMode() {
|
||||||
_isProModeActive = !_isProModeActive;
|
_isProModeActive = !_isProModeActive;
|
||||||
|
LocalStorage.prefs.setBool(_kProModeKey, _isProModeActive);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setIsProModeActive(bool value) {
|
void setIsProModeActive(bool value) {
|
||||||
_isProModeActive = value;
|
_isProModeActive = value;
|
||||||
|
LocalStorage.prefs.setBool(_kProModeKey, value);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,24 +5,57 @@ import 'package:prosapp_web_app/services/api_service.dart';
|
|||||||
class ProfessionalsProvider extends ChangeNotifier {
|
class ProfessionalsProvider extends ChangeNotifier {
|
||||||
List<UsuarioProfesional> professionals = [];
|
List<UsuarioProfesional> professionals = [];
|
||||||
bool isLoading = true;
|
bool isLoading = true;
|
||||||
String? _city;
|
bool _initialized = false;
|
||||||
|
String? _currentUserId;
|
||||||
|
String? _locationCity;
|
||||||
|
double? _locationLat;
|
||||||
|
double? _locationLng;
|
||||||
|
String? _lastSearch;
|
||||||
final _api = ApiService.instance;
|
final _api = ApiService.instance;
|
||||||
|
|
||||||
void updateCity(String? city) {
|
void updateCity(String? city, {String? userId}) {
|
||||||
if (_city != city) {
|
// Reset state when a different user logs in (prevents cross-user data leak)
|
||||||
_city = city;
|
if (_currentUserId != userId) {
|
||||||
|
_currentUserId = userId;
|
||||||
|
_initialized = false;
|
||||||
|
_locationCity = null;
|
||||||
|
_locationLat = null;
|
||||||
|
_locationLng = null;
|
||||||
|
_lastSearch = null;
|
||||||
|
professionals = [];
|
||||||
|
}
|
||||||
|
if (!_initialized) {
|
||||||
|
_initialized = true;
|
||||||
|
_locationCity = city;
|
||||||
getProfessionals();
|
getProfessionals();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getProfessionals() async {
|
double? get patientLat => _locationLat;
|
||||||
|
double? get patientLng => _locationLng;
|
||||||
|
|
||||||
|
void setLocationContext({required String? city, required double lat, required double lng}) {
|
||||||
|
if (city != null && city.isNotEmpty) _locationCity = city;
|
||||||
|
_locationLat = lat;
|
||||||
|
_locationLng = lng;
|
||||||
|
getProfessionals(search: _lastSearch);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> getProfessionals({String? search}) async {
|
||||||
|
if (search != null) _lastSearch = search.trim().isEmpty ? null : search.trim();
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
final path = (_city != null && _city!.isNotEmpty)
|
final params = <String, String>{};
|
||||||
? '/professionals?city=${Uri.encodeComponent(_city!)}'
|
if (_lastSearch != null && _lastSearch!.isNotEmpty) params['search'] = _lastSearch!;
|
||||||
: '/professionals';
|
if (_locationCity != null && _locationCity!.isNotEmpty) params['city'] = _locationCity!.trim();
|
||||||
final res = await _api.get(path);
|
if (_locationLat != null) params['lat'] = _locationLat.toString();
|
||||||
|
if (_locationLng != null) params['lng'] = _locationLng.toString();
|
||||||
|
|
||||||
|
final query = params.isEmpty
|
||||||
|
? ''
|
||||||
|
: '?${params.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}';
|
||||||
|
final res = await _api.get('/professionals$query');
|
||||||
final list = (res is Map ? res['data'] : res) as List;
|
final list = (res is Map ? res['data'] : res) as List;
|
||||||
professionals = list
|
professionals = list
|
||||||
.map((e) => UsuarioProfesional.fromDocument(e as Map<String, dynamic>))
|
.map((e) => UsuarioProfesional.fromDocument(e as Map<String, dynamic>))
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class ServicesProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> changeServiceStatus(String serviceId, ServiceStatus newStatus) async {
|
Future<void> changeServiceStatus(String serviceId, ServiceStatus newStatus) async {
|
||||||
try {
|
try {
|
||||||
await _api.patch('/services/$serviceId/status', {'status': enumToIntService(newStatus)});
|
await _api.patch('/services/$serviceId/status', {'status': enumToStringService(newStatus)});
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error al actualizar el estado: $e');
|
print('Error al actualizar el estado: $e');
|
||||||
@@ -51,14 +51,34 @@ class ServicesProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Blocks a slot on the professional's calendar by creating a self_booked service.
|
||||||
|
Future<void> blockSlot(String day, TimeOfDay time) async {
|
||||||
|
final h = time.hour.toString().padLeft(2, '0');
|
||||||
|
final m = time.minute.toString().padLeft(2, '0');
|
||||||
|
await _api.post('/services/block', {'day': day, 'range1_hour1': '$h:$m'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unblocks a previously blocked slot by cancelling the self_booked service.
|
||||||
|
Future<void> unblockSlot(String serviceId) async {
|
||||||
|
await _api.patch('/services/$serviceId/status', {'status': 'cancelled'});
|
||||||
|
}
|
||||||
|
|
||||||
getServiceForUser(String serviceId) async {
|
getServiceForUser(String serviceId) async {
|
||||||
try {
|
try {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
final data = await _api.get('/services/$serviceId');
|
final data = await _api.get('/services/$serviceId');
|
||||||
final map = data as Map<String, dynamic>;
|
final map = data as Map<String, dynamic>;
|
||||||
final servicio = Service.fromJson(map, map['id'] as String);
|
final servicio = Service.fromJson(map, map['id'] as String);
|
||||||
final userData = await _api.get('/users/${servicio.professionalId}');
|
// findById embeds professionals.users — use it instead of a second /users/:id call
|
||||||
final user = Usuario.fromDocument(userData as Map<String, dynamic>);
|
// (professional_id is a professionals-table UUID, not a user UUID)
|
||||||
|
final profDoc = map['professionals'] as Map<String, dynamic>?;
|
||||||
|
final userDoc = profDoc?['users'] as Map<String, dynamic>?;
|
||||||
|
final user = userDoc != null
|
||||||
|
? Usuario.fromDocument(userDoc)
|
||||||
|
: Usuario(id: servicio.professionalId, email: null, phone: null,
|
||||||
|
name: '?', nickname: null, city: null, picture: null,
|
||||||
|
birthday: null, gender: null, proState: ProState.inactive, token: null);
|
||||||
service = ServicioProfesional(user: user, service: servicio);
|
service = ServicioProfesional(user: user, service: servicio);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
service = null;
|
service = null;
|
||||||
@@ -71,11 +91,17 @@ class ServicesProvider extends ChangeNotifier {
|
|||||||
getServiceForProfessional(String serviceId) async {
|
getServiceForProfessional(String serviceId) async {
|
||||||
try {
|
try {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
final data = await _api.get('/services/$serviceId');
|
final data = await _api.get('/services/$serviceId');
|
||||||
final map = data as Map<String, dynamic>;
|
final map = data as Map<String, dynamic>;
|
||||||
final servicio = Service.fromJson(map, map['id'] as String);
|
final servicio = Service.fromJson(map, map['id'] as String);
|
||||||
final userData = await _api.get('/users/${servicio.userId}');
|
// findById embeds users (the patient) directly — use it instead of a second /users/:id call
|
||||||
final user = Usuario.fromDocument(userData as Map<String, dynamic>);
|
final userDoc = map['users'] as Map<String, dynamic>?;
|
||||||
|
final user = userDoc != null
|
||||||
|
? Usuario.fromDocument(userDoc)
|
||||||
|
: Usuario(id: servicio.userId, email: null, phone: null,
|
||||||
|
name: '?', nickname: null, city: null, picture: null,
|
||||||
|
birthday: null, gender: null, proState: ProState.inactive, token: null);
|
||||||
service = ServicioProfesional(user: user, service: servicio);
|
service = ServicioProfesional(user: user, service: servicio);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
service = null;
|
service = null;
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ class ApiService {
|
|||||||
throw Exception(body['message'] ?? 'Error ${res.statusCode}');
|
throw Exception(body['message'] ?? 'Error ${res.statusCode}');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const _timeout = Duration(seconds: 30);
|
||||||
|
|
||||||
Future<dynamic> get(String path) async {
|
Future<dynamic> get(String path) async {
|
||||||
final res = await http.get(Uri.parse('$baseUrl$path'), headers: await _headers());
|
final res = await http.get(Uri.parse('$baseUrl$path'), headers: await _headers()).timeout(_timeout);
|
||||||
return _parse(res);
|
return _parse(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +42,7 @@ class ApiService {
|
|||||||
Uri.parse('$baseUrl$path'),
|
Uri.parse('$baseUrl$path'),
|
||||||
headers: await _headers(),
|
headers: await _headers(),
|
||||||
body: jsonEncode(body),
|
body: jsonEncode(body),
|
||||||
);
|
).timeout(_timeout);
|
||||||
return _parse(res);
|
return _parse(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,12 +51,12 @@ class ApiService {
|
|||||||
Uri.parse('$baseUrl$path'),
|
Uri.parse('$baseUrl$path'),
|
||||||
headers: await _headers(),
|
headers: await _headers(),
|
||||||
body: jsonEncode(body),
|
body: jsonEncode(body),
|
||||||
);
|
).timeout(_timeout);
|
||||||
return _parse(res);
|
return _parse(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<dynamic> delete(String path) async {
|
Future<dynamic> delete(String path) async {
|
||||||
final res = await http.delete(Uri.parse('$baseUrl$path'), headers: await _headers());
|
final res = await http.delete(Uri.parse('$baseUrl$path'), headers: await _headers()).timeout(_timeout);
|
||||||
return _parse(res);
|
return _parse(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import 'dart:js' as js;
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:prosapp_web_app/services/api_service.dart';
|
import 'package:prosapp_web_app/services/api_service.dart';
|
||||||
|
|
||||||
|
// Result of a reverse geocode via the Maps JS SDK Geocoder
|
||||||
|
class GeocodeResult {
|
||||||
|
final String formattedAddress;
|
||||||
|
final List<Map<String, dynamic>> addressComponents;
|
||||||
|
const GeocodeResult({required this.formattedAddress, required this.addressComponents});
|
||||||
|
}
|
||||||
|
|
||||||
class MapsService {
|
class MapsService {
|
||||||
static bool _loaded = false;
|
static bool _loaded = false;
|
||||||
static Completer<bool>? _completer;
|
static Completer<bool>? _completer;
|
||||||
@@ -38,6 +45,59 @@ class MapsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Uses the already-loaded Maps JS SDK Geocoder — no separate Geocoding API enablement needed.
|
||||||
|
static Future<GeocodeResult?> reverseGeocode(double lat, double lng) async {
|
||||||
|
final completer = Completer<GeocodeResult?>();
|
||||||
|
final id = '_gc${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
|
||||||
|
try {
|
||||||
|
js.context['${id}_ok'] = js.allowInterop((String address, String compsJson) {
|
||||||
|
try { js.context.deleteProperty('${id}_ok'); } catch (_) {}
|
||||||
|
try {
|
||||||
|
final rawList = js.context.callMethod('eval', ['JSON.parse(\'${compsJson.replaceAll("'", "\\'")}\')']);
|
||||||
|
final comps = <Map<String, dynamic>>[];
|
||||||
|
// rawList is a JS array; iterate by index
|
||||||
|
final len = (rawList['length'] as num?)?.toInt() ?? 0;
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
final item = rawList[i] as js.JsObject;
|
||||||
|
final types = <String>[];
|
||||||
|
final tLen = (item['types']['length'] as num?)?.toInt() ?? 0;
|
||||||
|
for (var t = 0; t < tLen; t++) types.add(item['types'][t].toString());
|
||||||
|
comps.add({'long_name': item['long_name'].toString(), 'types': types});
|
||||||
|
}
|
||||||
|
completer.complete(GeocodeResult(formattedAddress: address, addressComponents: comps));
|
||||||
|
} catch (_) {
|
||||||
|
completer.complete(GeocodeResult(formattedAddress: address, addressComponents: []));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
js.context['${id}_err'] = js.allowInterop(() {
|
||||||
|
try { js.context.deleteProperty('${id}_err'); } catch (_) {}
|
||||||
|
completer.complete(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
js.context.callMethod('eval', ['''
|
||||||
|
(function(){
|
||||||
|
try {
|
||||||
|
var g = new google.maps.Geocoder();
|
||||||
|
g.geocode({location:{lat:$lat,lng:$lng},language:'es'}, function(r,s){
|
||||||
|
if(s==='OK'&&r&&r.length>0){
|
||||||
|
var compsStr = JSON.stringify(r[0].address_components.map(function(c){
|
||||||
|
return {long_name:c.long_name,types:c.types};
|
||||||
|
}));
|
||||||
|
window['${id}_ok'](r[0].formatted_address, compsStr);
|
||||||
|
} else { window['${id}_err'](); }
|
||||||
|
});
|
||||||
|
} catch(e){ window['${id}_err'](); }
|
||||||
|
})();
|
||||||
|
''']);
|
||||||
|
} catch (e) {
|
||||||
|
completer.complete(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return completer.future.timeout(const Duration(seconds: 10), onTimeout: () => null);
|
||||||
|
}
|
||||||
|
|
||||||
static void _injectScript(String apiKey) {
|
static void _injectScript(String apiKey) {
|
||||||
// Verifica si ya está cargado
|
// Verifica si ya está cargado
|
||||||
final existing = js.context.callMethod('eval', [
|
final existing = js.context.callMethod('eval', [
|
||||||
|
|||||||
@@ -179,9 +179,20 @@ class Sidebar extends StatelessWidget {
|
|||||||
cursor: SystemMouseCursors.click,
|
cursor: SystemMouseCursors.click,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (authProvider.user?.name == null ||
|
final missing = <String>[
|
||||||
authProvider.user?.name == '') {
|
if (authProvider.user?.name == null ||
|
||||||
NotificationsService.showSnackBarError('Primero completa tu perfil');
|
authProvider.user?.name == '')
|
||||||
|
'nombre',
|
||||||
|
if (authProvider.user?.city == null ||
|
||||||
|
authProvider.user?.city == '')
|
||||||
|
'ciudad',
|
||||||
|
if (authProvider.user?.phone == null ||
|
||||||
|
authProvider.user?.phone == '')
|
||||||
|
'teléfono',
|
||||||
|
];
|
||||||
|
if (missing.isNotEmpty) {
|
||||||
|
NotificationsService.showSnackBarError(
|
||||||
|
'Completa tu perfil: falta ${missing.join(', ')}');
|
||||||
navigateTo(Flurorouter.profileRoute);
|
navigateTo(Flurorouter.profileRoute);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+229
-308
@@ -3,10 +3,10 @@ import 'package:intl/intl.dart';
|
|||||||
import 'package:prosapp_web_app/models/profesional.dart';
|
import 'package:prosapp_web_app/models/profesional.dart';
|
||||||
import 'package:prosapp_web_app/models/schedules_entity.dart';
|
import 'package:prosapp_web_app/models/schedules_entity.dart';
|
||||||
import 'package:prosapp_web_app/models/service.dart';
|
import 'package:prosapp_web_app/models/service.dart';
|
||||||
|
import 'package:prosapp_web_app/models/service_status.dart';
|
||||||
import 'package:prosapp_web_app/providers/professional_form_provider.dart';
|
import 'package:prosapp_web_app/providers/professional_form_provider.dart';
|
||||||
import 'package:prosapp_web_app/providers/professional_provider.dart';
|
import 'package:prosapp_web_app/providers/professional_provider.dart';
|
||||||
import 'package:prosapp_web_app/providers/calendar_services_provider.dart';
|
import 'package:prosapp_web_app/providers/calendar_services_provider.dart';
|
||||||
import 'package:prosapp_web_app/services/notifications_service.dart';
|
|
||||||
import 'package:prosapp_web_app/ui/cards/white_card.dart';
|
import 'package:prosapp_web_app/ui/cards/white_card.dart';
|
||||||
import 'package:prosapp_web_app/utils/time_of_day_extension.dart';
|
import 'package:prosapp_web_app/utils/time_of_day_extension.dart';
|
||||||
import 'package:prosapp_web_app/utils/time_of_day_utils.dart';
|
import 'package:prosapp_web_app/utils/time_of_day_utils.dart';
|
||||||
@@ -23,361 +23,282 @@ class CalendarView extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _CalendarViewState extends State<CalendarView> {
|
class _CalendarViewState extends State<CalendarView> {
|
||||||
List<Service>? _services;
|
// Use local time throughout — UTC dates can shift the calendar date near midnight
|
||||||
DateTime today = DateTime.now();
|
DateTime _today = DateTime.now();
|
||||||
late int numDay;
|
Profesional? _profesional;
|
||||||
|
List<Service> _services = [];
|
||||||
|
bool _loadingPro = true;
|
||||||
|
bool _loadingServices = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_normalizeDay();
|
||||||
today = DateTime.utc(today.year, today.month, today.day);
|
_fetchProfessional();
|
||||||
numDay = today.weekday;
|
|
||||||
|
|
||||||
_fetchProfessionalAndServices();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _fetchProfessionalAndServices() async {
|
void _normalizeDay() {
|
||||||
final professionalFormProvider =
|
// Strip time component; keep local date only
|
||||||
Provider.of<ProfessionalFormProvider>(context, listen: false);
|
_today = DateTime(_today.year, _today.month, _today.day);
|
||||||
final servicesProvider =
|
|
||||||
Provider.of<CalendarServicesProvider>(context, listen: false);
|
|
||||||
final proProvider =
|
|
||||||
Provider.of<ProfessionalProvider>(context, listen: false);
|
|
||||||
|
|
||||||
final professional =
|
|
||||||
await proProvider.getProfessional(widget.professionalId);
|
|
||||||
professionalFormProvider.setProfesional(professional);
|
|
||||||
|
|
||||||
final services =
|
|
||||||
await servicesProvider.getServicesForProfessional(professional.id);
|
|
||||||
setState(() {
|
|
||||||
_services = services;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onDaySelected(DateTime day, DateTime focusedDay) {
|
// Loads professional data once — does NOT re-run on day change
|
||||||
|
Future<void> _fetchProfessional() async {
|
||||||
|
setState(() => _loadingPro = true);
|
||||||
|
try {
|
||||||
|
final proProvider = Provider.of<ProfessionalProvider>(context, listen: false);
|
||||||
|
final fp = Provider.of<ProfessionalFormProvider>(context, listen: false);
|
||||||
|
final pro = await proProvider.getProfessionalById(widget.professionalId);
|
||||||
|
fp.setProfesional(pro);
|
||||||
|
if (mounted) setState(() { _profesional = pro; _loadingPro = false; });
|
||||||
|
await _fetchServices();
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) setState(() => _loadingPro = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetches only services — called on day change and after initial load
|
||||||
|
Future<void> _fetchServices() async {
|
||||||
|
if (_profesional == null) return;
|
||||||
|
setState(() => _loadingServices = true);
|
||||||
|
try {
|
||||||
|
final sp = Provider.of<CalendarServicesProvider>(context, listen: false);
|
||||||
|
final list = await sp.getPublicServicesForProfessional(_profesional!.id);
|
||||||
|
if (mounted) setState(() { _services = list; _loadingServices = false; });
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) setState(() => _loadingServices = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDaySelected(DateTime day, DateTime _) {
|
||||||
setState(() {
|
setState(() {
|
||||||
today = day;
|
_today = DateTime(day.year, day.month, day.day);
|
||||||
numDay = today.weekday;
|
|
||||||
});
|
});
|
||||||
_fetchProfessionalAndServices();
|
// Professional data is cached — only re-fetch services
|
||||||
|
_fetchServices();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Consumer<ProfessionalFormProvider>(
|
if (_loadingPro || _profesional == null) {
|
||||||
builder: (context, professionalFormProvider, child) {
|
return const Center(child: CircularProgressIndicator());
|
||||||
if (professionalFormProvider.profesional == null) {
|
}
|
||||||
return const Center(
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final profesional = professionalFormProvider.profesional!;
|
return ListView(
|
||||||
|
physics: const ClampingScrollPhysics(),
|
||||||
return ListView(
|
children: [
|
||||||
physics: const ClampingScrollPhysics(),
|
Center(
|
||||||
children: [
|
child: ConstrainedBox(
|
||||||
Center(
|
constraints: const BoxConstraints(maxWidth: 900),
|
||||||
child: ConstrainedBox(
|
child: WhiteCard(
|
||||||
constraints: const BoxConstraints(maxWidth: 900),
|
title: 'Calendario',
|
||||||
child: WhiteCard(
|
child: Column(
|
||||||
title: 'Calendario',
|
children: [
|
||||||
child: Column(
|
TableCalendar(
|
||||||
children: [
|
locale: 'es_CO',
|
||||||
TableCalendar(
|
firstDay: DateTime.now(),
|
||||||
locale: 'es_CO',
|
lastDay: DateTime.now().add(const Duration(days: 180)),
|
||||||
firstDay: DateTime.now(),
|
focusedDay: _today,
|
||||||
lastDay: DateTime.now().add(const Duration(days: 180)),
|
availableGestures: AvailableGestures.all,
|
||||||
focusedDay: today,
|
onDaySelected: _onDaySelected,
|
||||||
availableGestures: AvailableGestures.all,
|
selectedDayPredicate: (day) => isSameDay(day, _today),
|
||||||
onDaySelected: _onDaySelected,
|
),
|
||||||
selectedDayPredicate: (day) => isSameDay(day, today),
|
const Divider(height: 0),
|
||||||
),
|
SizedBox(
|
||||||
const Divider(height: 0),
|
width: double.infinity,
|
||||||
SizedBox(
|
child: Padding(
|
||||||
width: double.infinity,
|
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 8),
|
||||||
child: Padding(
|
child: Text(
|
||||||
padding: const EdgeInsets.symmetric(
|
DateFormat('dd MMMM yyyy', 'es').format(_today),
|
||||||
horizontal: 15,
|
style: const TextStyle(
|
||||||
vertical: 8,
|
color: Colors.black,
|
||||||
),
|
fontSize: 16,
|
||||||
child: Text(
|
fontWeight: FontWeight.w600,
|
||||||
DateFormat('dd MMMM yyyy', 'es').format(today),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.black,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 0),
|
),
|
||||||
|
const Divider(height: 0),
|
||||||
|
if (_loadingServices)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 32),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
)
|
||||||
|
else
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
..._rangesItems(
|
..._rangesItems(
|
||||||
_getScheduleFromNumDay(numDay, profesional),
|
_getScheduleFromNumDay(_today.weekday, _profesional!),
|
||||||
context),
|
context,
|
||||||
|
stepMinutes: _profesional!.slotDurationMinutes,
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
);
|
],
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ScheduleEntity? _getScheduleFromNumDay(
|
ScheduleEntity? _getScheduleFromNumDay(int numDay, Profesional pro) {
|
||||||
int numDay, Profesional userProfessional) {
|
|
||||||
switch (numDay) {
|
switch (numDay) {
|
||||||
case 1:
|
case 1: return pro.schedules.monday;
|
||||||
return userProfessional.schedules.monday;
|
case 2: return pro.schedules.tuesday;
|
||||||
case 2:
|
case 3: return pro.schedules.wednesday;
|
||||||
return userProfessional.schedules.tuesday;
|
case 4: return pro.schedules.thursday;
|
||||||
case 3:
|
case 5: return pro.schedules.friday;
|
||||||
return userProfessional.schedules.wednesday;
|
case 6: return pro.schedules.saturday;
|
||||||
case 4:
|
case 7: return pro.schedules.sunday;
|
||||||
return userProfessional.schedules.thursday;
|
default: return null;
|
||||||
case 5:
|
|
||||||
return userProfessional.schedules.friday;
|
|
||||||
case 6:
|
|
||||||
return userProfessional.schedules.saturday;
|
|
||||||
case 7:
|
|
||||||
return userProfessional.schedules.sunday;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _rangesItems(ScheduleEntity? schedule, BuildContext context) {
|
List<Widget> _rangesItems(ScheduleEntity? schedule, BuildContext context, {int stepMinutes = 30}) {
|
||||||
if (schedule == null) {
|
if (schedule == null || !schedule.enabled) {
|
||||||
return [
|
return [const Padding(padding: EdgeInsets.only(top: 20), child: Text('No hay horarios disponibles'))];
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.only(top: 20),
|
|
||||||
child: Text("No hay horarios disponibles"),
|
|
||||||
)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (!schedule.enabled) {
|
|
||||||
return [
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.only(top: 20),
|
|
||||||
child: Text("No hay horarios disponibles"),
|
|
||||||
)
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final List<TimeOfDay> ranges;
|
||||||
if (schedule.continuousDay) {
|
if (schedule.continuousDay) {
|
||||||
if (schedule.range1Hour1 == null || schedule.range2Hour2 == null || schedule.range1Hour1!.compareTo(schedule.range2Hour2!) >= 0) { return [
|
if (schedule.range1Hour1 == null || schedule.range2Hour2 == null ||
|
||||||
const Padding(
|
schedule.range1Hour1!.compareTo(schedule.range2Hour2!) >= 0) {
|
||||||
padding: EdgeInsets.only(top: 20),
|
return [const Padding(padding: EdgeInsets.only(top: 20), child: Text('No hay horarios disponibles'))];
|
||||||
child: Text("No hay horarios disponibles"),
|
|
||||||
)
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
ranges = TimeOfDayUtils.genRanges(schedule.range1Hour1!, schedule.range2Hour2!, stepMinutes: stepMinutes);
|
||||||
List<TimeOfDay> ranges = TimeOfDayUtils.genRanges(
|
|
||||||
schedule.range1Hour1!,
|
|
||||||
schedule.range2Hour2!,
|
|
||||||
);
|
|
||||||
|
|
||||||
return rangesItemList(ranges, _services, today, context);
|
|
||||||
} else {
|
} else {
|
||||||
if (schedule.range1Hour1 == null || schedule.range1Hour2 == null || schedule.range2Hour1 == null || schedule.range2Hour2 == null || schedule.range1Hour1!.compareTo(schedule.range1Hour2!) >= 0 || schedule.range2Hour1!.compareTo(schedule.range2Hour2!) >= 0) {
|
if (schedule.range1Hour1 == null || schedule.range1Hour2 == null ||
|
||||||
return [
|
schedule.range2Hour1 == null || schedule.range2Hour2 == null ||
|
||||||
const Padding(
|
schedule.range1Hour1!.compareTo(schedule.range1Hour2!) >= 0 ||
|
||||||
padding: EdgeInsets.only(top: 20),
|
schedule.range2Hour1!.compareTo(schedule.range2Hour2!) >= 0) {
|
||||||
child: Text("No hay horarios disponibles"),
|
return [const Padding(padding: EdgeInsets.only(top: 20), child: Text('No hay horarios disponibles'))];
|
||||||
)
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
ranges = [
|
||||||
List<TimeOfDay> ranges1 = TimeOfDayUtils.genRanges(
|
...TimeOfDayUtils.genRanges(schedule.range1Hour1!, schedule.range1Hour2!, stepMinutes: stepMinutes),
|
||||||
schedule.range1Hour1!,
|
...TimeOfDayUtils.genRanges(schedule.range2Hour1!, schedule.range2Hour2!, stepMinutes: stepMinutes),
|
||||||
schedule.range1Hour2!,
|
|
||||||
);
|
|
||||||
List<TimeOfDay> ranges2 = TimeOfDayUtils.genRanges(
|
|
||||||
schedule.range2Hour1!,
|
|
||||||
schedule.range2Hour2!,
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
|
||||||
...rangesItemList(ranges1, _services, today, context),
|
|
||||||
...rangesItemList(ranges2, _services, today, context),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return _rangesItemList(ranges, _services, _today, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isHora1Ocupada(
|
// A slot is blocked (self-booked by the professional)
|
||||||
TimeOfDay hora1, List<Service>? events, DateTime selectedDay) {
|
bool _isBlocked(TimeOfDay hora1, List<Service> services, DateTime selectedDay) {
|
||||||
if (events != null) {
|
final dayStr = _dayStr(selectedDay);
|
||||||
for (Service event in events) {
|
return services.any((e) =>
|
||||||
if (selectedDay.toIso8601String().split('T').first == event.day) {
|
e.day == dayStr && e.range1Hour1 == hora1 && e.status == ServiceStatus.selfBooked);
|
||||||
if (hora1 == event.range1Hour1) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> rangesItemList(List<TimeOfDay> ranges, List<Service>? events,DateTime selectedDay, BuildContext context) {
|
// A slot is occupied by a real patient booking
|
||||||
final currentDateTime = DateTime.now();
|
// Excludes: selfBooked (professional blocks), cancelled, denied
|
||||||
|
bool _isOccupied(TimeOfDay hora1, List<Service> services, DateTime selectedDay) {
|
||||||
|
final dayStr = _dayStr(selectedDay);
|
||||||
|
return services.any((e) =>
|
||||||
|
e.day == dayStr &&
|
||||||
|
e.range1Hour1 == hora1 &&
|
||||||
|
e.status != ServiceStatus.selfBooked &&
|
||||||
|
e.status != ServiceStatus.cancelled &&
|
||||||
|
e.status != ServiceStatus.denied);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _dayStr(DateTime d) => '${d.year.toString().padLeft(4, '0')}-'
|
||||||
|
'${d.month.toString().padLeft(2, '0')}-'
|
||||||
|
'${d.day.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
List<Widget> _rangesItemList(
|
||||||
|
List<TimeOfDay> ranges, List<Service> services, DateTime selectedDay, BuildContext context) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
return ranges.map((time) {
|
return ranges.map((time) {
|
||||||
final selectedDateTime = DateTime(
|
// Compare using local time to avoid timezone edge cases
|
||||||
selectedDay.year,
|
final slotDt = DateTime(selectedDay.year, selectedDay.month, selectedDay.day, time.hour, time.minute);
|
||||||
selectedDay.month,
|
final isPast = slotDt.isBefore(now.add(const Duration(hours: 3)));
|
||||||
selectedDay.day,
|
final isBlocked = _isBlocked(time, services, selectedDay);
|
||||||
time.hour,
|
final isOccupied = !isBlocked && _isOccupied(time, services, selectedDay);
|
||||||
time.minute,
|
|
||||||
|
if (isPast || isBlocked) {
|
||||||
|
return _slotCard(
|
||||||
|
time: time,
|
||||||
|
circleColor: Colors.grey,
|
||||||
|
icon: isBlocked ? Icons.block : Icons.access_time,
|
||||||
|
label: 'No disponible',
|
||||||
|
labelColor: Colors.red,
|
||||||
|
onTap: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOccupied) {
|
||||||
|
return _slotCard(
|
||||||
|
time: time,
|
||||||
|
circleGradient: const LinearGradient(
|
||||||
|
colors: [Colors.yellow, Colors.red, Colors.red],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
icon: Icons.access_time,
|
||||||
|
label: 'Ocupado',
|
||||||
|
labelColor: Colors.red,
|
||||||
|
onTap: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _slotCard(
|
||||||
|
time: time,
|
||||||
|
circleGradient: const LinearGradient(
|
||||||
|
colors: [Colors.blue, Colors.green],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
icon: Icons.access_time,
|
||||||
|
label: 'Disponible',
|
||||||
|
labelColor: Colors.green,
|
||||||
|
onTap: () => Navigator.pop(context, [selectedDay, time]),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (selectedDateTime
|
|
||||||
.isBefore(currentDateTime.add(const Duration(hours: 3)))) {
|
|
||||||
return Card(
|
|
||||||
elevation: 4,
|
|
||||||
margin: const EdgeInsets.only(top: 15, left: 10, right: 10),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
child: ListTile(
|
|
||||||
contentPadding: const EdgeInsets.all(16),
|
|
||||||
leading: Container(
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: Colors.grey,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Center(
|
|
||||||
child: Icon(
|
|
||||||
Icons.access_time,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
ScheduleEntity.getFormatTime(time) ?? '',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
subtitle: const Text(
|
|
||||||
'No disponible',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.red,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_isHora1Ocupada(time, events, selectedDay)) {
|
|
||||||
return Card(
|
|
||||||
elevation: 4,
|
|
||||||
margin: const EdgeInsets.only(top: 15, left: 10, right: 10),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
child: ListTile(
|
|
||||||
onTap: () {
|
|
||||||
if (events != null) {
|
|
||||||
for (Service event in events) {
|
|
||||||
if (selectedDay.toIso8601String().split('T').first ==
|
|
||||||
event.day) {
|
|
||||||
if (time == event.range1Hour1) {
|
|
||||||
NotificationsService.showSnackbar('Ocupado');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
contentPadding: const EdgeInsets.all(16),
|
|
||||||
leading: Container(
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [Colors.yellow, Colors.red, Colors.red],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Center(
|
|
||||||
child: Icon(
|
|
||||||
Icons.access_time,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
ScheduleEntity.getFormatTime(time) ?? '',
|
|
||||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
subtitle: const Text(
|
|
||||||
'Ocupado',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.red, fontSize: 13, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return Card(
|
|
||||||
elevation: 4,
|
|
||||||
margin: const EdgeInsets.only(top: 15, left: 10, right: 10),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
child: ListTile(
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context, [selectedDay, time]);
|
|
||||||
},
|
|
||||||
contentPadding: const EdgeInsets.all(16),
|
|
||||||
leading: Container(
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [Colors.blue, Colors.green],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Center(
|
|
||||||
child: Icon(
|
|
||||||
Icons.access_time,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
ScheduleEntity.getFormatTime(time) ?? '',
|
|
||||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
subtitle: const Text(
|
|
||||||
'Disponible',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.green,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _slotCard({
|
||||||
|
required TimeOfDay time,
|
||||||
|
Color? circleColor,
|
||||||
|
Gradient? circleGradient,
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required Color labelColor,
|
||||||
|
VoidCallback? onTap,
|
||||||
|
}) {
|
||||||
|
return Card(
|
||||||
|
elevation: 4,
|
||||||
|
margin: const EdgeInsets.only(top: 15, left: 10, right: 10),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
child: ListTile(
|
||||||
|
onTap: onTap,
|
||||||
|
contentPadding: const EdgeInsets.all(16),
|
||||||
|
leading: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: circleGradient == null ? circleColor : null,
|
||||||
|
gradient: circleGradient,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Center(child: Icon(icon, color: Colors.white)),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
ScheduleEntity.getFormatTime(time) ?? '',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: onTap == null ? Colors.grey : Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(color: labelColor, fontSize: 13, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,19 +7,19 @@ import 'package:http/http.dart' as http;
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:prosapp_web_app/models/schedules_entity.dart';
|
import 'package:prosapp_web_app/models/schedules_entity.dart';
|
||||||
import 'package:prosapp_web_app/models/service.dart';
|
import 'package:prosapp_web_app/models/service.dart';
|
||||||
import 'package:prosapp_web_app/models/service_location_preferences.dart';
|
import 'package:prosapp_web_app/models/location_preferences.dart';
|
||||||
import 'package:prosapp_web_app/models/service_status.dart';
|
import 'package:prosapp_web_app/models/service_status.dart';
|
||||||
import 'package:prosapp_web_app/models/usuario.dart';
|
import 'package:prosapp_web_app/models/usuario.dart';
|
||||||
import 'package:prosapp_web_app/models/usuario_profesional.dart';
|
import 'package:prosapp_web_app/models/usuario_profesional.dart';
|
||||||
import 'package:prosapp_web_app/providers/auth_provider.dart';
|
import 'package:prosapp_web_app/providers/auth_provider.dart';
|
||||||
import 'package:prosapp_web_app/providers/cities_provider.dart';
|
import 'package:prosapp_web_app/providers/cities_provider.dart';
|
||||||
|
import 'package:prosapp_web_app/providers/professionals_provider.dart';
|
||||||
import 'package:prosapp_web_app/providers/theme_provider.dart';
|
import 'package:prosapp_web_app/providers/theme_provider.dart';
|
||||||
import 'package:prosapp_web_app/router/router.dart';
|
import 'package:prosapp_web_app/router/router.dart';
|
||||||
import 'package:prosapp_web_app/services/api_service.dart';
|
import 'package:prosapp_web_app/services/api_service.dart';
|
||||||
import 'package:prosapp_web_app/services/maps_service.dart';
|
import 'package:prosapp_web_app/services/maps_service.dart';
|
||||||
import 'package:prosapp_web_app/services/navigation_service.dart';
|
import 'package:prosapp_web_app/services/navigation_service.dart';
|
||||||
import 'package:prosapp_web_app/services/notifications_service.dart';
|
import 'package:prosapp_web_app/services/notifications_service.dart';
|
||||||
import 'package:prosapp_web_app/utils/local_notifications.dart';
|
|
||||||
import 'package:prosapp_web_app/utils/network_utility.dart';
|
import 'package:prosapp_web_app/utils/network_utility.dart';
|
||||||
import 'package:prosapp_web_app/utils/time_of_day_extension.dart';
|
import 'package:prosapp_web_app/utils/time_of_day_extension.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -36,7 +36,7 @@ class _DashboardViewState extends State<DashboardView> {
|
|||||||
|
|
||||||
// Map
|
// Map
|
||||||
GoogleMapController? _mapController;
|
GoogleMapController? _mapController;
|
||||||
LatLng _mapCenter = const LatLng(4.6097, -74.0817);
|
LatLng _mapCenter = const LatLng(6.2442, -75.5812); // Medellín — centro geográfico de Colombia como fallback neutro
|
||||||
bool _mapsReady = false;
|
bool _mapsReady = false;
|
||||||
bool _mapsLoading = true;
|
bool _mapsLoading = true;
|
||||||
bool _geocoding = false;
|
bool _geocoding = false;
|
||||||
@@ -54,13 +54,20 @@ class _DashboardViewState extends State<DashboardView> {
|
|||||||
DateTime? _selectedDay;
|
DateTime? _selectedDay;
|
||||||
TimeOfDay? _selectedHour;
|
TimeOfDay? _selectedHour;
|
||||||
bool _requesting = false;
|
bool _requesting = false;
|
||||||
|
// For professionals that accept both office and delivery, the patient chooses.
|
||||||
|
// true = delivery, false = office. Meaningless when professional only accepts one.
|
||||||
|
bool _bookAsDelivery = false;
|
||||||
|
|
||||||
// Throttle reverse geocode on camera idle
|
// Throttle reverse geocode on camera idle
|
||||||
DateTime _lastGeocode = DateTime(0);
|
DateTime _lastGeocode = DateTime(0);
|
||||||
|
|
||||||
// Ciudad detectada y disponibilidad
|
// Indica si _mapCenter ya fue fijado por GPS o geocoding (no el default)
|
||||||
|
bool _mapPositioned = false;
|
||||||
|
|
||||||
|
// Ciudad detectada, disponibilidad y coincidencia con perfil
|
||||||
String _detectedCity = '';
|
String _detectedCity = '';
|
||||||
bool _cityAvailable = true;
|
bool _cityAvailable = true;
|
||||||
|
bool _cityMismatch = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -76,6 +83,8 @@ class _DashboardViewState extends State<DashboardView> {
|
|||||||
final data = await ApiService.instance.get('/settings/maps-key');
|
final data = await ApiService.instance.get('/settings/maps-key');
|
||||||
_mapsApiKey = data['api_key'] as String? ?? '';
|
_mapsApiKey = data['api_key'] as String? ?? '';
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
// Pre-position map on user's city so autocomplete bias is correct from the start
|
||||||
|
await _centerOnUserCity();
|
||||||
setState(() {
|
setState(() {
|
||||||
_mapsReady = ok;
|
_mapsReady = ok;
|
||||||
_mapsLoading = false;
|
_mapsLoading = false;
|
||||||
@@ -86,14 +95,14 @@ class _DashboardViewState extends State<DashboardView> {
|
|||||||
Future<void> _detectLocation() async {
|
Future<void> _detectLocation() async {
|
||||||
try {
|
try {
|
||||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||||
if (!serviceEnabled) return;
|
if (!serviceEnabled) { await _centerOnUserCity(); return; }
|
||||||
|
|
||||||
LocationPermission permission = await Geolocator.checkPermission();
|
LocationPermission permission = await Geolocator.checkPermission();
|
||||||
if (permission == LocationPermission.denied) {
|
if (permission == LocationPermission.denied) {
|
||||||
permission = await Geolocator.requestPermission();
|
permission = await Geolocator.requestPermission();
|
||||||
if (permission == LocationPermission.denied) return;
|
if (permission == LocationPermission.denied) { await _centerOnUserCity(); return; }
|
||||||
}
|
}
|
||||||
if (permission == LocationPermission.deniedForever) return;
|
if (permission == LocationPermission.deniedForever) { await _centerOnUserCity(); return; }
|
||||||
|
|
||||||
final pos = await Geolocator.getCurrentPosition(
|
final pos = await Geolocator.getCurrentPosition(
|
||||||
locationSettings: const LocationSettings(
|
locationSettings: const LocationSettings(
|
||||||
@@ -103,44 +112,106 @@ class _DashboardViewState extends State<DashboardView> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final latlng = LatLng(pos.latitude, pos.longitude);
|
final latlng = LatLng(pos.latitude, pos.longitude);
|
||||||
if (mounted) setState(() => _mapCenter = latlng);
|
if (mounted) setState(() { _mapCenter = latlng; _mapPositioned = true; });
|
||||||
_mapController?.animateCamera(CameraUpdate.newLatLngZoom(latlng, 16));
|
_mapController?.animateCamera(CameraUpdate.newLatLngZoom(latlng, 16));
|
||||||
_reverseGeocode(latlng);
|
_reverseGeocode(latlng);
|
||||||
} catch (_) {}
|
} catch (_) {
|
||||||
|
await _centerOnUserCity();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static const Map<String, LatLng> _colombiaCityCoords = {
|
||||||
|
'bucaramanga': LatLng(7.1198, -73.1227),
|
||||||
|
'bogota': LatLng(4.7110, -74.0721),
|
||||||
|
'medellin': LatLng(6.2442, -75.5812),
|
||||||
|
'cali': LatLng(3.4516, -76.5320),
|
||||||
|
'barranquilla': LatLng(10.9639, -74.7964),
|
||||||
|
'cartagena': LatLng(10.3932, -75.4832),
|
||||||
|
'cucuta': LatLng(7.8939, -72.5078),
|
||||||
|
'pereira': LatLng(4.8087, -75.6906),
|
||||||
|
'manizales': LatLng(5.0703, -75.5138),
|
||||||
|
'santa marta': LatLng(11.2408, -74.1990),
|
||||||
|
'ibague': LatLng(4.4389, -75.2322),
|
||||||
|
'villavicencio': LatLng(4.1420, -73.6266),
|
||||||
|
'pasto': LatLng(1.2136, -77.2811),
|
||||||
|
'monteria': LatLng(8.7575, -75.8845),
|
||||||
|
'neiva': LatLng(2.9273, -75.2819),
|
||||||
|
'armenia': LatLng(4.5339, -75.6811),
|
||||||
|
'sincelejo': LatLng(9.3047, -75.3978),
|
||||||
|
'tunja': LatLng(5.5353, -73.3678),
|
||||||
|
'floridablanca': LatLng(7.0640, -73.0868),
|
||||||
|
'giron': LatLng(7.0730, -73.1701),
|
||||||
|
'piedecuesta': LatLng(6.9907, -73.0494),
|
||||||
|
'soledad': LatLng(10.9200, -74.7647),
|
||||||
|
'bello': LatLng(6.3367, -75.5572),
|
||||||
|
'soacha': LatLng(4.5797, -74.2172),
|
||||||
|
'buenaventura': LatLng(3.8833, -77.0311),
|
||||||
|
'valledupar': LatLng(10.4779, -73.2536),
|
||||||
|
'palmira': LatLng(3.5394, -76.3035),
|
||||||
|
'popayan': LatLng(2.4448, -76.6147),
|
||||||
|
'riohacha': LatLng(11.5444, -72.9072),
|
||||||
|
'quibdo': LatLng(5.6940, -76.6583),
|
||||||
|
};
|
||||||
|
|
||||||
|
LatLng? _cityFallbackCoords(String city) {
|
||||||
|
String norm(String s) => s.toLowerCase().trim()
|
||||||
|
.replaceAll(RegExp(r'[áà]'), 'a').replaceAll(RegExp(r'[éè]'), 'e')
|
||||||
|
.replaceAll(RegExp(r'[íì]'), 'i').replaceAll(RegExp(r'[óò]'), 'o')
|
||||||
|
.replaceAll(RegExp(r'[úù]'), 'u').replaceAll('.', '').replaceAll(',', '');
|
||||||
|
final normalized = norm(city);
|
||||||
|
for (final entry in _colombiaCityCoords.entries) {
|
||||||
|
if (normalized == entry.key || normalized.startsWith('${entry.key} ') || entry.key.startsWith('$normalized ')) {
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _centerOnUserCity() async {
|
||||||
|
final city = user?.city;
|
||||||
|
if (city == null || city.isEmpty) return;
|
||||||
|
|
||||||
|
// Apply hardcoded fallback immediately so the map never loads on the wrong city
|
||||||
|
final fallback = _cityFallbackCoords(city);
|
||||||
|
if (fallback != null && mounted) {
|
||||||
|
setState(() { _mapCenter = fallback; _mapPositioned = true; });
|
||||||
|
_mapController?.animateCamera(CameraUpdate.newLatLngZoom(fallback, 14));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then try geocoding for a more precise position
|
||||||
|
await _geocodeAndMoveMap(city);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _reverseGeocode(LatLng pos) async {
|
Future<void> _reverseGeocode(LatLng pos) async {
|
||||||
if (_mapsApiKey == null || _mapsApiKey!.isEmpty) return;
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
if (now.difference(_lastGeocode).inMilliseconds < 800) return;
|
if (now.difference(_lastGeocode).inMilliseconds < 800) return;
|
||||||
_lastGeocode = now;
|
_lastGeocode = now;
|
||||||
|
|
||||||
setState(() => _geocoding = true);
|
if (mounted) setState(() => _geocoding = true);
|
||||||
try {
|
try {
|
||||||
final res = await http.get(Uri.parse(
|
// Use Maps JS SDK Geocoder — works with the same API key as the map,
|
||||||
'https://maps.googleapis.com/maps/api/geocode/json'
|
// no separate Geocoding API billing required
|
||||||
'?latlng=${pos.latitude},${pos.longitude}'
|
final result = await MapsService.reverseGeocode(pos.latitude, pos.longitude);
|
||||||
'&key=$_mapsApiKey&language=es',
|
if (result != null && mounted) {
|
||||||
));
|
final city = _extractCity(result.addressComponents);
|
||||||
if (res.statusCode == 200) {
|
final citiesProvider = Provider.of<CitiesProvider>(context, listen: false);
|
||||||
final data = jsonDecode(res.body);
|
final available = city.isEmpty || citiesProvider.isCityAvailable(city);
|
||||||
final results = data['results'] as List?;
|
final userCity = user?.city ?? '';
|
||||||
if (results != null && results.isNotEmpty) {
|
final mismatch = userCity.isNotEmpty && city.isNotEmpty && !_citiesMatch(city, userCity);
|
||||||
final address = results[0]['formatted_address'] as String;
|
|
||||||
final components = results[0]['address_components'] as List? ?? [];
|
|
||||||
final city = _extractCity(components);
|
|
||||||
|
|
||||||
final citiesProvider = Provider.of<CitiesProvider>(context, listen: false);
|
setState(() {
|
||||||
final available = city.isEmpty || citiesProvider.isCityAvailable(city);
|
_currentAddress = result.formattedAddress;
|
||||||
|
_searchController.text = result.formattedAddress;
|
||||||
if (mounted) {
|
_detectedCity = city;
|
||||||
setState(() {
|
_cityAvailable = available;
|
||||||
_currentAddress = address;
|
_cityMismatch = mismatch;
|
||||||
_searchController.text = address;
|
});
|
||||||
_detectedCity = city;
|
if (!mismatch && city.isNotEmpty) {
|
||||||
_cityAvailable = available;
|
context.read<ProfessionalsProvider>().setLocationContext(
|
||||||
});
|
city: city,
|
||||||
}
|
lat: pos.latitude,
|
||||||
|
lng: pos.longitude,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -157,6 +228,19 @@ class _DashboardViewState extends State<DashboardView> {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _citiesMatch(String a, String b) {
|
||||||
|
String normalize(String s) => s.toLowerCase().trim()
|
||||||
|
.replaceAll(RegExp(r'[áà]'), 'a')
|
||||||
|
.replaceAll(RegExp(r'[éè]'), 'e')
|
||||||
|
.replaceAll(RegExp(r'[íì]'), 'i')
|
||||||
|
.replaceAll(RegExp(r'[óò]'), 'o')
|
||||||
|
.replaceAll(RegExp(r'[úù]'), 'u');
|
||||||
|
final na = normalize(a);
|
||||||
|
final nb = normalize(b);
|
||||||
|
// Exact match, or one is a full-word prefix of the other (handles "Bogotá D.C." vs "Bogotá")
|
||||||
|
return na == nb || na.startsWith('$nb ') || nb.startsWith('$na ');
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _geocodeAndMoveMap(String address) async {
|
Future<void> _geocodeAndMoveMap(String address) async {
|
||||||
if (_mapsApiKey == null || _mapsApiKey!.isEmpty) return;
|
if (_mapsApiKey == null || _mapsApiKey!.isEmpty) return;
|
||||||
try {
|
try {
|
||||||
@@ -174,7 +258,7 @@ class _DashboardViewState extends State<DashboardView> {
|
|||||||
(loc['lat'] as num).toDouble(),
|
(loc['lat'] as num).toDouble(),
|
||||||
(loc['lng'] as num).toDouble(),
|
(loc['lng'] as num).toDouble(),
|
||||||
);
|
);
|
||||||
if (mounted) setState(() => _mapCenter = pos);
|
if (mounted) setState(() { _mapCenter = pos; _mapPositioned = true; });
|
||||||
_mapController?.animateCamera(CameraUpdate.newLatLngZoom(pos, 16));
|
_mapController?.animateCamera(CameraUpdate.newLatLngZoom(pos, 16));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,10 +272,16 @@ class _DashboardViewState extends State<DashboardView> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_debounce = Timer(const Duration(milliseconds: 450), () async {
|
_debounce = Timer(const Duration(milliseconds: 450), () async {
|
||||||
final uri = Uri.https('admin.prosapp.co', '/autocomplete', {
|
// Bias toward user's city: if map not yet positioned, append city to query
|
||||||
'input': value,
|
// and skip the coordinates bias (which would be a wrong default location)
|
||||||
'location': '${_mapCenter.latitude},${_mapCenter.longitude}',
|
final userCity = user?.city;
|
||||||
});
|
final cityMissing = userCity != null &&
|
||||||
|
userCity.isNotEmpty &&
|
||||||
|
!value.toLowerCase().contains(userCity.toLowerCase());
|
||||||
|
final input = cityMissing ? '$value $userCity' : value;
|
||||||
|
final params = <String, String>{'input': input};
|
||||||
|
if (_mapPositioned) params['location'] = '${_mapCenter.latitude},${_mapCenter.longitude}';
|
||||||
|
final uri = Uri.https('admin.prosapp.co', '/autocomplete', params);
|
||||||
final response = await NetworkUtility.fetchUrl(uri);
|
final response = await NetworkUtility.fetchUrl(uri);
|
||||||
if (response != null && mounted) {
|
if (response != null && mounted) {
|
||||||
final decoded = jsonDecode(response);
|
final decoded = jsonDecode(response);
|
||||||
@@ -211,23 +301,42 @@ class _DashboardViewState extends State<DashboardView> {
|
|||||||
_geocodeAndMoveMap(address);
|
_geocodeAndMoveMap(address);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _selectProfessionalFromMarker(UsuarioProfesional prof) async {
|
||||||
|
try {
|
||||||
|
final result = await NavigationService.navigateToFuture(
|
||||||
|
'/dashboard/calendar/${prof.user.id}',
|
||||||
|
);
|
||||||
|
if (result is List && result.length >= 2) {
|
||||||
|
setState(() {
|
||||||
|
_professional = prof;
|
||||||
|
_selectedDay = result[0] as DateTime;
|
||||||
|
_selectedHour = result[1] as TimeOfDay;
|
||||||
|
_bookAsDelivery = prof.professionalInfo.locationPreferences == LocationPreferences.delivery;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _selectProfessional() async {
|
Future<void> _selectProfessional() async {
|
||||||
try {
|
try {
|
||||||
|
// Pass current location context to professionals list before navigating
|
||||||
|
context.read<ProfessionalsProvider>().setLocationContext(
|
||||||
|
city: _detectedCity.isNotEmpty ? _detectedCity : user?.city,
|
||||||
|
lat: _mapCenter.latitude,
|
||||||
|
lng: _mapCenter.longitude,
|
||||||
|
);
|
||||||
final result = await NavigationService.navigateToFuture(Flurorouter.professionalsRoute);
|
final result = await NavigationService.navigateToFuture(Flurorouter.professionalsRoute);
|
||||||
final prof = result[0] as UsuarioProfesional;
|
final prof = result[0] as UsuarioProfesional;
|
||||||
setState(() {
|
setState(() {
|
||||||
_professional = prof;
|
_professional = prof;
|
||||||
_selectedDay = result[1];
|
_selectedDay = result[1];
|
||||||
_selectedHour = result[2];
|
_selectedHour = result[2];
|
||||||
|
_bookAsDelivery = prof.professionalInfo.locationPreferences == LocationPreferences.delivery;
|
||||||
});
|
});
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _requestService() async {
|
Future<void> _requestService() async {
|
||||||
if (_currentAddress.isEmpty) {
|
|
||||||
NotificationsService.showSnackBarError('Mueve el mapa para seleccionar tu dirección');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_professional == null) {
|
if (_professional == null) {
|
||||||
NotificationsService.showSnackBarError('Selecciona un profesional');
|
NotificationsService.showSnackBarError('Selecciona un profesional');
|
||||||
return;
|
return;
|
||||||
@@ -236,36 +345,39 @@ class _DashboardViewState extends State<DashboardView> {
|
|||||||
NotificationsService.showSnackBarError('Selecciona fecha y hora');
|
NotificationsService.showSnackBarError('Selecciona fecha y hora');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final profPrefs = _professional!.professionalInfo.locationPreferences;
|
||||||
|
// Determine if this booking is for office or delivery
|
||||||
|
final bool isOfficeBooking = profPrefs == LocationPreferences.office ||
|
||||||
|
(profPrefs == LocationPreferences.both && !_bookAsDelivery);
|
||||||
|
|
||||||
|
// Dirección solo requerida cuando el profesional hace domicilios
|
||||||
|
if (!isOfficeBooking && _currentAddress.isEmpty) {
|
||||||
|
NotificationsService.showSnackBarError('Mueve el mapa para seleccionar tu dirección');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() => _requesting = true);
|
setState(() => _requesting = true);
|
||||||
try {
|
try {
|
||||||
final service = Service(
|
final slot = _selectedHour!;
|
||||||
id: null,
|
final endSlot = slot.add(minute: _professional!.professionalInfo.slotDurationMinutes);
|
||||||
professionalId: _professional!.user.id,
|
String pad(int n) => n.toString().padLeft(2, '0');
|
||||||
professionalScored: false,
|
|
||||||
userId: user!.id,
|
// Build payload matching CreateServiceDto exactly
|
||||||
userScored: false,
|
final payload = {
|
||||||
address: _currentAddress,
|
'professional_id': _professional!.user.id,
|
||||||
aditionalAddress: '',
|
// @IsDateString() expects ISO 8601 date — "yyyy-MM-dd"
|
||||||
latitude: _mapCenter.latitude,
|
'day': '${_selectedDay!.year}-${pad(_selectedDay!.month)}-${pad(_selectedDay!.day)}',
|
||||||
longitude: _mapCenter.longitude,
|
'range1_hour1': '${pad(slot.hour)}:${pad(slot.minute)}',
|
||||||
day: _selectedDay.toString(),
|
'range1_hour2': '${pad(endSlot.hour)}:${pad(endSlot.minute)}',
|
||||||
createdAt: DateTime.now().toIso8601String(),
|
'address': isOfficeBooking ? _professional!.professionalInfo.address : _currentAddress,
|
||||||
description: '',
|
'latitude': isOfficeBooking ? _professional!.professionalInfo.latitude : _mapCenter.latitude,
|
||||||
range1Hour1: _selectedHour!,
|
'longitude': isOfficeBooking ? _professional!.professionalInfo.longitude : _mapCenter.longitude,
|
||||||
range1Hour2: _selectedHour!.add(hour: 2),
|
// @IsEnum(['office','delivery']) expects the string value, not an integer
|
||||||
rate: '',
|
'location_preference': isOfficeBooking ? 'office' : 'delivery',
|
||||||
status: ServiceStatus.pending,
|
};
|
||||||
location: ServiceLocationPreferences.delivery,
|
await ApiService.instance.post('/services', payload);
|
||||||
);
|
|
||||||
await ApiService.instance.post('/services', service.toDocument());
|
|
||||||
NotificationsService.showSnackbar('Servicio solicitado exitosamente');
|
NotificationsService.showSnackbar('Servicio solicitado exitosamente');
|
||||||
if (_professional!.user.token != null) {
|
|
||||||
LocalNotifications.sendPushNotification(
|
|
||||||
_professional!.user.token!,
|
|
||||||
'Nuevo servicio',
|
|
||||||
'Tienes una nueva solicitud de servicio pendiente',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_professional = null;
|
_professional = null;
|
||||||
_selectedDay = null;
|
_selectedDay = null;
|
||||||
@@ -301,7 +413,34 @@ class _DashboardViewState extends State<DashboardView> {
|
|||||||
final hintColor = isDark ? Colors.white38 : Colors.grey;
|
final hintColor = isDark ? Colors.white38 : Colors.grey;
|
||||||
final inputFill = isDark ? const Color(0xFF0F172A) : Colors.white;
|
final inputFill = isDark ? const Color(0xFF0F172A) : Colors.white;
|
||||||
|
|
||||||
return GestureDetector(
|
// Marcadores de consultorios: profesionales con coordenadas reales y atención presencial
|
||||||
|
final professionalsProvider = context.watch<ProfessionalsProvider>();
|
||||||
|
final officeMarkers = <Marker>{};
|
||||||
|
for (final prof in professionalsProvider.professionals) {
|
||||||
|
final lat = prof.professionalInfo.latitude;
|
||||||
|
final lng = prof.professionalInfo.longitude;
|
||||||
|
if (lat == 0.0 && lng == 0.0) continue;
|
||||||
|
final prefs = prof.professionalInfo.locationPreferences;
|
||||||
|
if (prefs == LocationPreferences.delivery) continue;
|
||||||
|
officeMarkers.add(Marker(
|
||||||
|
markerId: MarkerId(prof.professionalInfo.id),
|
||||||
|
position: LatLng(lat, lng),
|
||||||
|
icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueAzure),
|
||||||
|
infoWindow: InfoWindow(
|
||||||
|
title: prof.user.name,
|
||||||
|
snippet: [
|
||||||
|
if (prof.professionalInfo.profession.isNotEmpty) prof.professionalInfo.profession,
|
||||||
|
if (prof.professionalInfo.address.isNotEmpty) prof.professionalInfo.address,
|
||||||
|
].join(' • '),
|
||||||
|
),
|
||||||
|
onTap: () => _selectProfessionalFromMarker(prof),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaQuery(
|
||||||
|
// Prevent keyboard from shifting the map+card layout on tablets
|
||||||
|
data: MediaQuery.of(context).copyWith(viewInsets: EdgeInsets.zero),
|
||||||
|
child: GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
_searchFocus.unfocus();
|
_searchFocus.unfocus();
|
||||||
setState(() => _suggestions = []);
|
setState(() => _suggestions = []);
|
||||||
@@ -318,6 +457,7 @@ class _DashboardViewState extends State<DashboardView> {
|
|||||||
myLocationEnabled: true,
|
myLocationEnabled: true,
|
||||||
myLocationButtonEnabled: false,
|
myLocationButtonEnabled: false,
|
||||||
zoomControlsEnabled: false,
|
zoomControlsEnabled: false,
|
||||||
|
markers: officeMarkers,
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Container(
|
Container(
|
||||||
@@ -400,6 +540,8 @@ class _DashboardViewState extends State<DashboardView> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_suggestions = [];
|
_suggestions = [];
|
||||||
_currentAddress = '';
|
_currentAddress = '';
|
||||||
|
_detectedCity = '';
|
||||||
|
_cityMismatch = false;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -508,33 +650,149 @@ class _DashboardViewState extends State<DashboardView> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
// Dirección actual
|
// Tipo de servicio (solo cuando el profesional acepta ambas modalidades)
|
||||||
if (_currentAddress.isNotEmpty)
|
if (_professional?.professionalInfo.locationPreferences == LocationPreferences.both) ...[
|
||||||
Container(
|
Row(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
children: [
|
||||||
margin: const EdgeInsets.only(bottom: 10),
|
Expanded(
|
||||||
decoration: BoxDecoration(
|
child: GestureDetector(
|
||||||
color: isDark
|
onTap: () => setState(() => _bookAsDelivery = false),
|
||||||
? const Color(0xFF42A4EF).withOpacity(0.12)
|
child: AnimatedContainer(
|
||||||
: const Color(0xFFF0F8FF),
|
duration: const Duration(milliseconds: 180),
|
||||||
borderRadius: BorderRadius.circular(8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
border: Border.all(color: const Color(0xFF42A4EF).withOpacity(0.35)),
|
decoration: BoxDecoration(
|
||||||
),
|
color: !_bookAsDelivery
|
||||||
child: Row(
|
? const Color(0xFF42A4EF)
|
||||||
children: [
|
: (isDark ? const Color(0xFF1E293B) : Colors.grey[100]),
|
||||||
const Icon(Icons.location_on, size: 16, color: Color(0xFF42A4EF)),
|
borderRadius: BorderRadius.circular(8),
|
||||||
const SizedBox(width: 8),
|
border: Border.all(
|
||||||
Expanded(
|
color: !_bookAsDelivery
|
||||||
child: Text(
|
? const Color(0xFF42A4EF)
|
||||||
_currentAddress,
|
: cardBorder,
|
||||||
style: TextStyle(fontSize: 12, color: textColor),
|
),
|
||||||
maxLines: 2,
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.store_outlined, size: 14,
|
||||||
|
color: !_bookAsDelivery ? Colors.white : subtextColor),
|
||||||
|
const SizedBox(width: 5),
|
||||||
|
Text('Consultorio',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: !_bookAsDelivery ? Colors.white : subtextColor)),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => setState(() => _bookAsDelivery = true),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _bookAsDelivery
|
||||||
|
? const Color(0xFF42A4EF)
|
||||||
|
: (isDark ? const Color(0xFF1E293B) : Colors.grey[100]),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: _bookAsDelivery
|
||||||
|
? const Color(0xFF42A4EF)
|
||||||
|
: cardBorder,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.home_outlined, size: 14,
|
||||||
|
color: _bookAsDelivery ? Colors.white : subtextColor),
|
||||||
|
const SizedBox(width: 5),
|
||||||
|
Text('Domicilio',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: _bookAsDelivery ? Colors.white : subtextColor)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Dirección: consultorio del profesional o dirección del cliente
|
||||||
|
Builder(builder: (context) {
|
||||||
|
final prefs = _professional?.professionalInfo.locationPreferences;
|
||||||
|
final isOfficeBooking = prefs == LocationPreferences.office ||
|
||||||
|
(prefs == LocationPreferences.both && !_bookAsDelivery);
|
||||||
|
if (isOfficeBooking) {
|
||||||
|
// Mostrar dirección del consultorio del profesional
|
||||||
|
final profAddress = _professional?.professionalInfo.address ?? '';
|
||||||
|
if (profAddress.isNotEmpty) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark
|
||||||
|
? const Color(0xFF42A4EF).withOpacity(0.08)
|
||||||
|
: const Color(0xFFF0F8FF),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: const Color(0xFF42A4EF).withOpacity(0.25)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.store_outlined, size: 16, color: Color(0xFF42A4EF)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Consultorio: $profAddress',
|
||||||
|
style: TextStyle(fontSize: 12, color: textColor),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
// Domicilio: mostrar dirección del cliente
|
||||||
|
if (_currentAddress.isNotEmpty) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark
|
||||||
|
? const Color(0xFF42A4EF).withOpacity(0.12)
|
||||||
|
: const Color(0xFFF0F8FF),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: const Color(0xFF42A4EF).withOpacity(0.35)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.location_on, size: 16, color: Color(0xFF42A4EF)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_currentAddress,
|
||||||
|
style: TextStyle(fontSize: 12, color: textColor),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}),
|
||||||
|
|
||||||
// Profesional
|
// Profesional
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
@@ -618,8 +876,8 @@ class _DashboardViewState extends State<DashboardView> {
|
|||||||
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Aviso ciudad no disponible
|
// Aviso ciudad no disponible (solo aplica cuando el servicio es a domicilio)
|
||||||
if (!_cityAvailable && _detectedCity.isNotEmpty)
|
if (!_cityAvailable && _detectedCity.isNotEmpty && _bookAsDelivery)
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
margin: const EdgeInsets.only(bottom: 10),
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
@@ -656,10 +914,52 @@ class _DashboardViewState extends State<DashboardView> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Warning: dirección fuera de la ciudad del usuario (solo en modo domicilio)
|
||||||
|
if (_cityMismatch && _detectedCity.isNotEmpty && _bookAsDelivery)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFFEBEE),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: const Color(0xFFEF9A9A)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.location_off_outlined, color: Color(0xFFC62828), size: 20),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Dirección fuera de tu ciudad',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(0xFFC62828),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Solo puedes solicitar servicios en ${user?.city ?? 'tu ciudad'}. Mueve el mapa a una dirección de ${user?.city ?? 'tu ciudad'}.',
|
||||||
|
style: const TextStyle(color: Color(0xFFC62828), fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: (_requesting || !_cityAvailable) ? null : _requestService,
|
onPressed: _requesting
|
||||||
|
? null
|
||||||
|
: ((!_bookAsDelivery || (_cityAvailable && !_cityMismatch))
|
||||||
|
? _requestService
|
||||||
|
: null),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFF42A4EF),
|
backgroundColor: const Color(0xFF42A4EF),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
@@ -686,6 +986,7 @@ class _DashboardViewState extends State<DashboardView> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,17 @@ import 'package:prosapp_web_app/models/service.dart';
|
|||||||
import 'package:prosapp_web_app/providers/auth_provider.dart';
|
import 'package:prosapp_web_app/providers/auth_provider.dart';
|
||||||
import 'package:prosapp_web_app/providers/professional_form_provider.dart';
|
import 'package:prosapp_web_app/providers/professional_form_provider.dart';
|
||||||
import 'package:prosapp_web_app/providers/professional_provider.dart';
|
import 'package:prosapp_web_app/providers/professional_provider.dart';
|
||||||
|
import 'package:prosapp_web_app/models/service_status.dart';
|
||||||
import 'package:prosapp_web_app/providers/services_provider.dart';
|
import 'package:prosapp_web_app/providers/services_provider.dart';
|
||||||
import 'package:prosapp_web_app/utils/time_of_day_utils.dart';
|
import 'package:prosapp_web_app/utils/time_of_day_utils.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:table_calendar/table_calendar.dart';
|
import 'package:table_calendar/table_calendar.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
const _kPrimary = Color(0xFF1565C0);
|
const _kPrimary = Color(0xFF1565C0);
|
||||||
const _kAvailable = Color(0xFF16A34A);
|
const _kAvailable = Color(0xFF16A34A);
|
||||||
const _kOccupied = Color(0xFFDC2626);
|
const _kOccupied = Color(0xFFDC2626);
|
||||||
|
const _kBlocked = Color(0xFFF59E0B);
|
||||||
|
|
||||||
extension _Th on BuildContext {
|
extension _Th on BuildContext {
|
||||||
ThemeData get _t => Theme.of(this);
|
ThemeData get _t => Theme.of(this);
|
||||||
@@ -54,8 +56,12 @@ class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
|
|||||||
final pro = await Provider.of<ProfessionalProvider>(context, listen: false)
|
final pro = await Provider.of<ProfessionalProvider>(context, listen: false)
|
||||||
.getProfessional(auth.user!.id);
|
.getProfessional(auth.user!.id);
|
||||||
fp.setProfesional(pro);
|
fp.setProfesional(pro);
|
||||||
final services = await sp.getServicesForProfessional(pro.id);
|
// getServicesForProfessional returns void; read from sp.services after it resolves
|
||||||
if (mounted) setState(() { _services = services; _loading = false; });
|
await sp.getServicesForProfessional(pro.id);
|
||||||
|
if (mounted) setState(() {
|
||||||
|
_services = sp.services.map((s) => s.service).toList();
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onDaySelected(DateTime day, DateTime _) =>
|
void _onDaySelected(DateTime day, DateTime _) =>
|
||||||
@@ -71,20 +77,22 @@ class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
|
|||||||
return const Center(child: CircularProgressIndicator(color: _kPrimary));
|
return const Center(child: CircularProgressIndicator(color: _kPrimary));
|
||||||
}
|
}
|
||||||
final pro = fp.profesional!;
|
final pro = fp.profesional!;
|
||||||
|
final sp = Provider.of<ServicesProvider>(context, listen: false);
|
||||||
final schedule = _scheduleFor(_selected.weekday, pro);
|
final schedule = _scheduleFor(_selected.weekday, pro);
|
||||||
final slots = _buildSlots(schedule);
|
final slots = _buildSlots(schedule, pro.slotDurationMinutes);
|
||||||
final occupied = slots.where((t) => _isOccupied(t, _services, _selected)).length;
|
final occupied = slots.where((t) => _isOccupied(t, _services, _selected)).length;
|
||||||
|
final blocked = slots.where((t) => _isSelfBooked(t, _services, _selected)).length;
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
physics: const ClampingScrollPhysics(),
|
physics: const ClampingScrollPhysics(),
|
||||||
padding: const EdgeInsets.only(bottom: 32),
|
padding: const EdgeInsets.only(bottom: 32),
|
||||||
children: [
|
children: [
|
||||||
_calendarCard(context),
|
_calendarCard(context),
|
||||||
_dayHeader(context, schedule, slots.length, occupied),
|
_dayHeader(context, schedule, slots.length, occupied, blocked),
|
||||||
if (slots.isEmpty)
|
if (slots.isEmpty)
|
||||||
_emptyState(context)
|
_emptyState(context)
|
||||||
else
|
else
|
||||||
..._slotCards(context, slots),
|
..._slotCards(context, slots, sp, pro.id),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -142,10 +150,10 @@ class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _dayHeader(BuildContext context, ScheduleEntity? schedule, int total, int occupied) {
|
Widget _dayHeader(BuildContext context, ScheduleEntity? schedule, int total, int occupied, int blocked) {
|
||||||
final dayName = DateFormat('EEEE', 'es').format(_selected);
|
final dayName = DateFormat('EEEE', 'es').format(_selected);
|
||||||
final dateStr = DateFormat('d MMMM yyyy', 'es').format(_selected);
|
final dateStr = DateFormat('d MMMM yyyy', 'es').format(_selected);
|
||||||
final available = total - occupied;
|
final available = total - occupied - blocked;
|
||||||
final hasSchedule = schedule != null && schedule.enabled;
|
final hasSchedule = schedule != null && schedule.enabled;
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
@@ -179,6 +187,8 @@ class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
|
|||||||
if (hasSchedule && total > 0) ...[
|
if (hasSchedule && total > 0) ...[
|
||||||
_StatPill(label: '$occupied', sublabel: 'ocupadas', color: _kOccupied),
|
_StatPill(label: '$occupied', sublabel: 'ocupadas', color: _kOccupied),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
_StatPill(label: '$blocked', sublabel: 'bloqueadas', color: _kBlocked),
|
||||||
|
const SizedBox(width: 8),
|
||||||
_StatPill(label: '$available', sublabel: 'libres', color: _kAvailable),
|
_StatPill(label: '$available', sublabel: 'libres', color: _kAvailable),
|
||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
@@ -187,11 +197,28 @@ class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _slotCards(BuildContext context, List<TimeOfDay> slots) {
|
List<Widget> _slotCards(BuildContext context, List<TimeOfDay> slots, ServicesProvider sp, String professionalId) {
|
||||||
return slots.map((time) {
|
return slots.map((time) {
|
||||||
final occ = _isOccupied(time, _services, _selected);
|
final selfBooked = _isSelfBooked(time, _services, _selected);
|
||||||
final matchService = occ ? _serviceFor(time, _services, _selected) : null;
|
final occ = !selfBooked && _isOccupied(time, _services, _selected);
|
||||||
final color = occ ? _kOccupied : _kAvailable;
|
final matchService = (occ || selfBooked) ? _serviceFor(time, _services, _selected) : null;
|
||||||
|
|
||||||
|
final Color color;
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
if (selfBooked) {
|
||||||
|
color = _kBlocked;
|
||||||
|
label = 'Bloqueado';
|
||||||
|
icon = Icons.lock_outline;
|
||||||
|
} else if (occ) {
|
||||||
|
color = _kOccupied;
|
||||||
|
label = 'Ocupado';
|
||||||
|
icon = Icons.event_busy_outlined;
|
||||||
|
} else {
|
||||||
|
color = _kAvailable;
|
||||||
|
label = 'Disponible';
|
||||||
|
icon = Icons.event_available_outlined;
|
||||||
|
}
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
@@ -224,25 +251,37 @@ class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(occ ? 'Ocupado' : 'Disponible',
|
Text(label, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: color)),
|
||||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: color)),
|
|
||||||
if (matchService != null && matchService.description.isNotEmpty)
|
if (matchService != null && matchService.description.isNotEmpty)
|
||||||
Text(matchService.description,
|
Text(matchService.description,
|
||||||
style: TextStyle(fontSize: 12, color: context.muted),
|
style: TextStyle(fontSize: 12, color: context.muted),
|
||||||
maxLines: 1, overflow: TextOverflow.ellipsis)
|
maxLines: 1, overflow: TextOverflow.ellipsis)
|
||||||
else if (!occ)
|
else if (!occ && !selfBooked)
|
||||||
Text('Horario libre para nuevas citas',
|
Text('Horario libre para nuevas citas',
|
||||||
style: TextStyle(fontSize: 11, color: context.subtle)),
|
style: TextStyle(fontSize: 11, color: context.subtle)),
|
||||||
],
|
],
|
||||||
)),
|
)),
|
||||||
Container(
|
// Action button
|
||||||
width: 32, height: 32,
|
if (selfBooked)
|
||||||
decoration: BoxDecoration(
|
_ActionButton(
|
||||||
color: color.withOpacity(0.1), shape: BoxShape.circle),
|
icon: Icons.lock_open_outlined,
|
||||||
child: Icon(
|
color: _kBlocked,
|
||||||
occ ? Icons.event_busy_outlined : Icons.event_available_outlined,
|
tooltip: 'Desbloquear',
|
||||||
size: 17, color: color),
|
onTap: () => _confirmUnblock(context, sp, matchService!.id!, time),
|
||||||
),
|
)
|
||||||
|
else if (!occ)
|
||||||
|
_ActionButton(
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
color: context.subtle,
|
||||||
|
tooltip: 'Bloquear horario',
|
||||||
|
onTap: () => _confirmBlock(context, sp, time),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Container(
|
||||||
|
width: 32, height: 32,
|
||||||
|
decoration: BoxDecoration(color: color.withOpacity(0.1), shape: BoxShape.circle),
|
||||||
|
child: Icon(icon, size: 17, color: color),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -255,6 +294,59 @@ class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
|
|||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmBlock(BuildContext context, ServicesProvider sp, TimeOfDay time) async {
|
||||||
|
final timeStr = ScheduleEntity.getFormatTime(time) ?? '';
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: const Text('Bloquear horario'),
|
||||||
|
content: Text('¿Bloquear el horario de $timeStr? Los usuarios no podrán agendarse en este slot.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancelar')),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: _kBlocked),
|
||||||
|
child: const Text('Bloquear', style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
|
try {
|
||||||
|
final dayStr = _selected.toIso8601String().split('T').first;
|
||||||
|
await sp.blockSlot(dayStr, time);
|
||||||
|
await _load();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmUnblock(BuildContext context, ServicesProvider sp, String serviceId, TimeOfDay time) async {
|
||||||
|
final timeStr = ScheduleEntity.getFormatTime(time) ?? '';
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: const Text('Desbloquear horario'),
|
||||||
|
content: Text('¿Desbloquear el horario de $timeStr? Estará disponible para nuevas citas.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancelar')),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: _kAvailable),
|
||||||
|
child: const Text('Desbloquear', style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
|
try {
|
||||||
|
await sp.unblockSlot(serviceId);
|
||||||
|
await _load();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _emptyState(BuildContext context) {
|
Widget _emptyState(BuildContext context) {
|
||||||
return Center(
|
return Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
@@ -294,20 +386,36 @@ class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
|
|||||||
_ => pro.schedules.sunday,
|
_ => pro.schedules.sunday,
|
||||||
};
|
};
|
||||||
|
|
||||||
List<TimeOfDay> _buildSlots(ScheduleEntity? s) {
|
List<TimeOfDay> _buildSlots(ScheduleEntity? s, int stepMinutes) {
|
||||||
if (s == null || !s.enabled || s.range1Hour1 == null || s.range2Hour2 == null) return [];
|
if (s == null || !s.enabled || s.range1Hour1 == null || s.range2Hour2 == null) return [];
|
||||||
if (s.continuousDay) return TimeOfDayUtils.genRanges(s.range1Hour1!, s.range2Hour2!);
|
if (s.continuousDay) {
|
||||||
|
return TimeOfDayUtils.genRanges(s.range1Hour1!, s.range2Hour2!, stepMinutes: stepMinutes);
|
||||||
|
}
|
||||||
if (s.range1Hour2 == null || s.range2Hour1 == null) return [];
|
if (s.range1Hour2 == null || s.range2Hour1 == null) return [];
|
||||||
return [
|
return [
|
||||||
...TimeOfDayUtils.genRanges(s.range1Hour1!, s.range1Hour2!),
|
...TimeOfDayUtils.genRanges(s.range1Hour1!, s.range1Hour2!, stepMinutes: stepMinutes),
|
||||||
...TimeOfDayUtils.genRanges(s.range2Hour1!, s.range2Hour2!),
|
...TimeOfDayUtils.genRanges(s.range2Hour1!, s.range2Hour2!, stepMinutes: stepMinutes),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isOccupied(TimeOfDay time, List<Service>? services, DateTime day) {
|
bool _isOccupied(TimeOfDay time, List<Service>? services, DateTime day) {
|
||||||
if (services == null) return false;
|
if (services == null) return false;
|
||||||
final dayStr = day.toIso8601String().split('T').first;
|
final dayStr = day.toIso8601String().split('T').first;
|
||||||
return services.any((s) => s.day == dayStr && s.range1Hour1 == time);
|
return services.any((s) =>
|
||||||
|
s.day == dayStr &&
|
||||||
|
s.range1Hour1 == time &&
|
||||||
|
s.status != ServiceStatus.selfBooked &&
|
||||||
|
s.status != ServiceStatus.cancelled &&
|
||||||
|
s.status != ServiceStatus.denied);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isSelfBooked(TimeOfDay time, List<Service>? services, DateTime day) {
|
||||||
|
if (services == null) return false;
|
||||||
|
final dayStr = day.toIso8601String().split('T').first;
|
||||||
|
return services.any((s) =>
|
||||||
|
s.day == dayStr &&
|
||||||
|
s.range1Hour1 == time &&
|
||||||
|
s.status == ServiceStatus.selfBooked);
|
||||||
}
|
}
|
||||||
|
|
||||||
Service? _serviceFor(TimeOfDay time, List<Service>? services, DateTime day) {
|
Service? _serviceFor(TimeOfDay time, List<Service>? services, DateTime day) {
|
||||||
@@ -321,6 +429,29 @@ class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
|
|||||||
String _capitalize(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
|
String _capitalize(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ActionButton extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
final String tooltip;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const _ActionButton({required this.icon, required this.color, required this.tooltip, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
width: 32, height: 32,
|
||||||
|
decoration: BoxDecoration(color: color.withOpacity(0.1), shape: BoxShape.circle),
|
||||||
|
child: Icon(icon, size: 17, color: color),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _StatPill extends StatelessWidget {
|
class _StatPill extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final String sublabel;
|
final String sublabel;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:prosapp_web_app/models/location_preferences.dart';
|
import 'package:prosapp_web_app/models/location_preferences.dart';
|
||||||
|
import 'package:prosapp_web_app/models/profesional.dart';
|
||||||
import 'package:prosapp_web_app/models/schedules_entity.dart';
|
import 'package:prosapp_web_app/models/schedules_entity.dart';
|
||||||
import 'package:prosapp_web_app/models/usuario.dart';
|
import 'package:prosapp_web_app/models/usuario.dart';
|
||||||
import 'package:prosapp_web_app/providers/auth_provider.dart';
|
import 'package:prosapp_web_app/providers/auth_provider.dart';
|
||||||
@@ -49,9 +50,13 @@ class _ProfessionalProfileViewState extends State<ProfessionalProfileView> {
|
|||||||
final pfp = Provider.of<ProfileFormProvider>(context, listen: false);
|
final pfp = Provider.of<ProfileFormProvider>(context, listen: false);
|
||||||
final fp = Provider.of<ProfessionalFormProvider>(context, listen: false);
|
final fp = Provider.of<ProfessionalFormProvider>(context, listen: false);
|
||||||
pfp.user = auth.user;
|
pfp.user = auth.user;
|
||||||
|
// Don't clear fp.profesional — if it's already loaded (e.g. navigating from
|
||||||
|
// schedule page) the form renders immediately with no spinner. If it's null
|
||||||
|
// the Consumer already shows the spinner until the future resolves.
|
||||||
Provider.of<ProfessionalProvider>(context, listen: false)
|
Provider.of<ProfessionalProvider>(context, listen: false)
|
||||||
.getProfessional(auth.user!.id)
|
.getProfessional(auth.user!.id)
|
||||||
.then(fp.setProfesional);
|
.then(fp.setProfesional)
|
||||||
|
.catchError((_) => fp.setProfesional(Profesional.empty()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -382,17 +387,20 @@ class _ProfileFormState extends State<_ProfileForm> {
|
|||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: _saving ? null : () async {
|
onPressed: _saving ? null : () async {
|
||||||
setState(() => _saving = true);
|
setState(() => _saving = true);
|
||||||
LocationPreferences lp;
|
try {
|
||||||
if (_delivery && _office) {
|
LocationPreferences lp;
|
||||||
lp = LocationPreferences.both;
|
if (_delivery && _office) {
|
||||||
} else if (_delivery) {
|
lp = LocationPreferences.both;
|
||||||
lp = LocationPreferences.delivery;
|
} else if (_delivery) {
|
||||||
} else {
|
lp = LocationPreferences.delivery;
|
||||||
lp = LocationPreferences.office;
|
} else {
|
||||||
|
lp = LocationPreferences.office;
|
||||||
|
}
|
||||||
|
fp.copyProfesionalWith(locationPreferences: lp, ratePreferences: _rateEnabled);
|
||||||
|
await fp.updateProfesionalProfileInfo(user.id);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _saving = false);
|
||||||
}
|
}
|
||||||
fp.copyProfesionalWith(locationPreferences: lp, ratePreferences: _rateEnabled);
|
|
||||||
await fp.updateProfesionalProfileInfo(user.id);
|
|
||||||
if (mounted) setState(() => _saving = false);
|
|
||||||
},
|
},
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: _kPrimary,
|
backgroundColor: _kPrimary,
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:prosapp_web_app/models/location_preferences.dart';
|
||||||
import 'package:prosapp_web_app/models/usuario_profesional.dart';
|
import 'package:prosapp_web_app/models/usuario_profesional.dart';
|
||||||
import 'package:prosapp_web_app/providers/professionals_provider.dart';
|
import 'package:prosapp_web_app/providers/professionals_provider.dart';
|
||||||
import 'package:prosapp_web_app/providers/theme_provider.dart';
|
import 'package:prosapp_web_app/providers/theme_provider.dart';
|
||||||
@@ -14,19 +17,35 @@ class ProfessionalsView extends StatefulWidget {
|
|||||||
|
|
||||||
class _ProfessionalsViewState extends State<ProfessionalsView> {
|
class _ProfessionalsViewState extends State<ProfessionalsView> {
|
||||||
String _search = '';
|
String _search = '';
|
||||||
|
Timer? _debounce;
|
||||||
|
final _controller = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_debounce?.cancel();
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSearchChanged(String value) {
|
||||||
|
setState(() => _search = value);
|
||||||
|
_debounce?.cancel();
|
||||||
|
_debounce = Timer(const Duration(milliseconds: 450), () {
|
||||||
|
context.read<ProfessionalsProvider>().getProfessionals(search: value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearSearch() {
|
||||||
|
_controller.clear();
|
||||||
|
_onSearchChanged('');
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final provider = context.watch<ProfessionalsProvider>();
|
final provider = context.watch<ProfessionalsProvider>();
|
||||||
final isDark = context.watch<ThemeProvider>().isDark;
|
final isDark = context.watch<ThemeProvider>().isDark;
|
||||||
|
|
||||||
final filtered = provider.professionals.where((p) {
|
final filtered = provider.professionals;
|
||||||
if (_search.isEmpty) return true;
|
|
||||||
final q = _search.toLowerCase();
|
|
||||||
return p.user.name.toLowerCase().contains(q) ||
|
|
||||||
p.professionalInfo.profession.toLowerCase().contains(q) ||
|
|
||||||
(p.user.city ?? '').toLowerCase().contains(q);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
final cardBg = isDark ? const Color(0xFF1E293B) : Colors.white;
|
final cardBg = isDark ? const Color(0xFF1E293B) : Colors.white;
|
||||||
final border = isDark ? const Color(0xFF334155) : const Color(0xFFE5E7EB);
|
final border = isDark ? const Color(0xFF334155) : const Color(0xFFE5E7EB);
|
||||||
@@ -39,7 +58,8 @@ class _ProfessionalsViewState extends State<ProfessionalsView> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(0, 0, 0, 14),
|
padding: const EdgeInsets.fromLTRB(0, 0, 0, 14),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
onChanged: (v) => setState(() => _search = v),
|
controller: _controller,
|
||||||
|
onChanged: _onSearchChanged,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isDark ? Colors.white : const Color(0xFF111827),
|
color: isDark ? Colors.white : const Color(0xFF111827),
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@@ -52,7 +72,7 @@ class _ProfessionalsViewState extends State<ProfessionalsView> {
|
|||||||
suffixIcon: _search.isNotEmpty
|
suffixIcon: _search.isNotEmpty
|
||||||
? IconButton(
|
? IconButton(
|
||||||
icon: Icon(Icons.close, size: 18, color: hintColor),
|
icon: Icon(Icons.close, size: 18, color: hintColor),
|
||||||
onPressed: () => setState(() => _search = ''),
|
onPressed: _clearSearch,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
filled: true,
|
filled: true,
|
||||||
@@ -109,6 +129,8 @@ class _ProfessionalsViewState extends State<ProfessionalsView> {
|
|||||||
cardBg: cardBg,
|
cardBg: cardBg,
|
||||||
border: border,
|
border: border,
|
||||||
compact: cols == 1,
|
compact: cols == 1,
|
||||||
|
patientLat: provider.patientLat,
|
||||||
|
patientLng: provider.patientLng,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final result = await NavigationService
|
final result = await NavigationService
|
||||||
.navigateToFuture(
|
.navigateToFuture(
|
||||||
@@ -134,12 +156,31 @@ class _ProfessionalsViewState extends State<ProfessionalsView> {
|
|||||||
|
|
||||||
// ── Card ─────────────────────────────────────────────────────────────────────
|
// ── Card ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
double? _haversineKm(double? lat1, double? lng1, double? lat2, double? lng2) {
|
||||||
|
if (lat1 == null || lng1 == null || lat2 == null || lng2 == null) return null;
|
||||||
|
if (lat2 == 0.0 && lng2 == 0.0) return null;
|
||||||
|
const r = 6371.0;
|
||||||
|
final dLat = (lat2 - lat1) * math.pi / 180;
|
||||||
|
final dLng = (lng2 - lng1) * math.pi / 180;
|
||||||
|
final a = math.sin(dLat / 2) * math.sin(dLat / 2) +
|
||||||
|
math.cos(lat1 * math.pi / 180) * math.cos(lat2 * math.pi / 180) *
|
||||||
|
math.sin(dLng / 2) * math.sin(dLng / 2);
|
||||||
|
return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDistance(double km) {
|
||||||
|
if (km < 1) return '${(km * 1000).round()} m';
|
||||||
|
return '${km.toStringAsFixed(1)} km';
|
||||||
|
}
|
||||||
|
|
||||||
class _ProfCard extends StatelessWidget {
|
class _ProfCard extends StatelessWidget {
|
||||||
final UsuarioProfesional data;
|
final UsuarioProfesional data;
|
||||||
final bool isDark;
|
final bool isDark;
|
||||||
final Color cardBg;
|
final Color cardBg;
|
||||||
final Color border;
|
final Color border;
|
||||||
final bool compact;
|
final bool compact;
|
||||||
|
final double? patientLat;
|
||||||
|
final double? patientLng;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const _ProfCard({
|
const _ProfCard({
|
||||||
@@ -149,6 +190,8 @@ class _ProfCard extends StatelessWidget {
|
|||||||
required this.border,
|
required this.border,
|
||||||
required this.compact,
|
required this.compact,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
|
this.patientLat,
|
||||||
|
this.patientLng,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -161,6 +204,12 @@ class _ProfCard extends StatelessWidget {
|
|||||||
final score = data.averageScore;
|
final score = data.averageScore;
|
||||||
final pm = data.professionalInfo.paymentMethods;
|
final pm = data.professionalInfo.paymentMethods;
|
||||||
|
|
||||||
|
final profLat = data.professionalInfo.latitude;
|
||||||
|
final profLng = data.professionalInfo.longitude;
|
||||||
|
final distKm = _haversineKm(patientLat, patientLng, profLat, profLng);
|
||||||
|
final hasOffice = data.professionalInfo.locationPreferences != LocationPreferences.delivery;
|
||||||
|
final address = data.professionalInfo.address;
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
// Fila horizontal en pantallas pequeñas
|
// Fila horizontal en pantallas pequeñas
|
||||||
return Material(
|
return Material(
|
||||||
@@ -209,7 +258,27 @@ class _ProfCard extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11, color: textSecondary)),
|
fontSize: 11, color: textSecondary)),
|
||||||
]),
|
]),
|
||||||
|
if (distKm != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(Icons.near_me_outlined, size: 11, color: const Color(0xFF42A4EF)),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Text(_formatDistance(distKm),
|
||||||
|
style: const TextStyle(fontSize: 11, color: Color(0xFF42A4EF), fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
]),
|
]),
|
||||||
|
if (hasOffice && address.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Row(children: [
|
||||||
|
Icon(Icons.store_outlined, size: 11, color: textSecondary),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Flexible(
|
||||||
|
child: Text(address,
|
||||||
|
style: TextStyle(fontSize: 11, color: textSecondary),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -360,6 +429,44 @@ class _ProfCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Distancia al consultorio
|
||||||
|
if (distKm != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.near_me_outlined, size: 11, color: Color(0xFF42A4EF)),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Text(_formatDistance(distKm),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Color(0xFF42A4EF),
|
||||||
|
fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Dirección del consultorio
|
||||||
|
if (hasOffice && address.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.store_outlined, size: 11, color: textSecondary),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
address,
|
||||||
|
style: TextStyle(fontSize: 11, color: textSecondary),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
|
||||||
// Métodos de pago
|
// Métodos de pago
|
||||||
|
|||||||
+165
-95
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:prosapp_web_app/models/profesional.dart';
|
||||||
import 'package:prosapp_web_app/models/usuario.dart';
|
import 'package:prosapp_web_app/models/usuario.dart';
|
||||||
import 'package:prosapp_web_app/providers/auth_provider.dart';
|
import 'package:prosapp_web_app/providers/auth_provider.dart';
|
||||||
import 'package:prosapp_web_app/providers/professional_form_provider.dart';
|
import 'package:prosapp_web_app/providers/professional_form_provider.dart';
|
||||||
@@ -10,6 +11,63 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:prosapp_web_app/ui/shared/widgets/schedule_day_tile.dart';
|
import 'package:prosapp_web_app/ui/shared/widgets/schedule_day_tile.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
// ── Slot duration options ──────────────────────────────────────────────────
|
||||||
|
const _kSlotOptions = [
|
||||||
|
(15, '15 min'),
|
||||||
|
(20, '20 min'),
|
||||||
|
(30, '30 min'),
|
||||||
|
(45, '45 min'),
|
||||||
|
(60, '1 hora'),
|
||||||
|
(90, '1 h 30'),
|
||||||
|
(120, '2 horas'),
|
||||||
|
];
|
||||||
|
|
||||||
|
class _SlotDurationPicker extends StatelessWidget {
|
||||||
|
final int value;
|
||||||
|
final ValueChanged<int> onChanged;
|
||||||
|
const _SlotDurationPicker({required this.value, required this.onChanged});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Duración de cada cita',
|
||||||
|
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: _kSlotOptions.map((opt) {
|
||||||
|
final (mins, label) = opt;
|
||||||
|
final selected = value == mins;
|
||||||
|
return ChoiceChip(
|
||||||
|
label: Text(label),
|
||||||
|
selected: selected,
|
||||||
|
onSelected: (_) => onChanged(mins),
|
||||||
|
selectedColor: Colors.blue.shade400,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: selected ? Colors.white : null,
|
||||||
|
fontWeight: selected ? FontWeight.w700 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Los usuarios verán bloques de $value min en el calendario.',
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class ScheduleView extends StatefulWidget {
|
class ScheduleView extends StatefulWidget {
|
||||||
const ScheduleView({super.key});
|
const ScheduleView({super.key});
|
||||||
|
|
||||||
@@ -36,8 +94,11 @@ class _ScheduleViewState extends State<ScheduleView> {
|
|||||||
|
|
||||||
profileFormProvider.user = authProvider.user;
|
profileFormProvider.user = authProvider.user;
|
||||||
|
|
||||||
|
professionalFormProvider.clear();
|
||||||
proProvider.getProfessional(authProvider.user!.id).then((value) {
|
proProvider.getProfessional(authProvider.user!.id).then((value) {
|
||||||
professionalFormProvider.setProfesional(value);
|
professionalFormProvider.setProfesional(value);
|
||||||
|
}).catchError((_) {
|
||||||
|
professionalFormProvider.setProfesional(Profesional.empty());
|
||||||
});
|
});
|
||||||
|
|
||||||
profileFormProvider.user = authProvider.user;
|
profileFormProvider.user = authProvider.user;
|
||||||
@@ -82,24 +143,25 @@ class _ScheduleViewState extends State<ScheduleView> {
|
|||||||
schedule: profesional.schedules.monday,
|
schedule: profesional.schedules.monday,
|
||||||
onEnableChanged: (value) {
|
onEnableChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
professionalFormProvider.copyProfesionalWith(
|
final cur = profesional.schedules.monday;
|
||||||
schedules: profesional.schedules.copyWith(
|
final updated = (value && cur.range1Hour1 == null)
|
||||||
monday: profesional.schedules.monday.copyWith(
|
? cur.copyWith(enabled: true, continuousDay: true, range1Hour1: const TimeOfDay(hour: 8, minute: 0), range2Hour2: const TimeOfDay(hour: 18, minute: 0))
|
||||||
enabled: value,
|
: cur.copyWith(enabled: value);
|
||||||
),
|
professionalFormProvider.copyProfesionalWith(schedules: profesional.schedules.copyWith(monday: updated));
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onContinuousDayChanged: (value) {
|
onContinuousDayChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
final cur = profesional.schedules.monday;
|
||||||
|
final updated = !value
|
||||||
|
? cur.copyWith(
|
||||||
|
continuousDay: false,
|
||||||
|
range1Hour2: cur.range1Hour2 ?? const TimeOfDay(hour: 12, minute: 0),
|
||||||
|
range2Hour1: cur.range2Hour1 ?? const TimeOfDay(hour: 14, minute: 0),
|
||||||
|
)
|
||||||
|
: cur.copyWith(continuousDay: true);
|
||||||
professionalFormProvider.copyProfesionalWith(
|
professionalFormProvider.copyProfesionalWith(
|
||||||
schedules: profesional.schedules.copyWith(
|
schedules: profesional.schedules.copyWith(monday: updated));
|
||||||
monday: profesional.schedules.monday.copyWith(
|
|
||||||
continuousDay: value,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onRange1Hour1Pick: (pickedTime) {
|
onRange1Hour1Pick: (pickedTime) {
|
||||||
@@ -147,24 +209,25 @@ class _ScheduleViewState extends State<ScheduleView> {
|
|||||||
schedule: profesional.schedules.tuesday,
|
schedule: profesional.schedules.tuesday,
|
||||||
onEnableChanged: (value) {
|
onEnableChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
professionalFormProvider.copyProfesionalWith(
|
final cur = profesional.schedules.tuesday;
|
||||||
schedules: profesional.schedules.copyWith(
|
final updated = (value && cur.range1Hour1 == null)
|
||||||
tuesday: profesional.schedules.tuesday.copyWith(
|
? cur.copyWith(enabled: true, continuousDay: true, range1Hour1: const TimeOfDay(hour: 8, minute: 0), range2Hour2: const TimeOfDay(hour: 18, minute: 0))
|
||||||
enabled: value,
|
: cur.copyWith(enabled: value);
|
||||||
),
|
professionalFormProvider.copyProfesionalWith(schedules: profesional.schedules.copyWith(tuesday: updated));
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onContinuousDayChanged: (value) {
|
onContinuousDayChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
final cur = profesional.schedules.tuesday;
|
||||||
|
final updated = !value
|
||||||
|
? cur.copyWith(
|
||||||
|
continuousDay: false,
|
||||||
|
range1Hour2: cur.range1Hour2 ?? const TimeOfDay(hour: 12, minute: 0),
|
||||||
|
range2Hour1: cur.range2Hour1 ?? const TimeOfDay(hour: 14, minute: 0),
|
||||||
|
)
|
||||||
|
: cur.copyWith(continuousDay: true);
|
||||||
professionalFormProvider.copyProfesionalWith(
|
professionalFormProvider.copyProfesionalWith(
|
||||||
schedules: profesional.schedules.copyWith(
|
schedules: profesional.schedules.copyWith(tuesday: updated));
|
||||||
tuesday: profesional.schedules.tuesday.copyWith(
|
|
||||||
continuousDay: value,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onRange1Hour1Pick: (pickedTime) {
|
onRange1Hour1Pick: (pickedTime) {
|
||||||
@@ -212,26 +275,25 @@ class _ScheduleViewState extends State<ScheduleView> {
|
|||||||
schedule: profesional.schedules.wednesday,
|
schedule: profesional.schedules.wednesday,
|
||||||
onEnableChanged: (value) {
|
onEnableChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
professionalFormProvider.copyProfesionalWith(
|
final cur = profesional.schedules.wednesday;
|
||||||
schedules: profesional.schedules.copyWith(
|
final updated = (value && cur.range1Hour1 == null)
|
||||||
wednesday:
|
? cur.copyWith(enabled: true, continuousDay: true, range1Hour1: const TimeOfDay(hour: 8, minute: 0), range2Hour2: const TimeOfDay(hour: 18, minute: 0))
|
||||||
profesional.schedules.wednesday.copyWith(
|
: cur.copyWith(enabled: value);
|
||||||
enabled: value,
|
professionalFormProvider.copyProfesionalWith(schedules: profesional.schedules.copyWith(wednesday: updated));
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onContinuousDayChanged: (value) {
|
onContinuousDayChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
final cur = profesional.schedules.wednesday;
|
||||||
|
final updated = !value
|
||||||
|
? cur.copyWith(
|
||||||
|
continuousDay: false,
|
||||||
|
range1Hour2: cur.range1Hour2 ?? const TimeOfDay(hour: 12, minute: 0),
|
||||||
|
range2Hour1: cur.range2Hour1 ?? const TimeOfDay(hour: 14, minute: 0),
|
||||||
|
)
|
||||||
|
: cur.copyWith(continuousDay: true);
|
||||||
professionalFormProvider.copyProfesionalWith(
|
professionalFormProvider.copyProfesionalWith(
|
||||||
schedules: profesional.schedules.copyWith(
|
schedules: profesional.schedules.copyWith(wednesday: updated));
|
||||||
wednesday:
|
|
||||||
profesional.schedules.wednesday.copyWith(
|
|
||||||
continuousDay: value,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onRange1Hour1Pick: (pickedTime) {
|
onRange1Hour1Pick: (pickedTime) {
|
||||||
@@ -279,24 +341,25 @@ class _ScheduleViewState extends State<ScheduleView> {
|
|||||||
schedule: profesional.schedules.thursday,
|
schedule: profesional.schedules.thursday,
|
||||||
onEnableChanged: (value) {
|
onEnableChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
professionalFormProvider.copyProfesionalWith(
|
final cur = profesional.schedules.thursday;
|
||||||
schedules: profesional.schedules.copyWith(
|
final updated = (value && cur.range1Hour1 == null)
|
||||||
thursday: profesional.schedules.thursday.copyWith(
|
? cur.copyWith(enabled: true, continuousDay: true, range1Hour1: const TimeOfDay(hour: 8, minute: 0), range2Hour2: const TimeOfDay(hour: 18, minute: 0))
|
||||||
enabled: value,
|
: cur.copyWith(enabled: value);
|
||||||
),
|
professionalFormProvider.copyProfesionalWith(schedules: profesional.schedules.copyWith(thursday: updated));
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onContinuousDayChanged: (value) {
|
onContinuousDayChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
final cur = profesional.schedules.thursday;
|
||||||
|
final updated = !value
|
||||||
|
? cur.copyWith(
|
||||||
|
continuousDay: false,
|
||||||
|
range1Hour2: cur.range1Hour2 ?? const TimeOfDay(hour: 12, minute: 0),
|
||||||
|
range2Hour1: cur.range2Hour1 ?? const TimeOfDay(hour: 14, minute: 0),
|
||||||
|
)
|
||||||
|
: cur.copyWith(continuousDay: true);
|
||||||
professionalFormProvider.copyProfesionalWith(
|
professionalFormProvider.copyProfesionalWith(
|
||||||
schedules: profesional.schedules.copyWith(
|
schedules: profesional.schedules.copyWith(thursday: updated));
|
||||||
thursday: profesional.schedules.thursday.copyWith(
|
|
||||||
continuousDay: value,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onRange1Hour1Pick: (pickedTime) {
|
onRange1Hour1Pick: (pickedTime) {
|
||||||
@@ -344,24 +407,25 @@ class _ScheduleViewState extends State<ScheduleView> {
|
|||||||
schedule: profesional.schedules.friday,
|
schedule: profesional.schedules.friday,
|
||||||
onEnableChanged: (value) {
|
onEnableChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
professionalFormProvider.copyProfesionalWith(
|
final cur = profesional.schedules.friday;
|
||||||
schedules: profesional.schedules.copyWith(
|
final updated = (value && cur.range1Hour1 == null)
|
||||||
friday: profesional.schedules.friday.copyWith(
|
? cur.copyWith(enabled: true, continuousDay: true, range1Hour1: const TimeOfDay(hour: 8, minute: 0), range2Hour2: const TimeOfDay(hour: 18, minute: 0))
|
||||||
enabled: value,
|
: cur.copyWith(enabled: value);
|
||||||
),
|
professionalFormProvider.copyProfesionalWith(schedules: profesional.schedules.copyWith(friday: updated));
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onContinuousDayChanged: (value) {
|
onContinuousDayChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
final cur = profesional.schedules.friday;
|
||||||
|
final updated = !value
|
||||||
|
? cur.copyWith(
|
||||||
|
continuousDay: false,
|
||||||
|
range1Hour2: cur.range1Hour2 ?? const TimeOfDay(hour: 12, minute: 0),
|
||||||
|
range2Hour1: cur.range2Hour1 ?? const TimeOfDay(hour: 14, minute: 0),
|
||||||
|
)
|
||||||
|
: cur.copyWith(continuousDay: true);
|
||||||
professionalFormProvider.copyProfesionalWith(
|
professionalFormProvider.copyProfesionalWith(
|
||||||
schedules: profesional.schedules.copyWith(
|
schedules: profesional.schedules.copyWith(friday: updated));
|
||||||
friday: profesional.schedules.friday.copyWith(
|
|
||||||
continuousDay: value,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onRange1Hour1Pick: (pickedTime) {
|
onRange1Hour1Pick: (pickedTime) {
|
||||||
@@ -409,24 +473,25 @@ class _ScheduleViewState extends State<ScheduleView> {
|
|||||||
schedule: profesional.schedules.saturday,
|
schedule: profesional.schedules.saturday,
|
||||||
onEnableChanged: (value) {
|
onEnableChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
professionalFormProvider.copyProfesionalWith(
|
final cur = profesional.schedules.saturday;
|
||||||
schedules: profesional.schedules.copyWith(
|
final updated = (value && cur.range1Hour1 == null)
|
||||||
saturday: profesional.schedules.saturday.copyWith(
|
? cur.copyWith(enabled: true, continuousDay: true, range1Hour1: const TimeOfDay(hour: 8, minute: 0), range2Hour2: const TimeOfDay(hour: 18, minute: 0))
|
||||||
enabled: value,
|
: cur.copyWith(enabled: value);
|
||||||
),
|
professionalFormProvider.copyProfesionalWith(schedules: profesional.schedules.copyWith(saturday: updated));
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onContinuousDayChanged: (value) {
|
onContinuousDayChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
final cur = profesional.schedules.saturday;
|
||||||
|
final updated = !value
|
||||||
|
? cur.copyWith(
|
||||||
|
continuousDay: false,
|
||||||
|
range1Hour2: cur.range1Hour2 ?? const TimeOfDay(hour: 12, minute: 0),
|
||||||
|
range2Hour1: cur.range2Hour1 ?? const TimeOfDay(hour: 14, minute: 0),
|
||||||
|
)
|
||||||
|
: cur.copyWith(continuousDay: true);
|
||||||
professionalFormProvider.copyProfesionalWith(
|
professionalFormProvider.copyProfesionalWith(
|
||||||
schedules: profesional.schedules.copyWith(
|
schedules: profesional.schedules.copyWith(saturday: updated));
|
||||||
saturday: profesional.schedules.saturday.copyWith(
|
|
||||||
continuousDay: value,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onRange1Hour1Pick: (pickedTime) {
|
onRange1Hour1Pick: (pickedTime) {
|
||||||
@@ -474,24 +539,25 @@ class _ScheduleViewState extends State<ScheduleView> {
|
|||||||
schedule: profesional.schedules.sunday,
|
schedule: profesional.schedules.sunday,
|
||||||
onEnableChanged: (value) {
|
onEnableChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
professionalFormProvider.copyProfesionalWith(
|
final cur = profesional.schedules.sunday;
|
||||||
schedules: profesional.schedules.copyWith(
|
final updated = (value && cur.range1Hour1 == null)
|
||||||
sunday: profesional.schedules.sunday.copyWith(
|
? cur.copyWith(enabled: true, continuousDay: true, range1Hour1: const TimeOfDay(hour: 8, minute: 0), range2Hour2: const TimeOfDay(hour: 18, minute: 0))
|
||||||
enabled: value,
|
: cur.copyWith(enabled: value);
|
||||||
),
|
professionalFormProvider.copyProfesionalWith(schedules: profesional.schedules.copyWith(sunday: updated));
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onContinuousDayChanged: (value) {
|
onContinuousDayChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
final cur = profesional.schedules.sunday;
|
||||||
|
final updated = !value
|
||||||
|
? cur.copyWith(
|
||||||
|
continuousDay: false,
|
||||||
|
range1Hour2: cur.range1Hour2 ?? const TimeOfDay(hour: 12, minute: 0),
|
||||||
|
range2Hour1: cur.range2Hour1 ?? const TimeOfDay(hour: 14, minute: 0),
|
||||||
|
)
|
||||||
|
: cur.copyWith(continuousDay: true);
|
||||||
professionalFormProvider.copyProfesionalWith(
|
professionalFormProvider.copyProfesionalWith(
|
||||||
schedules: profesional.schedules.copyWith(
|
schedules: profesional.schedules.copyWith(sunday: updated));
|
||||||
sunday: profesional.schedules.sunday.copyWith(
|
|
||||||
continuousDay: value,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onRange1Hour1Pick: (pickedTime) {
|
onRange1Hour1Pick: (pickedTime) {
|
||||||
@@ -531,9 +597,13 @@ class _ScheduleViewState extends State<ScheduleView> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(
|
const Divider(height: 20),
|
||||||
height: 20,
|
_SlotDurationPicker(
|
||||||
|
value: profesional.slotDurationMinutes,
|
||||||
|
onChanged: (v) => professionalFormProvider
|
||||||
|
.copyProfesionalWith(slotDurationMinutes: v),
|
||||||
),
|
),
|
||||||
|
const Divider(height: 20),
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.only(top: 10),
|
margin: const EdgeInsets.only(top: 10),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:prosapp_web_app/utils/local_notifications.dart';
|
import 'package:prosapp_web_app/utils/local_notifications.dart';
|
||||||
|
|
||||||
class ServiceView extends StatelessWidget {
|
class ServiceView extends StatefulWidget {
|
||||||
final String type;
|
final String type;
|
||||||
final String serviceId;
|
final String serviceId;
|
||||||
|
|
||||||
@@ -26,19 +26,25 @@ class ServiceView extends StatelessWidget {
|
|||||||
required this.serviceId,
|
required this.serviceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ServiceView> createState() => _ServiceViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServiceViewState extends State<ServiceView> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final sp = Provider.of<ServicesProvider>(context, listen: false);
|
||||||
|
if (widget.type == 'user') {
|
||||||
|
sp.getServiceForUser(widget.serviceId);
|
||||||
|
} else if (widget.type == 'professional') {
|
||||||
|
sp.getServiceForProfessional(widget.serviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final settingsProvider = Provider.of<SettingsProvider>(context);
|
final settingsProvider = Provider.of<SettingsProvider>(context);
|
||||||
final servicesProvider =
|
|
||||||
Provider.of<ServicesProvider>(context, listen: false);
|
|
||||||
|
|
||||||
if (type == 'user') {
|
|
||||||
servicesProvider.getServiceForUser(serviceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type == 'professional') {
|
|
||||||
servicesProvider.getServiceForProfessional(serviceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Consumer<ServicesProvider>(
|
return Consumer<ServicesProvider>(
|
||||||
builder: (context, servicesProvider, child) {
|
builder: (context, servicesProvider, child) {
|
||||||
@@ -212,12 +218,12 @@ class ServiceView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (type == 'user') {
|
if (widget.type == 'user') {
|
||||||
NavigationService.navigateTo(
|
NavigationService.navigateTo(
|
||||||
'/dashboard/user/service/${service.id}/chat/${user.id}');
|
'/dashboard/user/service/${service.id}/chat/${user.id}');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type == 'professional') {
|
if (widget.type == 'professional') {
|
||||||
NavigationService.navigateTo(
|
NavigationService.navigateTo(
|
||||||
'/dashboard/professional/service/${service.id}/chat/${user.id}');
|
'/dashboard/professional/service/${service.id}/chat/${user.id}');
|
||||||
}
|
}
|
||||||
@@ -260,7 +266,7 @@ class ServiceView extends StatelessWidget {
|
|||||||
service.range1Hour2.minute,
|
service.range1Hour2.minute,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (type == 'user') {
|
if (widget.type == 'user') {
|
||||||
if (service.status == ServiceStatus.completed &&
|
if (service.status == ServiceStatus.completed &&
|
||||||
service.professionalScored == false) {
|
service.professionalScored == false) {
|
||||||
return Stack(
|
return Stack(
|
||||||
@@ -476,7 +482,7 @@ class ServiceView extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type == 'professional') {
|
if (widget.type == 'professional') {
|
||||||
if (service.status == ServiceStatus.completed &&
|
if (service.status == ServiceStatus.completed &&
|
||||||
service.userScored == false) {
|
service.userScored == false) {
|
||||||
return Stack(
|
return Stack(
|
||||||
@@ -726,7 +732,7 @@ class ServiceView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
if (type == 'professional') {
|
if (widget.type == 'professional') {
|
||||||
if (service.status == ServiceStatus.pending) {
|
if (service.status == ServiceStatus.pending) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -847,7 +853,7 @@ class ServiceView extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type == 'user') {
|
if (widget.type == 'user') {
|
||||||
if (service.status == ServiceStatus.pending) {
|
if (service.status == ServiceStatus.pending) {
|
||||||
return ConstrainedBox(
|
return ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 130),
|
constraints: const BoxConstraints(maxWidth: 130),
|
||||||
@@ -925,10 +931,10 @@ class ServiceView extends StatelessWidget {
|
|||||||
constraints: const BoxConstraints(maxWidth: 130),
|
constraints: const BoxConstraints(maxWidth: 130),
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (type == 'professional') {
|
if (widget.type == 'professional') {
|
||||||
navigateTo(Flurorouter.professionalServicesRequestsRoute);
|
navigateTo(Flurorouter.professionalServicesRequestsRoute);
|
||||||
}
|
}
|
||||||
if (type == 'user') {
|
if (widget.type == 'user') {
|
||||||
navigateTo(Flurorouter.dashboardRoute);
|
navigateTo(Flurorouter.dashboardRoute);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -947,13 +953,13 @@ class ServiceView extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _CustomServiceLocation(ServiceLocationPreferences location) {
|
String _CustomServiceLocation(ServiceLocationPreferences location) {
|
||||||
if (type == 'user') {
|
if (widget.type == 'user') {
|
||||||
if (location == ServiceLocationPreferences.office) {
|
if (location == ServiceLocationPreferences.office) {
|
||||||
return 'Servicio en sitio / consultorio';
|
return 'Servicio en sitio / consultorio';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type == 'professional') {
|
if (widget.type == 'professional') {
|
||||||
if (location == ServiceLocationPreferences.office) {
|
if (location == ServiceLocationPreferences.office) {
|
||||||
return 'Servicio en tu consultorio';
|
return 'Servicio en tu consultorio';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class _SetupNameViewState extends State<SetupNameView> {
|
|||||||
setState(() { _loading = true; _error = null; });
|
setState(() { _loading = true; _error = null; });
|
||||||
try {
|
try {
|
||||||
await context.read<AuthProvider>().updateName(name);
|
await context.read<AuthProvider>().updateName(name);
|
||||||
NavigationService.replaceTo(Flurorouter.dashboardRoute);
|
NavigationService.replaceTo(Flurorouter.setupCityRoute);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
setState(() => _loading = false);
|
setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
extension TimeOfDayExtension on TimeOfDay {
|
extension TimeOfDayExtension on TimeOfDay {
|
||||||
TimeOfDay add({int hour = 0, int minute = 0}) {
|
TimeOfDay add({int hour = 0, int minute = 0}) {
|
||||||
return replacing(hour: this.hour + hour, minute: this.minute + minute);
|
final total = this.hour * 60 + this.minute + hour * 60 + minute;
|
||||||
|
return TimeOfDay(hour: (total ~/ 60) % 24, minute: total % 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
int compareTo(TimeOfDay other) {
|
int compareTo(TimeOfDay other) {
|
||||||
|
|||||||
@@ -2,13 +2,18 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:prosapp_web_app/utils/time_of_day_extension.dart';
|
import 'package:prosapp_web_app/utils/time_of_day_extension.dart';
|
||||||
|
|
||||||
class TimeOfDayUtils {
|
class TimeOfDayUtils {
|
||||||
static List<TimeOfDay> genRanges(TimeOfDay timeStart, TimeOfDay timeEnd) {
|
static List<TimeOfDay> genRanges(
|
||||||
List<TimeOfDay> ranges = [];
|
TimeOfDay timeStart,
|
||||||
TimeOfDay current = timeStart;
|
TimeOfDay timeEnd, {
|
||||||
while (current.isBefore(timeEnd)) {
|
int stepMinutes = 30,
|
||||||
ranges.add(current);
|
}) {
|
||||||
// Sumar 2 horas al objeto DateTime
|
final List<TimeOfDay> ranges = [];
|
||||||
current = current.add(hour: 2);
|
int curr = timeStart.hour * 60 + timeStart.minute;
|
||||||
|
final end = timeEnd.hour * 60 + timeEnd.minute;
|
||||||
|
final step = stepMinutes.clamp(5, 480);
|
||||||
|
while (curr < end) {
|
||||||
|
ranges.add(TimeOfDay(hour: curr ~/ 60, minute: curr % 60));
|
||||||
|
curr += step;
|
||||||
}
|
}
|
||||||
return ranges;
|
return ranges;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,15 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Unregister any previously installed service worker so stale cache is cleared
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.getRegistrations().then(function(regs) {
|
||||||
|
regs.forEach(function(r) { r.unregister(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Force cache busting: append build timestamp so stale JS/CSS is never reused
|
// Force cache busting: append build timestamp so stale JS/CSS is never reused
|
||||||
var _v = new Date().getTime();
|
var _v = new Date().getTime();
|
||||||
|
|||||||
Reference in New Issue
Block a user