Compare commits
46
Commits
eb63a3e415
...
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,
|
||||||
|
'enabled': enabled,
|
||||||
|
'continuous_day': continuousDay,
|
||||||
|
'range1_hour1': _formatTimePadded(range1Hour1),
|
||||||
|
'range1_hour2': _formatTimePadded(range1Hour2),
|
||||||
|
'range2_hour1': _formatTimePadded(range2Hour1),
|
||||||
|
'range2_hour2': _formatTimePadded(range2Hour2),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
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];
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class Usuario {
|
|||||||
final String? birthday;
|
final String? birthday;
|
||||||
final String? gender;
|
final String? gender;
|
||||||
final ProState proState;
|
final ProState proState;
|
||||||
|
final DateTime? proRejectedAt;
|
||||||
final String? token;
|
final String? token;
|
||||||
final bool isPhoneVerified;
|
final bool isPhoneVerified;
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ class Usuario {
|
|||||||
required this.gender,
|
required this.gender,
|
||||||
required this.proState,
|
required this.proState,
|
||||||
required this.token,
|
required this.token,
|
||||||
|
this.proRejectedAt,
|
||||||
this.isPhoneVerified = false,
|
this.isPhoneVerified = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,11 +59,24 @@ class Usuario {
|
|||||||
birthday: doc['birthday'],
|
birthday: doc['birthday'],
|
||||||
gender: doc['gender'],
|
gender: doc['gender'],
|
||||||
proState: intToEnum((doc['professional_state'] as int?) ?? 0),
|
proState: intToEnum((doc['professional_state'] as int?) ?? 0),
|
||||||
|
proRejectedAt: _parseRejectedAt(doc),
|
||||||
token: doc['token'],
|
token: doc['token'],
|
||||||
isPhoneVerified: doc['is_phone_verified'] as bool? ?? false,
|
isPhoneVerified: doc['is_phone_verified'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static DateTime? _parseRejectedAt(Map<String, dynamic> doc) {
|
||||||
|
// /auth/me returns professionals nested; if pro_state==3 use professionals.updated_at
|
||||||
|
final state = (doc['professional_state'] as int?) ?? 0;
|
||||||
|
if (state != 3) return null;
|
||||||
|
final pros = doc['professionals'];
|
||||||
|
if (pros is Map) {
|
||||||
|
final raw = pros['updated_at'];
|
||||||
|
if (raw is String) return DateTime.tryParse(raw);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'User(id: $id, email: $email, phone: $phone, name: $name, nickname: $nickname, city: $city, picture: $picture, birthday: $birthday, gender: $gender, proState: $proState, token: $token)';
|
return 'User(id: $id, email: $email, phone: $phone, name: $name, nickname: $nickname, city: $city, picture: $picture, birthday: $birthday, gender: $gender, proState: $proState, token: $token)';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
try {
|
||||||
await _api.patch('/professionals/me', profesional!.toDocument());
|
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;
|
||||||
|
try {
|
||||||
await _api.patch('/professionals/me', profesional!.toDocument());
|
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', [
|
||||||
|
|||||||
+90
-20
@@ -29,13 +29,62 @@ class Sidebar extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
width: 220,
|
width: 220,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
decoration: buildBoxDecoration(),
|
decoration: buildBoxDecoration(professionalProvider.isProModeActive),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
physics: const ClampingScrollPhysics(),
|
physics: const ClampingScrollPhysics(),
|
||||||
children: [
|
children: [
|
||||||
const Logo(),
|
const Logo(),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Mode indicator pill
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: professionalProvider.isProModeActive
|
||||||
|
? const Color(0xFF16A34A).withOpacity(0.18)
|
||||||
|
: const Color(0xFF42A4EF).withOpacity(0.14),
|
||||||
|
border: Border.all(
|
||||||
|
color: professionalProvider.isProModeActive
|
||||||
|
? const Color(0xFF22C55E).withOpacity(0.5)
|
||||||
|
: const Color(0xFF42A4EF).withOpacity(0.4),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
professionalProvider.isProModeActive
|
||||||
|
? Icons.work_outline
|
||||||
|
: Icons.person_outline,
|
||||||
|
size: 13,
|
||||||
|
color: professionalProvider.isProModeActive
|
||||||
|
? const Color(0xFF4ADE80)
|
||||||
|
: const Color(0xFF7DD3FC),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
professionalProvider.isProModeActive
|
||||||
|
? 'Modo Profesional'
|
||||||
|
: 'Modo Usuario',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: professionalProvider.isProModeActive
|
||||||
|
? const Color(0xFF4ADE80)
|
||||||
|
: const Color(0xFF7DD3FC),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
|
||||||
if (professionalProvider.isProModeActive) ...[
|
if (professionalProvider.isProModeActive) ...[
|
||||||
const TextSeparator(text: 'Profesional'),
|
const TextSeparator(text: 'Profesional'),
|
||||||
MenuItem(
|
MenuItem(
|
||||||
@@ -130,9 +179,20 @@ class Sidebar extends StatelessWidget {
|
|||||||
cursor: SystemMouseCursors.click,
|
cursor: SystemMouseCursors.click,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
final missing = <String>[
|
||||||
if (authProvider.user?.name == null ||
|
if (authProvider.user?.name == null ||
|
||||||
authProvider.user?.name == '') {
|
authProvider.user?.name == '')
|
||||||
NotificationsService.showSnackBarError('Primero completa tu perfil');
|
'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;
|
||||||
}
|
}
|
||||||
@@ -147,29 +207,40 @@ class Sidebar extends StatelessWidget {
|
|||||||
navigateTo(Flurorouter.requestProfessionalRoute);
|
navigateTo(Flurorouter.requestProfessionalRoute);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Container(
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 11),
|
padding: const EdgeInsets.symmetric(vertical: 11),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: const LinearGradient(
|
gradient: LinearGradient(
|
||||||
colors: [Color(0xFF42A4EF), Color(0xFF1565C0)],
|
colors: professionalProvider.isProModeActive
|
||||||
|
? [const Color(0xFF374151), const Color(0xFF1F2937)]
|
||||||
|
: [const Color(0xFF42A4EF), const Color(0xFF1565C0)],
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: professionalProvider.isProModeActive
|
||||||
|
? Border.all(
|
||||||
|
color: Colors.white.withOpacity(0.1), width: 1)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
professionalProvider.isProModeActive
|
Icons.swap_horiz,
|
||||||
? Icons.person_outlined
|
color: professionalProvider.isProModeActive
|
||||||
: Icons.work_outline,
|
? Colors.white70
|
||||||
color: Colors.white,
|
: Colors.white,
|
||||||
size: 16,
|
size: 18,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Modo ${professionalProvider.isProModeActive ? 'Usuario' : 'Profesional'}',
|
professionalProvider.isProModeActive
|
||||||
style: const TextStyle(
|
? 'Cambiar a Usuario'
|
||||||
color: Colors.white,
|
: 'Cambiar a Profesional',
|
||||||
|
style: TextStyle(
|
||||||
|
color: professionalProvider.isProModeActive
|
||||||
|
? Colors.white70
|
||||||
|
: Colors.white,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
@@ -193,16 +264,15 @@ class Sidebar extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
BoxDecoration buildBoxDecoration() => const BoxDecoration(
|
BoxDecoration buildBoxDecoration(bool isProMode) => BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [
|
colors: isProMode
|
||||||
Color(0xFF0D1B3E),
|
? const [Color(0xFF0A1F18), Color(0xFF061510)]
|
||||||
Color(0xFF0A1628),
|
: const [Color(0xFF0D1B3E), Color(0xFF0A1628)],
|
||||||
],
|
|
||||||
),
|
),
|
||||||
boxShadow: [
|
boxShadow: const [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black38,
|
color: Colors.black38,
|
||||||
blurRadius: 12,
|
blurRadius: 12,
|
||||||
|
|||||||
+174
-253
@@ -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,59 +23,67 @@ 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(
|
return ListView(
|
||||||
physics: const ClampingScrollPhysics(),
|
physics: const ClampingScrollPhysics(),
|
||||||
children: [
|
children: [
|
||||||
@@ -90,21 +98,18 @@ class _CalendarViewState extends State<CalendarView> {
|
|||||||
locale: 'es_CO',
|
locale: 'es_CO',
|
||||||
firstDay: DateTime.now(),
|
firstDay: DateTime.now(),
|
||||||
lastDay: DateTime.now().add(const Duration(days: 180)),
|
lastDay: DateTime.now().add(const Duration(days: 180)),
|
||||||
focusedDay: today,
|
focusedDay: _today,
|
||||||
availableGestures: AvailableGestures.all,
|
availableGestures: AvailableGestures.all,
|
||||||
onDaySelected: _onDaySelected,
|
onDaySelected: _onDaySelected,
|
||||||
selectedDayPredicate: (day) => isSameDay(day, today),
|
selectedDayPredicate: (day) => isSameDay(day, _today),
|
||||||
),
|
),
|
||||||
const Divider(height: 0),
|
const Divider(height: 0),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 8),
|
||||||
horizontal: 15,
|
|
||||||
vertical: 8,
|
|
||||||
),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
DateFormat('dd MMMM yyyy', 'es').format(today),
|
DateFormat('dd MMMM yyyy', 'es').format(_today),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@@ -114,11 +119,19 @@ class _CalendarViewState extends State<CalendarView> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -129,255 +142,163 @@ class _CalendarViewState extends State<CalendarView> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = [
|
||||||
|
...TimeOfDayUtils.genRanges(schedule.range1Hour1!, schedule.range1Hour2!, stepMinutes: stepMinutes),
|
||||||
|
...TimeOfDayUtils.genRanges(schedule.range2Hour1!, schedule.range2Hour2!, stepMinutes: stepMinutes),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
List<TimeOfDay> ranges1 = TimeOfDayUtils.genRanges(
|
return _rangesItemList(ranges, _services, _today, context);
|
||||||
schedule.range1Hour1!,
|
|
||||||
schedule.range1Hour2!,
|
|
||||||
);
|
|
||||||
List<TimeOfDay> ranges2 = TimeOfDayUtils.genRanges(
|
|
||||||
schedule.range2Hour1!,
|
|
||||||
schedule.range2Hour2!,
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
|
||||||
...rangesItemList(ranges1, _services, today, context),
|
|
||||||
...rangesItemList(ranges2, _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 (selectedDateTime
|
if (isPast || isBlocked) {
|
||||||
.isBefore(currentDateTime.add(const Duration(hours: 3)))) {
|
return _slotCard(
|
||||||
return Card(
|
time: time,
|
||||||
elevation: 4,
|
circleColor: Colors.grey,
|
||||||
margin: const EdgeInsets.only(top: 15, left: 10, right: 10),
|
icon: isBlocked ? Icons.block : Icons.access_time,
|
||||||
shape: RoundedRectangleBorder(
|
label: 'No disponible',
|
||||||
borderRadius: BorderRadius.circular(10),
|
labelColor: Colors.red,
|
||||||
),
|
onTap: null,
|
||||||
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)) {
|
if (isOccupied) {
|
||||||
return Card(
|
return _slotCard(
|
||||||
elevation: 4,
|
time: time,
|
||||||
margin: const EdgeInsets.only(top: 15, left: 10, right: 10),
|
circleGradient: const LinearGradient(
|
||||||
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],
|
colors: [Colors.yellow, Colors.red, Colors.red],
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
),
|
),
|
||||||
shape: BoxShape.circle,
|
icon: Icons.access_time,
|
||||||
),
|
label: 'Ocupado',
|
||||||
child: const Center(
|
labelColor: Colors.red,
|
||||||
child: Icon(
|
onTap: null,
|
||||||
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,
|
return _slotCard(
|
||||||
margin: const EdgeInsets.only(top: 15, left: 10, right: 10),
|
time: time,
|
||||||
shape: RoundedRectangleBorder(
|
circleGradient: const LinearGradient(
|
||||||
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],
|
colors: [Colors.blue, Colors.green],
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
),
|
),
|
||||||
|
icon: Icons.access_time,
|
||||||
|
label: 'Disponible',
|
||||||
|
labelColor: Colors.green,
|
||||||
|
onTap: () => Navigator.pop(context, [selectedDay, time]),
|
||||||
|
);
|
||||||
|
}).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,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: Center(child: Icon(icon, color: Colors.white)),
|
||||||
child: Icon(
|
|
||||||
Icons.access_time,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
ScheduleEntity.getFormatTime(time) ?? '',
|
ScheduleEntity.getFormatTime(time) ?? '',
|
||||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
subtitle: const Text(
|
|
||||||
'Disponible',
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.green,
|
fontSize: 15,
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
color: onTap == null ? Colors.grey : Colors.black,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(color: labelColor, fontSize: 13, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 data = jsonDecode(res.body);
|
|
||||||
final results = data['results'] as List?;
|
|
||||||
if (results != null && results.isNotEmpty) {
|
|
||||||
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);
|
final citiesProvider = Provider.of<CitiesProvider>(context, listen: false);
|
||||||
final available = city.isEmpty || citiesProvider.isCityAvailable(city);
|
final available = city.isEmpty || citiesProvider.isCityAvailable(city);
|
||||||
|
final userCity = user?.city ?? '';
|
||||||
|
final mismatch = userCity.isNotEmpty && city.isNotEmpty && !_citiesMatch(city, userCity);
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentAddress = address;
|
_currentAddress = result.formattedAddress;
|
||||||
_searchController.text = address;
|
_searchController.text = result.formattedAddress;
|
||||||
_detectedCity = city;
|
_detectedCity = city;
|
||||||
_cityAvailable = available;
|
_cityAvailable = available;
|
||||||
|
_cityMismatch = mismatch;
|
||||||
});
|
});
|
||||||
}
|
if (!mismatch && city.isNotEmpty) {
|
||||||
|
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,9 +650,122 @@ 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(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => setState(() => _bookAsDelivery = false),
|
||||||
|
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.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),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
margin: const EdgeInsets.only(bottom: 10),
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -534,7 +789,10 @@ class _DashboardViewState extends State<DashboardView> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
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> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,33 @@ 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/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';
|
||||||
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/ui/cards/white_card.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 _kAvailable = Color(0xFF16A34A);
|
||||||
|
const _kOccupied = Color(0xFFDC2626);
|
||||||
|
const _kBlocked = Color(0xFFF59E0B);
|
||||||
|
|
||||||
|
extension _Th on BuildContext {
|
||||||
|
ThemeData get _t => Theme.of(this);
|
||||||
|
Color get bg => _t.scaffoldBackgroundColor;
|
||||||
|
Color get card => _t.cardColor;
|
||||||
|
Color get onSurface => _t.colorScheme.onSurface;
|
||||||
|
Color get muted => _t.colorScheme.onSurface.withOpacity(0.55);
|
||||||
|
Color get subtle => _t.colorScheme.onSurface.withOpacity(0.35);
|
||||||
|
bool get isDark => _t.brightness == Brightness.dark;
|
||||||
|
Color get shadow => isDark ? Colors.transparent : Colors.black.withOpacity(0.07);
|
||||||
|
Color get shadowSm => isDark ? Colors.transparent : Colors.black.withOpacity(0.04);
|
||||||
|
}
|
||||||
|
|
||||||
class ProfessionalCalendarView extends StatefulWidget {
|
class ProfessionalCalendarView extends StatefulWidget {
|
||||||
const ProfessionalCalendarView({super.key});
|
const ProfessionalCalendarView({super.key});
|
||||||
|
|
||||||
@@ -22,335 +38,441 @@ class ProfessionalCalendarView extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
|
class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
|
||||||
DateTime today = DateTime.now();
|
DateTime _selected = DateTime.now();
|
||||||
late int numDay;
|
|
||||||
|
|
||||||
List<Service>? _services;
|
List<Service>? _services;
|
||||||
|
bool _loading = true;
|
||||||
Usuario? user;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
numDay = today.weekday;
|
_load();
|
||||||
_fetchProfessionalAndServices();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _fetchProfessionalAndServices() async {
|
Future<void> _load() async {
|
||||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
setState(() => _loading = true);
|
||||||
final professionalFormProvider = Provider.of<ProfessionalFormProvider>(context, listen: false);
|
final auth = Provider.of<AuthProvider>(context, listen: false);
|
||||||
final servicesProvider = Provider.of<ServicesProvider>(context, listen: false);
|
final fp = Provider.of<ProfessionalFormProvider>(context, listen: false);
|
||||||
|
final sp = Provider.of<ServicesProvider>(context, listen: false);
|
||||||
|
final pro = await Provider.of<ProfessionalProvider>(context, listen: false)
|
||||||
|
.getProfessional(auth.user!.id);
|
||||||
final proProvider = Provider.of<ProfessionalProvider>(context, listen: false);
|
fp.setProfesional(pro);
|
||||||
|
// getServicesForProfessional returns void; read from sp.services after it resolves
|
||||||
final professional = await proProvider.getProfessional(authProvider.user!.id);
|
await sp.getServicesForProfessional(pro.id);
|
||||||
professionalFormProvider.setProfesional(professional);
|
if (mounted) setState(() {
|
||||||
|
_services = sp.services.map((s) => s.service).toList();
|
||||||
final services = await servicesProvider.getServicesForProfessional(professional.id);
|
_loading = false;
|
||||||
setState(() {
|
|
||||||
_services = services;
|
|
||||||
user = authProvider.user;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onDaySelected(DateTime day, DateTime focusedDay) {
|
void _onDaySelected(DateTime day, DateTime _) =>
|
||||||
setState(() {
|
setState(() => _selected = day);
|
||||||
today = day;
|
|
||||||
numDay = today.weekday;
|
|
||||||
});
|
|
||||||
_fetchProfessionalAndServices();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Consumer<ProfessionalFormProvider>(
|
return Container(
|
||||||
builder: (context, professionalFormProvider, child) {
|
color: context.bg,
|
||||||
if (professionalFormProvider.profesional == null) {
|
child: Consumer<ProfessionalFormProvider>(
|
||||||
return const Center(
|
builder: (context, fp, _) {
|
||||||
child: CircularProgressIndicator(),
|
if (fp.profesional == null || _loading) {
|
||||||
);
|
return const Center(child: CircularProgressIndicator(color: _kPrimary));
|
||||||
}
|
}
|
||||||
|
final pro = fp.profesional!;
|
||||||
final profesional = professionalFormProvider.profesional!;
|
final sp = Provider.of<ServicesProvider>(context, listen: false);
|
||||||
|
final schedule = _scheduleFor(_selected.weekday, pro);
|
||||||
|
final slots = _buildSlots(schedule, pro.slotDurationMinutes);
|
||||||
|
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),
|
||||||
children: [
|
children: [
|
||||||
Center(
|
_calendarCard(context),
|
||||||
|
_dayHeader(context, schedule, slots.length, occupied, blocked),
|
||||||
|
if (slots.isEmpty)
|
||||||
|
_emptyState(context)
|
||||||
|
else
|
||||||
|
..._slotCards(context, slots, sp, pro.id),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _calendarCard(BuildContext context) {
|
||||||
|
return Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 900),
|
constraints: const BoxConstraints(maxWidth: 720),
|
||||||
child: WhiteCard(
|
child: Container(
|
||||||
title: 'Calendario',
|
margin: const EdgeInsets.fromLTRB(16, 20, 16, 0),
|
||||||
child: Column(
|
decoration: BoxDecoration(
|
||||||
children: [
|
color: context.card,
|
||||||
TableCalendar(
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [BoxShadow(color: context.shadow, blurRadius: 16, offset: const Offset(0, 4))],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: TableCalendar(
|
||||||
locale: 'es_CO',
|
locale: 'es_CO',
|
||||||
firstDay: DateTime.now(),
|
firstDay: DateTime.now().subtract(const Duration(days: 365)),
|
||||||
lastDay: DateTime.utc(2030, 3, 14),
|
lastDay: DateTime.utc(2030, 12, 31),
|
||||||
focusedDay: today,
|
focusedDay: _selected,
|
||||||
availableGestures: AvailableGestures.all,
|
availableGestures: AvailableGestures.all,
|
||||||
onDaySelected: _onDaySelected,
|
onDaySelected: _onDaySelected,
|
||||||
selectedDayPredicate: (day) => isSameDay(day, today),
|
selectedDayPredicate: (d) => isSameDay(d, _selected),
|
||||||
|
calendarStyle: CalendarStyle(
|
||||||
|
todayDecoration: BoxDecoration(
|
||||||
|
border: Border.all(color: _kPrimary, width: 2), shape: BoxShape.circle),
|
||||||
|
todayTextStyle: const TextStyle(color: _kPrimary, fontWeight: FontWeight.w700),
|
||||||
|
selectedDecoration: const BoxDecoration(color: _kPrimary, shape: BoxShape.circle),
|
||||||
|
selectedTextStyle: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700),
|
||||||
|
weekendTextStyle: TextStyle(color: Colors.red.shade400),
|
||||||
|
defaultTextStyle: TextStyle(color: context.onSurface),
|
||||||
|
outsideDaysVisible: false,
|
||||||
),
|
),
|
||||||
const Divider(height: 0),
|
headerStyle: HeaderStyle(
|
||||||
SizedBox(
|
formatButtonVisible: false,
|
||||||
width: double.infinity,
|
titleCentered: true,
|
||||||
|
titleTextStyle: TextStyle(
|
||||||
|
fontSize: 15, fontWeight: FontWeight.w700, color: context.onSurface),
|
||||||
|
leftChevronIcon: const Icon(Icons.chevron_left, color: _kPrimary),
|
||||||
|
rightChevronIcon: const Icon(Icons.chevron_right, color: _kPrimary),
|
||||||
|
),
|
||||||
|
daysOfWeekStyle: DaysOfWeekStyle(
|
||||||
|
weekdayStyle: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: context.muted),
|
||||||
|
weekendStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFFEF4444)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _dayHeader(BuildContext context, ScheduleEntity? schedule, int total, int occupied, int blocked) {
|
||||||
|
final dayName = DateFormat('EEEE', 'es').format(_selected);
|
||||||
|
final dateStr = DateFormat('d MMMM yyyy', 'es').format(_selected);
|
||||||
|
final available = total - occupied - blocked;
|
||||||
|
final hasSchedule = schedule != null && schedule.enabled;
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 720),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.card,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [BoxShadow(color: context.shadowSm, blurRadius: 12, offset: const Offset(0, 2))],
|
||||||
|
),
|
||||||
|
child: Row(children: [
|
||||||
|
Container(
|
||||||
|
width: 48, height: 52,
|
||||||
|
decoration: BoxDecoration(color: _kPrimary, borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||||
|
Text(DateFormat('d').format(_selected),
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.w800, height: 1)),
|
||||||
|
Text(DateFormat('MMM', 'es').format(_selected).toUpperCase(),
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 10, fontWeight: FontWeight.w600)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text(_capitalize(dayName),
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: context.onSurface)),
|
||||||
|
Text(dateStr, style: TextStyle(fontSize: 12, color: context.subtle)),
|
||||||
|
])),
|
||||||
|
if (hasSchedule && total > 0) ...[
|
||||||
|
_StatPill(label: '$occupied', sublabel: 'ocupadas', color: _kOccupied),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_StatPill(label: '$blocked', sublabel: 'bloqueadas', color: _kBlocked),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_StatPill(label: '$available', sublabel: 'libres', color: _kAvailable),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _slotCards(BuildContext context, List<TimeOfDay> slots, ServicesProvider sp, String professionalId) {
|
||||||
|
return slots.map((time) {
|
||||||
|
final selfBooked = _isSelfBooked(time, _services, _selected);
|
||||||
|
final occ = !selfBooked && _isOccupied(time, _services, _selected);
|
||||||
|
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(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 720),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.card,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
boxShadow: [BoxShadow(color: context.shadowSm, blurRadius: 8, offset: const Offset(0, 2))],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: IntrinsicHeight(
|
||||||
|
child: Row(children: [
|
||||||
|
Container(width: 4, color: color),
|
||||||
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
horizontal: 15,
|
child: Row(children: [
|
||||||
vertical: 8,
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.08), borderRadius: BorderRadius.circular(8)),
|
||||||
|
child: Text(ScheduleEntity.getFormatTime(time) ?? '',
|
||||||
|
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w800, color: color)),
|
||||||
),
|
),
|
||||||
child: Text(
|
const SizedBox(width: 14),
|
||||||
DateFormat('dd MMMM yyyy', 'es').format(today),
|
Expanded(child: Column(
|
||||||
style: const TextStyle(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
color: Colors.black,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(height: 0),
|
|
||||||
Column(
|
|
||||||
children: [
|
children: [
|
||||||
..._rangesItems(_getScheduleFromNumDay(numDay, profesional),context),
|
Text(label, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: color)),
|
||||||
|
if (matchService != null && matchService.description.isNotEmpty)
|
||||||
|
Text(matchService.description,
|
||||||
|
style: TextStyle(fontSize: 12, color: context.muted),
|
||||||
|
maxLines: 1, overflow: TextOverflow.ellipsis)
|
||||||
|
else if (!occ && !selfBooked)
|
||||||
|
Text('Horario libre para nuevas citas',
|
||||||
|
style: TextStyle(fontSize: 11, color: context.subtle)),
|
||||||
],
|
],
|
||||||
),
|
)),
|
||||||
],
|
// Action button
|
||||||
),
|
if (selfBooked)
|
||||||
),
|
_ActionButton(
|
||||||
),
|
icon: Icons.lock_open_outlined,
|
||||||
),
|
color: _kBlocked,
|
||||||
],
|
tooltip: 'Desbloquear',
|
||||||
);
|
onTap: () => _confirmUnblock(context, sp, matchService!.id!, time),
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ScheduleEntity? _getScheduleFromNumDay(int numDay, Profesional userProfessional) {
|
|
||||||
switch (numDay) {
|
|
||||||
case 1:
|
|
||||||
return userProfessional.schedules.monday;
|
|
||||||
case 2:
|
|
||||||
return userProfessional.schedules.tuesday;
|
|
||||||
case 3:
|
|
||||||
return userProfessional.schedules.wednesday;
|
|
||||||
case 4:
|
|
||||||
return userProfessional.schedules.thursday;
|
|
||||||
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) {
|
|
||||||
if (schedule == null ||
|
|
||||||
!schedule.enabled ||
|
|
||||||
schedule.range1Hour1 == null ||
|
|
||||||
schedule.range2Hour2 == null) {
|
|
||||||
return [
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 25),
|
|
||||||
child: Text("No hay horarios disponibles"),
|
|
||||||
)
|
)
|
||||||
];
|
else if (!occ)
|
||||||
}
|
_ActionButton(
|
||||||
|
icon: Icons.lock_outline,
|
||||||
if (schedule.continuousDay) {
|
color: context.subtle,
|
||||||
List<TimeOfDay> ranges = TimeOfDayUtils.genRanges(
|
tooltip: 'Bloquear horario',
|
||||||
schedule.range1Hour1!,
|
onTap: () => _confirmBlock(context, sp, time),
|
||||||
schedule.range2Hour2!,
|
)
|
||||||
);
|
else
|
||||||
|
Container(
|
||||||
return rangesItemList(ranges, _services, today, context);
|
width: 32, height: 32,
|
||||||
}
|
decoration: BoxDecoration(color: color.withOpacity(0.1), shape: BoxShape.circle),
|
||||||
|
child: Icon(icon, size: 17, color: color),
|
||||||
List<TimeOfDay> ranges1 = TimeOfDayUtils.genRanges(
|
|
||||||
schedule.range1Hour1!,
|
|
||||||
schedule.range1Hour2!,
|
|
||||||
);
|
|
||||||
List<TimeOfDay> ranges2 = TimeOfDayUtils.genRanges(
|
|
||||||
schedule.range2Hour1!,
|
|
||||||
schedule.range2Hour2!,
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
|
||||||
...rangesItemList(ranges1, _services, today, context),
|
|
||||||
...rangesItemList(ranges2, _services, today, context),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isHora1Ocupada(
|
|
||||||
TimeOfDay hora1, List<Service>? events, DateTime selectedDay) {
|
|
||||||
if (events != null) {
|
|
||||||
for (Service event in events) {
|
|
||||||
if (selectedDay.toIso8601String().split('T').first == event.day) {
|
|
||||||
if (hora1 == event.range1Hour1) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Widget> rangesItemList(List<TimeOfDay> ranges, List<Service>? events,
|
|
||||||
DateTime selectedDay, BuildContext context) {
|
|
||||||
return ranges.map((time) {
|
|
||||||
if (_isHora1Ocupada(time, events, selectedDay)) {
|
|
||||||
return Card(
|
|
||||||
elevation: 4,
|
|
||||||
margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 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) {
|
|
||||||
if (event.userId == event.professionalId) {
|
|
||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Horario ocupado por ti'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Navigator.push(
|
|
||||||
// context,
|
|
||||||
// CupertinoPageRoute(
|
|
||||||
// builder: (context) => ProfessionalServiceScreen(
|
|
||||||
// serviceId: event.id!,
|
|
||||||
// ),
|
|
||||||
// ),
|
|
||||||
// );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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.symmetric(vertical: 5, horizontal: 10),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
child: ListTile(
|
|
||||||
onTap: () {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (BuildContext dialogContext) {
|
|
||||||
return AlertDialog(
|
|
||||||
title: const Text('Reservar hora'),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'¿Estás seguro de que deseas reservar a las ${ScheduleEntity.getFormatTime(time)} del ${DateFormat('dd-MM-yyyy').format(today)}?',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
const Text(
|
|
||||||
'⚠️ Esta acción no se puede deshacer ⚠️',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(dialogContext);
|
|
||||||
},
|
|
||||||
child: const Text('No, cancelar'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(dialogContext);
|
|
||||||
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
child: const Text('Sí, reservar'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 720),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.card,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [BoxShadow(color: context.shadowSm, blurRadius: 12, offset: const Offset(0, 2))],
|
||||||
|
),
|
||||||
|
child: Column(children: [
|
||||||
|
Container(
|
||||||
|
width: 64, height: 64,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.subtle.withOpacity(0.1), shape: BoxShape.circle),
|
||||||
|
child: Icon(Icons.event_busy_outlined, size: 32, color: context.subtle),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('Sin horario este día',
|
||||||
|
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: context.muted)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text('No tienes horario de atención configurado\npara este día de la semana.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 13, color: context.subtle, height: 1.5)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ScheduleEntity? _scheduleFor(int weekday, Profesional pro) => switch (weekday) {
|
||||||
|
1 => pro.schedules.monday, 2 => pro.schedules.tuesday,
|
||||||
|
3 => pro.schedules.wednesday, 4 => pro.schedules.thursday,
|
||||||
|
5 => pro.schedules.friday, 6 => pro.schedules.saturday,
|
||||||
|
_ => pro.schedules.sunday,
|
||||||
|
};
|
||||||
|
|
||||||
|
List<TimeOfDay> _buildSlots(ScheduleEntity? s, int stepMinutes) {
|
||||||
|
if (s == null || !s.enabled || s.range1Hour1 == null || s.range2Hour2 == null) return [];
|
||||||
|
if (s.continuousDay) {
|
||||||
|
return TimeOfDayUtils.genRanges(s.range1Hour1!, s.range2Hour2!, stepMinutes: stepMinutes);
|
||||||
|
}
|
||||||
|
if (s.range1Hour2 == null || s.range2Hour1 == null) return [];
|
||||||
|
return [
|
||||||
|
...TimeOfDayUtils.genRanges(s.range1Hour1!, s.range1Hour2!, stepMinutes: stepMinutes),
|
||||||
|
...TimeOfDayUtils.genRanges(s.range2Hour1!, s.range2Hour2!, stepMinutes: stepMinutes),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isOccupied(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 &&
|
||||||
|
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) {
|
||||||
|
if (services == null) return null;
|
||||||
|
final dayStr = day.toIso8601String().split('T').first;
|
||||||
|
try {
|
||||||
|
return services.firstWhere((s) => s.day == dayStr && s.range1Hour1 == time);
|
||||||
|
} catch (_) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
final String label;
|
||||||
|
final String sublabel;
|
||||||
|
final Color color;
|
||||||
|
const _StatPill({required this.label, required this.sublabel, required this.color});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.08),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: color.withOpacity(0.25)),
|
||||||
|
),
|
||||||
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
Text(label,
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w800, color: color, height: 1)),
|
||||||
|
Text(sublabel,
|
||||||
|
style: TextStyle(fontSize: 9, color: color.withOpacity(0.8), fontWeight: FontWeight.w600)),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:prosapp_web_app/providers/professional_provider.dart';
|
|||||||
import 'package:prosapp_web_app/providers/professions_provider.dart';
|
import 'package:prosapp_web_app/providers/professions_provider.dart';
|
||||||
import 'package:prosapp_web_app/providers/profile_form_provider.dart';
|
import 'package:prosapp_web_app/providers/profile_form_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/services/api_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';
|
||||||
|
|
||||||
@@ -65,16 +66,20 @@ class _FormBody extends StatefulWidget {
|
|||||||
class _FormBodyState extends State<_FormBody> {
|
class _FormBodyState extends State<_FormBody> {
|
||||||
final _rethusCtrl = TextEditingController();
|
final _rethusCtrl = TextEditingController();
|
||||||
final _specCtrl = TextEditingController();
|
final _specCtrl = TextEditingController();
|
||||||
|
final _cedulaNumCtrl = TextEditingController();
|
||||||
String? _selectedProfession;
|
String? _selectedProfession;
|
||||||
List<String> _specs = [];
|
List<String> _specs = [];
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
bool _cedulaError = false;
|
bool _cedulaError = false;
|
||||||
|
bool _cedulaNumError = false;
|
||||||
|
bool _certError = false;
|
||||||
bool _profError = false;
|
bool _profError = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_rethusCtrl.dispose();
|
_rethusCtrl.dispose();
|
||||||
_specCtrl.dispose();
|
_specCtrl.dispose();
|
||||||
|
_cedulaNumCtrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,25 +93,30 @@ class _FormBodyState extends State<_FormBody> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _validate(String cedulaUrl) {
|
bool _validate(String cedulaUrl, String certUrl) {
|
||||||
final cErr = cedulaUrl.isEmpty;
|
final cErr = cedulaUrl.isEmpty;
|
||||||
final pErr =
|
final nErr = _cedulaNumCtrl.text.trim().isEmpty;
|
||||||
_selectedProfession == null || _selectedProfession!.isEmpty;
|
final dErr = certUrl.isEmpty;
|
||||||
|
final pErr = _selectedProfession == null || _selectedProfession!.isEmpty;
|
||||||
setState(() {
|
setState(() {
|
||||||
_cedulaError = cErr;
|
_cedulaError = cErr;
|
||||||
|
_cedulaNumError = nErr;
|
||||||
|
_certError = dErr;
|
||||||
_profError = pErr;
|
_profError = pErr;
|
||||||
});
|
});
|
||||||
return !cErr && !pErr;
|
return !cErr && !nErr && !dErr && !pErr;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _submit() async {
|
Future<void> _submit() async {
|
||||||
final fp = context.read<ProfessionalFormProvider>();
|
final fp = context.read<ProfessionalFormProvider>();
|
||||||
final cedulaUrl = fp.profesional?.identificationPicture ?? '';
|
final cedulaUrl = fp.profesional?.identificationPicture ?? '';
|
||||||
if (!_validate(cedulaUrl)) return;
|
final certUrl = fp.profesional?.certificatePicture ?? '';
|
||||||
|
if (!_validate(cedulaUrl, certUrl)) return;
|
||||||
|
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
try {
|
||||||
fp.copyProfesionalWith(
|
fp.copyProfesionalWith(
|
||||||
|
identification: _cedulaNumCtrl.text.trim(),
|
||||||
profession: _selectedProfession,
|
profession: _selectedProfession,
|
||||||
rethusCode: _rethusCtrl.text.trim(),
|
rethusCode: _rethusCtrl.text.trim(),
|
||||||
specializations: _specs,
|
specializations: _specs,
|
||||||
@@ -151,7 +161,7 @@ class _FormBodyState extends State<_FormBody> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = context.watch<ThemeProvider>().isDark;
|
final isDark = context.watch<ThemeProvider>().isDark;
|
||||||
final fp = context.watch<ProfessionalFormProvider>();
|
final fp = context.watch<ProfessionalFormProvider>();
|
||||||
final user = context.read<AuthProvider>().user!;
|
final user = context.watch<AuthProvider>().user!;
|
||||||
final professions = context.watch<ProfessionsProvider>().professions;
|
final professions = context.watch<ProfessionsProvider>().professions;
|
||||||
final proStateInt = enumToInt(user.proState);
|
final proStateInt = enumToInt(user.proState);
|
||||||
|
|
||||||
@@ -193,6 +203,8 @@ class _FormBodyState extends State<_FormBody> {
|
|||||||
|
|
||||||
final cedulaUploaded =
|
final cedulaUploaded =
|
||||||
(fp.profesional?.identificationPicture ?? '').isNotEmpty;
|
(fp.profesional?.identificationPicture ?? '').isNotEmpty;
|
||||||
|
final certUploaded =
|
||||||
|
(fp.profesional?.certificatePicture ?? '').isNotEmpty;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@@ -228,30 +240,58 @@ class _FormBodyState extends State<_FormBody> {
|
|||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// ── 1. Foto / PDF de cédula (obligatorio) ──────────────────────────
|
// ── 1. Cédula (número + foto) ───────────────────────────────────────
|
||||||
_SectionCard(
|
_SectionCard(
|
||||||
cardBg: cardBg,
|
cardBg: cardBg,
|
||||||
border: border,
|
border: border,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_Label('Foto / PDF de cédula', Icons.badge_outlined,
|
_Label('Cédula de ciudadanía', Icons.badge_outlined, required: true),
|
||||||
required: true),
|
const SizedBox(height: 4),
|
||||||
const SizedBox(height: 12),
|
// Tipo fijo
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? const Color(0xFF334155) : const Color(0xFFF1F5F9),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: border),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.lock_outline, size: 14, color: textSec),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text('Tipo: Cédula de ciudadanía', style: TextStyle(fontSize: 13, color: textSec)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
// Número
|
||||||
|
TextField(
|
||||||
|
controller: _cedulaNumCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
style: TextStyle(color: textPrimary, fontSize: 14),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Número de cédula',
|
||||||
|
hintStyle: TextStyle(color: textSec, fontSize: 13),
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
errorText: _cedulaNumError ? 'Ingresa tu número de cédula' : null,
|
||||||
|
),
|
||||||
|
onChanged: (_) { if (_cedulaNumError) setState(() => _cedulaNumError = false); },
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
// Foto
|
||||||
_UploadTile(
|
_UploadTile(
|
||||||
label: cedulaUploaded
|
label: cedulaUploaded ? 'Foto de cédula subida ✓' : 'Subir foto o PDF de la cédula',
|
||||||
? 'Cédula subida ✓'
|
|
||||||
: 'Subir foto o PDF de la cédula',
|
|
||||||
uploaded: cedulaUploaded,
|
uploaded: cedulaUploaded,
|
||||||
onTap: () => _pickAndUpload(
|
onTap: () => _pickAndUpload((b) => fp.uploadPdfIdentification(b, user.id)),
|
||||||
(b) => fp.uploadPdfIdentification(b, user.id)),
|
|
||||||
),
|
),
|
||||||
if (_cedulaError)
|
if (_cedulaError)
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.only(top: 8),
|
padding: EdgeInsets.only(top: 8),
|
||||||
child: Text('Debes subir la foto de tu cédula',
|
child: Text('Debes subir la foto de tu cédula',
|
||||||
style: TextStyle(
|
style: TextStyle(color: Colors.redAccent, fontSize: 12)),
|
||||||
color: Colors.redAccent, fontSize: 12)),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -259,7 +299,38 @@ class _FormBodyState extends State<_FormBody> {
|
|||||||
|
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
// ── 2. Profesión (obligatorio) ──────────────────────────────────────
|
// ── 2. Diploma / Certificado (obligatorio) ─────────────────────────
|
||||||
|
_SectionCard(
|
||||||
|
cardBg: cardBg,
|
||||||
|
border: border,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_Label('Diploma o certificado profesional', Icons.school_outlined, required: true),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Sube el diploma, acta de grado o tarjeta profesional',
|
||||||
|
style: TextStyle(fontSize: 11, color: textSec),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_UploadTile(
|
||||||
|
label: certUploaded ? 'Documento subido ✓' : 'Subir diploma / certificado (PDF o imagen)',
|
||||||
|
uploaded: certUploaded,
|
||||||
|
onTap: () => _pickAndUpload((b) => fp.uploadPdfCertificate(b, user.id)),
|
||||||
|
),
|
||||||
|
if (_certError)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 8),
|
||||||
|
child: Text('Debes subir el diploma o certificado',
|
||||||
|
style: TextStyle(color: Colors.redAccent, fontSize: 12)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
|
// ── 4. Profesión (obligatorio) ──────────────────────────────────────
|
||||||
_SectionCard(
|
_SectionCard(
|
||||||
cardBg: cardBg,
|
cardBg: cardBg,
|
||||||
border: border,
|
border: border,
|
||||||
@@ -578,7 +649,7 @@ class _UploadTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StatusCard extends StatelessWidget {
|
class _StatusCard extends StatefulWidget {
|
||||||
final Color cardBg;
|
final Color cardBg;
|
||||||
final Color border;
|
final Color border;
|
||||||
final Color textPrimary;
|
final Color textPrimary;
|
||||||
@@ -593,26 +664,73 @@ class _StatusCard extends StatelessWidget {
|
|||||||
required this.isPending,
|
required this.isPending,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_StatusCard> createState() => _StatusCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusCardState extends State<_StatusCard> {
|
||||||
|
int _waitDays = 7;
|
||||||
|
bool _loadingSettings = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (!widget.isPending) _loadWaitDays();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadWaitDays() async {
|
||||||
|
try {
|
||||||
|
final data = await ApiService.instance.get('/settings') as Map<String, dynamic>;
|
||||||
|
final v = data['rejection_wait_days'];
|
||||||
|
if (v != null) setState(() => _waitDays = (v as num).toInt());
|
||||||
|
} catch (_) {}
|
||||||
|
if (mounted) setState(() => _loadingSettings = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _retry() async {
|
||||||
|
try {
|
||||||
|
await ApiService.instance.delete('/professionals/me');
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) NotificationsService.showSnackbar('Error al reiniciar solicitud: $e');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
await context.read<AuthProvider>().isAuthenticated();
|
||||||
|
if (!mounted) return;
|
||||||
|
// Reload professional data so the form shows fresh state
|
||||||
|
final auth = context.read<AuthProvider>();
|
||||||
|
context.read<ProfessionalProvider>()
|
||||||
|
.getProfessional(auth.user!.id)
|
||||||
|
.then((pro) => context.read<ProfessionalFormProvider>().setProfesional(pro))
|
||||||
|
.catchError((_) => context.read<ProfessionalFormProvider>().setProfesional(Profesional.empty()));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final accent = isPending
|
final accent = widget.isPending
|
||||||
? const Color(0xFFF59E0B)
|
? const Color(0xFFF59E0B)
|
||||||
: const Color(0xFFEF4444);
|
: const Color(0xFFEF4444);
|
||||||
|
|
||||||
|
final user = context.watch<AuthProvider>().user;
|
||||||
|
final rejectedAt = user?.proRejectedAt;
|
||||||
|
final daysSince = rejectedAt != null
|
||||||
|
? DateTime.now().difference(rejectedAt).inDays
|
||||||
|
: _waitDays;
|
||||||
|
final daysLeft = (_waitDays - daysSince).clamp(0, _waitDays);
|
||||||
|
final canRetry = !widget.isPending && daysLeft == 0;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(top: 24),
|
margin: const EdgeInsets.only(top: 24),
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: cardBg,
|
color: widget.cardBg,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: border),
|
border: Border.all(color: widget.border),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (isPending)
|
if (widget.isPending)
|
||||||
const Image(
|
const Image(image: AssetImage('assets/checklist.gif'), width: 160)
|
||||||
image: AssetImage('assets/checklist.gif'),
|
|
||||||
width: 160)
|
|
||||||
else
|
else
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
@@ -620,63 +738,70 @@ class _StatusCard extends StatelessWidget {
|
|||||||
color: accent.withOpacity(0.1),
|
color: accent.withOpacity(0.1),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(Icons.sentiment_dissatisfied_outlined, size: 52, color: accent),
|
||||||
isPending
|
|
||||||
? Icons.hourglass_empty_rounded
|
|
||||||
: Icons.sentiment_dissatisfied_outlined,
|
|
||||||
size: 52,
|
|
||||||
color: accent),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
Text(
|
Text(
|
||||||
isPending
|
widget.isPending ? 'Solicitud en revisión' : 'Solicitud no aprobada',
|
||||||
? 'Solicitud en revisión'
|
style: TextStyle(fontSize: 19, fontWeight: FontWeight.bold, color: widget.textPrimary),
|
||||||
: 'Solicitud no aprobada',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 19,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: textPrimary),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text(
|
Text(
|
||||||
isPending
|
widget.isPending
|
||||||
? 'Estamos revisando tus datos. Te notificaremos cuando tu cuenta esté aprobada.'
|
? 'Estamos revisando tus datos. Te notificaremos cuando tu cuenta esté aprobada.'
|
||||||
: 'Tu solicitud no fue aprobada. Revisa tus documentos y vuelve a intentarlo.',
|
: canRetry
|
||||||
|
? 'Puedes volver a enviar tu solicitud con los documentos corregidos.'
|
||||||
|
: 'Tu solicitud no fue aprobada. Podrás reintentar en $daysLeft día${daysLeft == 1 ? '' : 's'}.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style:
|
style: TextStyle(color: widget.textSec, fontSize: 13, height: 1.5),
|
||||||
TextStyle(color: textSec, fontSize: 13, height: 1.5),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
if (!widget.isPending && !_loadingSettings) ...[
|
||||||
|
if (canRetry)
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _retry,
|
||||||
|
icon: const Icon(Icons.refresh, size: 18),
|
||||||
|
label: const Text('Volver a solicitar'),
|
||||||
|
style: FilledButton.styleFrom(backgroundColor: const Color(0xFF42A4EF)),
|
||||||
|
)
|
||||||
|
else
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||||
horizontal: 14, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: accent.withOpacity(0.12),
|
color: accent.withOpacity(0.12),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
border:
|
border: Border.all(color: accent.withOpacity(0.4)),
|
||||||
Border.all(color: accent.withOpacity(0.4)),
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(Icons.timer_outlined, color: accent, size: 16),
|
||||||
isPending
|
|
||||||
? Icons.hourglass_empty_rounded
|
|
||||||
: Icons.cancel_outlined,
|
|
||||||
color: accent,
|
|
||||||
size: 16,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
isPending ? 'Revisión en proceso' : 'No aprobado',
|
'Disponible en $daysLeft día${daysLeft == 1 ? '' : 's'}',
|
||||||
style: TextStyle(
|
style: TextStyle(color: accent, fontWeight: FontWeight.w600, fontSize: 13),
|
||||||
color: accent,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 13),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
] else if (widget.isPending)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accent.withOpacity(0.12),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: accent.withOpacity(0.4)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.hourglass_empty_rounded, color: accent, size: 16),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text('Revisión en proceso',
|
||||||
|
style: TextStyle(color: accent, fontWeight: FontWeight.w600, fontSize: 13)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
+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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,138 +3,273 @@ 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_status.dart';
|
import 'package:prosapp_web_app/models/service_status.dart';
|
||||||
|
import 'package:prosapp_web_app/models/servicio_profesional.dart';
|
||||||
import 'package:prosapp_web_app/services/navigation_service.dart';
|
import 'package:prosapp_web_app/services/navigation_service.dart';
|
||||||
import 'package:prosapp_web_app/ui/labels/custom_labels.dart';
|
|
||||||
import 'package:prosapp_web_app/ui/shared/widgets/status_item.dart';
|
|
||||||
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.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/services_provider.dart';
|
import 'package:prosapp_web_app/providers/services_provider.dart';
|
||||||
|
|
||||||
import 'package:prosapp_web_app/ui/cards/white_card.dart';
|
const _kPrimary = Color(0xFF1565C0);
|
||||||
|
|
||||||
|
extension _Th on BuildContext {
|
||||||
|
ThemeData get _t => Theme.of(this);
|
||||||
|
Color get bg => _t.scaffoldBackgroundColor;
|
||||||
|
Color get card => _t.cardColor;
|
||||||
|
Color get onSurface => _t.colorScheme.onSurface;
|
||||||
|
Color get muted => _t.colorScheme.onSurface.withOpacity(0.55);
|
||||||
|
Color get subtle => _t.colorScheme.onSurface.withOpacity(0.35);
|
||||||
|
bool get isDark => _t.brightness == Brightness.dark;
|
||||||
|
Color get shadow => isDark ? Colors.transparent : Colors.black.withOpacity(0.05);
|
||||||
|
Color get shadowSm => isDark ? Colors.transparent : Colors.black.withOpacity(0.04);
|
||||||
|
}
|
||||||
|
|
||||||
class ServicesRequestsView extends StatelessWidget {
|
class ServicesRequestsView extends StatelessWidget {
|
||||||
const ServicesRequestsView({super.key});
|
const ServicesRequestsView({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final servicesProvider =
|
final sp = Provider.of<ServicesProvider>(context, listen: false);
|
||||||
Provider.of<ServicesProvider>(context, listen: false);
|
sp.getServicesRequestsForProfessional(
|
||||||
|
|
||||||
servicesProvider.getServicesRequestsForProfessional(
|
|
||||||
Provider.of<AuthProvider>(context, listen: false).user!.id);
|
Provider.of<AuthProvider>(context, listen: false).user!.id);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
color: context.bg,
|
||||||
|
child: Consumer<ServicesProvider>(
|
||||||
|
builder: (context, sp, _) {
|
||||||
|
if (sp.isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator(color: _kPrimary));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
physics: const ClampingScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.only(bottom: 32),
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 720),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
|
||||||
|
child: Row(children: [
|
||||||
|
Container(
|
||||||
|
width: 36, height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _kPrimary.withOpacity(0.08),
|
||||||
|
borderRadius: BorderRadius.circular(10)),
|
||||||
|
child: const Icon(Icons.inbox_outlined, size: 19, color: _kPrimary),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text('Solicitudes',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18, fontWeight: FontWeight.w800, color: context.onSurface)),
|
||||||
|
const Spacer(),
|
||||||
|
if (sp.services.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _kPrimary.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Text('${sp.services.length}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13, fontWeight: FontWeight.w700, color: _kPrimary)),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (sp.services.isEmpty)
|
||||||
|
_emptyState(context)
|
||||||
|
else
|
||||||
|
...sp.services.map((data) => _RequestCard(data: data)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _emptyState(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 720),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 56, horizontal: 24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.card,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [BoxShadow(color: context.shadow, blurRadius: 12, offset: const Offset(0, 2))],
|
||||||
|
),
|
||||||
|
child: Column(children: [
|
||||||
|
Container(
|
||||||
|
width: 64, height: 64,
|
||||||
|
decoration: BoxDecoration(color: context.subtle.withOpacity(0.1), shape: BoxShape.circle),
|
||||||
|
child: Icon(Icons.inbox_outlined, size: 32, color: context.subtle),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('Sin solicitudes pendientes',
|
||||||
|
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: context.muted)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text('Cuando un cliente solicite tus servicios\naparecerá aquí.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 13, color: context.subtle, height: 1.5)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RequestCard extends StatelessWidget {
|
||||||
|
final ServicioProfesional data;
|
||||||
|
const _RequestCard({required this.data});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final service = data.service;
|
||||||
|
final user = data.user;
|
||||||
|
final statusInfo = _statusInfo(service.status);
|
||||||
|
final dateStr = DateFormat('dd MMM yyyy', 'es').format(DateTime.parse(service.day));
|
||||||
|
final timeStr = ScheduleEntity.getFormatTime(service.range1Hour1) ?? '';
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 900),
|
constraints: const BoxConstraints(maxWidth: 720),
|
||||||
child: Consumer<ServicesProvider>(
|
child: Container(
|
||||||
builder: (context, servicesProvider, child) {
|
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
if (servicesProvider.isLoading) {
|
decoration: BoxDecoration(
|
||||||
return const Center(
|
color: context.card,
|
||||||
child: CircularProgressIndicator(),
|
borderRadius: BorderRadius.circular(14),
|
||||||
);
|
boxShadow: [BoxShadow(color: context.shadowSm, blurRadius: 10, offset: const Offset(0, 2))],
|
||||||
}
|
|
||||||
|
|
||||||
if (servicesProvider.services.isEmpty) {
|
|
||||||
return ListView(
|
|
||||||
children: const [
|
|
||||||
WhiteCard(
|
|
||||||
child: Center(child: Text('No hay servicios disponibles.')),
|
|
||||||
),
|
),
|
||||||
],
|
child: ClipRRect(
|
||||||
);
|
borderRadius: BorderRadius.circular(14),
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.builder(
|
|
||||||
itemCount: servicesProvider.services.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final data = servicesProvider.services[index];
|
|
||||||
|
|
||||||
final image =
|
|
||||||
(data.user.picture == '' || data.user.picture == null)
|
|
||||||
? const Image(image: AssetImage('no-image.jpg'))
|
|
||||||
: FadeInImage.assetNetwork(
|
|
||||||
placeholder: 'loader.gif',
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
image: data.user.picture!,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 10),
|
|
||||||
child: MouseRegion(
|
child: MouseRegion(
|
||||||
cursor: SystemMouseCursors.click,
|
cursor: SystemMouseCursors.click,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () {
|
onTap: () => NavigationService.replaceTo(
|
||||||
NavigationService.replaceTo(
|
'/dashboard/professional/service/${service.id}'),
|
||||||
'/dashboard/professional/service/${data.service.id}');
|
child: IntrinsicHeight(
|
||||||
},
|
child: Row(children: [
|
||||||
child: WhiteCard(
|
Container(width: 4, color: statusInfo.color),
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 10),
|
padding: const EdgeInsets.all(14),
|
||||||
child: SizedBox(
|
child: Container(
|
||||||
width: 80,
|
width: 52, height: 52,
|
||||||
height: 80,
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: statusInfo.color.withOpacity(0.3), width: 2)),
|
||||||
child: ClipOval(
|
child: ClipOval(
|
||||||
child: image,
|
child: (user.picture == null || user.picture!.isEmpty)
|
||||||
|
? Container(
|
||||||
|
color: _kPrimary.withOpacity(0.1),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
user.name.isNotEmpty ? user.name[0].toUpperCase() : '?',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 20, fontWeight: FontWeight.w700, color: _kPrimary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: FadeInImage.assetNetwork(
|
||||||
|
placeholder: 'loader.gif', image: user.picture!, fit: BoxFit.cover),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 20),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(user.name,
|
||||||
data.user.name,
|
style: TextStyle(
|
||||||
style: CustomLabels.h2,
|
fontSize: 14, fontWeight: FontWeight.w700, color: context.onSurface)),
|
||||||
),
|
if (service.description.isNotEmpty) ...[
|
||||||
if (data.service.description != '')
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text('"${service.description}"',
|
||||||
'"${data.service.description}"',
|
style: TextStyle(
|
||||||
style: CustomLabels.h5,
|
fontSize: 12, color: context.muted, fontStyle: FontStyle.italic),
|
||||||
),
|
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(children: [
|
||||||
|
Icon(Icons.calendar_today_outlined, size: 12, color: context.subtle),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text('$dateStr · $timeStr',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12, color: context.muted, fontWeight: FontWeight.w500)),
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: 10),
|
padding: const EdgeInsets.fromLTRB(8, 14, 12, 14),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
_StatusBadge(info: statusInfo),
|
||||||
'${ScheduleEntity.getFormatTime(data.service.range1Hour1)} - ${DateFormat('dd MMMM yyyy', 'es').format(DateTime.parse(data.service.day))}',
|
const SizedBox(height: 8),
|
||||||
style: const TextStyle(
|
Icon(Icons.chevron_right, size: 18, color: context.subtle),
|
||||||
color: Colors.black54, fontSize: 16),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 10),
|
|
||||||
child: customStatus(data.service),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget customStatus(Service service) {
|
class _StatusBadge extends StatelessWidget {
|
||||||
if (service.status == ServiceStatus.pending) {
|
final _StatusInfo info;
|
||||||
return const StatusItem(text: 'Solicitud', color: Colors.black45);
|
const _StatusBadge({required this.info});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: info.color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: info.color.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
Icon(info.icon, size: 11, color: info.color),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(info.label,
|
||||||
|
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: info.color)),
|
||||||
|
]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return const SizedBox();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _StatusInfo {
|
||||||
|
final String label;
|
||||||
|
final Color color;
|
||||||
|
final IconData icon;
|
||||||
|
const _StatusInfo(this.label, this.color, this.icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
_StatusInfo _statusInfo(ServiceStatus status) => switch (status) {
|
||||||
|
ServiceStatus.pending =>
|
||||||
|
const _StatusInfo('Pendiente', Color(0xFFD97706), Icons.hourglass_top_outlined),
|
||||||
|
ServiceStatus.acepted =>
|
||||||
|
const _StatusInfo('Aceptado', Color(0xFF1565C0), Icons.check_circle_outline),
|
||||||
|
ServiceStatus.active =>
|
||||||
|
const _StatusInfo('En curso', Color(0xFF16A34A), Icons.play_circle_outline),
|
||||||
|
ServiceStatus.completed =>
|
||||||
|
const _StatusInfo('Completado', Color(0xFF64748B), Icons.task_alt_outlined),
|
||||||
|
ServiceStatus.denied =>
|
||||||
|
const _StatusInfo('Rechazado', Color(0xFFDC2626), Icons.cancel_outlined),
|
||||||
|
ServiceStatus.cancelled =>
|
||||||
|
const _StatusInfo('Cancelado', Color(0xFF9CA3AF), Icons.remove_circle_outline),
|
||||||
|
ServiceStatus.selfBooked =>
|
||||||
|
const _StatusInfo('Reservado', Color(0xFF7C3AED), Icons.bookmark_outline),
|
||||||
|
};
|
||||||
|
|||||||
@@ -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