Compare commits

..
12 Commits
Author SHA1 Message Date
Lizandro GuarnizoandClaude Opus 5 5ed0376520 fix(booking): enumerate the missing fields with commas, not repeated 'y'
ci-651288 / run (push) Waiting to run
ci-946620 / run (push) Waiting to run
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:27:07 -05:00
Lizandro GuarnizoandClaude Opus 5 6cb2755858 fix(booking): read the live agenda and re-check the slot before confirming
/services/public-calendar also returns the professional's current opening
hours; the client threw them away and drew the day from the snapshot the
search list carried, which can be days old. It now uses the fresh ones.

Between picking an hour and pressing "Pedir cita" another patient can
take the slot. The confirm step asks the agenda again and, if the hour is
gone, says so and clears the selection instead of creating a service the
professional would have to deny. A failed re-check lets the booking
through: a network hiccup should not block a patient.

Also:
- tapping a busy slot with no id explains itself instead of doing nothing
- the profile refuses to save an empty rate with the rate switch on,
  which was reaching the backend as rate: ''

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:21:08 -05:00
Lizandro GuarnizoandClaude Opus 5 0a8e5d11a2 fix(agenda): one slot generator for professional and patient
The two calendars each built their own list of hours. The patient's
enforced a 3-hour booking lead time; the professional's did not, so at
15:00 an 08:00-18:00 agenda reported "3 libres" (15, 16, 17) that no
patient could actually take.

Both now go through SlotGenerator. The professional still sees the
near-term hours (blocking the next hour is legitimate) but they are
labelled "Sin reserva" and excluded from the "libres" count, so the
number on his agenda means what the patient sees.

Also in this pass:

- endOf() clamps the slot end at 23:59; a 120-minute slot booked at
  23:00 was sending "25:00" to the backend.
- ScheduleEntity.copyWith can set an hour back to null, so returning a
  day to jornada continua no longer keeps the split hours around.
- Removed the Firebase-era schedule map ('habilitado', 'range1Hour1')
  together with the dead ProfessionalEntity.fromDocument that fed it.
- Settings loads guard on mounted and swallow failures instead of
  calling setState after dispose.
- "Pedir cita" says what is missing instead of doing nothing.

Tests: 9 passing, including the clamp and the empty/inverted ranges.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:17:03 -05:00
Lizandro GuarnizoandClaude Opus 5 3327220557 fix(settings): stop dropping the tarifas and domicilios flags
getSettings rebuilt the response into a hand-written map and left both feature
flags out, so SettingEntity always read null. Everything gated on them stayed
off no matter what the backend said:

- "Mostrar tarifa" in the professional profile could not be switched on
- "Servicio a domicilio" never rendered, and touching the on-site switch forced
  location to office, losing both/delivery
- the patient never saw the home-vs-office dialog, so every appointment was
  created as office with the professional's address
- every appointment was created with rate 0

The web reads the response as-is and never had this. Note the backend does not
ship a `tarifas` key at all yet, so rates stay off until it is added to
settings.global — this fix only stops the app from discarding it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:00:55 -05:00
Lizandro GuarnizoandClaude Opus 5 0bf96a935d fix(schedules): actually persist the professional's opening hours
Saving opening hours from the app never did anything. The payload went to
PATCH /professionals/me as a map keyed by day name ({"monday": {"habilitado":
true, "range1Hour1": "8:0"}}), and that endpoint silently drops a `schedules`
key — it answers 200 with the record unchanged. So the professional edited
their hours, got "Información actualizada correctamente", and the agenda kept
whatever it had.

The real endpoint is PATCH /professionals/me/schedules and it takes an array
of {day_of_week, enabled, continuous_day, range*_hour*} with zero-padded
times, which is what the web has been sending all along.

Verified against the backend: the previous payload returns 200 and changes
nothing; the new one returns 200 and the rows come back updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 12:52:34 -05:00
Lizandro GuarnizoandClaude Opus 5 29ff91a790 fix(chat): open the conversation with the other participant
ci-651288 / run (push) Waiting to run
ci-946620 / run (push) Waiting to run
/chat/start/:id starts a chat between the caller and :id, so it takes the
counterpart — the web already passes the client when a professional opens it.
Mobile always sent the professional, so a professional opening the chat
started a conversation with themselves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 21:50:57 -05:00
Lizandro GuarnizoandClaude Opus 5 7930890d09 fix: clients read the professional's real agenda, not their own
/services/public-calendar/{professionalId} exists after all — the web has been
using it all along. My earlier probes missed it because they guessed the URL
shape, and because it is keyed by the professionals row id rather than the
owning user id, which ProfessionalEntity was discarding.

Adds ProfessionalEntity.recordId and points the client calendar at the public
endpoint. Until now a client browsing a professional's availability was served
their own agenda, so every slot looked free and two people could book the same
hour. A failed load now hides the slots and offers a retry instead of showing
them as available.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 21:29:31 -05:00
Lizandro GuarnizoandClaude Opus 5 389f876cfa fix: repair the booking flow end to end
ci-651288 / run (push) Waiting to run
ci-946620 / run (push) Waiting to run
Verified the real contracts against the backend before changing anything.

Chat (three defects, one root cause):
- every chat endpoint is keyed by the chat id, not the service id. The app
  called POST /chat/start, threw away the id it returned and kept using the
  service id, so every later request 404'd.
- messages arrive as {data, meta}; reading the body as a bare list threw and
  surfaced as an empty conversation.
- the bloc created the chat and then never emitted ChatLoaded (the else hung
  off `if (chat == null)`), leaving a permanent spinner. Sending a message
  emitted nothing at all, so it vanished until reopening.
Messages now render optimistically and roll back if the send fails, and the
screen distinguishes "loading" from "could not open" with a retry.

Appointments:
- a null range1_hour2 parsed as 00:00, so new appointments were born
  "Caducado" and every action was hidden. It now falls back to the start time.
- service requests validate the HTTP status and tolerate an empty body: a 4xx
  was treated as success and a 204 as failure.
- creating a service with no id in the response no longer reports success and
  navigates to a service that does not exist.
- dispatching LoadService from build() looped forever on failure; the three
  detail screens now load once and offer a retry.

Ratings:
- both sides read userScored, so only one of the two could ever rate. The
  client side now reads professionalScored.
- the screen closed before the request finished, killing the provider mid
  flight while addComment swallowed every error. It now waits for confirmation.
- score/reputation parsing tolerates integers and numeric strings instead of
  emptying the review list.

Also: guarded map lookups in ScoreBloc, a nullable name in the search list,
and error states with retry where a failure used to shimmer forever.

Verified against the backend: `accepted` is the status the API expects, so the
suspected spelling bug was a false alarm and was left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:47:54 -05:00
Lizandro GuarnizoandClaude Opus 5 8c6e2ae024 fix: compare service days as dates, not raw strings
DateTime.toString() renders "2026-07-13 00:00:00.000Z" while the backend
sends "2026-07-13T00:00:00.000Z", so every day comparison was false and no
booked slot was ever detected. Both calendars showed fully free days.

The client calendar now also treats an unloaded agenda as busy instead of
free: rendering a taken slot as available leads straight to a double booking.

Note: the client still reads /services/professional/calendar, which returns
the *caller's* calendar. There is no endpoint for another professional's
agenda, so a client cannot yet see which of that professional's slots are
taken. That needs a backend endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:27:40 -05:00
Lizandro GuarnizoandClaude Opus 5 2c1ba7f6d5 fix(calendar): distinguish blocked slots from booked ones
ci-651288 / run (push) Waiting to run
ci-946620 / run (push) Waiting to run
_isOccupied matched any service on the slot regardless of status. Unblocking
sets the service to cancelled, so a released slot still rendered as occupied
and the unblock looked like it did nothing. Cancelled and denied services are
now ignored, and self-booked slots get their own "Bloqueado" state with a
release action, mirroring the web.

Self-booking now spans the professional's configured slot duration instead of
a hardcoded two hours, which no longer matched the generated slots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 16:50:57 -05:00
Lizandro GuarnizoandClaude Opus 5 30b8689814 chore: sync generated plugin registrants after Firebase package removal
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 16:46:04 -05:00
Lizandro GuarnizoandClaude Opus 5 8631e6f729 fix: port 7 web features and repair the endless-loading screens
Root cause behind most "stuck loading" reports: the backend changed shape
(schedules became an array, location_preferences a string) while the mobile
parser still hard-cast to Map/int. The TypeError was swallowed by a silent
catch that returned null, and screens only handled the success state, so a
parse failure rendered as a permanent spinner. Same class of bug appeared
across service lists via non-null map lookups and a total absence of
request timeouts.

Ported from prosappweb:
- in-app suggestions (POST /suggestions)
- policies/terms from GET /settings/policies
- configurable appointment length (slot_duration_minutes)
- block/unblock calendar slots (POST /services/block)
- GPS city detection on the profile (Nominatim)
- server-side professional search with haversine distance
- retry cooldown after a rejected professional application

Reliability:
- parse schedules array (day_of_week 0=Mon) and string location_preferences
- read times as wall clock, so 08:00 stays 08:00 across timezones
- carry minutes into hours in TimeOfDay.add; a minute-based step used to
  loop forever and freeze the calendar (covered by test/time_slots_test.dart)
- semver update check instead of string equality, which blocked every build
  that did not exactly match the configured version
- request timeouts across all repositories
- surface HTTP >= 400 instead of reporting failed writes as success
- error states with retry instead of an indefinite shimmer

Includes pre-existing uncommitted work from the UI redesign.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 16:45:42 -05:00
108 changed files with 6776 additions and 5704 deletions
+1
View File
@@ -1,5 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.prosappco"> package="com.example.prosappco">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<queries> <queries>
+3
View File
@@ -32,3 +32,6 @@ Runner/GeneratedPluginRegistrant.*
!default.mode2v3 !default.mode2v3
!default.pbxuser !default.pbxuser
!default.perspectivev3 !default.perspectivev3
# Vendored third-party source tree (466MB), not part of the app build
boringssl/
+2
View File
@@ -1,2 +1,4 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"
CLANG_ENABLE_EXPLICIT_MODULES = NO
+2
View File
@@ -1,2 +1,4 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"
CLANG_ENABLE_EXPLICIT_MODULES = NO
+5 -1
View File
@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project # Uncomment this line to define a global platform for your project
platform :ios, '12.0' platform :ios, '14.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency. # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true' ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -39,5 +39,9 @@ end
post_install do |installer| post_install do |installer|
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target) flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES'
config.build_settings['CLANG_ENABLE_EXPLICIT_MODULES'] = 'NO'
end
end end
end end
+96 -1260
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -3,7 +3,7 @@ import Flutter
import GoogleMaps import GoogleMaps
import Firebase import Firebase
@UIApplicationMain @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate {
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
+41 -27
View File
@@ -10,6 +10,10 @@ part 'chat_state.dart';
class ChatBloc extends Bloc<ChatEvent, ChatState> { class ChatBloc extends Bloc<ChatEvent, ChatState> {
final ApiChatRepository _chatRepository; final ApiChatRepository _chatRepository;
/// Resolved once per conversation. Every chat endpoint is keyed by the chat
/// id, which the app used to confuse with the service id.
String? _chatId;
ChatBloc({ ChatBloc({
required ApiChatRepository chatRepository, required ApiChatRepository chatRepository,
}) : _chatRepository = chatRepository, }) : _chatRepository = chatRepository,
@@ -19,32 +23,11 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
} }
void _onLoadChatEvent(LoadChatEvent event, Emitter<ChatState> emit) async { void _onLoadChatEvent(LoadChatEvent event, Emitter<ChatState> emit) async {
bool autoCreate = true; emit(ChatLoading());
try { try {
emit(ChatLoading()); final chat = await _chatRepository.loadConversation(event.professionalId);
_chatId = chat.id;
Stream<ChatEntity?> chatStream = _chatRepository.getChatById( emit(ChatLoaded(chat: chat));
event.serviceId,
);
await for (var chat in chatStream) {
if (chat == null) {
if (autoCreate) {
chat = await _chatRepository.createNewChat(
event.serviceId,
event.userId,
event.professionalId,
);
autoCreate = false;
} else {
emit(ChatFailure());
}
} else {
emit(ChatLoaded(chat: chat));
}
}
} catch (e) { } catch (e) {
log(e.toString()); log(e.toString());
emit(ChatFailure()); emit(ChatFailure());
@@ -53,11 +36,42 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
void _onSendMessageEvent( void _onSendMessageEvent(
SendMessageEvent event, Emitter<ChatState> emit) async { SendMessageEvent event, Emitter<ChatState> emit) async {
final current = state;
// Show the message immediately; the reload below reconciles with the
// backend. Previously nothing was emitted at all and the message vanished.
if (current is ChatLoaded) {
emit(ChatLoaded(
chat: ChatEntity(
id: current.chat.id,
userId: current.chat.userId,
professionalId: current.chat.professionalId,
messages: [...current.chat.messages, event.message],
),
));
}
try { try {
await _chatRepository.sendMessage(event.serviceId, event.message); final chatId = _chatId;
if (chatId == null || chatId.isEmpty) {
throw Exception('El chat aún no está abierto');
}
await _chatRepository.sendMessage(chatId, event.message);
final messages = await _chatRepository.fetchMessages(chatId);
emit(ChatLoaded(
chat: ChatEntity(
id: chatId,
userId: current is ChatLoaded ? current.chat.userId : '',
professionalId:
current is ChatLoaded ? current.chat.professionalId : '',
messages: messages,
),
));
} catch (e) { } catch (e) {
log(e.toString()); log(e.toString());
emit(ChatFailure()); // Roll the optimistic message back so nobody believes it was delivered.
if (current is ChatLoaded) emit(ChatLoaded(chat: current.chat));
emit(SendMessageFailure());
} }
} }
} }
+4
View File
@@ -20,4 +20,8 @@ class ChatLoaded extends ChatState {
List<Object> get props => [chat]; List<Object> get props => [chat];
} }
/// The conversation could not be opened — the screen has nothing to show.
class ChatFailure extends ChatState {} class ChatFailure extends ChatState {}
/// The conversation is on screen but one message failed to send.
class SendMessageFailure extends ChatState {}
@@ -22,6 +22,9 @@ class ProfessionalBloc extends Bloc<ProfessionalEvent, ProfessionalState> {
bool isProModeActive = _professionalRepository.isProModeActive; bool isProModeActive = _professionalRepository.isProModeActive;
final proInfo = _professionalRepository.lastProInfo(); final proInfo = _professionalRepository.lastProInfo();
emit(LoadedModeProState(isProModeActive, proInfo)); emit(LoadedModeProState(isProModeActive, proInfo));
if (isProModeActive && proInfo == null) {
_fetchMyProInfo();
}
_professionalRepository.streamProInfo().listen((proInfo) { _professionalRepository.streamProInfo().listen((proInfo) {
if (proInfo == null) { if (proInfo == null) {
@@ -40,14 +43,33 @@ class ProfessionalBloc extends Bloc<ProfessionalEvent, ProfessionalState> {
await _professionalRepository.switchProMode(); await _professionalRepository.switchProMode();
}); });
on<ResetProfessionalApplicationEvent>((event, emit) async {
emit(ResetProfessionalApplicationLoading());
try {
await _professionalRepository.deleteProfessionalInfo();
emit(ResetProfessionalApplicationSuccess());
} catch (e) {
log(e.toString());
emit(ProfessionalStateFailure(message: e.toString()));
}
});
on<UpdateProfessionalEvent>((event, emit) async { on<UpdateProfessionalEvent>((event, emit) async {
final proInfo = _professionalRepository.lastProInfo(); final proInfo = _professionalRepository.lastProInfo();
emit(LoadedModeProState(event.isProModeActive, proInfo)); emit(LoadedModeProState(event.isProModeActive, proInfo));
if (event.isProModeActive && proInfo == null) {
await _fetchMyProInfo();
}
}); });
on<SendProfessionalToReviewEvent>((event, emit) async { on<SendProfessionalToReviewEvent>((event, emit) async {
emit(SendProfessionalToReviewLoading());
final myUser = await _userRepository.lastUser(); final myUser = await _userRepository.lastUser();
if (myUser == null) return; if (myUser == null) {
emit(const SendProfessionalToReviewFailure(
message: 'No se pudo identificar tu usuario'));
return;
}
try { try {
final identificationPdfUrl = await _professionalRepository final identificationPdfUrl = await _professionalRepository
.uploadPdfCedula(event.identificationPicture, myUser.id); .uploadPdfCedula(event.identificationPicture, myUser.id);
@@ -83,10 +105,18 @@ class ProfessionalBloc extends Bloc<ProfessionalEvent, ProfessionalState> {
schedules: Schedules.empty, schedules: Schedules.empty,
paymentMethods: PaymentMethodEntity.empty, paymentMethods: PaymentMethodEntity.empty,
)); ));
emit(SendProfessionalToReviewSuccess());
} catch (e) { } catch (e) {
log('error acceso a pro ${e.toString()}'); log('error acceso a pro ${e.toString()}');
emit(SendProfessionalToReviewFailure(message: e.toString()));
} }
// _userRepository.updateUserInfo(myUser.copyWith(proState: ProState.pending));
}); });
} }
Future<void> _fetchMyProInfo() async {
final myUser = await _userRepository.lastUser();
if (myUser == null) return;
await _professionalRepository.updateFromFirebase(userId: myUser.id);
}
} }
@@ -19,6 +19,10 @@ class SwitchProModeEvent extends ProfessionalEvent {
const SwitchProModeEvent(); const SwitchProModeEvent();
} }
class ResetProfessionalApplicationEvent extends ProfessionalEvent {
const ResetProfessionalApplicationEvent();
}
class SendProfessionalToReviewEvent extends ProfessionalEvent { class SendProfessionalToReviewEvent extends ProfessionalEvent {
final String id; final String id;
final String identification; final String identification;
@@ -17,6 +17,23 @@ class ProfessionalStateFailure extends ProfessionalState {
class ProfessionalStateProcess extends ProfessionalState {} class ProfessionalStateProcess extends ProfessionalState {}
class ResetProfessionalApplicationLoading extends ProfessionalState {}
class ResetProfessionalApplicationSuccess extends ProfessionalState {}
class SendProfessionalToReviewLoading extends ProfessionalState {}
class SendProfessionalToReviewSuccess extends ProfessionalState {}
class SendProfessionalToReviewFailure extends ProfessionalState {
final String? message;
const SendProfessionalToReviewFailure({this.message});
@override
List<Object?> get props => [message];
}
class LoadedModeProState extends ProfessionalState { class LoadedModeProState extends ProfessionalState {
final bool isProModeActive; final bool isProModeActive;
final ProfessionalEntity? proInfo; final ProfessionalEntity? proInfo;
@@ -1,3 +1,6 @@
import 'dart:developer';
import 'dart:math' as math;
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:professional_repository/professional_repository.dart'; import 'package:professional_repository/professional_repository.dart';
@@ -22,29 +25,90 @@ class ProfessionalListBloc
void _onProfessionalListFetch(ProfessionalListFetch event, Emitter<ProfessionalListState> emit) async { void _onProfessionalListFetch(ProfessionalListFetch event, Emitter<ProfessionalListState> emit) async {
emit(ProfessionalListLoading()); emit(ProfessionalListLoading());
try {
await _fetch(event, emit);
} catch (e) {
// Without this the bloc stayed in Loading and the screen shimmered
// forever on any network hiccup.
log(e.toString());
emit(ProfessionalListFailure());
}
}
Future<void> _fetch(ProfessionalListFetch event,
Emitter<ProfessionalListState> emit) async {
final users = await _userRepository.getUsersProfessionalActive(); final users = await _userRepository.getUsersProfessionalActive();
final professionals = final hasSearchParams = event.search != null ||
await _firebaseProfessionalRepository.getProfessionalInfo(); event.city != null ||
event.lat != null ||
event.lng != null;
final professionalInfoMap = {for (var doc in professionals) doc.id: doc}; if (!hasSearchParams) {
final professionals =
await _firebaseProfessionalRepository.getProfessionalInfo();
final usersMap = users final professionalInfoMap = {for (var doc in professionals) doc.id: doc};
.map((user) => UserProfessional(
myUser: user, final usersMap = users
professionalInfo: professionalInfoMap[user.id]!, .where((user) => professionalInfoMap.containsKey(user.id))
.map((user) => UserProfessional(
myUser: user,
professionalInfo: professionalInfoMap[user.id]!,
))
.toList();
emit(ProfessionalListSuccess(users: usersMap));
return;
}
final usersDir = {for (var u in users) u.id: u};
final professionals = await _firebaseProfessionalRepository.searchProfessionals(
search: event.search,
city: event.city,
lat: event.lat,
lng: event.lng,
);
final usersMap = professionals
.where((p) => usersDir.containsKey(p.id))
.map((p) => UserProfessional(
myUser: usersDir[p.id]!,
professionalInfo: p,
distanceKm: (event.lat != null && event.lng != null)
? _haversineKm(event.lat!, event.lng!, p.latitude, p.longitude)
: null,
)) ))
.toList(); .toList();
emit(ProfessionalListSuccess(users: usersMap)); emit(ProfessionalListSuccess(users: usersMap));
} }
double? _haversineKm(double lat1, double lng1, double lat2, double lng2) {
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));
}
} }
class UserProfessional { class UserProfessional {
final MyUser myUser; final MyUser myUser;
final ProfessionalEntity professionalInfo; final ProfessionalEntity professionalInfo;
final double? distanceKm;
UserProfessional({required this.myUser, required this.professionalInfo}); UserProfessional({
required this.myUser,
required this.professionalInfo,
this.distanceKm,
});
@override @override
String toString() { String toString() {
@@ -8,8 +8,18 @@ abstract class ProfessionalListEvent extends Equatable {
} }
class ProfessionalListFetch extends ProfessionalListEvent { class ProfessionalListFetch extends ProfessionalListEvent {
const ProfessionalListFetch(); final String? search;
final String? city;
final double? lat;
final double? lng;
const ProfessionalListFetch({this.search, this.city, this.lat, this.lng});
@override @override
List<Object> get props => []; List<Object> get props => [
search ?? '',
city ?? '',
lat ?? 0,
lng ?? 0,
];
} }
@@ -11,6 +11,9 @@ class ProfessionalListInitial extends ProfessionalListState {}
class ProfessionalListLoading extends ProfessionalListState {} class ProfessionalListLoading extends ProfessionalListState {}
/// The list could not be loaded — distinct from "nobody matched".
class ProfessionalListFailure extends ProfessionalListState {}
class ProfessionalListSuccess extends ProfessionalListState { class ProfessionalListSuccess extends ProfessionalListState {
final List<UserProfessional> users; final List<UserProfessional> users;
@@ -45,6 +45,7 @@ class ProfessionalProfileBloc
event.longitude, event.longitude,
event.schedules, event.schedules,
event.paymentMethods, event.paymentMethods,
slotDurationMinutes: event.slotDurationMinutes,
); );
emit(const UpdateProfessionalInfoSuccess()); emit(const UpdateProfessionalInfoSuccess());
@@ -28,6 +28,7 @@ class UpdateProfessionalProfileInfo extends ProfessionalProfileEvent {
final double longitude; final double longitude;
final Schedules schedules; final Schedules schedules;
final PaymentMethodEntity paymentMethods; final PaymentMethodEntity paymentMethods;
final int slotDurationMinutes;
const UpdateProfessionalProfileInfo({ const UpdateProfessionalProfileInfo({
required this.address, required this.address,
@@ -39,6 +40,7 @@ class UpdateProfessionalProfileInfo extends ProfessionalProfileEvent {
required this.longitude, required this.longitude,
required this.schedules, required this.schedules,
required this.paymentMethods, required this.paymentMethods,
this.slotDurationMinutes = 120,
}); });
@override @override
@@ -51,6 +53,7 @@ class UpdateProfessionalProfileInfo extends ProfessionalProfileEvent {
latitude, latitude,
longitude, longitude,
schedules, schedules,
paymentMethods paymentMethods,
slotDurationMinutes,
]; ];
} }
+9 -2
View File
@@ -38,10 +38,13 @@ class ScoreBloc extends Bloc<ScoreEvent, ScoreState> {
} }
void _onSendScoreEvent(SendScoreEvent event, Emitter<ScoreState> emit) async { void _onSendScoreEvent(SendScoreEvent event, Emitter<ScoreState> emit) async {
emit(ScoreSending());
try { try {
await _scoreRepository.addComment(event.comment); await _scoreRepository.addComment(event.comment);
emit(ScoreSent());
} catch (e) { } catch (e) {
log(e.toString()); log(e.toString());
emit(ScoreSendFailure());
} }
} }
@@ -62,7 +65,9 @@ class ScoreBloc extends Bloc<ScoreEvent, ScoreState> {
final usersDir = {for (var e in users) e.id: e}; final usersDir = {for (var e in users) e.id: e};
final scoresInfo = scores.map((e) { final scoresInfo = scores
.where((e) => usersDir.containsKey(e.authorId))
.map((e) {
return ScoreInfoUI( return ScoreInfoUI(
score: e, score: e,
user: usersDir[e.authorId]!, user: usersDir[e.authorId]!,
@@ -99,7 +104,9 @@ class ScoreBloc extends Bloc<ScoreEvent, ScoreState> {
final usersDir = {for (var e in users) e.id: e}; final usersDir = {for (var e in users) e.id: e};
final scoresInfo = scores.map((e) { final scoresInfo = scores
.where((e) => usersDir.containsKey(e.authorId))
.map((e) {
return ScoreInfoUI( return ScoreInfoUI(
score: e, score: e,
user: usersDir[e.authorId]!, user: usersDir[e.authorId]!,
+9
View File
@@ -13,6 +13,15 @@ class ScoreFailure extends ScoreState {}
class ScoreLoading extends ScoreState {} class ScoreLoading extends ScoreState {}
/// Rating submission is in flight.
class ScoreSending extends ScoreState {}
/// Rating stored by the backend.
class ScoreSent extends ScoreState {}
/// Rating could not be stored — it must not be reported as sent.
class ScoreSendFailure extends ScoreState {}
class ScoreSuccess extends ScoreState { class ScoreSuccess extends ScoreState {
final ReputationEntity reputation; final ReputationEntity reputation;
const ScoreSuccess(this.reputation); const ScoreSuccess(this.reputation);
+31 -5
View File
@@ -25,6 +25,7 @@ class ServiceBloc extends Bloc<ServiceEvent, ServiceState> {
_professionalRepository = professionRepository, _professionalRepository = professionRepository,
super(CreateServiceInitial()) { super(CreateServiceInitial()) {
on<CreateService>(_onCreateService); on<CreateService>(_onCreateService);
on<BlockSlot>(_onBlockSlot);
on<LoadService>(_onLoadService); on<LoadService>(_onLoadService);
on<UpdateServiceStatus>(_onUpdateServiceStatus); on<UpdateServiceStatus>(_onUpdateServiceStatus);
on<LoadServicesForUser>(_onLoadServicesForUser); on<LoadServicesForUser>(_onLoadServicesForUser);
@@ -72,6 +73,17 @@ class ServiceBloc extends Bloc<ServiceEvent, ServiceState> {
} }
} }
void _onBlockSlot(BlockSlot event, Emitter<ServiceState> emit) async {
try {
emit(CreateServiceLoading());
final serviceId = await _serviceRepository.blockSlot(event.day, event.hour1);
emit(CreateServiceSuccess(serviceId));
} catch (e) {
log(e.toString());
emit(CreateServiceFailure());
}
}
void _onLoadService(LoadService event, Emitter<ServiceState> emit) async { void _onLoadService(LoadService event, Emitter<ServiceState> emit) async {
try { try {
final serviceStream = _serviceRepository.getService(event.serviceId); final serviceStream = _serviceRepository.getService(event.serviceId);
@@ -108,7 +120,11 @@ class ServiceBloc extends Bloc<ServiceEvent, ServiceState> {
final professionsDir = {for (var e in professionsList) e.id: e}; final professionsDir = {for (var e in professionsList) e.id: e};
final servicesInfo = services.map((e) { final servicesInfo = services
.where((e) =>
usersDir.containsKey(e.professionalId) &&
professionsDir.containsKey(e.professionalId))
.map((e) {
return ServiceInfoUI( return ServiceInfoUI(
service: e, service: e,
user: usersDir[e.professionalId]!, user: usersDir[e.professionalId]!,
@@ -141,7 +157,9 @@ class ServiceBloc extends Bloc<ServiceEvent, ServiceState> {
final usersDir = {for (var e in users) e.id: e}; final usersDir = {for (var e in users) e.id: e};
final servicesInfo = services.map((e) { final servicesInfo = services
.where((e) => usersDir.containsKey(e.userId))
.map((e) {
return ServiceInfoUI( return ServiceInfoUI(
service: e, service: e,
user: usersDir[e.userId]!, user: usersDir[e.userId]!,
@@ -180,7 +198,11 @@ class ServiceBloc extends Bloc<ServiceEvent, ServiceState> {
final professionsDir = {for (var e in professionsList) e.id: e}; final professionsDir = {for (var e in professionsList) e.id: e};
final servicesInfo = services.map((e) { final servicesInfo = services
.where((e) =>
usersDir.containsKey(e.professionalId) &&
professionsDir.containsKey(e.professionalId))
.map((e) {
return ServiceInfoUI( return ServiceInfoUI(
service: e, service: e,
user: usersDir[e.professionalId]!, user: usersDir[e.professionalId]!,
@@ -213,7 +235,9 @@ class ServiceBloc extends Bloc<ServiceEvent, ServiceState> {
final usersDir = {for (var e in users) e.id: e}; final usersDir = {for (var e in users) e.id: e};
final servicesInfo = services.map((e) { final servicesInfo = services
.where((e) => usersDir.containsKey(e.userId))
.map((e) {
return ServiceInfoUI( return ServiceInfoUI(
service: e, service: e,
user: usersDir[e.userId]!, user: usersDir[e.userId]!,
@@ -246,7 +270,9 @@ class ServiceBloc extends Bloc<ServiceEvent, ServiceState> {
final usersDir = {for (var e in users) e.id: e}; final usersDir = {for (var e in users) e.id: e};
final servicesInfo = services.map((e) { final servicesInfo = services
.where((e) => usersDir.containsKey(e.userId))
.map((e) {
return ServiceInfoUI( return ServiceInfoUI(
service: e, service: e,
user: usersDir[e.userId]!, user: usersDir[e.userId]!,
+10
View File
@@ -26,6 +26,16 @@ class UpdateServiceStatus extends ServiceEvent {
List<Object> get props => [serviceId, newStatus]; List<Object> get props => [serviceId, newStatus];
} }
class BlockSlot extends ServiceEvent {
final String day;
final TimeOfDay hour1;
const BlockSlot({required this.day, required this.hour1});
@override
List<Object> get props => [day, hour1];
}
class LoadServicesForUser extends ServiceEvent { class LoadServicesForUser extends ServiceEvent {
final String userId; final String userId;
+2 -1
View File
@@ -22,7 +22,8 @@ class SignUpBloc extends Bloc<SignUpEvent, SignUpState> {
await _userRepository.setUserData(user); await _userRepository.setUserData(user);
emit(SignUpSuccess()); emit(SignUpSuccess());
} catch (e) { } catch (e) {
emit(SignUpFailure()); final msg = e.toString().replaceFirst('Exception: ', '');
emit(SignUpFailure(message: msg));
} }
} }
} }
+7 -1
View File
@@ -11,6 +11,12 @@ class SignUpInitial extends SignUpState {}
class SignUpSuccess extends SignUpState {} class SignUpSuccess extends SignUpState {}
class SignUpFailure extends SignUpState {} class SignUpFailure extends SignUpState {
final String message;
const SignUpFailure({this.message = 'Error al registrarse. Intenta de nuevo.'});
@override
List<Object> get props => [message];
}
class SignUpProcess extends SignUpState {} class SignUpProcess extends SignUpState {}
@@ -0,0 +1,30 @@
import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:suggestion_repository/suggestion_repository.dart';
part 'suggestion_event.dart';
part 'suggestion_state.dart';
class SuggestionBloc extends Bloc<SuggestionEvent, SuggestionState> {
final ApiSuggestionRepository _suggestionRepository;
SuggestionBloc({required ApiSuggestionRepository suggestionRepository})
: _suggestionRepository = suggestionRepository,
super(SuggestionInitial()) {
on<SubmitSuggestion>(_onSubmitSuggestion);
}
void _onSubmitSuggestion(
SubmitSuggestion event, Emitter<SuggestionState> emit) async {
emit(SuggestionLoading());
try {
await _suggestionRepository.createSuggestion(event.message);
emit(SuggestionSuccess());
} catch (e) {
log(e.toString());
emit(SuggestionFailure(message: e.toString()));
}
}
}
@@ -0,0 +1,17 @@
part of 'suggestion_bloc.dart';
abstract class SuggestionEvent extends Equatable {
const SuggestionEvent();
@override
List<Object> get props => [];
}
class SubmitSuggestion extends SuggestionEvent {
final String message;
const SubmitSuggestion({required this.message});
@override
List<Object> get props => [message];
}
@@ -0,0 +1,23 @@
part of 'suggestion_bloc.dart';
abstract class SuggestionState extends Equatable {
const SuggestionState();
@override
List<Object?> get props => [];
}
class SuggestionInitial extends SuggestionState {}
class SuggestionLoading extends SuggestionState {}
class SuggestionSuccess extends SuggestionState {}
class SuggestionFailure extends SuggestionState {
final String? message;
const SuggestionFailure({this.message});
@override
List<Object?> get props => [message];
}
+336 -308
View File
@@ -12,7 +12,6 @@ import 'package:prosappco/components/general_drawer_header.dart';
import 'package:prosappco/components/general_drawer_item.dart'; import 'package:prosappco/components/general_drawer_item.dart';
import 'package:prosappco/screens/configuration/configuration_screen.dart'; import 'package:prosappco/screens/configuration/configuration_screen.dart';
import 'package:prosappco/screens/configuration/configuration_support_screen.dart'; import 'package:prosappco/screens/configuration/configuration_support_screen.dart';
import 'package:prosappco/screens/lists/dropwdon.dart';
import 'package:prosappco/screens/lists/professional_score_list_screen.dart'; import 'package:prosappco/screens/lists/professional_score_list_screen.dart';
import 'package:prosappco/screens/lists/professional_service_history_list_screen.dart'; import 'package:prosappco/screens/lists/professional_service_history_list_screen.dart';
import 'package:prosappco/screens/lists/professional_service_list_screen.dart'; import 'package:prosappco/screens/lists/professional_service_list_screen.dart';
@@ -25,23 +24,22 @@ import 'package:prosappco/screens/professional/professional_form_screen.dart';
import 'package:prosappco/screens/professional/professional_pending_screen.dart'; import 'package:prosappco/screens/professional/professional_pending_screen.dart';
import 'package:prosappco/screens/professional/professional_profile_screen.dart'; import 'package:prosappco/screens/professional/professional_profile_screen.dart';
import 'package:prosappco/screens/profile/profile_screen.dart'; import 'package:prosappco/screens/profile/profile_screen.dart';
import 'package:prosappco/screens/web/web_view_screen.dart'; import 'package:prosappco/screens/suggestions/suggestion_screen.dart';
import 'package:service_repository/service_repository.dart'; import 'package:service_repository/service_repository.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:user_repository/user_repository.dart'; import 'package:user_repository/user_repository.dart';
const _kPro = Color(0xFF0D9488);
const _kUser = Color(0xFF1565C0);
class GeneralDrawer extends StatelessWidget { class GeneralDrawer extends StatelessWidget {
const GeneralDrawer({super.key}); const GeneralDrawer({super.key});
Future<void> _irSugerencias() async { Future<void> _irSugerencias() async {
const url = 'https://admin.prosapp.co/sugerencias'; const url = 'https://admin.prosapp.co/sugerencias';
final Uri uri = Uri.parse(url);
final Uri _url = Uri.parse(url); if (await canLaunchUrl(uri)) {
await launchUrl(uri);
if (await canLaunchUrl(_url)) {
await launchUrl(_url);
} else {
throw 'No se pudo abrir la URL $url';
} }
} }
@@ -53,8 +51,9 @@ class GeneralDrawer extends StatelessWidget {
builder: (context, userState) { builder: (context, userState) {
return BlocBuilder<ProfessionalBloc, ProfessionalState>( return BlocBuilder<ProfessionalBloc, ProfessionalState>(
builder: (context, professionalState) { builder: (context, professionalState) {
final isProMode = _isProModeActive(professionalState);
return Drawer( return Drawer(
backgroundColor: Theme.of(context).colorScheme.secondary, backgroundColor: Theme.of(context).colorScheme.surface,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@@ -71,111 +70,100 @@ class GeneralDrawer extends StatelessWidget {
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
children: [ children: [
shouldProModeActive(context, professionalState) if (isProMode)
? GeneralDrawerItem( GeneralDrawerItem(
leading: Icons.person_outline_rounded, leading: Icons.person_outline_rounded,
label: 'Perfil profesional', label: 'Perfil profesional',
onTap: () { onTap: () => Navigator.push(
Navigator.push( context,
context, CupertinoPageRoute(
CupertinoPageRoute( builder: (_) =>
builder: (context) => const ProfessionalProfileScreen(),
const ProfessionalProfileScreen(), ),
), ),
); ),
},
)
: const SizedBox(),
GeneralDrawerItem( GeneralDrawerItem(
leading: Icons.checklist_outlined, leading: Icons.checklist_outlined,
label: 'Mis servicios', label: 'Mis servicios',
onTap: () { onTap: () => isProMode
shouldProModeActive(context, professionalState) ? Navigator.push(
? Navigator.push( context,
context, CupertinoPageRoute(
CupertinoPageRoute( builder: (_) =>
builder: (context) => const ProfessionalServiceListScreen()),
const ProfessionalServiceListScreen(), )
// const UserServicesScreen(), : Navigator.push(
), context,
) CupertinoPageRoute(
: Navigator.push( builder: (_) =>
context, const UserServiceListScreen()),
CupertinoPageRoute( ),
builder: (context) =>
const UserServiceListScreen(),
// const UserServicesScreen(),
),
);
},
), ),
GeneralDrawerItem( GeneralDrawerItem(
leading: Icons.access_time_outlined, leading: Icons.access_time_outlined,
label: 'Historial', label: 'Historial',
onTap: () { onTap: () => isProMode
shouldProModeActive(context, professionalState) ? Navigator.push(
? Navigator.push( context,
context, CupertinoPageRoute(
CupertinoPageRoute( builder: (_) =>
builder: (context) => const ProfessionalServiceHistoryListScreen()),
const ProfessionalServiceHistoryListScreen(), )
), : Navigator.push(
) context,
: Navigator.push( CupertinoPageRoute(
context, builder: (_) =>
CupertinoPageRoute( const UserServiceHistoryListScreen()),
builder: (context) => ),
const UserServiceHistoryListScreen(),
),
);
},
), ),
shouldProModeActive(context, professionalState) if (isProMode)
? GeneralDrawerItem( GeneralDrawerItem(
onTap: () { leading: Icons.calendar_month_outlined,
if (professionalState label: 'Calendario',
is LoadedModeProState) { onTap: () {
Navigator.push( if (professionalState
context, is LoadedModeProState &&
CupertinoPageRoute( professionalState.proInfo != null) {
builder: (context) => Navigator.push(
ProfessionalCalendarScreen( context,
userProfessional: CupertinoPageRoute(
professionalState.proInfo!, builder: (_) =>
), ProfessionalCalendarScreen(
), userProfessional:
); professionalState.proInfo!,
} ),
}, ),
label: 'Calendario', );
leading: Icons.calendar_month_outlined, } else {
) ScaffoldMessenger.of(context)
: const SizedBox(), .clearSnackBars();
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(
content: Text(
'Cargando tu información de profesional, intenta de nuevo en un momento'),
));
}
},
),
GeneralDrawerItem( GeneralDrawerItem(
leading: Icons.settings_outlined, leading: Icons.settings_outlined,
label: 'Configuración', label: 'Configuración',
onTap: () { onTap: () => Navigator.push(
Navigator.push( context,
context, CupertinoPageRoute(
CupertinoPageRoute( builder: (_) =>
builder: (context) => const ConfigurationScreen()),
const ConfigurationScreen(), ),
),
);
},
), ),
GeneralDrawerItem( GeneralDrawerItem(
leading: Icons.help_outline, leading: Icons.help_outline,
label: 'Soporte', label: 'Soporte',
onTap: () { onTap: () => Navigator.push(
Navigator.push( context,
context, CupertinoPageRoute(
CupertinoPageRoute( builder: (_) =>
builder: (context) => const ConfigurationSupportScreen()),
const ConfigurationSupportScreen(), ),
),
);
},
), ),
GeneralDrawerItem( GeneralDrawerItem(
leading: Icons.campaign_outlined, leading: Icons.campaign_outlined,
@@ -187,137 +175,45 @@ class GeneralDrawer extends StatelessWidget {
Navigator.push( Navigator.push(
context, context,
CupertinoPageRoute( CupertinoPageRoute(
builder: (BuildContext context) { builder: (_) => const SuggestionScreen(),
return WebViewScreen(
label: 'Sugerencias',
link:
'https://admin.prosapp.co/sugerencias');
},
), ),
); );
} }
}, },
), ),
Container( // CTA item — go to main action
color: const Color(0xFF2BA4EC), _CtaItem(
child: ListTile( isProMode: isProMode,
onTap: () { professionalState: professionalState,
Navigator.pop(context);
},
trailing: shouldProModeActive(context, professionalState)
? FutureBuilder(builder: (context, AsyncSnapshot<int> snapshot) {
if (!snapshot.hasData || snapshot.data! < 1) {
return const SizedBox();
}
final int notificationCount = snapshot.data!;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(12),
),
child: Text(
notificationCount > 9
? '+9'
: notificationCount.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
);
},
future: Injector.appInstance
.get<ApiServiceRepository>()
.countPendingServicesForProfessional(
ApiUserRepository.currentUserId ?? ''),
)
: const Icon(
Icons.keyboard_arrow_right,
color: Colors.white,
size: 25,
),
title: Text(
shouldProModeActive(context, professionalState)
? 'Solicitudes'
: 'Solicitar servicio',
style: const TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
), ),
// Rating row
DrawerReputation(builder: (reputation) { DrawerReputation(builder: (reputation) {
final isProModeActive = final total = isProMode
(professionalState is LoadedModeProState) &&
professionalState.isProModeActive;
final total = isProModeActive
? reputation.totalPro ? reputation.totalPro
: reputation.total; : reputation.total;
final average = isProMode
final average = isProModeActive
? reputation.averagePro ? reputation.averagePro
: reputation.average; : reputation.average;
return ListTile( return _RatingRow(
onTap: () { average: average,
final isProModeActive = (professionalState is LoadedModeProState) && professionalState.isProModeActive; total: total,
isProModeActive ? Navigator.push(context, isProMode: isProMode,
CupertinoPageRoute( professionalState: professionalState,
builder: (context) {
return const ProfessionalScoreListScreen();
}))
: Navigator.push(context,
CupertinoPageRoute(
builder: (context) {
return const UserScoreListScreen();
}));
},
trailing: const Icon(Icons.keyboard_arrow_right,
color: Colors.black),
title: Row(
children: [
RatingBar.builder(
initialRating: calculoRating(average),
minRating: 1,
direction: Axis.horizontal,
allowHalfRating: true,
itemCount: 5,
itemSize: 25,
maxRating: 5,
itemBuilder: (context, _) => const Icon(
Icons.star,
color: Color(0xFF2BA4EC),
),
onRatingUpdate: (rating) {},
ignoreGestures: true,
),
const SizedBox(width: 5),
Text(
'${average.toStringAsFixed(1)} (${total.toString()})',
),
],
),
); );
}), }),
const Divider( const Divider(height: 1, thickness: 0.5),
height: 1,
thickness: 0.5,
),
Padding( Padding(
padding: const EdgeInsets.only(top: 10), padding: const EdgeInsets.only(top: 10, bottom: 4),
child: Text( child: Text(
'Prosapp ® todos los derechos reservados', 'Prosapp ® todos los derechos reservados',
textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
color: Colors.grey[700], color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.4),
), ),
), ),
), ),
@@ -336,7 +232,11 @@ class GeneralDrawer extends StatelessWidget {
const SizedBox(height: 15), const SizedBox(height: 15),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
child: buttonOfState(context, professionalState), child: _ModeButton(
professionalState: professionalState,
userState: userState,
isProMode: isProMode,
),
), ),
const SizedBox(height: 15), const SizedBox(height: 15),
], ],
@@ -349,145 +249,273 @@ class GeneralDrawer extends StatelessWidget {
); );
} }
double calculoRating(double average) { bool _isProModeActive(ProfessionalState state) =>
String numeroString = average.toString(); (state is LoadedModeProState) ? state.isProModeActive : false;
List<String> partes = numeroString.split('.'); }
int parteEntera = int.parse(partes[0]);
int parteFraccionaria = partes.length > 1 ? int.parse(partes[1]) : 0;
if (parteFraccionaria >= 3) { // CTA tile navigating to solicitudes / solicitar servicio
parteFraccionaria = 5; class _CtaItem extends StatelessWidget {
} else { final bool isProMode;
parteFraccionaria = 0; final ProfessionalState professionalState;
} _CtaItem(
{required this.isProMode, required this.professionalState});
// Unir la parte entera y fraccionaria y convertirlo nuevamente a double @override
double resultado = double.parse('$parteEntera.$parteFraccionaria'); Widget build(BuildContext context) {
return resultado; return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isProMode
? _kPro.withOpacity(0.1)
: _kUser.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isProMode
? _kPro.withOpacity(0.25)
: _kUser.withOpacity(0.25)),
),
child: ListTile(
onTap: () => Navigator.pop(context),
leading: Icon(
isProMode ? Icons.inbox_outlined : Icons.search_rounded,
color: isProMode ? _kPro : _kUser,
),
title: Text(
isProMode ? 'Solicitudes' : 'Solicitar servicio',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: isProMode ? _kPro : _kUser,
),
),
trailing: isProMode
? FutureBuilder<int>(
future: Injector.appInstance
.get<ApiServiceRepository>()
.countPendingServicesForProfessional(
ApiUserRepository.currentUserId ?? ''),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data! < 1) {
return Icon(Icons.keyboard_arrow_right,
color: _kPro.withOpacity(0.5));
}
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(12),
),
child: Text(
snapshot.data! > 9
? '+9'
: snapshot.data!.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600),
),
);
},
)
: Icon(Icons.keyboard_arrow_right,
color: _kUser.withOpacity(0.5)),
),
);
}
}
class _RatingRow extends StatelessWidget {
final double average;
final int total;
final bool isProMode;
final ProfessionalState professionalState;
_RatingRow(
{required this.average,
required this.total,
required this.isProMode,
required this.professionalState});
double _calcRating(double avg) {
final parts = avg.toString().split('.');
final frac = parts.length > 1 ? int.parse(parts[1]) : 0;
return double.parse(
'${parts[0]}.${frac >= 3 ? 5 : 0}');
} }
buttonOfState(BuildContext context, ProfessionalState state) { @override
Widget build(BuildContext context) {
return ListTile(
onTap: () {
isProMode
? Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => const ProfessionalScoreListScreen()))
: Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => const UserScoreListScreen()));
},
leading: Icon(Icons.star_outline_rounded,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6)),
trailing: Icon(Icons.keyboard_arrow_right,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.4)),
title: Row(
children: [
RatingBar.builder(
initialRating: _calcRating(average),
minRating: 1,
direction: Axis.horizontal,
allowHalfRating: true,
itemCount: 5,
itemSize: 20,
maxRating: 5,
itemBuilder: (_, __) => const Icon(Icons.star,
color: Color(0xFF1565C0)),
onRatingUpdate: (_) {},
ignoreGestures: true,
),
const SizedBox(width: 6),
Text(
'${average.toStringAsFixed(1)} ($total)',
style: TextStyle(
fontSize: 13,
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.7)),
),
],
),
);
}
}
class _ModeButton extends StatelessWidget {
final ProfessionalState professionalState;
final MyUserState userState;
final bool isProMode;
_ModeButton({
required this.professionalState,
required this.userState,
required this.isProMode,
});
@override
Widget build(BuildContext context) {
return Stack( return Stack(
children: [ children: [
SizedBox( SizedBox(
width: MediaQuery.of(context).size.width * 0.7, width: double.infinity,
child: ElevatedButton( child: ElevatedButton.icon(
onPressed: () { onPressed: () {
final myUserState = context.read<MyUserBloc>().state; if (userState.status == MyUserStatus.success) {
if (myUserState.status == MyUserStatus.success) { final user = userState.user!;
final user = myUserState.user!; if (user.name != null &&
user.city != null &&
if (user.name != null && user.email != null && user.city != null && user.phone != null) { user.phone != null) {
switch (user.proState) { switch (user.proState) {
case ProState.active: Navigator.pop(context); case ProState.active:
Navigator.pop(context);
context.read<ProfessionalBloc>().add(const SwitchProModeEvent()); context
.read<ProfessionalBloc>()
.add(const SwitchProModeEvent());
break; break;
case ProState.inactive: case ProState.inactive:
Navigator.push( Navigator.push(
context, context,
CupertinoPageRoute( CupertinoPageRoute(
builder: (context) => const ProfessionalFormScreen(), builder: (_) => const ProfessionalFormScreen()),
),
); );
break; break;
case ProState.pending: case ProState.pending:
Navigator.push( Navigator.push(
context, context,
CupertinoPageRoute( CupertinoPageRoute(
builder: (context) => const ProfessionalPendingScreen(), builder: (_) =>
), const ProfessionalPendingScreen()),
); );
break; break;
case ProState.denied: case ProState.denied:
Navigator.push( Navigator.push(
context, context,
CupertinoPageRoute( CupertinoPageRoute(
builder: (context) => const ProfessionalDeniedScreen(), builder: (_) =>
), const ProfessionalDeniedScreen()),
); );
break; break;
} }
} else { } else {
final missing = <String>[
if (user.name == null) 'nombre',
if (user.city == null) 'ciudad',
if (user.phone == null) 'teléfono',
];
ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(const SnackBar( ScaffoldMessenger.of(context).showSnackBar(
content: Text('Por favor, completa tu perfil'), SnackBar(
)); content: Text(
'Completa tu perfil: falta ${missing.join(', ')}')),
);
Navigator.push( Navigator.push(
context, context,
CupertinoPageRoute( CupertinoPageRoute(
builder: (context) => const ProfileScreen(), builder: (_) => const ProfileScreen()),
),
); );
} }
} else {} }
}, },
style: FilledButton.styleFrom( icon: const Icon(Icons.swap_horiz, size: 20),
backgroundColor: Theme.of(context).colorScheme.primary, label: Text(
padding: const EdgeInsets.symmetric(vertical: 15), isProMode ? 'Cambiar a modo cliente' : 'Cambiar a modo profesional',
style: const TextStyle(fontSize: 15),
),
style: ElevatedButton.styleFrom(
backgroundColor:
isProMode ? _kPro : _kUser,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(12),
), ),
), ),
child: (state is LoadedModeProState)
? Text(
state.isProModeActive ? 'Modo cliente' : 'Modo profesional',
style: const TextStyle(color: Colors.white, fontSize: 18),
)
: const Text(
'Modo profesional',
style: TextStyle(color: Colors.white, fontSize: 18),
),
), ),
), ),
(state is LoadedModeProState) if (!isProMode && professionalState is LoadedModeProState)
? Visibility( Positioned(
visible: !state.isProModeActive, top: 0,
child: Positioned( right: 0,
top: 0, child: FutureBuilder<int>(
right: 0, future: Injector.appInstance
child: FutureBuilder( .get<ApiServiceRepository>()
builder: (context, AsyncSnapshot<int> snapshot) { .countPendingServicesForProfessional(
if (!snapshot.hasData || snapshot.data! < 1) { ApiUserRepository.currentUserId ?? ''),
return const SizedBox(); builder: (context, snapshot) {
} if (!snapshot.hasData || snapshot.data! < 1) {
final int notificationCount = snapshot.data!; return const SizedBox();
return Container( }
padding: const EdgeInsets.symmetric( return Container(
horizontal: 8, padding:
vertical: 2, const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
), decoration: BoxDecoration(
decoration: BoxDecoration( color: Colors.red,
color: Colors.red, borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(12),
),
child: Text(
notificationCount > 9
? '+9'
: notificationCount.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
);
},
future: Injector.appInstance
.get<ApiServiceRepository>()
.countPendingServicesForProfessional(
ApiUserRepository.currentUserId ?? ''),
), ),
), child: Text(
) snapshot.data! > 9 ? '+9' : snapshot.data!.toString(),
: const SizedBox(), style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600),
),
);
},
),
),
], ],
); );
} }
shouldProModeActive(BuildContext context, ProfessionalState state) {
return (state is LoadedModeProState) ? state.isProModeActive : false;
}
} }
+101 -60
View File
@@ -12,98 +12,139 @@ class GeneralDrawerHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<MyUserBloc, MyUserState>( return BlocBuilder<MyUserBloc, MyUserState>(
builder: (context, state) { builder: (context, userState) {
if (state.status == MyUserStatus.success) { if (userState.status == MyUserStatus.success) {
final user = state.user!; final user = userState.user!;
return BlocBuilder<ProfessionalBloc, ProfessionalState>( return BlocBuilder<ProfessionalBloc, ProfessionalState>(
builder: (context, state) { builder: (context, proState) {
return ListTile( final isProMode = (proState is LoadedModeProState) &&
proState.isProModeActive;
return InkWell(
onTap: () { onTap: () {
shouldProModeActive(context, state) isProMode
? Navigator.push( ? Navigator.push(
context, context,
CupertinoPageRoute( CupertinoPageRoute(
builder: (BuildContext context) { builder: (_) => const ProfessionalProfileScreen(),
return const ProfessionalProfileScreen();
},
), ),
) )
: Navigator.push( : Navigator.push(
context, context,
CupertinoPageRoute( CupertinoPageRoute(
builder: (BuildContext context) { builder: (_) => const ProfileScreen(),
return const ProfileScreen();
},
), ),
); );
}, },
title: Text(user.name ?? '', child: Padding(
overflow: TextOverflow.ellipsis, padding: EdgeInsets.fromLTRB(
style: const TextStyle(fontWeight: FontWeight.bold)), 20,
subtitle: Text(user.drawerLabel, MediaQuery.of(context).padding.top + 20,
overflow: TextOverflow.ellipsis, 20,
style: const TextStyle(fontSize: 12)), 16),
leading: pictureWidget(user.picture, context), child: Row(
trailing: children: [
const Icon(Icons.keyboard_arrow_right, color: Colors.black), _avatar(user.picture),
contentPadding: const SizedBox(width: 14),
const EdgeInsets.symmetric(vertical: 10, horizontal: 15), Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.name ?? '',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 5),
_modePill(isProMode),
],
),
),
Icon(
Icons.keyboard_arrow_right,
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.4),
),
],
),
),
); );
}, },
); );
} else if (state.status == MyUserStatus.failure) { } else if (userState.status == MyUserStatus.failure) {
return const Text('Error obteniendo datos del usuario'); return const Padding(
padding: EdgeInsets.all(16),
child: Text('Error obteniendo datos del usuario'),
);
} else { } else {
return const CircularProgressIndicator(); return const Padding(
padding: EdgeInsets.all(24),
child: CircularProgressIndicator(),
);
} }
}, },
); );
} }
shouldProModeActive(BuildContext context, ProfessionalState state) { Widget _modePill(bool isProMode) {
return (state is LoadedModeProState) ? state.isProModeActive : false; final color =
isProMode ? const Color(0xFF0D9488) : const Color(0xFF1565C0);
final icon = isProMode ? Icons.badge_outlined : Icons.person_outline;
final label = isProMode ? 'Profesional' : 'Cliente';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white, size: 12),
const SizedBox(width: 4),
Text(
label,
style: const TextStyle(
fontSize: 11,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
],
),
);
} }
Widget pictureWidget(String? pictureUrl, BuildContext context) { Widget _avatar(String? pictureUrl) {
ImageProvider<Object>? imageProvider; ImageProvider<Object>? imageProvider;
if (pictureUrl != null && pictureUrl.isNotEmpty) { if (pictureUrl != null && pictureUrl.isNotEmpty) {
imageProvider = NetworkImage(pictureUrl); imageProvider = NetworkImage(pictureUrl);
} }
return Hero( return Hero(
tag: 'picture-profile', tag: 'picture-profile',
child: pictureContainerWidget( child: Container(
imageProvider, width: 52,
height: 52,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFF1565C0).withOpacity(0.3), width: 2),
image: imageProvider == null
? null
: DecorationImage(image: imageProvider, fit: BoxFit.cover),
),
child: imageProvider == null
? Icon(CupertinoIcons.person,
color: Colors.grey.shade500, size: 28)
: null,
), ),
); );
} }
Widget pictureContainerWidget(ImageProvider<Object>? imageProvider) {
final image = imageProvider == null
? null
: DecorationImage(
image: imageProvider,
fit: BoxFit.contain,
);
final widget = image == null
? Icon(
CupertinoIcons.person,
color: Colors.grey.shade400,
size: 40,
)
: null;
return Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
image: image,
),
child: widget,
);
}
} }
+6 -11
View File
@@ -20,28 +20,23 @@ class GeneralDrawerItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final onSurface = Theme.of(context).colorScheme.onSurface;
return ListTile( return ListTile(
onTap: onTap == null ? null : () => onTap!(), onTap: onTap == null ? null : () => onTap!(),
leading: leading == null leading: leading == null
? null ? null
: Icon( : Icon(leading, color: color ?? onSurface.withOpacity(0.75)),
leading,
color: Colors.black,
),
trailing: trailing trailing: trailing
? const Icon( ? Icon(Icons.keyboard_arrow_right, color: onSurface.withOpacity(0.4))
Icons.keyboard_arrow_right,
color: Colors.black,
)
: null, : null,
title: Text( title: Text(
label, label,
style: TextStyle(fontSize: 15, color: color), style: TextStyle(fontSize: 15, color: color ?? onSurface),
), ),
subtitle: subtitle == null subtitle: subtitle == null
? null ? null
: Text(subtitle ?? "", style: const TextStyle(color: Colors.black54)), : Text(subtitle!,
// dense: true, style: TextStyle(color: onSurface.withOpacity(0.55))),
); );
} }
} }
+9
View File
@@ -16,9 +16,11 @@ import 'package:prosappco/blocs/service_bloc/service_bloc.dart';
import 'package:prosappco/blocs/setting_bloc/setting_bloc.dart'; import 'package:prosappco/blocs/setting_bloc/setting_bloc.dart';
import 'package:prosappco/blocs/sign_up_bloc/sign_up_bloc.dart'; import 'package:prosappco/blocs/sign_up_bloc/sign_up_bloc.dart';
import 'package:prosappco/blocs/sing_in_bloc/sign_in_bloc.dart'; import 'package:prosappco/blocs/sing_in_bloc/sign_in_bloc.dart';
import 'package:prosappco/blocs/suggestion_bloc/suggestion_bloc.dart';
import 'package:prosappco/local_notifications/local_notifications.dart'; import 'package:prosappco/local_notifications/local_notifications.dart';
import 'package:score_repository/score_repository.dart'; import 'package:score_repository/score_repository.dart';
import 'package:service_repository/service_repository.dart'; import 'package:service_repository/service_repository.dart';
import 'package:suggestion_repository/suggestion_repository.dart';
import 'package:user_repository/user_repository.dart'; import 'package:user_repository/user_repository.dart';
import 'package:city_repository/city_repository.dart'; import 'package:city_repository/city_repository.dart';
import 'package:setting_repository/setting_repository.dart'; import 'package:setting_repository/setting_repository.dart';
@@ -42,6 +44,7 @@ class AppDI {
injector.registerSingleton(() => ApiServiceRepository()); injector.registerSingleton(() => ApiServiceRepository());
injector.registerSingleton(() => ApiChatRepository()); injector.registerSingleton(() => ApiChatRepository());
injector.registerSingleton(() => ApiScoreRepository()); injector.registerSingleton(() => ApiScoreRepository());
injector.registerSingleton(() => ApiSuggestionRepository());
injector.registerSingleton<AuthenticationBloc>((() => injector.registerSingleton<AuthenticationBloc>((() =>
AuthenticationBloc(myUserRepository: injector.get<UserRepository>()))); AuthenticationBloc(myUserRepository: injector.get<UserRepository>())));
@@ -105,5 +108,11 @@ class AppDI {
userRepository: injector.get<UserRepository>(), userRepository: injector.get<UserRepository>(),
), ),
); );
injector.registerDependency<SuggestionBloc>(
() => SuggestionBloc(
suggestionRepository: injector.get<ApiSuggestionRepository>(),
),
);
} }
} }
@@ -45,7 +45,12 @@ class _SignUpScreenState extends State<SignUpScreen> {
signUpRequired = true; signUpRequired = true;
}); });
} else if (state is SignUpFailure) { } else if (state is SignUpFailure) {
return; setState(() {
signUpRequired = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
} }
}, },
child: Column( child: Column(
+53 -8
View File
@@ -34,19 +34,32 @@ class _ChatScreenState extends State<ChatScreen> {
chatBloc = Injector.appInstance.get<ChatBloc>(); chatBloc = Injector.appInstance.get<ChatBloc>();
chatBloc.add(LoadChatEvent( _loadChat();
serviceId: widget.service.id!,
userId: widget.service.userId,
professionalId: widget.service.professionalId,
));
_getUserAndProfessionalInfo(widget.service).then((userInfo) { _getUserAndProfessionalInfo(widget.service).then((userInfo) {
if (!mounted) return;
setState(() { setState(() {
_userInfo = userInfo; _userInfo = userInfo;
}); });
}); });
} }
void _loadChat() {
// /chat/start/:id opens the conversation between the caller and :id, so it
// takes the *other* participant. Always sending the professional meant a
// professional opening the chat started a conversation with themselves.
final me = ApiUserRepository.currentUserId ?? '';
final counterpartId = me == widget.service.professionalId
? widget.service.userId
: widget.service.professionalId;
chatBloc.add(LoadChatEvent(
serviceId: widget.service.id ?? '',
userId: widget.service.userId,
professionalId: counterpartId,
));
}
@override @override
void dispose() { void dispose() {
_messageController.dispose(); _messageController.dispose();
@@ -62,7 +75,16 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
body: BlocProvider( body: BlocProvider(
create: (context) => chatBloc, create: (context) => chatBloc,
child: BlocBuilder<ChatBloc, ChatState>( child: BlocConsumer<ChatBloc, ChatState>(
listener: (context, state) {
if (state is SendMessageFailure) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No se pudo enviar el mensaje')),
);
}
},
builder: (context, state) { builder: (context, state) {
if (state is ChatLoaded) { if (state is ChatLoaded) {
return Column( return Column(
@@ -110,7 +132,7 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
), ),
subtitle: widget.service.userId == subtitle: widget.service.userId ==
ApiUserRepository.currentUserId ?? '' (ApiUserRepository.currentUserId ?? '')
? GeneralReputation( ? GeneralReputation(
userId: widget.service.professionalId, userId: widget.service.professionalId,
builder: (context, reputation) { builder: (context, reputation) {
@@ -327,6 +349,29 @@ class _ChatScreenState extends State<ChatScreen> {
); );
} }
if (state is ChatFailure) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.chat_bubble_outline,
size: 40, color: Colors.grey),
const SizedBox(height: 12),
const Text('No se pudo abrir la conversación',
textAlign: TextAlign.center),
const SizedBox(height: 16),
OutlinedButton(
onPressed: _loadChat,
child: const Text('Reintentar'),
),
],
),
),
);
}
return const Center( return const Center(
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
); );
@@ -339,7 +384,7 @@ class _ChatScreenState extends State<ChatScreen> {
List<Widget> _messagesList(List<MessageEntity> messages) { List<Widget> _messagesList(List<MessageEntity> messages) {
return messages return messages
.map( .map(
(e) => e.ownerId != ApiUserRepository.currentUserId ?? '' (e) => e.ownerId != (ApiUserRepository.currentUserId ?? '')
? ListTile( ? ListTile(
title: Column( title: Column(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
@@ -1,12 +1,10 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:injector/injector.dart'; import 'package:injector/injector.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:prosappco/components/general_drawer_item.dart'; import 'package:prosappco/components/general_drawer_item.dart';
import 'package:prosappco/screens/web/web_view_screen.dart'; import 'package:prosappco/screens/configuration/policy_text_screen.dart';
import 'package:setting_repository/setting_repository.dart'; import 'package:setting_repository/setting_repository.dart';
import 'package:url_launcher/url_launcher.dart';
import 'dart:io' show Platform; import 'dart:io' show Platform;
class ConfigurationAboutScreen extends StatefulWidget { class ConfigurationAboutScreen extends StatefulWidget {
@@ -37,15 +35,24 @@ class _ConfigurationAboutScreenState extends State<ConfigurationAboutScreen> {
); );
} }
void _launchURL(String url) async { Future<void> _openPolicy(String title, String Function(PoliciesEntity) pick) async {
if (await canLaunch(url)) { try {
await launch(url, forceSafariVC: false, forceWebView: false); final policies = await settingRepository.getPolicies();
} else { if (!mounted) return;
throw 'No se pudo abrir el enlace $url'; Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => PolicyTextScreen(title: title, content: pick(policies)),
),
);
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No se pudo cargar el contenido')),
);
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -56,44 +63,18 @@ class _ConfigurationAboutScreenState extends State<ConfigurationAboutScreen> {
children: [ children: [
GeneralDrawerItem( GeneralDrawerItem(
label: 'Políticas de privacidad', label: 'Políticas de privacidad',
onTap: () { onTap: () => _openPolicy(
if (kIsWeb) { 'Políticas de privacidad',
_launchURL(settings?.politicasPrivacidad ?? ''); (p) => p.privacy,
} else { ),
Navigator.push(
context,
CupertinoPageRoute(
builder: (BuildContext context) {
return WebViewScreen(
label: 'Políticas de privacidad',
link: settings?.politicasPrivacidad ?? '',
);
},
),
);
}
},
trailing: true, trailing: true,
), ),
GeneralDrawerItem( GeneralDrawerItem(
label: 'Términos y condiciones', label: 'Términos y condiciones',
onTap: () { onTap: () => _openPolicy(
if (kIsWeb) { 'Términos y condiciones',
_launchURL(settings?.terminosCondiciones ?? ''); (p) => p.terms,
} else { ),
Navigator.push(
context,
CupertinoPageRoute(
builder: (BuildContext context) {
return WebViewScreen(
label: 'Términos y condiciones',
link: settings?.terminosCondiciones ?? '',
);
},
),
);
}
},
trailing: true, trailing: true,
), ),
Platform.isIOS Platform.isIOS
@@ -1,130 +1,3 @@
// import 'package:cloud_firestore/cloud_firestore.dart';
// import 'package:firebase_auth/firebase_auth.dart';
// import 'package:flutter/cupertino.dart';
// import 'package:flutter/material.dart';
// import 'package:get/get.dart';
// import 'package:prosappco/src/components/pop_appbar.dart';
// import 'package:prosappco/src/presentation/screens/about.dart';
// class ConfigurationScreen extends StatefulWidget {
// const ConfigurationScreen({super.key});
// @override
// State<ConfigurationScreen> createState() => _ConfigurationScreenState();
// }
// class _ConfigurationScreenState extends State<ConfigurationScreen> {
// late final FirebaseAuth _auth;
// @override
// void initState() {
// super.initState();
// _auth = FirebaseAuth.instance;
// }
// Future<void> deleteAccount() async {
// try {
// final currentUser = _auth.currentUser;
// if (currentUser != null) {
// final uid = currentUser.uid;
// await FirebaseFirestore.instance.collection('users').doc(uid).delete();
// await currentUser.delete();
// await _auth.signOut();
// Get.snackbar(
// 'Cuenta Eliminada',
// 'Tu cuenta ha sido eliminada con éxito.',
// snackPosition: SnackPosition.BOTTOM,
// );
// }
// } catch (e) {
// Get.snackbar(
// 'Error al Eliminar Cuenta',
// 'Hubo un error al eliminar tu cuenta. Por favor, inténtalo de nuevo más tarde.',
// snackPosition: SnackPosition.BOTTOM,
// );
// }
// }
// Future<void> _showDeleteAccountConfirmationDialog(
// BuildContext context) async {
// return showDialog(
// context: context,
// builder: (BuildContext context) {
// return AlertDialog(
// title: const Text('Eliminar Cuenta'),
// content: const Text(
// '¿Estás seguro de que deseas eliminar tu cuenta? Esta acción no se puede deshacer.'),
// actions: [
// TextButton(
// onPressed: () {
// Navigator.of(context).pop();
// },
// child: const Text('Cancelar'),
// ),
// TextButton(
// onPressed: () {
// deleteAccount();
// Navigator.of(context).pop();
// },
// child: const Text(
// 'Eliminar',
// style:
// TextStyle(color: Colors.red, fontWeight: FontWeight.w600),
// ),
// ),
// ],
// );
// },
// );
// }
// @override
// Widget build(BuildContext context) {
// return Scaffold(
// appBar: PopAppbar(
// onPressed: () {
// Navigator.pop(context);
// },
// label: 'Configuración'),
// body: ListView(
// children: [
// ListTile(
// onTap: () {
// Navigator.push(
// context,
// CupertinoPageRoute(
// builder: (BuildContext context) {
// return const AboutScreen();
// },
// ),
// );
// },
// title: const Text('Acerca de la aplicación'),
// trailing: const Icon(
// Icons.keyboard_arrow_right,
// color: Colors.black,
// ),
// ),
// ListTile(
// onTap: () {
// _showDeleteAccountConfirmationDialog(context);
// },
// title: const Text(
// 'Eliminar cuenta',
// style: TextStyle(color: Colors.red),
// ),
// ),
// ],
// ),
// );
// }
// }
import 'dart:developer'; import 'dart:developer';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
@@ -132,9 +5,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injector/injector.dart'; import 'package:injector/injector.dart';
import 'package:prosappco/blocs/setting_bloc/setting_bloc.dart'; import 'package:prosappco/blocs/setting_bloc/setting_bloc.dart';
import 'package:prosappco/components/general_drawer_item.dart';
import 'package:prosappco/screens/configuration/configuration_about_screen.dart'; import 'package:prosappco/screens/configuration/configuration_about_screen.dart';
const _kPrimary = Color(0xFF1565C0);
class ConfigurationScreen extends StatelessWidget { class ConfigurationScreen extends StatelessWidget {
const ConfigurationScreen({super.key}); const ConfigurationScreen({super.key});
@@ -153,38 +27,41 @@ class ConfigurationScreen extends StatelessWidget {
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Configuración'), title: const Text('Configuración'),
backgroundColor: _kPrimary,
foregroundColor: Colors.white,
elevation: 0,
), ),
body: ListView( body: ListView(
children: [ children: [
GeneralDrawerItem( const SizedBox(height: 8),
_ConfigItem(
icon: Icons.info_outline_rounded,
label: 'Acerca de la aplicación', label: 'Acerca de la aplicación',
onTap: () { onTap: () => Navigator.push(
Navigator.push( context,
context, CupertinoPageRoute(
CupertinoPageRoute( builder: (_) => const ConfigurationAboutScreen(),
builder: (BuildContext context) { ),
return const ConfigurationAboutScreen(); ),
},
),
);
},
trailing: true,
), ),
GeneralDrawerItem( _ConfigItem(
icon: Icons.logout_rounded,
label: 'Cerrar sesión', label: 'Cerrar sesión',
onTap: () { onTap: () {
try { try {
settingBloc.add(SettingLogoutRequest()); settingBloc.add(SettingLogoutRequest());
} catch (e) { } catch (e) {
log('xd conigura ${e.toString()}'); log('logout error: ${e.toString()}');
} }
}, },
), ),
GeneralDrawerItem( const Divider(height: 1),
_ConfigItem(
icon: Icons.delete_outline_rounded,
label: 'Eliminar cuenta', label: 'Eliminar cuenta',
onTap: () {},
color: Theme.of(context).colorScheme.error, color: Theme.of(context).colorScheme.error,
) onTap: () {},
),
], ],
), ),
), ),
@@ -192,3 +69,39 @@ class ConfigurationScreen extends StatelessWidget {
); );
} }
} }
class _ConfigItem extends StatelessWidget {
final IconData icon;
final String label;
final Color? color;
final VoidCallback onTap;
const _ConfigItem({
required this.icon,
required this.label,
required this.onTap,
this.color,
});
@override
Widget build(BuildContext context) {
final onSurface = Theme.of(context).colorScheme.onSurface;
final c = color ?? onSurface;
return ListTile(
onTap: onTap,
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: c.withOpacity(0.08),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: c, size: 20),
),
title: Text(label, style: TextStyle(color: c, fontWeight: FontWeight.w500)),
trailing: color == null
? Icon(Icons.keyboard_arrow_right, color: onSurface.withOpacity(0.35))
: null,
);
}
}
@@ -81,13 +81,17 @@ class _ConfigurationSupportScreenState
Padding( Padding(
padding: const EdgeInsets.only(top: 30, left: 35, right: 35), padding: const EdgeInsets.only(top: 30, left: 35, right: 35),
child: Text( child: Text(
'${settings?.tituloSoporte}', settings == null
? 'Cargando...'
: (settings?.tituloSoporte ?? 'Soporte'),
style: const TextStyle(fontWeight: FontWeight.w600), style: const TextStyle(fontWeight: FontWeight.w600),
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 30, horizontal: 35), padding: const EdgeInsets.symmetric(vertical: 30, horizontal: 35),
child: Text(settings?.parrafoSoporte ?? 'Cargando...'), child: Text(
settings == null ? 'Cargando...' : (settings?.parrafoSoporte ?? ''),
),
), ),
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
class PolicyTextScreen extends StatelessWidget {
final String title;
final String content;
const PolicyTextScreen({
super.key,
required this.title,
required this.content,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(title)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Text(
content,
style: const TextStyle(fontSize: 14, height: 1.5),
),
),
);
}
}
+11 -8
View File
@@ -5,6 +5,8 @@ import 'package:prosappco/components/general_drawer.dart';
import 'package:prosappco/screens/lists/professional_pending_service_list.dart'; import 'package:prosappco/screens/lists/professional_pending_service_list.dart';
import 'package:prosappco/screens/user/user_map_screen.dart'; import 'package:prosappco/screens/user/user_map_screen.dart';
const _kPrimary = Color(0xFF1565C0);
class HomeScreen extends StatelessWidget { class HomeScreen extends StatelessWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@@ -15,19 +17,20 @@ class HomeScreen extends StatelessWidget {
return SafeArea( return SafeArea(
child: shouldProModeActive(context, state) child: shouldProModeActive(context, state)
? Scaffold( ? Scaffold(
backgroundColor: Theme.of(context).colorScheme.background, backgroundColor: Theme.of(context).colorScheme.surface,
drawer: GeneralDrawer(), drawer: const GeneralDrawer(),
appBar: AppBar( appBar: AppBar(
title: const Text('Solicitudes'), title: const Text('Solicitudes'),
backgroundColor: _kPrimary,
foregroundColor: Colors.white,
elevation: 0,
), ),
body: ProfessionalPendingServiceListScreen(), body: const ProfessionalPendingServiceListScreen(),
) )
: Scaffold( : Scaffold(
backgroundColor: Theme.of(context).colorScheme.background, backgroundColor: Theme.of(context).colorScheme.surface,
drawer: GeneralDrawer(), drawer: const GeneralDrawer(),
body: const Center( body: const UserMapScreen(),
child: UserMapScreen(),
),
), ),
); );
}, },
+113 -8
View File
@@ -1,8 +1,11 @@
import 'package:user_repository/user_repository.dart'; import 'package:user_repository/user_repository.dart';
import 'dart:async';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:geolocator/geolocator.dart';
import 'package:injector/injector.dart'; import 'package:injector/injector.dart';
import 'package:intl_phone_field/helpers.dart'; import 'package:intl_phone_field/helpers.dart';
import 'package:profession_repository/profession_repository.dart'; import 'package:profession_repository/profession_repository.dart';
@@ -34,6 +37,11 @@ class _ProfessionalListScreenState extends State<ProfessionalListScreen> {
bool _isLoading = true; bool _isLoading = true;
double? _lat;
double? _lng;
bool _isLocating = false;
Timer? _debounce;
late final ProfessionalListBloc bloc; late final ProfessionalListBloc bloc;
@override @override
@@ -46,6 +54,56 @@ class _ProfessionalListScreenState extends State<ProfessionalListScreen> {
_loadProfessions(); _loadProfessions();
} }
@override
void dispose() {
_debounce?.cancel();
super.dispose();
}
void _onSearchChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 450), () {
bloc.add(ProfessionalListFetch(
search: value.isEmpty ? null : value,
lat: _lat,
lng: _lng,
));
});
}
Future<void> _useMyLocation() async {
setState(() => _isLocating = true);
try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) throw Exception('Location services disabled');
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
throw Exception('Location permission denied');
}
final position = await Geolocator.getCurrentPosition();
_lat = position.latitude;
_lng = position.longitude;
bloc.add(ProfessionalListFetch(
search: _searchController.text.isEmpty ? null : _searchController.text,
lat: _lat,
lng: _lng,
));
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No se pudo obtener tu ubicación')),
);
} finally {
if (mounted) setState(() => _isLocating = false);
}
}
void _loadSettings() { void _loadSettings() {
settingRepository.getSettings().then( settingRepository.getSettings().then(
(value) => setState(() { (value) => setState(() {
@@ -56,11 +114,19 @@ class _ProfessionalListScreenState extends State<ProfessionalListScreen> {
void _loadProfessions() { void _loadProfessions() {
professionRepository.getProfessions().then((Professions element) { professionRepository.getProfessions().then((Professions element) {
if (!mounted) return;
setState(() { setState(() {
_professions = element.professions; _professions = element.professions;
_filteredProfessions = _professions; _filteredProfessions = _professions;
_isLoading = false; _isLoading = false;
}); });
}).catchError((e) {
// Without this the shimmer never stopped and the dropdown stayed empty.
if (!mounted) return;
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No se pudieron cargar las profesiones')),
);
}); });
} }
@@ -134,14 +200,30 @@ class _ProfessionalListScreenState extends State<ProfessionalListScreen> {
setState(() { setState(() {
_searchController.text = value; _searchController.text = value;
}); });
_onSearchChanged(value);
}, },
decoration: const InputDecoration( decoration: InputDecoration(
hintText: 'Busca un profesional', hintText: 'Busca un profesional',
prefixIcon: Icon(Icons.search), prefixIcon: const Icon(Icons.search),
enabledBorder: UnderlineInputBorder( suffixIcon: _isLocating
? const Padding(
padding: EdgeInsets.all(14),
child: SizedBox(
width: 16,
height: 16,
child:
CircularProgressIndicator(strokeWidth: 2),
),
)
: IconButton(
icon: const Icon(Icons.my_location_outlined),
tooltip: 'Usar mi ubicación',
onPressed: _useMyLocation,
),
enabledBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: Colors.grey), borderSide: BorderSide(color: Colors.grey),
), ),
focusedBorder: UnderlineInputBorder( focusedBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: Colors.grey), borderSide: BorderSide(color: Colors.grey),
), ),
), ),
@@ -164,17 +246,37 @@ class _ProfessionalListScreenState extends State<ProfessionalListScreen> {
} }
_body(ProfessionalListState state) { _body(ProfessionalListState state) {
if (state is ProfessionalListFailure) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 40, color: Colors.grey),
const SizedBox(height: 12),
const Text('No se pudo cargar la lista', textAlign: TextAlign.center),
const SizedBox(height: 16),
OutlinedButton(
onPressed: () => bloc.add(const ProfessionalListFetch()),
child: const Text('Reintentar'),
),
],
),
),
);
}
if (state is ProfessionalListSuccess) { if (state is ProfessionalListSuccess) {
final List<UserProfessional> filteredUsers; final List<UserProfessional> filteredUsers;
// TODO: encaso de filtro dañado // TODO: encaso de filtro dañado
// if (_searchController.text.isNotEmpty) { // if (_searchController.text.isNotEmpty) {
filteredUsers = state.users filteredUsers = state.users
.where((element) => removeDiacritics(element.myUser.name!) .where((element) => removeDiacritics(element.myUser.name ?? '')
.toLowerCase() .toLowerCase()
.contains(removeDiacritics(_searchController.text.toLowerCase()))) .contains(removeDiacritics(_searchController.text.toLowerCase())))
.where((user) => .where((user) =>
user.myUser.id != ApiUserRepository.currentUserId ?? '') user.myUser.id != (ApiUserRepository.currentUserId ?? ''))
.where( .where(
(user) => user.professionalInfo.profession == _selectedProfession) (user) => user.professionalInfo.profession == _selectedProfession)
.toList(); .toList();
@@ -226,7 +328,7 @@ class _ProfessionalListScreenState extends State<ProfessionalListScreen> {
); );
}, },
child: filteredUsers[index].professionalInfo.rate.isEmpty || child: filteredUsers[index].professionalInfo.rate.isEmpty ||
settings?.tarifas == false settings?.tarifas != true
? const Icon( ? const Icon(
Icons.keyboard_arrow_right, Icons.keyboard_arrow_right,
color: Colors.black, color: Colors.black,
@@ -295,7 +397,10 @@ class _ProfessionalListScreenState extends State<ProfessionalListScreen> {
], ],
), ),
subtitle: Text( subtitle: Text(
_disponibilidad(filteredUsers[index].professionalInfo), _disponibilidad(filteredUsers[index].professionalInfo) +
(filteredUsers[index].distanceKm != null
? ' · ${filteredUsers[index].distanceKm!.toStringAsFixed(1)} km'
: ''),
style: const TextStyle( style: const TextStyle(
fontSize: 13, fontSize: 13,
color: Colors.blue, color: Colors.blue,
@@ -66,6 +66,11 @@ class _ProfessionalPendingServiceListScreenState
_RequestCard(info: serviceState.services[index]), _RequestCard(info: serviceState.services[index]),
); );
} }
if (serviceState is CreateServiceFailure) {
return _errorState(context, () => serviceBloc.add(
LoadPendingServicesForProfessional(
ApiUserRepository.currentUserId ?? '')));
}
return _shimmerList(); return _shimmerList();
}, },
), ),
@@ -73,6 +78,37 @@ class _ProfessionalPendingServiceListScreenState
); );
} }
Widget _errorState(BuildContext context, VoidCallback onRetry) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: context.subtle.withOpacity(0.1), shape: BoxShape.circle),
child:
Icon(Icons.error_outline, size: 36, color: context.subtle),
),
const SizedBox(height: 16),
Text('No se pudo cargar la información',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: context.muted)),
const SizedBox(height: 6),
Text('Revisa tu conexión e inténtalo de nuevo.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13, color: context.subtle, height: 1.5)),
const SizedBox(height: 16),
OutlinedButton(onPressed: onRetry, child: const Text('Reintentar')),
]),
),
);
}
Widget _emptyState(BuildContext context) { Widget _emptyState(BuildContext context) {
return Center( return Center(
child: Padding( child: Padding(
@@ -78,6 +78,12 @@ class _ProfessionalServiceHistoryListScreenState
}, },
); );
} }
if (serviceState is CreateServiceFailure) {
return _errorState(
context,
() => serviceBloc.add(LoadServicesHistoryForProfessional(
ApiUserRepository.currentUserId ?? '')));
}
return _shimmerList(); return _shimmerList();
}, },
), ),
@@ -86,6 +92,34 @@ class _ProfessionalServiceHistoryListScreenState
} }
} }
Widget _errorState(BuildContext context, VoidCallback onRetry) {
final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35);
final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 72, height: 72,
decoration: BoxDecoration(
color: subtle.withOpacity(0.1), shape: BoxShape.circle),
child: Icon(Icons.error_outline, size: 36, color: subtle),
),
const SizedBox(height: 16),
Text('No se pudo cargar la información',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w700, color: muted)),
const SizedBox(height: 6),
Text('Revisa tu conexión e inténtalo de nuevo.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: subtle, height: 1.5)),
const SizedBox(height: 16),
OutlinedButton(onPressed: onRetry, child: const Text('Reintentar')),
]),
),
);
}
Widget _emptyState(BuildContext context) { Widget _emptyState(BuildContext context) {
final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35); final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35);
final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55); final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55);
@@ -78,6 +78,12 @@ class _ProfessionalServiceListScreenState
}, },
); );
} }
if (serviceState is CreateServiceFailure) {
return _errorState(
context,
() => serviceBloc.add(LoadServicesForProfessional(
ApiUserRepository.currentUserId ?? '')));
}
return _shimmerList(); return _shimmerList();
}, },
), ),
@@ -86,6 +92,34 @@ class _ProfessionalServiceListScreenState
} }
} }
Widget _errorState(BuildContext context, VoidCallback onRetry) {
final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35);
final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 72, height: 72,
decoration: BoxDecoration(
color: subtle.withOpacity(0.1), shape: BoxShape.circle),
child: Icon(Icons.error_outline, size: 36, color: subtle),
),
const SizedBox(height: 16),
Text('No se pudo cargar la información',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w700, color: muted)),
const SizedBox(height: 6),
Text('Revisa tu conexión e inténtalo de nuevo.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: subtle, height: 1.5)),
const SizedBox(height: 16),
OutlinedButton(onPressed: onRetry, child: const Text('Reintentar')),
]),
),
);
}
Widget _emptyState(BuildContext context) { Widget _emptyState(BuildContext context) {
final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35); final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35);
final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55); final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55);
@@ -78,6 +78,12 @@ class _UserServiceHistoryListScreenState
}, },
); );
} }
if (serviceState is CreateServiceFailure) {
return _errorState(
context,
() => serviceBloc.add(LoadServicesHistoryForUser(
ApiUserRepository.currentUserId ?? '')));
}
return _shimmerList(); return _shimmerList();
}, },
), ),
@@ -86,6 +92,34 @@ class _UserServiceHistoryListScreenState
} }
} }
Widget _errorState(BuildContext context, VoidCallback onRetry) {
final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35);
final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 72, height: 72,
decoration: BoxDecoration(
color: subtle.withOpacity(0.1), shape: BoxShape.circle),
child: Icon(Icons.error_outline, size: 36, color: subtle),
),
const SizedBox(height: 16),
Text('No se pudo cargar la información',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w700, color: muted)),
const SizedBox(height: 6),
Text('Revisa tu conexión e inténtalo de nuevo.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: subtle, height: 1.5)),
const SizedBox(height: 16),
OutlinedButton(onPressed: onRetry, child: const Text('Reintentar')),
]),
),
);
}
Widget _emptyState(BuildContext context) { Widget _emptyState(BuildContext context) {
final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35); final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35);
final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55); final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55);
@@ -75,6 +75,10 @@ class _UserServiceListScreenState extends State<UserServiceListScreen> {
}, },
); );
} }
if (serviceState is CreateServiceFailure) {
return _errorState(context, () => serviceBloc.add(
LoadServicesForUser(ApiUserRepository.currentUserId ?? '')));
}
return _shimmerList(); return _shimmerList();
}, },
), ),
@@ -83,6 +87,34 @@ class _UserServiceListScreenState extends State<UserServiceListScreen> {
} }
} }
Widget _errorState(BuildContext context, VoidCallback onRetry) {
final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35);
final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 72, height: 72,
decoration: BoxDecoration(
color: subtle.withOpacity(0.1), shape: BoxShape.circle),
child: Icon(Icons.error_outline, size: 36, color: subtle),
),
const SizedBox(height: 16),
Text('No se pudo cargar la información',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w700, color: muted)),
const SizedBox(height: 6),
Text('Revisa tu conexión e inténtalo de nuevo.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: subtle, height: 1.5)),
const SizedBox(height: 16),
OutlinedButton(onPressed: onRetry, child: const Text('Reintentar')),
]),
),
);
}
Widget _emptyState(BuildContext context) { Widget _emptyState(BuildContext context) {
final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35); final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35);
final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55); final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55);
@@ -31,6 +31,18 @@ class ScheduleItem extends StatelessWidget {
Switch( Switch(
value: schedule.enabled, value: schedule.enabled,
onChanged: (value) { onChanged: (value) {
// Turning a day on used to leave every hour empty, so the day
// read as "open" and offered zero slots. Seed a normal
// workday; the professional can adjust it right below.
if (value && schedule.range1Hour1 == null) {
onChanged.call(schedule.copyWith(
enabled: true,
continuousDay: true,
range1Hour1: const TimeOfDay(hour: 8, minute: 0),
range2Hour2: const TimeOfDay(hour: 18, minute: 0),
));
return;
}
onChanged.call(schedule.copyWith(enabled: value)); onChanged.call(schedule.copyWith(enabled: value));
}, },
), ),
@@ -53,6 +65,17 @@ class ScheduleItem extends StatelessWidget {
Switch( Switch(
value: schedule.continuousDay, value: schedule.continuousDay,
onChanged: (value) { onChanged: (value) {
// Same reasoning as the day switch: splitting the day
// reveals two new empty fields, and a half-filled day
// generates no slots at all.
if (!value && schedule.range1Hour2 == null) {
onChanged.call(schedule.copyWith(
continuousDay: false,
range1Hour2: const TimeOfDay(hour: 12, minute: 0),
range2Hour1: const TimeOfDay(hour: 14, minute: 0),
));
return;
}
onChanged.call(schedule.copyWith(continuousDay: value)); onChanged.call(schedule.copyWith(continuousDay: value));
}, },
), ),
@@ -6,13 +6,15 @@ import 'package:intl/intl.dart';
import 'package:professional_repository/professional_repository.dart'; import 'package:professional_repository/professional_repository.dart';
import 'package:prosappco/blocs/service_bloc/service_bloc.dart'; import 'package:prosappco/blocs/service_bloc/service_bloc.dart';
import 'package:prosappco/screens/service/professional_service_screen.dart'; import 'package:prosappco/screens/service/professional_service_screen.dart';
import 'package:prosappco/utils/time_of_day_utils.dart'; import 'package:prosappco/utils/service_day.dart';
import 'package:prosappco/utils/slot_generator.dart';
import 'package:service_repository/service_repository.dart'; import 'package:service_repository/service_repository.dart';
import 'package:table_calendar/table_calendar.dart'; import 'package:table_calendar/table_calendar.dart';
const _kPrimary = Color(0xFF1565C0); const _kPrimary = Color(0xFF1565C0);
const _kAvailable = Color(0xFF16A34A); const _kAvailable = Color(0xFF16A34A);
const _kOccupied = Color(0xFFDC2626); const _kOccupied = Color(0xFFDC2626);
const _kBlocked = Color(0xFF7C3AED);
extension _Th on BuildContext { extension _Th on BuildContext {
ThemeData get _t => Theme.of(this); ThemeData get _t => Theme.of(this);
@@ -44,6 +46,7 @@ class _ProfessionalCalendarScreenState
List<ServiceEntity>? _services; List<ServiceEntity>? _services;
CalendarFormat _calendarFormat = CalendarFormat.month; CalendarFormat _calendarFormat = CalendarFormat.month;
bool isLoading = false; bool isLoading = false;
bool _loadFailed = false;
@override @override
void initState() { void initState() {
@@ -54,9 +57,20 @@ class _ProfessionalCalendarScreenState
} }
void _loadServices() { void _loadServices() {
setState(() {
_loadFailed = false;
_services = null;
});
serviceRepository serviceRepository
.getServicesForProfessionalforCalendar(widget.userProfessional.id) .getServicesForProfessionalforCalendar(widget.userProfessional.id)
.then((services) => setState(() => _services = services)); .then((services) {
if (!mounted) return;
setState(() => _services = services);
}).catchError((e) {
if (!mounted) return;
// Never fall back to an empty list: booked slots would render as free.
setState(() => _loadFailed = true);
});
} }
void _onDaySelected(DateTime day, DateTime focusedDay) { void _onDaySelected(DateTime day, DateTime focusedDay) {
@@ -72,7 +86,15 @@ class _ProfessionalCalendarScreenState
final slots = _buildSlots(schedule); final slots = _buildSlots(schedule);
final occupied = final occupied =
slots.where((t) => _isOccupied(t, _services, today)).length; slots.where((t) => _isOccupied(t, _services, today)).length;
final available = slots.length - occupied; final blocked = slots.where((t) => _isBlocked(t, _services, today)).length;
// "Libres" means bookable by a patient, not merely empty: a free 15:00
// at 14:00 is inside the booking lead time and nobody can take it.
final available = slots
.where((t) =>
!_isOccupied(t, _services, today) &&
!_isBlocked(t, _services, today) &&
_isBookable(t))
.length;
return Scaffold( return Scaffold(
backgroundColor: context.bg, backgroundColor: context.bg,
@@ -87,18 +109,43 @@ class _ProfessionalCalendarScreenState
child: BlocConsumer<ServiceBloc, ServiceState>( child: BlocConsumer<ServiceBloc, ServiceState>(
listener: (context, state) { listener: (context, state) {
if (state is CreateServiceLoading) isLoading = true; if (state is CreateServiceLoading) isLoading = true;
if (state is CreateServiceFailure) isLoading = false; if (state is CreateServiceFailure) {
isLoading = false;
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No se pudo completar la acción')),
);
}
if (state is CreateServiceSuccess || state is ServiceStatusUpdated) {
isLoading = false;
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(state is CreateServiceSuccess
? 'Horario bloqueado'
: 'Horario liberado'),
));
_loadServices();
}
}, },
builder: (context, state) { builder: (context, state) {
return ListView( return ListView(
padding: const EdgeInsets.only(bottom: 32), padding: const EdgeInsets.only(bottom: 32),
children: [ children: [
_calendarCard(context), _calendarCard(context),
_dayHeader(context, schedule, slots.length, occupied, available), if (_loadFailed)
if (slots.isEmpty) _loadErrorState(context)
_emptyState(context) else if (_services == null)
else // Until the agenda arrives every slot would read "Disponible"
..._slotCards(context, slots, state), // and could be blocked on top of a real appointment.
_loadingState(context)
else ...[
_dayHeader(context, schedule, slots.length, occupied,
blocked, available),
if (slots.isEmpty)
_emptyState(context)
else
..._slotCards(context, slots, state),
],
], ],
); );
}, },
@@ -184,7 +231,7 @@ class _ProfessionalCalendarScreenState
} }
Widget _dayHeader(BuildContext context, ScheduleEntity? schedule, int total, Widget _dayHeader(BuildContext context, ScheduleEntity? schedule, int total,
int occupied, int available) { int occupied, int blocked, int available) {
final dayName = DateFormat('EEEE', 'es').format(today); final dayName = DateFormat('EEEE', 'es').format(today);
final dateStr = DateFormat('d MMMM yyyy', 'es').format(today); final dateStr = DateFormat('d MMMM yyyy', 'es').format(today);
final hasSchedule = schedule != null && schedule.enabled && total > 0; final hasSchedule = schedule != null && schedule.enabled && total > 0;
@@ -236,6 +283,11 @@ class _ProfessionalCalendarScreenState
if (hasSchedule) ...[ if (hasSchedule) ...[
_StatPill(label: '$occupied', sublabel: 'ocupadas', color: _kOccupied), _StatPill(label: '$occupied', sublabel: 'ocupadas', color: _kOccupied),
const SizedBox(width: 8), const SizedBox(width: 8),
if (blocked > 0) ...[
_StatPill(
label: '$blocked', sublabel: 'bloqueadas', color: _kBlocked),
const SizedBox(width: 8),
],
_StatPill( _StatPill(
label: '$available', sublabel: 'libres', color: _kAvailable), label: '$available', sublabel: 'libres', color: _kAvailable),
], ],
@@ -247,7 +299,19 @@ class _ProfessionalCalendarScreenState
BuildContext context, List<TimeOfDay> slots, ServiceState state) { BuildContext context, List<TimeOfDay> slots, ServiceState state) {
return slots.map((time) { return slots.map((time) {
final occ = _isOccupied(time, _services, today); final occ = _isOccupied(time, _services, today);
final color = occ ? _kOccupied : _kAvailable; final blocked = _isBlocked(time, _services, today);
final taken = occ || blocked;
// Free but too close to reserve: still blockable by the professional,
// just not offered to patients. Shown greyed so the agenda matches
// what the patient sees instead of promising a slot nobody can take.
final soon = !taken && !_isBookable(time);
final color = occ
? _kOccupied
: blocked
? _kBlocked
: soon
? Colors.grey
: _kAvailable;
return Container( return Container(
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0), margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
@@ -264,7 +328,8 @@ class _ProfessionalCalendarScreenState
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
child: InkWell( child: InkWell(
onTap: () => occ ? _onOccupied(time) : _onAvailable(context, time, state), onTap: () =>
taken ? _onOccupied(time) : _onAvailable(context, time, state),
child: IntrinsicHeight( child: IntrinsicHeight(
child: Row(children: [ child: Row(children: [
Container(width: 4, color: color), Container(width: 4, color: color),
@@ -293,7 +358,14 @@ class _ProfessionalCalendarScreenState
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text(occ ? 'Ocupado' : 'Disponible', Text(
occ
? 'Ocupado'
: blocked
? 'Bloqueado'
: soon
? 'Sin reserva'
: 'Disponible',
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@@ -301,7 +373,11 @@ class _ProfessionalCalendarScreenState
Text( Text(
occ occ
? 'Toca para ver el servicio' ? 'Toca para ver el servicio'
: 'Horario libre · toca para reservar', : blocked
? 'Toca para liberar el horario'
: soon
? 'Muy pronto para reservar · toca para bloquear'
: 'Horario libre · toca para bloquear',
style: TextStyle( style: TextStyle(
fontSize: 11, color: context.subtle), fontSize: 11, color: context.subtle),
), ),
@@ -317,7 +393,11 @@ class _ProfessionalCalendarScreenState
child: Icon( child: Icon(
occ occ
? Icons.event_busy_outlined ? Icons.event_busy_outlined
: Icons.event_available_outlined, : blocked
? Icons.lock_outline
: soon
? Icons.more_time
: Icons.event_available_outlined,
size: 17, size: 17,
color: color), color: color),
), ),
@@ -333,40 +413,38 @@ class _ProfessionalCalendarScreenState
} }
void _onOccupied(TimeOfDay time) { void _onOccupied(TimeOfDay time) {
if (_services == null) return; final event = _serviceAt(time, _services, today);
for (final event in _services!) { if (event?.id == null) {
if (today.toString() == event.day && time == event.range1Hour1) { // Tapping a busy slot used to do nothing at all when the row came back
if (event.userId == event.professionalId) { // without an id, which reads as a frozen screen.
ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
.showSnackBar(const SnackBar(content: Text('Horario ocupado por ti'))); content: Text('No pudimos abrir esta cita. Desliza para recargar.'),
} else { ));
Navigator.push( return;
context, }
CupertinoPageRoute(
builder: (_) => if (event!.status == ServiceStatus.selfBooked) {
ProfessionalServiceScreen(serviceId: event.id!)), _confirmUnblock(event.id!, time);
); } else {
} Navigator.push(
return; context,
} CupertinoPageRoute(
builder: (_) => ProfessionalServiceScreen(serviceId: event.id!)),
);
} }
} }
void _onAvailable(BuildContext context, TimeOfDay time, ServiceState state) { void _confirmUnblock(String serviceId, TimeOfDay time) {
showDialog( showDialog(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Reservar hora'), title: const Text('Desbloquear horario'),
content: Column(mainAxisSize: MainAxisSize.min, children: [ content: Text(
Text( '¿Quieres liberar el horario de las '
'¿Reservar a las ${ScheduleEntity.getFormatTime(time)} ' '${ScheduleEntity.getFormatTime(time)} '
'del ${DateFormat('dd-MM-yyyy').format(today)}?', '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: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(dialogContext), onPressed: () => Navigator.pop(dialogContext),
@@ -375,31 +453,118 @@ class _ProfessionalCalendarScreenState
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
Navigator.pop(context); context
context.read<ServiceBloc>().add(CreateService( .read<ServiceBloc>()
professionalId: widget.userProfessional.id, .add(UpdateServiceStatus(serviceId, ServiceStatus.cancelled));
userId: widget.userProfessional.id,
address: widget.userProfessional.address,
aditionalAddress: '',
latitude: 0,
longitude: 0,
day: today.toString(),
createdAt: DateTime.now().toIso8601String(),
description: '',
range1Hour1: time,
range1Hour2: time.replacing(hour: time.hour + 2),
rate: '0',
location: ServiceLocationPreferences.office,
status: ServiceStatus.selfBooked,
));
}, },
child: const Text('Reservar'), child: const Text('Desbloquear'),
), ),
], ],
), ),
); );
} }
/// Blocking is the only action on a free slot.
///
/// There used to be a second "Reservar" button that created a service with
/// the professional as their own patient. It also popped the calendar, and
/// the selfBooked status never reached the backend, so the slot came back as
/// a pending request the professional had to approve to themselves.
void _onAvailable(BuildContext context, TimeOfDay time, ServiceState state) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Bloquear horario'),
content: Text(
'¿Bloquear las ${ScheduleEntity.getFormatTime(time)} '
'del ${DateFormat('dd-MM-yyyy').format(today)}? '
'Nadie podrá agendar en esa hora hasta que la liberes.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancelar'),
),
TextButton(
onPressed: () {
Navigator.pop(dialogContext);
context.read<ServiceBloc>().add(
BlockSlot(day: _dayParam(today), hour1: time),
);
},
child: const Text('Bloquear'),
),
],
),
);
}
/// yyyy-MM-dd, the canonical form the web sends. DateTime.toString() produces
/// "2026-08-25 00:00:00.000Z", which happens to pass validation but differs
/// between the two clients writing to the same table.
static String _dayParam(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';
Widget _loadingState(BuildContext context) {
return Container(
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
padding: const EdgeInsets.symmetric(vertical: 48),
decoration: BoxDecoration(
color: context.card,
borderRadius: BorderRadius.circular(16),
),
child: Column(children: [
const CircularProgressIndicator(color: _kPrimary),
const SizedBox(height: 14),
Text('Cargando tu agenda…',
style: TextStyle(fontSize: 13, color: context.muted)),
]),
);
}
Widget _loadErrorState(BuildContext context) {
return 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: _kOccupied.withOpacity(0.1), shape: BoxShape.circle),
child: const Icon(Icons.wifi_off_outlined,
size: 32, color: _kOccupied),
),
const SizedBox(height: 16),
Text('No se pudo cargar tu agenda',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: context.muted)),
const SizedBox(height: 6),
Text(
'No mostramos horarios para evitar que reserves\nsobre una cita ya agendada.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: context.subtle, height: 1.5)),
const SizedBox(height: 16),
OutlinedButton(
onPressed: _loadServices, child: const Text('Reintentar')),
]),
);
}
Widget _emptyState(BuildContext context) { Widget _emptyState(BuildContext context) {
return Container( return Container(
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
@@ -447,27 +612,55 @@ class _ProfessionalCalendarScreenState
} }
} }
List<TimeOfDay> _buildSlots(ScheduleEntity? s) { /// Whether a patient could still reserve [time] today.
if (s == null || !s.enabled || s.range1Hour1 == null || s.range2Hour2 == null) { bool _isBookable(TimeOfDay time) {
return []; final at = DateTime(today.year, today.month, today.day, time.hour, time.minute);
} return !at.isBefore(DateTime.now().add(kBookingLeadTime));
if (s.continuousDay) {
return TimeOfDayUtils.genRanges(s.range1Hour1!, s.range2Hour2!);
}
if (s.range1Hour2 == null || s.range2Hour1 == null) return [];
return [
...TimeOfDayUtils.genRanges(s.range1Hour1!, s.range1Hour2!),
...TimeOfDayUtils.genRanges(s.range2Hour1!, s.range2Hour2!),
];
} }
bool _isOccupied( List<TimeOfDay> _buildSlots(ScheduleEntity? s) {
TimeOfDay time, List<ServiceEntity>? services, DateTime day) { // Same generator the patient uses, so "3 libres" here always means three
if (services == null) return false; // slots a patient can actually take. The professional keeps a shorter
return services // horizon than the patient's lead time: blocking the next hour is a
.any((s) => day.toString() == s.day && s.range1Hour1 == time); // legitimate thing to do, booking it is not.
return SlotGenerator.forDay(
schedule: s,
day: today,
slotDurationMinutes: widget.userProfessional.slotDurationMinutes,
notBefore: DateTime.now(),
);
} }
/// The still-live service sitting on [time], if any.
///
/// Cancelled and denied services are ignored on purpose: unblocking a slot
/// cancels its service, so counting those would leave the slot looking
/// occupied forever and make "desbloquear" appear to do nothing.
ServiceEntity? _serviceAt(
TimeOfDay time, List<ServiceEntity>? services, DateTime day) {
if (services == null) return null;
for (final s in services) {
if (!isSameServiceDay(day, s.day) || s.range1Hour1 != time) continue;
if (s.status == ServiceStatus.cancelled ||
s.status == ServiceStatus.denied) {
continue;
}
return s;
}
return null;
}
/// Booked by a client — tapping it opens the service detail.
bool _isOccupied(
TimeOfDay time, List<ServiceEntity>? services, DateTime day) =>
_serviceAt(time, services, day)?.status != null &&
_serviceAt(time, services, day)!.status != ServiceStatus.selfBooked;
/// Reserved by the professional themselves — tapping it offers to release it.
bool _isBlocked(
TimeOfDay time, List<ServiceEntity>? services, DateTime day) =>
_serviceAt(time, services, day)?.status == ServiceStatus.selfBooked;
String _capitalize(String s) => String _capitalize(String s) =>
s.isEmpty ? s : s[0].toUpperCase() + s.substring(1); s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
} }
@@ -1,101 +1,225 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injector/injector.dart';
import 'package:prosappco/blocs/authentication_bloc/authentication_bloc.dart'; import 'package:prosappco/blocs/authentication_bloc/authentication_bloc.dart';
import 'package:prosappco/blocs/my_user_bloc/my_user_bloc.dart';
import 'package:prosappco/blocs/professional_bloc/professional_bloc.dart';
import 'package:prosappco/screens/professional/professional_form_screen.dart';
import 'package:setting_repository/setting_repository.dart';
class ProfessionalDeniedScreen extends StatelessWidget { class ProfessionalDeniedScreen extends StatefulWidget {
const ProfessionalDeniedScreen({super.key}); const ProfessionalDeniedScreen({super.key});
@override
State<ProfessionalDeniedScreen> createState() =>
_ProfessionalDeniedScreenState();
}
class _ProfessionalDeniedScreenState extends State<ProfessionalDeniedScreen> {
final settingRepository = Injector.appInstance.get<SettingRepository>();
SettingEntity? _settings;
bool _loadingSettings = true;
bool _isResetting = false;
@override
void initState() {
super.initState();
settingRepository.getSettings().then((value) {
if (!mounted) return;
setState(() {
_settings = value;
_loadingSettings = false;
});
});
}
void _confirmRetry() {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Volver a registrarme'),
content: const Text(
'Esta acción reiniciará tu solicitud de profesional y no se puede deshacer. '
'¿Deseas continuar?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancelar'),
),
TextButton(
onPressed: () {
Navigator.pop(dialogContext);
context
.read<ProfessionalBloc>()
.add(const ResetProfessionalApplicationEvent());
},
child: const Text('Continuar'),
),
],
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return BlocListener<ProfessionalBloc, ProfessionalState>(
body: SafeArea( listener: (context, state) {
child: SingleChildScrollView( if (state is ResetProfessionalApplicationLoading) {
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 40), setState(() => _isResetting = true);
child: Column( } else if (state is ResetProfessionalApplicationSuccess) {
crossAxisAlignment: CrossAxisAlignment.center, setState(() => _isResetting = false);
children: [ Navigator.of(context).pushReplacement(
Container( CupertinoPageRoute(builder: (_) => const ProfessionalFormScreen()),
width: 88, );
height: 88, } else if (state is ProfessionalStateFailure) {
decoration: BoxDecoration( setState(() => _isResetting = false);
color: const Color(0xFFFFEBEE), ScaffoldMessenger.of(context).showSnackBar(
shape: BoxShape.circle, const SnackBar(content: Text('No se pudo reiniciar la solicitud')),
), );
child: const Icon(Icons.cancel_outlined, color: Color(0xFFE53935), size: 48), }
), },
const SizedBox(height: 24), child: BlocBuilder<MyUserBloc, MyUserState>(
const Text( builder: (context, myUserState) {
'Solicitud rechazada', final rejectedAt = myUserState.user?.rejectedAt;
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Color(0xFF1A1A2E)), // Backend currently ships rejection_wait_days = 0 (no cooldown).
textAlign: TextAlign.center, // 7 is only the fallback when /settings could not be read.
), final waitDays = _settings?.rejectionWaitDays?.toInt() ?? 7;
const SizedBox(height: 12),
const Text( // Fail-open, same as the web: if we cannot tell when the rejection
'Tu solicitud para convertirte en profesional no fue aprobada. Esto puede deberse a información incompleta o documentación no válida.', // happened, assume the wait already elapsed. Locking someone out of
textAlign: TextAlign.center, // re-applying forever is worse than letting them retry early.
style: TextStyle(fontSize: 14, color: Colors.grey, height: 1.5), final daysElapsed = rejectedAt != null
), ? DateTime.now().difference(rejectedAt).inDays
const SizedBox(height: 32), : waitDays;
Container(
width: double.infinity, final daysLeft = (waitDays - daysElapsed).clamp(0, waitDays);
padding: const EdgeInsets.all(20), final canRetry = !_loadingSettings && daysLeft == 0;
decoration: BoxDecoration(
color: const Color(0xFFFFF3E0), return Scaffold(
borderRadius: BorderRadius.circular(16), body: SafeArea(
border: Border.all(color: const Color(0xFFFFCC02).withOpacity(0.4)), child: SingleChildScrollView(
), padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 40),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
children: const [ children: [
Row( Container(
children: [ width: 88,
Icon(Icons.info_outline, color: Color(0xFFF57C00), size: 20), height: 88,
SizedBox(width: 8), decoration: BoxDecoration(
Text('¿Qué puedo hacer?', style: TextStyle(fontWeight: FontWeight.w600, color: Color(0xFFF57C00))), color: const Color(0xFFFFEBEE),
], shape: BoxShape.circle,
),
child: const Icon(Icons.cancel_outlined,
color: Color(0xFFE53935), size: 48),
),
const SizedBox(height: 24),
const Text(
'Solicitud rechazada',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Color(0xFF1A1A2E)),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
const Text(
'Tu solicitud para convertirte en profesional no fue aprobada. Esto puede deberse a información incompleta o documentación no válida.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey, height: 1.5),
),
const SizedBox(height: 32),
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFFFFF3E0),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: const Color(0xFFFFCC02).withOpacity(0.4)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Row(
children: [
Icon(Icons.info_outline,
color: Color(0xFFF57C00), size: 20),
SizedBox(width: 8),
Text('¿Qué puedo hacer?',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Color(0xFFF57C00))),
],
),
SizedBox(height: 12),
_BulletPoint(text: 'Verifica que todos tus datos sean correctos'),
_BulletPoint(
text: 'Asegúrate de haber adjuntado los documentos requeridos'),
_BulletPoint(
text: 'Vuelve a registrarte como profesional con la información actualizada'),
_BulletPoint(text: 'Contacta a soporte si crees que fue un error'),
],
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: (canRetry && !_isResetting) ? _confirmRetry : null,
icon: _isResetting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.refresh),
label: const Text('Volver a registrarme'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF42A4EF),
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey.shade300,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
elevation: 0,
),
),
),
if (!canRetry && !_loadingSettings) ...[
const SizedBox(height: 8),
Text(
'Podrás volver a intentarlo en $daysLeft día${daysLeft == 1 ? '' : 's'}',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => context
.read<AuthenticationBloc>()
.add(AuthenticationLogoutRequested()),
icon: const Icon(Icons.logout),
label: const Text('Cerrar sesión'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.grey,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
side: const BorderSide(color: Colors.grey),
),
),
), ),
SizedBox(height: 12),
_BulletPoint(text: 'Verifica que todos tus datos sean correctos'),
_BulletPoint(text: 'Asegúrate de haber adjuntado los documentos requeridos'),
_BulletPoint(text: 'Vuelve a registrarte como profesional con la información actualizada'),
_BulletPoint(text: 'Contacta a soporte si crees que fue un error'),
], ],
), ),
), ),
const SizedBox(height: 32), ),
SizedBox( );
width: double.infinity, },
child: ElevatedButton.icon(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.refresh),
label: const Text('Volver a registrarme'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF42A4EF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 0,
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => context.read<AuthenticationBloc>().add(AuthenticationLogoutRequested()),
icon: const Icon(Icons.logout),
label: const Text('Cerrar sesión'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.grey,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
side: const BorderSide(color: Colors.grey),
),
),
),
],
),
),
), ),
); );
} }
@@ -30,6 +30,8 @@ class _ProfessionalFormScreenState extends State<ProfessionalFormScreen> {
PlatformFile? _certificadoPdfFile; PlatformFile? _certificadoPdfFile;
List<PlatformFile>? _especializacionesPdfFiles = []; List<PlatformFile>? _especializacionesPdfFiles = [];
bool _isSubmitting = false;
late final AuthBloc authBloc; late final AuthBloc authBloc;
@override @override
@@ -50,7 +52,37 @@ class _ProfessionalFormScreenState extends State<ProfessionalFormScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<AuthBloc>( return BlocProvider<AuthBloc>(
create: (context) => authBloc, create: (context) => authBloc,
child: Scaffold( child: BlocListener<ProfessionalBloc, ProfessionalState>(
listener: (context, professionalState) {
if (professionalState is SendProfessionalToReviewLoading) {
setState(() => _isSubmitting = true);
} else if (professionalState is SendProfessionalToReviewSuccess) {
setState(() => _isSubmitting = false);
// The picture upload only runs once the application actually landed.
final user = context.read<MyUserBloc>().state.user;
if (user != null) {
context.read<ProfileBloc>().add(UpdateUserInfo(
myUser: user.copyWith(proState: ProState.pending),
filePicture: _imageFile?.path));
}
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Información enviada a revisión')),
);
Navigator.pop(context);
} else if (professionalState is SendProfessionalToReviewFailure) {
setState(() => _isSubmitting = false);
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'No se pudo enviar tu solicitud. Revisa tu conexión e inténtalo de nuevo.')),
);
}
},
child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Perfil profesional'), title: const Text('Perfil profesional'),
), ),
@@ -247,9 +279,20 @@ class _ProfessionalFormScreenState extends State<ProfessionalFormScreen> {
horizontal: 15, horizontal: 15,
), ),
child: FilledButton( child: FilledButton(
onPressed: () { onPressed: _isSubmitting
? null
: () {
final userId = final userId =
context.read<MyUserBloc>().state.user!.id; context.read<MyUserBloc>().state.user?.id;
if (userId == null) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'No se pudo identificar tu usuario, vuelve a iniciar sesión')));
return;
}
final String? cedulaPdfPath = _cedulaPdfFile?.path; final String? cedulaPdfPath = _cedulaPdfFile?.path;
final String? certificadoPdfPath = final String? certificadoPdfPath =
@@ -300,20 +343,6 @@ class _ProfessionalFormScreenState extends State<ProfessionalFormScreen> {
specializationsPictures: specializationsPictures:
especializacionesPdfPaths, especializacionesPdfPaths,
)); ));
final myUser =
state.user!.copyWith(proState: ProState.pending);
context.read<ProfileBloc>().add(UpdateUserInfo(
myUser: myUser, filePicture: _imageFile?.path));
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Información enviada a revisión')),
);
Navigator.pop(context);
}, },
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary, backgroundColor: Theme.of(context).colorScheme.primary,
@@ -325,19 +354,27 @@ class _ProfessionalFormScreenState extends State<ProfessionalFormScreen> {
child: Container( child: Container(
alignment: Alignment.center, alignment: Alignment.center,
width: double.infinity, width: double.infinity,
child: const Text( child: _isSubmitting
'Enviar a revisión', ? const SizedBox(
style: TextStyle( height: 22,
color: Colors.white, width: 22,
fontSize: 18, child: CircularProgressIndicator(
), strokeWidth: 2, color: Colors.white),
), )
: const Text(
'Enviar a revisión',
style: TextStyle(
color: Colors.white,
fontSize: 18,
),
),
), ),
)), )),
], ],
); );
}, },
), ),
),
), ),
); );
} }
@@ -51,7 +51,9 @@ class _ProfessionalProfileScreenState
final _rateController = TextEditingController(); final _rateController = TextEditingController();
Schedules schedules = Schedules.empty; Schedules schedules = Schedules.empty;
int _slotDurationMinutes = 120;
bool isInit = false; bool isInit = false;
bool _isSaving = false;
double _longitudeController = 0; double _longitudeController = 0;
double _latitudeController = 0; double _latitudeController = 0;
@@ -63,9 +65,10 @@ class _ProfessionalProfileScreenState
@override @override
void initState() { void initState() {
super.initState(); super.initState();
settingRepository.getSettings().then( settingRepository.getSettings().then((v) {
(v) => setState(() => settings = v), if (!mounted) return;
); setState(() => settings = v);
}).catchError((_) {});
} }
@override @override
@@ -78,7 +81,28 @@ class _ProfessionalProfileScreenState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return BlocListener<ProfessionalProfileBloc, ProfessionalProfileState>(
listener: (context, saveState) {
if (saveState is UpdateProfessionalInfoLoading) {
setState(() => _isSaving = true);
} else if (saveState is UpdateProfessionalInfoSuccess) {
setState(() => _isSaving = false);
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Información actualizada correctamente')),
);
} else if (saveState is UpdateProfessionalInfoFailure) {
setState(() => _isSaving = false);
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'No se pudo guardar. Revisa tu conexión e inténtalo de nuevo.')),
);
}
},
child: Scaffold(
backgroundColor: context.bg, backgroundColor: context.bg,
appBar: AppBar( appBar: AppBar(
title: const Text('Perfil Profesional'), title: const Text('Perfil Profesional'),
@@ -102,9 +126,32 @@ class _ProfessionalProfileScreenState
); );
} }
} }
if (state is ProfessionalStateFailure) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 40, color: context.subtle),
const SizedBox(height: 12),
const Text('No se pudo cargar tu perfil profesional'),
const SizedBox(height: 16),
OutlinedButton(
onPressed: () => context
.read<ProfessionalBloc>()
.add(const UpdateProfessionalEvent(isProModeActive: true)),
child: const Text('Reintentar'),
),
],
),
),
);
}
return const Center(child: Text('Vuelve atrás')); return const Center(child: Text('Vuelve atrás'));
}, },
), ),
),
); );
} }
@@ -128,6 +175,10 @@ class _ProfessionalProfileScreenState
deliveryValue = true; deliveryValue = true;
} }
_rateController.text = proInfo.rate; _rateController.text = proInfo.rate;
// Restored from the record, not left at its false default: opening the
// profile to fix an address and saving used to switch the rate off.
rateValue = proInfo.ratePreferences;
_slotDurationMinutes = proInfo.slotDurationMinutes;
loadFinish = true; loadFinish = true;
} }
@@ -261,23 +312,39 @@ class _ProfessionalProfileScreenState
color: _kPrimary)), color: _kPrimary)),
), ),
), ),
child: _scheduleRows(context), child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_slotDurationDropdown(context),
const SizedBox(height: 8),
_scheduleRows(context),
],
),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: ElevatedButton( child: ElevatedButton(
onPressed: _save, onPressed: _isSaving ? null : _save,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: _kPrimary, backgroundColor: _kPrimary,
foregroundColor: Colors.white, foregroundColor: Colors.white,
disabledBackgroundColor: _kPrimary.withOpacity(0.5),
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)), borderRadius: BorderRadius.circular(12)),
elevation: 0, elevation: 0,
), ),
child: const Text('Guardar cambios', child: _isSaving
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), ? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Text('Guardar cambios',
style:
TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
), ),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
@@ -481,6 +548,37 @@ class _ProfessionalProfileScreenState
]); ]);
} }
Widget _slotDurationDropdown(BuildContext context) {
const options = [15, 20, 30, 45, 60, 90, 120];
final value = options.contains(_slotDurationMinutes)
? _slotDurationMinutes
: 120;
return DropdownButtonFormField<int>(
value: value,
decoration: InputDecoration(
labelText: 'Duración de cada cita',
prefixIcon: Icon(Icons.timer_outlined, color: context.muted),
filled: true,
fillColor: context.isDark
? Colors.white.withOpacity(0.05)
: Colors.black.withOpacity(0.04),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
),
items: options
.map((m) => DropdownMenuItem(
value: m,
child: Text(m < 60 ? '$m min' : '${m ~/ 60}h${m % 60 == 0 ? '' : ' ${m % 60}min'}'),
))
.toList(),
onChanged: (v) {
if (v != null) setState(() => _slotDurationMinutes = v);
},
);
}
Widget _scheduleRows(BuildContext context) { Widget _scheduleRows(BuildContext context) {
final days = <_DayEntry>[ final days = <_DayEntry>[
_DayEntry('Lunes', schedules.monday), _DayEntry('Lunes', schedules.monday),
@@ -565,6 +663,26 @@ class _ProfessionalProfileScreenState
} }
void _save() { void _save() {
// Saving with the rate switch on and the field empty used to send
// `rate: ''`, and the patient then saw a professional with no price.
if (rateValue) {
final rate = int.tryParse(_rateController.text.trim()) ?? 0;
if (rate <= 0) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Escribe el valor de tu tarifa o desactívala'),
));
return;
}
}
if (_addressController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Escribe la dirección donde atiendes'),
));
return;
}
context.read<ProfessionalProfileBloc>().add(UpdateProfessionalProfileInfo( context.read<ProfessionalProfileBloc>().add(UpdateProfessionalProfileInfo(
address: _addressController.text, address: _addressController.text,
aditionalAddress: _aditionalAddressController.text, aditionalAddress: _aditionalAddressController.text,
@@ -584,15 +702,13 @@ class _ProfessionalProfileScreenState
), ),
schedules: schedules, schedules: schedules,
ratePreferences: rateValue, ratePreferences: rateValue,
rate: _rateController.text, rate: rateValue ? _rateController.text.trim() : '',
slotDurationMinutes: _slotDurationMinutes,
)); ));
context.read<ProfessionalProfileBloc>().add( if (_imageFile != null) {
UpdateProfessionalBannerInfo(fileBanner: _imageFile?.path)); context.read<ProfessionalProfileBloc>().add(
UpdateProfessionalBannerInfo(fileBanner: _imageFile!.path));
ScaffoldMessenger.of(context).clearSnackBars(); }
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Información actualizada correctamente')),
);
} }
} }
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:injector/injector.dart';
import 'package:professional_repository/professional_repository.dart'; import 'package:professional_repository/professional_repository.dart';
import 'package:prosappco/components/general_primary_button.dart'; import 'package:prosappco/components/general_primary_button.dart';
import 'package:prosappco/screens/professional/components/schedule_item.dart'; import 'package:prosappco/screens/professional/components/schedule_item.dart';
import 'package:prosappco/utils/schedule_validation.dart';
class ProfessionalScheduleScreen extends StatefulWidget { class ProfessionalScheduleScreen extends StatefulWidget {
Schedules schedules; Schedules schedules;
@@ -17,16 +19,85 @@ class ProfessionalScheduleScreen extends StatefulWidget {
class _ProfessionalScheduleScreenState class _ProfessionalScheduleScreenState
extends State<ProfessionalScheduleScreen> { extends State<ProfessionalScheduleScreen> {
final _repo = Injector.appInstance.get<ApiProfessionalRepository>();
Schedules schedules = Schedules.empty; Schedules schedules = Schedules.empty;
bool _saving = false;
bool _dirty = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
schedules = widget.schedules; schedules = widget.schedules;
} }
void _update(Schedules next) {
setState(() {
schedules = next;
_dirty = true;
});
}
/// Saving now writes to the backend instead of just handing the object back:
/// the old flow depended on the profile screen being saved afterwards, so
/// leaving without that second save silently discarded everything.
Future<void> _save() async {
final problems = validateSchedules(schedules);
if (problems.isNotEmpty) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(problems.first)),
);
return;
}
setState(() => _saving = true);
try {
await _repo.updateSchedules(schedules);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Horario guardado')),
);
Navigator.of(context).pop(schedules);
} catch (e) {
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'No se pudo guardar el horario. Revisa tu conexión e inténtalo de nuevo.')),
);
}
}
Future<bool> _confirmLeave() async {
if (!_dirty || _saving) return true;
final leave = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('¿Salir sin guardar?'),
content: const Text('Perderás los cambios que hiciste en tu horario.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Seguir editando')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Salir sin guardar')),
],
),
);
return leave ?? false;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return PopScope(
canPop: false,
onPopInvoked: (didPop) async {
if (didPop) return;
if (await _confirmLeave() && mounted) Navigator.of(context).pop();
},
child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Horario'), title: const Text('Horario'),
), ),
@@ -39,63 +110,62 @@ class _ProfessionalScheduleScreenState
label: "Lunes", label: "Lunes",
schedule: schedules.monday, schedule: schedules.monday,
onChanged: (s) { onChanged: (s) {
setState(() => schedules = schedules.copyWith(monday: s)); _update(schedules.copyWith(monday: s));
}, },
), ),
ScheduleItem( ScheduleItem(
label: "Martes", label: "Martes",
schedule: schedules.tuesday, schedule: schedules.tuesday,
onChanged: (s) { onChanged: (s) {
setState(() => schedules = schedules.copyWith(tuesday: s)); _update(schedules.copyWith(tuesday: s));
}, },
), ),
ScheduleItem( ScheduleItem(
label: "Miercoles", label: "Miercoles",
schedule: schedules.wednesday, schedule: schedules.wednesday,
onChanged: (s) { onChanged: (s) {
setState(() => schedules = schedules.copyWith(wednesday: s)); _update(schedules.copyWith(wednesday: s));
}, },
), ),
ScheduleItem( ScheduleItem(
label: "Jueves", label: "Jueves",
schedule: schedules.thursday, schedule: schedules.thursday,
onChanged: (s) { onChanged: (s) {
setState(() => schedules = schedules.copyWith(thursday: s)); _update(schedules.copyWith(thursday: s));
}, },
), ),
ScheduleItem( ScheduleItem(
label: "Viernes", label: "Viernes",
schedule: schedules.friday, schedule: schedules.friday,
onChanged: (s) { onChanged: (s) {
setState(() => schedules = schedules.copyWith(friday: s)); _update(schedules.copyWith(friday: s));
}, },
), ),
ScheduleItem( ScheduleItem(
label: "Sabado", label: "Sabado",
schedule: schedules.saturday, schedule: schedules.saturday,
onChanged: (s) { onChanged: (s) {
setState(() => schedules = schedules.copyWith(saturday: s)); _update(schedules.copyWith(saturday: s));
}, },
), ),
ScheduleItem( ScheduleItem(
label: "Domingo", label: "Domingo",
schedule: schedules.sunday, schedule: schedules.sunday,
onChanged: (s) { onChanged: (s) {
setState(() => schedules = schedules.copyWith(sunday: s)); _update(schedules.copyWith(sunday: s));
}, },
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
GeneralPrimaryButton( GeneralPrimaryButton(
label: "Guardar", label: _saving ? "Guardando…" : "Guardar",
onPressed: () { onPressed: _saving ? () {} : _save,
Navigator.of(context).pop(schedules);
},
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
], ],
), ),
), ),
), ),
),
); );
} }
} }
+347 -302
View File
@@ -1,7 +1,10 @@
import 'dart:io'; import 'dart:io';
import 'package:city_repository/city_repository.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:injector/injector.dart'; import 'package:injector/injector.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:intl_phone_field/helpers.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
@@ -15,6 +18,19 @@ import 'package:prosappco/screens/profile/components/profile_item.dart';
import 'package:prosappco/screens/profile/profile_register_email_screen.dart'; import 'package:prosappco/screens/profile/profile_register_email_screen.dart';
import 'package:prosappco/screens/profile/profile_register_phone_screen.dart'; import 'package:prosappco/screens/profile/profile_register_phone_screen.dart';
import 'package:prosappco/screens/profile/profile_update_password_screen.dart'; import 'package:prosappco/screens/profile/profile_update_password_screen.dart';
import 'package:prosappco/utils/nominatim_geocoder.dart';
const _kPrimary = Color(0xFF1565C0);
extension _Th on BuildContext {
ThemeData get _t => Theme.of(this);
Color get onSurface => _t.colorScheme.onSurface;
Color get muted => _t.colorScheme.onSurface.withOpacity(0.55);
Color get card => _t.cardColor;
bool get isDark => _t.brightness == Brightness.dark;
Color get shadowSm =>
isDark ? Colors.transparent : Colors.black.withOpacity(0.05);
}
class ProfileScreen extends StatefulWidget { class ProfileScreen extends StatefulWidget {
const ProfileScreen({super.key}); const ProfileScreen({super.key});
@@ -27,14 +43,13 @@ class _ProfileScreenState extends State<ProfileScreen> {
final TextEditingController _nameController = TextEditingController(); final TextEditingController _nameController = TextEditingController();
final TextEditingController _cityController = TextEditingController(); final TextEditingController _cityController = TextEditingController();
final TextEditingController _emailController = TextEditingController(); final TextEditingController _emailController = TextEditingController();
final TextEditingController _newEmailController = TextEditingController();
final TextEditingController _phoneController = TextEditingController(); final TextEditingController _phoneController = TextEditingController();
final TextEditingController _birthdayController = TextEditingController(); final TextEditingController _birthdayController = TextEditingController();
final TextEditingController _genderController = TextEditingController(); final TextEditingController _genderController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
XFile? _imageFile; XFile? _imageFile;
bool isLoading = false; bool isLoading = false;
bool _isLocating = false;
late final AuthBloc authBloc; late final AuthBloc authBloc;
@@ -49,44 +64,97 @@ class _ProfileScreenState extends State<ProfileScreen> {
_nameController.dispose(); _nameController.dispose();
_cityController.dispose(); _cityController.dispose();
_emailController.dispose(); _emailController.dispose();
_newEmailController.dispose();
_phoneController.dispose(); _phoneController.dispose();
_birthdayController.dispose(); _birthdayController.dispose();
_genderController.dispose(); _genderController.dispose();
_passwordController.dispose();
super.dispose(); super.dispose();
} }
Future<void> _useMyLocation() async {
setState(() => _isLocating = true);
try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) throw Exception('Location services disabled');
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
throw Exception('Location permission denied');
}
final position = await Geolocator.getCurrentPosition();
final cityName = await NominatimGeocoder.reverseGeocodeCity(
position.latitude, position.longitude);
if (cityName == null) throw Exception('City not resolved');
final cities =
await Injector.appInstance.get<CityRepository>().getCities();
final normalized = removeDiacritics(cityName.toLowerCase().trim());
CityUi? match;
for (final c in cities) {
if (removeDiacritics(c.cityName.toLowerCase().trim()) == normalized) {
match = c;
break;
}
}
if (match == null) {
for (final c in cities) {
final cName = removeDiacritics(c.cityName.toLowerCase());
if (cName.contains(normalized) || normalized.contains(cName)) {
match = c;
break;
}
}
}
if (!mounted) return;
if (match != null) {
setState(() => _cityController.text = match!.cityName);
} else {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text(
'No encontramos tu ciudad en la lista, selecciónala manualmente'),
));
}
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('No se pudo obtener tu ubicación, selecciona tu ciudad manualmente'),
));
} finally {
if (mounted) setState(() => _isLocating = false);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<AuthBloc>( return BlocProvider<AuthBloc>(
create: (context) => authBloc, create: (_) => authBloc,
child: BlocListener<ProfileBloc, ProfileState>( child: BlocListener<ProfileBloc, ProfileState>(
listener: (context, state) { listener: (context, state) {
if (state is UpdateUserInfoLoading) { if (state is UpdateUserInfoLoading) {
setState(() { setState(() => isLoading = true);
isLoading = true;
});
} else if (state is UpdateUserInfoSuccess) { } else if (state is UpdateUserInfoSuccess) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar( ScaffoldMessenger.of(context).showSnackBar(
content: Text('Información actualizada'), const SnackBar(content: Text('Información actualizada')));
)); setState(() => isLoading = false);
setState(() {
isLoading = false;
});
} else if (state is UpdateUserInfoFailure) { } else if (state is UpdateUserInfoFailure) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar( ScaffoldMessenger.of(context).showSnackBar(
content: Text('Error al actualizar la información'), const SnackBar(content: Text('Error al actualizar')));
)); setState(() => isLoading = false);
setState(() {
isLoading = false;
});
} }
}, },
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Perfil'), title: const Text('Mi perfil'),
backgroundColor: _kPrimary,
foregroundColor: Colors.white,
elevation: 0,
), ),
body: BlocBuilder<MyUserBloc, MyUserState>( body: BlocBuilder<MyUserBloc, MyUserState>(
builder: (context, state) { builder: (context, state) {
@@ -102,195 +170,21 @@ class _ProfileScreenState extends State<ProfileScreen> {
children: [ children: [
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
child: Padding( child: Column(
padding: const EdgeInsets.symmetric( crossAxisAlignment: CrossAxisAlignment.stretch,
horizontal: 40, vertical: 10), children: [
child: Column( _avatarSection(state, context),
crossAxisAlignment: CrossAxisAlignment.stretch, const SizedBox(height: 8),
children: [ _formSection(state, context),
pictureWidget(state, context), ],
const SizedBox(height: 30),
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Nombre',
prefixIcon: Icon(Icons.person),
hintText: 'Nombre (obligatorio)',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(10.0),
)),
errorBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.red),
),
focusedErrorBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.red, width: 2.0),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Por favor, ingrese su nombre';
}
return null;
},
),
const SizedBox(height: 20.0),
TextFormField(
controller: _cityController,
readOnly: true,
onTap: () async {
final cityName = await Navigator.push(
context,
CupertinoPageRoute(
builder: (BuildContext context) {
return const CityListScreen();
},
),
);
if (cityName != null) {
_cityController.text = cityName;
}
},
decoration: const InputDecoration(
labelText: 'Ciudad',
prefixIcon: Icon(Icons.near_me_rounded),
hintText: 'Selecciona tu ciudad',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(10.0),
)),
errorBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.red),
),
focusedErrorBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.red, width: 2.0),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Por favor, ingrese su nombre';
}
return null;
},
),
_birthdayController.text.isEmpty &&
_genderController.text.isEmpty
? Column(
children: [
const SizedBox(height: 20.0),
BirthdayPicker(
onDateSelected: (birthDay) {
_birthdayController.text =
DateFormat('dd/MM/yyyy')
.format(birthDay);
},
controller: _birthdayController,
),
const SizedBox(height: 20.0),
GenderDropdown(
controller: _genderController,
),
],
)
: const SizedBox(),
const SizedBox(height: 20),
ProfileItem(
title: 'Configurar inicio de sesión con correo',
subtitle: _emailController.text,
leading: Icons.email_rounded,
onTap: () {
if (state.user!.name == null ||
state.user!.name == '') {
ScaffoldMessenger.of(context)
.clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Por favor, ingrese su nombre')));
return;
}
if (state.user!.city == null ||
state.user!.city == '') {
ScaffoldMessenger.of(context)
.clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Por favor, ingrese su ciudad')));
return;
}
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => _emailController
.text.isEmpty
? ProfileRegisterEmailScreen()
: ProfileUpdatePasswordScreen(
email: _emailController.text)),
);
},
),
const SizedBox(height: 20),
ProfileItem(
title:
'Configurar inicio de sesión con celular',
subtitle: _phoneController.text,
leading: Icons.phone_iphone_rounded,
onTap: () {
if (state.user!.name == null ||
state.user!.name == '') {
ScaffoldMessenger.of(context)
.clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Por favor, ingrese su nombre')));
return;
}
if (state.user!.city == null ||
state.user!.city == '') {
ScaffoldMessenger.of(context)
.clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Por favor, ingrese su nombre')));
return;
}
if (_phoneController.text.isEmpty) {
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) =>
const ProfileRegisterPhoneScreen(),
),
);
}
},
),
const SizedBox(height: 10),
],
),
), ),
), ),
), ),
const Divider( const Divider(height: 1, thickness: 0.5),
height: 1,
thickness: 0.5,
),
Padding( Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
vertical: 10, vertical: 12, horizontal: 16),
horizontal: 15, child: _saveButton(state, context),
),
child: saveButton(state, context),
), ),
], ],
); );
@@ -304,29 +198,220 @@ class _ProfileScreenState extends State<ProfileScreen> {
); );
} }
Widget saveButton(MyUserState state, BuildContext context) { Widget _avatarSection(MyUserState state, BuildContext context) {
return Container(
color: _kPrimary,
padding: const EdgeInsets.only(bottom: 28),
child: Center(
child: Stack(
children: [
GestureDetector(
onTap: () async {
final picker = ImagePicker();
final image = await picker.pickImage(
source: ImageSource.gallery,
maxHeight: 500,
maxWidth: 500,
imageQuality: 40,
);
if (image != null) setState(() => _imageFile = image);
},
child: Hero(
tag: 'picture-profile',
child: _avatarContainer(state),
),
),
Positioned(
bottom: 0,
right: 0,
child: Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.15), blurRadius: 4)
],
),
child:
const Icon(Icons.camera_alt_outlined, size: 16, color: _kPrimary),
),
),
],
),
),
);
}
Widget _avatarContainer(MyUserState state) {
ImageProvider<Object>? imageProvider;
if (_imageFile?.path != null && _imageFile!.path.isNotEmpty) {
imageProvider = FileImage(File(_imageFile!.path));
} else if (state.user?.picture != null &&
state.user!.picture!.isNotEmpty) {
imageProvider = NetworkImage(state.user!.picture!);
}
return Container(
width: 96,
height: 96,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
image: imageProvider == null
? null
: DecorationImage(image: imageProvider, fit: BoxFit.cover),
),
child: imageProvider == null
? const Icon(CupertinoIcons.person, color: Colors.white70, size: 44)
: null,
);
}
Widget _formSection(MyUserState state, BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 16),
_field(
controller: _nameController,
label: 'Nombre',
icon: Icons.person_outline_rounded,
validator: (v) =>
(v == null || v.isEmpty) ? 'Ingresa tu nombre' : null,
),
const SizedBox(height: 14),
TextFormField(
controller: _cityController,
readOnly: true,
onTap: () async {
final cityName = await Navigator.push<String>(
context,
CupertinoPageRoute(builder: (_) => const CityListScreen()),
);
if (cityName != null) _cityController.text = cityName;
},
decoration: _inputDeco(
'Ciudad',
Icons.location_city_outlined,
hint: 'Selecciona tu ciudad',
suffixIcon: _isLocating
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: IconButton(
icon: const Icon(Icons.my_location_outlined),
tooltip: 'Usar mi ubicación',
onPressed: _useMyLocation,
),
),
),
if (_birthdayController.text.isEmpty &&
_genderController.text.isEmpty) ...[
const SizedBox(height: 14),
BirthdayPicker(
onDateSelected: (d) => _birthdayController.text =
DateFormat('dd/MM/yyyy').format(d),
controller: _birthdayController,
),
const SizedBox(height: 14),
GenderDropdown(controller: _genderController),
],
const SizedBox(height: 20),
Container(
decoration: BoxDecoration(
color: context.card,
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(color: context.shadowSm, blurRadius: 8)],
),
child: Column(
children: [
ProfileItem(
title: 'Inicio de sesión con correo',
subtitle: _emailController.text,
leading: Icons.email_outlined,
onTap: () {
if (!_validateProfile(state, context)) return;
Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => _emailController.text.isEmpty
? ProfileRegisterEmailScreen()
: ProfileUpdatePasswordScreen(
email: _emailController.text),
),
);
},
),
Divider(
height: 1,
thickness: 0.5,
color: context.onSurface.withOpacity(0.08)),
ProfileItem(
title: 'Inicio de sesión con celular',
subtitle: _phoneController.text,
leading: Icons.phone_iphone_rounded,
onTap: () {
if (!_validateProfile(state, context)) return;
if (_phoneController.text.isEmpty) {
Navigator.push(
context,
CupertinoPageRoute(
builder: (_) =>
const ProfileRegisterPhoneScreen()),
);
}
},
),
],
),
),
const SizedBox(height: 16),
],
),
);
}
bool _validateProfile(MyUserState state, BuildContext context) {
if (state.user!.name == null || state.user!.name == '') {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Por favor, ingresa tu nombre')));
return false;
}
if (state.user!.city == null || state.user!.city == '') {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Por favor, ingresa tu ciudad')));
return false;
}
return true;
}
Widget _saveButton(MyUserState state, BuildContext context) {
return ElevatedButton( return ElevatedButton(
onPressed: () { onPressed: () {
if (isLoading) { if (isLoading) return;
return;
}
if (_nameController.text.isEmpty) { if (_nameController.text.isEmpty) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Por favor, ingrese su nombre'))); const SnackBar(content: Text('Por favor, ingresa tu nombre')));
return; return;
} }
if (_cityController.text.isEmpty) { if (_cityController.text.isEmpty) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Por favor, ingrese su ciudad'))); const SnackBar(content: Text('Por favor, ingresa tu ciudad')));
return; return;
} }
final myUser = state.user!.copyWith( final myUser = state.user!.copyWith(
name: _nameController.text, name: _nameController.text,
city: _cityController.text, city: _cityController.text,
@@ -336,102 +421,62 @@ class _ProfileScreenState extends State<ProfileScreen> {
birthday: _birthdayController.text, birthday: _birthdayController.text,
gender: _genderController.text, gender: _genderController.text,
); );
context context
.read<ProfileBloc>() .read<ProfileBloc>()
.add(UpdateUserInfo(myUser: myUser, filePicture: _imageFile?.path)); .add(UpdateUserInfo(myUser: myUser, filePicture: _imageFile?.path));
ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Información actualizada...')), const SnackBar(content: Text('Actualizando información...')));
);
Navigator.pop(context); Navigator.pop(context);
}, },
style: FilledButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary, backgroundColor: _kPrimary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 15), padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder( elevation: 0,
borderRadius: BorderRadius.circular(10), shape:
), RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
), ),
child: Container( child: isLoading
alignment: Alignment.center, ? const SizedBox(
child: isLoading height: 20,
? const CircularProgressIndicator( width: 20,
color: Colors.white, child: CircularProgressIndicator(
) strokeWidth: 2, color: Colors.white))
: const Text( : const Text('Guardar cambios',
'Actualizar', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
style: TextStyle( );
color: Colors.white, }
fontSize: 18,
), static InputDecoration _inputDeco(String label, IconData icon,
), {String? hint, Widget? suffixIcon}) {
return InputDecoration(
labelText: label,
hintText: hint,
prefixIcon: Icon(icon),
suffixIcon: suffixIcon,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: _kPrimary, width: 2),
), ),
); );
} }
Widget pictureWidget(MyUserState state, BuildContext context) { Widget _field({
final pictureUrl = state.user?.picture; required TextEditingController controller,
final pathImageFile = _imageFile?.path; required String label,
required IconData icon,
ImageProvider<Object>? imageProvider; String? Function(String?)? validator,
}) {
if (pathImageFile != null && pathImageFile.isNotEmpty) { return TextFormField(
imageProvider = FileImage(File(pathImageFile)); controller: controller,
} else if (pictureUrl != null && pictureUrl.isNotEmpty) { decoration: _inputDeco(label, icon),
imageProvider = NetworkImage(pictureUrl); validator: validator,
}
return GestureDetector(
onTap: () async {
final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage(
source: ImageSource.gallery,
maxHeight: 500,
maxWidth: 500,
imageQuality: 40,
);
if (image != null) {
setState(() {
_imageFile = image;
});
}
},
child: Hero(
tag: 'picture-profile',
child: pictureContainerWidget(imageProvider),
),
);
}
Widget pictureContainerWidget(ImageProvider<Object>? imageProvider) {
final image = imageProvider == null
? null
: DecorationImage(
image: imageProvider,
fit: BoxFit.contain,
);
final widget = image == null
? Icon(
CupertinoIcons.person,
color: Colors.grey.shade400,
size: 40,
)
: null;
return Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
image: image,
),
child: widget,
); );
} }
} }
+306 -329
View File
@@ -10,9 +10,21 @@ import 'package:score_repository/score_repository.dart';
import 'package:service_repository/service_repository.dart'; import 'package:service_repository/service_repository.dart';
import 'package:user_repository/user_repository.dart'; import 'package:user_repository/user_repository.dart';
const _kPrimary = Color(0xFF1565C0);
extension _Th on BuildContext {
ThemeData get _t => Theme.of(this);
Color get bg => _t.scaffoldBackgroundColor;
Color get card => _t.cardColor;
Color get onSurface => _t.colorScheme.onSurface;
Color get muted => _t.colorScheme.onSurface.withOpacity(0.55);
bool get isDark => _t.brightness == Brightness.dark;
Color get shadowSm =>
isDark ? Colors.transparent : Colors.black.withOpacity(0.05);
}
class ScoreScreen extends StatefulWidget { class ScoreScreen extends StatefulWidget {
final ServiceEntity service; final ServiceEntity service;
const ScoreScreen({Key? key, required this.service}) : super(key: key); const ScoreScreen({Key? key, required this.service}) : super(key: key);
@override @override
@@ -21,20 +33,22 @@ class ScoreScreen extends StatefulWidget {
class _ScoreScreenState extends State<ScoreScreen> { class _ScoreScreenState extends State<ScoreScreen> {
double _rating = 1.0; double _rating = 1.0;
TextEditingController commentController = TextEditingController(); final TextEditingController _commentController = TextEditingController();
late Future<List<dynamic>> _userInfoFuture; late Future<List<dynamic>> _userInfoFuture;
late bool isProfessional; late bool isProfessional;
late String userId; late String userId;
bool _isSending = false;
/// True when the person rating is the client (they rate the professional).
bool get isUser =>
widget.service.userId == (ApiUserRepository.currentUserId ?? '');
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_userInfoFuture = _getUserAndProfessionalInfo(widget.service); _userInfoFuture = _getUserInfo(widget.service);
isProfessional = isProfessional =
ApiUserRepository.currentUserId ?? '' == widget.service.userId (ApiUserRepository.currentUserId ?? '') == widget.service.userId;
? true
: false;
userId = userId =
isProfessional ? widget.service.professionalId : widget.service.userId; isProfessional ? widget.service.professionalId : widget.service.userId;
} }
@@ -42,10 +56,45 @@ class _ScoreScreenState extends State<ScoreScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider( return BlocProvider(
create: (context) => Injector.appInstance.get<ScoreBloc>(), create: (_) => Injector.appInstance.get<ScoreBloc>(),
child: Scaffold( child: BlocListener<ScoreBloc, ScoreState>(
listener: (context, state) {
if (state is ScoreSending) {
setState(() => _isSending = true);
} else if (state is ScoreSent) {
setState(() => _isSending = false);
// Only now is the rating actually stored, so only now do we mark
// the service as scored and leave the screen.
if (isUser) {
context
.read<ServiceBloc>()
.add(UpdateProfessionalScored(widget.service.id!));
} else {
context
.read<ServiceBloc>()
.add(UpdateUserScored(widget.service.id!));
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('¡Gracias por tu calificación!')),
);
Navigator.pop(context);
} else if (state is ScoreSendFailure) {
setState(() => _isSending = false);
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'No se pudo enviar tu calificación. Inténtalo de nuevo.')),
);
}
},
child: Scaffold(
backgroundColor: context.bg,
appBar: AppBar( appBar: AppBar(
title: const Text('Calificación'), title: const Text('Calificar servicio'),
backgroundColor: _kPrimary,
foregroundColor: Colors.white,
elevation: 0,
), ),
body: Column( body: Column(
children: [ children: [
@@ -53,353 +102,281 @@ class _ScoreScreenState extends State<ScoreScreen> {
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
children: [ children: [
FutureBuilder( FutureBuilder<List<dynamic>>(
future: _userInfoFuture, future: _userInfoFuture,
builder: builder: (context, snapshot) {
(context, AsyncSnapshot<List<dynamic>> snapshot) {
if (snapshot.connectionState == if (snapshot.connectionState ==
ConnectionState.waiting) { ConnectionState.waiting) {
return const Center( return const Padding(
child: CircularProgressIndicator()); padding: EdgeInsets.all(40),
} else { child: CircularProgressIndicator(),
if (snapshot.hasError) { );
return Center(
child:
Text('Error inesperado: ${snapshot.error}'),
);
} else {
final userInfo = snapshot.data![0] as MyUser;
return SizedBox(
width: double.infinity,
child: Stack(
alignment: Alignment.center,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 95,
height: 95,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
image: userInfo.picture == null
? null
: DecorationImage(
image: NetworkImage(
userInfo.picture!),
fit: BoxFit.contain,
),
),
child: userInfo.picture == null
? Icon(
CupertinoIcons.person,
color: Colors.grey.shade400,
size: 50,
)
: null,
),
const SizedBox(height: 8),
Text(
'${userInfo.name}',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(
height: 15,
)
],
),
GeneralReputation(
userId: userId,
builder: (BuildContext context,
ReputationEntity reputation) {
final double average;
if (isProfessional) {
average = reputation.averagePro;
} else {
average = reputation.average;
}
return Positioned(
top: 3,
right:
MediaQuery.of(context).size.width *
0.3,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 3,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius:
BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black
.withOpacity(0.1),
spreadRadius: 1,
blurRadius: 2,
offset: const Offset(
0,
1,
),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.star,
color: Colors.yellow,
size: 20,
),
const SizedBox(width: 4),
Flexible(
child: Text(
average.toStringAsFixed(2),
style: const TextStyle(
fontSize: 15,
),
),
),
],
),
),
);
},
)
],
),
);
// Padding(
// padding: const EdgeInsets.symmetric(horizontal: 15),
// child: ListTile(
// leading: Container(
// width: 60,
// height: 60,
// decoration: BoxDecoration(
// color: Colors.grey.shade300,
// shape: BoxShape.circle,
// image: userInfo.picture == null
// ? null
// : DecorationImage(
// image: NetworkImage(
// userInfo.picture ?? ''),
// fit: BoxFit.contain,
// ),
// ),
// child: userInfo.picture == null
// ? Icon(
// CupertinoIcons.person,
// color: Colors.grey.shade400,
// size: 40,
// )
// : null,
// ),
// title: Text(
// userInfo.name ?? '',
// overflow: TextOverflow.ellipsis,
// style: const TextStyle(
// fontSize: 15,
// fontWeight: FontWeight.w600,
// ),
// ),
// subtitle: const Text(
// 'Rating: 5.0',
// style: TextStyle(
// fontSize: 13,
// color: Colors.blue,
// ),
// ),
// ),
// );
}
} }
if (snapshot.hasError) {
return Padding(
padding: const EdgeInsets.all(24),
child: Text('Error: ${snapshot.error}'),
);
}
final userInfo = snapshot.data![0] as MyUser;
return _profileHeader(context, userInfo);
}, },
), ),
const SizedBox(height: 10), const SizedBox(height: 4),
const Text( _ratingSection(context),
'Califica el servicio', _commentSection(context),
style:
TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
),
const SizedBox(height: 10),
RatingBar.builder(
initialRating: _rating,
minRating: 1,
direction: Axis.horizontal,
allowHalfRating: true,
itemCount: 5,
itemSize: 40,
glow: false,
maxRating: 5,
itemPadding: const EdgeInsets.symmetric(horizontal: 5),
itemBuilder: (context, _) => const Icon(
Icons.star,
color: Color(0xFF2BA4EC),
),
onRatingUpdate: (rating) {
setState(() {
_rating = rating;
});
},
ignoreGestures: false,
),
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
customMessage(_rating),
style: const TextStyle(
fontSize: 16,
color: Color(0xFF2BA4EC),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 40, vertical: 40),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Comentario',
style: TextStyle(
fontSize: 20, fontWeight: FontWeight.w600),
),
TextFormField(
maxLines: null,
maxLength: 400,
keyboardType: TextInputType.multiline,
controller: commentController,
),
],
),
),
], ],
), ),
), ),
), ),
const Divider( const Divider(height: 1, thickness: 0.5),
height: 1,
thickness: 0.5,
),
BlocProvider<ServiceBloc>( BlocProvider<ServiceBloc>(
create: (context) => Injector.appInstance.get<ServiceBloc>(), create: (_) => Injector.appInstance.get<ServiceBloc>(),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
vertical: 10, vertical: 12, horizontal: 16),
horizontal: 15,
),
child: BlocBuilder<ServiceBloc, ServiceState>( child: BlocBuilder<ServiceBloc, ServiceState>(
builder: (context, serviceState) { builder: (context, serviceState) {
return FilledButton( return _sendButton(context);
onPressed: () {
final CommentEntity comment = widget.service.userId ==
ApiUserRepository.currentUserId ?? ''
? CommentEntity(
serviceId: widget.service.id!,
authorId:
ApiUserRepository.currentUserId ?? '',
isFromUser: true,
score: _rating,
destinationId: widget.service.professionalId,
content: commentController.text.trim(),
createdAt: DateTime.now().toIso8601String(),
)
: CommentEntity(
serviceId: widget.service.id!,
authorId:
ApiUserRepository.currentUserId ?? '',
isFromUser: false,
score: _rating,
destinationId: widget.service.userId,
content: commentController.text.trim(),
createdAt: DateTime.now().toIso8601String(),
);
BlocProvider.of<ScoreBloc>(context)
.add(SendScoreEvent(comment: comment));
if (widget.service.userId ==
ApiUserRepository.currentUserId ?? '') {
context.read<ServiceBloc>().add(
UpdateProfessionalScored(widget.service.id!));
} else {
context
.read<ServiceBloc>()
.add(UpdateUserScored(widget.service.id!));
}
Navigator.pop(context);
},
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: Container(
alignment: Alignment.center,
width: double.infinity,
child: const Text(
'Enviar calificación',
style: TextStyle(
color: Colors.white,
fontSize: 18,
),
),
),
);
}, },
), ),
), ),
), ),
], ],
), ),
),
), ),
); );
} }
String customMessage(double rating) { Widget _profileHeader(BuildContext context, MyUser user) {
if (rating > 4.0) { return Container(
return '¡Excelente! 👏🌟'; width: double.infinity,
} margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
if (rating < 2.0) { decoration: BoxDecoration(
return '¡Malo! 😔❌'; color: context.card,
} borderRadius: BorderRadius.circular(16),
boxShadow: [
if (rating <= 4.0 && rating >= 3.0) { BoxShadow(
return '¡Bueno! 👍😊'; color: context.shadowSm,
} blurRadius: 12,
offset: const Offset(0, 2))
if (rating < 3.0 && rating >= 2.0) { ],
return '¡Regular! 😐🔄'; ),
} child: Stack(
alignment: Alignment.center,
return ''; children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 84,
height: 84,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: _kPrimary.withOpacity(0.25), width: 3),
color: Colors.grey.shade200,
image: user.picture != null && user.picture!.isNotEmpty
? DecorationImage(
image: NetworkImage(user.picture!),
fit: BoxFit.cover)
: null,
),
child: user.picture == null || user.picture!.isEmpty
? Icon(CupertinoIcons.person,
color: Colors.grey.shade500, size: 38)
: null,
),
const SizedBox(height: 10),
Text(user.name ?? '',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: context.onSurface)),
],
),
GeneralReputation(
userId: userId,
builder: (_, ReputationEntity reputation) {
final average = isProfessional
? reputation.averagePro
: reputation.average;
return Positioned(
top: 0,
right: MediaQuery.of(context).size.width * 0.18,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: context.card,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: context.shadowSm,
blurRadius: 6,
spreadRadius: 1)
],
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.star, color: Colors.amber, size: 14),
const SizedBox(width: 3),
Text(average.toStringAsFixed(1),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: context.onSurface)),
]),
),
);
},
),
],
),
);
} }
Future<List<dynamic>> _getUserAndProfessionalInfo( Widget _ratingSection(BuildContext context) {
ServiceEntity service) async { return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: context.card,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: context.shadowSm, blurRadius: 8)],
),
child: Column(
children: [
Text('¿Cómo fue el servicio?',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: context.onSurface)),
const SizedBox(height: 14),
RatingBar.builder(
initialRating: _rating,
minRating: 1,
direction: Axis.horizontal,
allowHalfRating: true,
itemCount: 5,
itemSize: 42,
glow: false,
maxRating: 5,
itemPadding: const EdgeInsets.symmetric(horizontal: 4),
itemBuilder: (_, __) =>
const Icon(Icons.star_rounded, color: _kPrimary),
onRatingUpdate: (r) => setState(() => _rating = r),
),
const SizedBox(height: 8),
Text(
_ratingMessage(_rating),
style:
TextStyle(fontSize: 14, color: _kPrimary, fontWeight: FontWeight.w500),
),
],
),
);
}
Widget _commentSection(BuildContext context) {
return Container(
margin: const EdgeInsets.fromLTRB(16, 12, 16, 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.card,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: context.shadowSm, blurRadius: 8)],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Comentario (opcional)',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: context.onSurface)),
const SizedBox(height: 10),
TextFormField(
controller: _commentController,
maxLines: 4,
maxLength: 400,
keyboardType: TextInputType.multiline,
decoration: InputDecoration(
hintText: 'Cuéntanos tu experiencia...',
hintStyle: TextStyle(color: context.muted, fontSize: 13),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: _kPrimary, width: 2),
),
contentPadding: const EdgeInsets.all(12),
),
),
],
),
);
}
Widget _sendButton(BuildContext context) {
return SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isSending
? null
: () {
final comment = CommentEntity(
serviceId: widget.service.id!,
authorId: ApiUserRepository.currentUserId ?? '',
isFromUser: isUser,
score: _rating,
destinationId: isUser
? widget.service.professionalId
: widget.service.userId,
content: _commentController.text.trim(),
createdAt: DateTime.now().toIso8601String(),
);
// No pop here: closing the screen used to kill the BlocProvider
// while the request was still in flight, so a failed rating looked
// exactly like a saved one. The listener closes it on success.
BlocProvider.of<ScoreBloc>(context)
.add(SendScoreEvent(comment: comment));
},
icon: const Icon(Icons.send_rounded, size: 18),
label: const Text('Enviar calificación',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
style: ElevatedButton.styleFrom(
backgroundColor: _kPrimary,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 14),
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
);
}
String _ratingMessage(double rating) {
if (rating > 4.0) return '¡Excelente!';
if (rating < 2.0) return 'Necesita mejorar';
if (rating >= 3.0) return '¡Bien!';
return 'Regular';
}
Future<List<dynamic>> _getUserInfo(ServiceEntity service) async {
final userRepo = Injector.appInstance.get<UserRepository>(); final userRepo = Injector.appInstance.get<UserRepository>();
final currentId = ApiUserRepository.currentUserId ?? '';
final MyUser? userInfo; final targetId =
currentId == service.userId ? service.professionalId : service.userId;
if (ApiUserRepository.currentUserId ?? '' == service.userId) { final userInfo = await userRepo.getMyUser(targetId);
userInfo = await userRepo.getMyUser(service.professionalId);
} else {
userInfo = await userRepo.getMyUser(service.userId);
}
return [userInfo]; return [userInfo];
} }
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injector/injector.dart';
import 'package:prosappco/blocs/suggestion_bloc/suggestion_bloc.dart';
const _kPrimary = Color(0xFF1565C0);
class SuggestionScreen extends StatefulWidget {
const SuggestionScreen({super.key});
@override
State<SuggestionScreen> createState() => _SuggestionScreenState();
}
class _SuggestionScreenState extends State<SuggestionScreen> {
final _formKey = GlobalKey<FormState>();
final _messageController = TextEditingController();
bool _isLoading = false;
@override
void dispose() {
_messageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider<SuggestionBloc>(
create: (_) => Injector.appInstance.get<SuggestionBloc>(),
child: BlocListener<SuggestionBloc, SuggestionState>(
listener: (context, state) {
if (state is SuggestionLoading) {
setState(() => _isLoading = true);
} else if (state is SuggestionSuccess) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('¡Gracias por tu sugerencia!')),
);
Navigator.of(context).pop();
} else if (state is SuggestionFailure) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No se pudo enviar la sugerencia'),
),
);
}
},
child: Scaffold(
appBar: AppBar(title: const Text('Sugerencias')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'¿Tienes una idea o algo que podamos mejorar? '
'Cuéntanos.',
style: TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 16),
TextFormField(
controller: _messageController,
minLines: 5,
maxLines: 8,
maxLength: 1000,
decoration: InputDecoration(
hintText: 'Escribe tu sugerencia aquí...',
alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide:
const BorderSide(color: _kPrimary, width: 2),
),
),
validator: (v) => (v == null || v.trim().isEmpty)
? 'Escribe un mensaje'
: null,
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: _kPrimary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: _isLoading
? null
: () {
if (_formKey.currentState?.validate() != true) {
return;
}
context.read<SuggestionBloc>().add(
SubmitSuggestion(
message: _messageController.text.trim(),
),
);
},
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Enviar'),
),
),
],
),
),
),
),
),
);
}
}
+102 -69
View File
@@ -4,8 +4,8 @@ import 'package:injector/injector.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:professional_repository/professional_repository.dart'; import 'package:professional_repository/professional_repository.dart';
import 'package:prosappco/blocs/professional_list_bloc/professional_list_bloc.dart'; import 'package:prosappco/blocs/professional_list_bloc/professional_list_bloc.dart';
import 'package:prosappco/utils/time_of_day_extension.dart'; import 'package:prosappco/utils/service_day.dart';
import 'package:prosappco/utils/time_of_day_utils.dart'; import 'package:prosappco/utils/slot_generator.dart';
import 'package:service_repository/service_repository.dart'; import 'package:service_repository/service_repository.dart';
import 'package:setting_repository/setting_repository.dart'; import 'package:setting_repository/setting_repository.dart';
import 'package:table_calendar/table_calendar.dart'; import 'package:table_calendar/table_calendar.dart';
@@ -30,6 +30,8 @@ class UserCalendarScreenState extends State<UserCalendarScreen> {
late int numDay; late int numDay;
List<ServiceEntity>? _services; List<ServiceEntity>? _services;
Schedules? _schedules;
bool _loadFailed = false;
CalendarFormat _calendarFormat = CalendarFormat.month; CalendarFormat _calendarFormat = CalendarFormat.month;
@@ -45,21 +47,32 @@ class UserCalendarScreenState extends State<UserCalendarScreen> {
} }
void _loadSettings() { void _loadSettings() {
settingRepository.getSettings().then( settingRepository.getSettings().then((value) {
(value) => setState(() { if (!mounted) return;
settings = value; setState(() => settings = value);
}), }).catchError((_) {
); // Settings only gate optional features; failing to read them must not
// break the screen, but it must not setState after dispose either.
});
} }
void _loadServices() { void _loadServices() {
setState(() => _loadFailed = false);
// Public calendar of *this* professional. The old call returned the
// caller's own agenda, so every slot looked free.
serviceRepository serviceRepository
.getServicesForProfessionalforCalendar( .getPublicCalendar(widget.userProfessional.professionalInfo.recordId)
widget.userProfessional.myUser.id) .then((calendar) {
.then((services) { if (!mounted) return;
setState(() { setState(() {
_services = services; _services = calendar.services;
// Opening hours as the professional has them today, not the copy the
// search list handed over, which can be days old.
_schedules = calendar.schedules;
}); });
}).catchError((e) {
if (!mounted) return;
setState(() => _loadFailed = true);
}); });
} }
@@ -142,27 +155,74 @@ class UserCalendarScreenState extends State<UserCalendarScreen> {
} }
ScheduleEntity? _getScheduleFromNumDay(int numDay) { ScheduleEntity? _getScheduleFromNumDay(int numDay) {
// Prefer the hours the calendar endpoint just returned; fall back to the
// snapshot carried by the search list only until it arrives.
final schedules =
_schedules ?? widget.userProfessional.professionalInfo.schedules;
switch (numDay) { switch (numDay) {
case 1: case 1:
return widget.userProfessional.professionalInfo.schedules.monday; return schedules.monday;
case 2: case 2:
return widget.userProfessional.professionalInfo.schedules.tuesday; return schedules.tuesday;
case 3: case 3:
return widget.userProfessional.professionalInfo.schedules.wednesday; return schedules.wednesday;
case 4: case 4:
return widget.userProfessional.professionalInfo.schedules.thursday; return schedules.thursday;
case 5: case 5:
return widget.userProfessional.professionalInfo.schedules.friday; return schedules.friday;
case 6: case 6:
return widget.userProfessional.professionalInfo.schedules.saturday; return schedules.saturday;
case 7: case 7:
return widget.userProfessional.professionalInfo.schedules.sunday; return schedules.sunday;
default: default:
return null; return null;
} }
} }
List<Widget> _rangesItems(ScheduleEntity? schedule) { List<Widget> _rangesItems(ScheduleEntity? schedule) {
if (_services == null && !_loadFailed) {
// Treating "unknown" as busy is the safe default, but without this the
// patient saw every hour in red and concluded the professional was full.
return [
const Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: Center(
child: Column(children: [
CircularProgressIndicator(),
SizedBox(height: 12),
Text('Consultando disponibilidad…',
style: TextStyle(fontSize: 13, color: Colors.grey)),
]),
),
),
];
}
if (_loadFailed) {
// Slots are hidden rather than shown as free: booking blind is how you
// end up with two people in the same hour.
return [
Padding(
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
child: Column(
children: [
const Icon(Icons.wifi_off_outlined, size: 32, color: Colors.grey),
const SizedBox(height: 10),
const Text('No se pudo cargar la disponibilidad',
textAlign: TextAlign.center,
style: TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
const Text(
'No mostramos horarios para evitar que reserves sobre una cita ya agendada.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: Colors.grey)),
const SizedBox(height: 12),
OutlinedButton(
onPressed: _loadServices, child: const Text('Reintentar')),
],
),
),
];
}
if (schedule == null) { if (schedule == null) {
return [ return [
const Padding( const Padding(
@@ -180,64 +240,37 @@ class UserCalendarScreenState extends State<UserCalendarScreen> {
]; ];
} }
if (schedule.continuousDay) { // One generator for both calendars: what the professional counts as free
if (schedule.range1Hour1 == null || // in his agenda is exactly what shows up here.
schedule.range2Hour2 == null || final ranges = SlotGenerator.forDay(
schedule.range1Hour1!.compareTo(schedule.range2Hour2!) >= 0) { schedule: schedule,
return [ day: today,
const Padding( slotDurationMinutes:
padding: EdgeInsets.symmetric(vertical: 20), widget.userProfessional.professionalInfo.slotDurationMinutes,
child: Text("No hay horarios disponibles"), );
) if (ranges.isEmpty) {
];
}
List<TimeOfDay> ranges = TimeOfDayUtils.genRanges(
schedule.range1Hour1!,
schedule.range2Hour2!,
);
return rangesItemList(ranges, _services, today);
} 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) {
return [
const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Text("No hay horarios disponibles"),
)
];
}
List<TimeOfDay> ranges1 = TimeOfDayUtils.genRanges(
schedule.range1Hour1!,
schedule.range1Hour2!,
);
List<TimeOfDay> ranges2 = TimeOfDayUtils.genRanges(
schedule.range2Hour1!,
schedule.range2Hour2!,
);
return [ return [
...rangesItemList(ranges1, _services, today), const Padding(
...rangesItemList(ranges2, _services, today), padding: EdgeInsets.symmetric(vertical: 20),
child: Text("No hay horarios disponibles"),
)
]; ];
} }
return rangesItemList(ranges, _services, today);
} }
bool _isHora1Ocupada( bool _isHora1Ocupada(
TimeOfDay hora1, List<ServiceEntity>? events, DateTime selectedDay) { TimeOfDay hora1, List<ServiceEntity>? events, DateTime selectedDay) {
if (events != null) { // Treat "could not load the agenda" as busy rather than free: showing a
for (ServiceEntity event in events) { // taken slot as available leads straight to a double booking.
if (selectedDay.toString() == event.day) { if (events == null) return true;
if (hora1 == event.range1Hour1) { for (final event in events) {
return true; if (!isSameServiceDay(selectedDay, event.day)) continue;
} if (event.status == ServiceStatus.cancelled ||
} event.status == ServiceStatus.denied) {
continue;
} }
if (hora1 == event.range1Hour1) return true;
} }
return false; return false;
} }
@@ -256,7 +289,7 @@ class UserCalendarScreenState extends State<UserCalendarScreen> {
); );
if (selectedDateTime if (selectedDateTime
.isBefore(currentDateTime.add(const Duration(hours: 3)))) { .isBefore(currentDateTime.add(kBookingLeadTime))) {
return Card( return Card(
elevation: 4, elevation: 4,
margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 10), margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 10),
+154 -39
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:prosappco/utils/slot_generator.dart';
import 'dart:developer'; import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
@@ -19,9 +20,14 @@ import 'package:prosappco/blocs/service_bloc/service_bloc.dart';
import 'package:prosappco/constansts.dart'; import 'package:prosappco/constansts.dart';
import 'package:prosappco/local_notifications/local_notifications.dart'; import 'package:prosappco/local_notifications/local_notifications.dart';
import 'package:prosappco/screens/lists/professional_list_screen.dart'; import 'package:prosappco/screens/lists/professional_list_screen.dart';
import 'package:prosappco/screens/profile/profile_screen.dart';
import 'package:prosappco/screens/user/user_service_screen.dart'; import 'package:prosappco/screens/user/user_service_screen.dart';
import 'package:prosappco/utils/time_of_day_extension.dart'; import 'package:prosappco/utils/nominatim_geocoder.dart';
import 'package:prosappco/utils/service_day.dart';
import 'package:prosappco/utils/service_day_param.dart';
import 'package:prosappco/utils/version_utils.dart';
import 'package:service_repository/service_repository.dart'; import 'package:service_repository/service_repository.dart';
import 'package:service_repository/service_repository.dart' as srv;
import 'package:setting_repository/setting_repository.dart'; import 'package:setting_repository/setting_repository.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@@ -33,6 +39,7 @@ class UserMapScreen extends StatefulWidget {
} }
class _UserMapScreenState extends State<UserMapScreen> { class _UserMapScreenState extends State<UserMapScreen> {
final _serviceRepository = Injector.appInstance.get<ApiServiceRepository>();
final Completer<GoogleMapController> _mapController = final Completer<GoogleMapController> _mapController =
Completer<GoogleMapController>(); Completer<GoogleMapController>();
LatLng? _currentP; LatLng? _currentP;
@@ -42,7 +49,7 @@ class _UserMapScreenState extends State<UserMapScreen> {
final settingRepository = Injector.appInstance.get<SettingRepository>(); final settingRepository = Injector.appInstance.get<SettingRepository>();
SettingEntity? settings; SettingEntity? settings;
late String appVersion; String appVersion = '';
final DateFormat formatter = DateFormat('dd/MM/yyyy'); final DateFormat formatter = DateFormat('dd/MM/yyyy');
@@ -125,7 +132,7 @@ class _UserMapScreenState extends State<UserMapScreen> {
void _checkForUpdate(SettingEntity settings) { void _checkForUpdate(SettingEntity settings) {
if (Platform.isAndroid) { if (Platform.isAndroid) {
if (appVersion != settings.versionAndroid) { if (isUpdateRequired(appVersion, settings.versionAndroid)) {
showDialog( showDialog(
context: context, context: context,
builder: (context) { builder: (context) {
@@ -165,7 +172,7 @@ class _UserMapScreenState extends State<UserMapScreen> {
} }
} }
if (Platform.isIOS) { if (Platform.isIOS) {
if (appVersion != settings.versionIos) { if (isUpdateRequired(appVersion, settings.versionIos)) {
showDialog( showDialog(
context: context, context: context,
builder: (context) { builder: (context) {
@@ -206,6 +213,44 @@ class _UserMapScreenState extends State<UserMapScreen> {
} }
} }
/// "a, b y c" — join(' y ') read as "a y b y c".
String _enumerar(List<String> items) {
if (items.length <= 1) return items.join();
return '${items.sublist(0, items.length - 1).join(', ')} y ${items.last}';
}
/// Re-reads the professional's agenda right before creating the service.
///
/// Returns true when the slot is still free, or when the agenda cannot be
/// read: blocking a booking because the network hiccuped would be worse
/// than the rare collision the backend can still reject.
Future<bool> _slotStillFree() async {
final pro = profesionalSeleccionado;
final day = fechaSeleccionada;
final hour = horaSeleccionada;
if (pro == null || day == null || hour == null) return false;
try {
final calendar = await _serviceRepository
.getPublicCalendar(pro.professionalInfo.recordId);
final taken = calendar.services.any((s) =>
s.status != srv.ServiceStatus.cancelled &&
s.status != srv.ServiceStatus.denied &&
isSameServiceDay(day, s.day) &&
s.range1Hour1 == hour);
if (!taken) return true;
if (!mounted) return false;
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Ese horario acaba de ocuparse. Elige otro.'),
));
setState(() => horaSeleccionada = null);
return false;
} catch (_) {
return true;
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<ServiceBloc>( return BlocProvider<ServiceBloc>(
@@ -217,11 +262,34 @@ class _UserMapScreenState extends State<UserMapScreen> {
} }
if (serviceState is CreateServiceFailure) { if (serviceState is CreateServiceFailure) {
isLoading = false; isLoading = false;
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text(
'No pudimos agendar tu cita. Revisa tu conexión e inténtalo otra vez.'),
));
} }
if (serviceState is CreateServiceSuccess) { if (serviceState is CreateServiceSuccess) {
isLoading = false; isLoading = false;
final token = profesionalSeleccionado?.myUser.token;
if (token != null) {
LocalNotifications.sendPushNotification(
token,
'Nuevo servicio',
'Tienes una nueva solicitud de servicio pendiente',
);
}
fechaSeleccionada = null;
horaSeleccionada = null;
profesionalSeleccionado = null;
isClearButtonVisible = false;
_observationController.text = '';
serviceLocationPreference = null;
polylines.clear();
markers.clear();
Navigator.push( Navigator.push(
context, context,
CupertinoPageRoute( CupertinoPageRoute(
@@ -377,9 +445,16 @@ class _UserMapScreenState extends State<UserMapScreen> {
state.user?.phone == null) { state.user?.phone == null) {
ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text( content: const Text('Por favor completa tu perfil y agrega tu celular'),
'Por favor completa tu perfil y agrega tu celular'), action: SnackBarAction(
label: 'Ir al perfil',
onPressed: () {
Navigator.push(context, CupertinoPageRoute(
builder: (context) => const ProfileScreen(),
));
},
),
), ),
); );
return; return;
@@ -514,8 +589,23 @@ class _UserMapScreenState extends State<UserMapScreen> {
child: FilledButton( child: FilledButton(
onPressed: isLoading onPressed: isLoading
? null ? null
: () { : () async {
if (profesionalSeleccionado == null) return; // Used to `return` in silence when anything was
// missing, so the main button simply did nothing.
final faltan = <String>[
if (profesionalSeleccionado == null)
'un profesional',
if (fechaSeleccionada == null) 'la fecha',
if (horaSeleccionada == null) 'la hora',
];
if (faltan.isNotEmpty) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
'Falta ${_enumerar(faltan)} para pedir la cita'),
));
return;
}
if (state.user?.name == null || if (state.user?.name == null ||
state.user?.name == '' || state.user?.name == '' ||
@@ -531,6 +621,12 @@ class _UserMapScreenState extends State<UserMapScreen> {
return; return;
} }
// Minutes can pass between picking the hour and
// confirming. Ask the agenda again so two patients
// do not walk away holding the same slot.
if (!await _slotStillFree()) return;
if (!context.mounted) return;
if (serviceLocationPreference == if (serviceLocationPreference ==
ServiceLocationPreferences.office) { ServiceLocationPreferences.office) {
if (settings?.tarifas == true) { if (settings?.tarifas == true) {
@@ -547,12 +643,15 @@ class _UserMapScreenState extends State<UserMapScreen> {
.professionalInfo.latitude, .professionalInfo.latitude,
longitude: profesionalSeleccionado! longitude: profesionalSeleccionado!
.professionalInfo.longitude, .professionalInfo.longitude,
day: fechaSeleccionada.toString(), day: serviceDayParam(fechaSeleccionada!),
createdAt: DateTime.now().toIso8601String(), createdAt: DateTime.now().toIso8601String(),
description: _observationController.text, description: _observationController.text,
range1Hour1: horaSeleccionada!, range1Hour1: horaSeleccionada!,
range1Hour2: range1Hour2: SlotGenerator.endOf(
horaSeleccionada!.add(hour: 2), horaSeleccionada!,
profesionalSeleccionado!
.professionalInfo
.slotDurationMinutes),
rate: profesionalSeleccionado! rate: profesionalSeleccionado!
.professionalInfo.rate, .professionalInfo.rate,
location: serviceLocationPreference!, location: serviceLocationPreference!,
@@ -572,12 +671,15 @@ class _UserMapScreenState extends State<UserMapScreen> {
.professionalInfo.latitude, .professionalInfo.latitude,
longitude: profesionalSeleccionado! longitude: profesionalSeleccionado!
.professionalInfo.longitude, .professionalInfo.longitude,
day: fechaSeleccionada.toString(), day: serviceDayParam(fechaSeleccionada!),
createdAt: DateTime.now().toIso8601String(), createdAt: DateTime.now().toIso8601String(),
description: _observationController.text, description: _observationController.text,
range1Hour1: horaSeleccionada!, range1Hour1: horaSeleccionada!,
range1Hour2: range1Hour2: SlotGenerator.endOf(
horaSeleccionada!.add(hour: 2), horaSeleccionada!,
profesionalSeleccionado!
.professionalInfo
.slotDurationMinutes),
rate: '0', rate: '0',
location: serviceLocationPreference!, location: serviceLocationPreference!,
), ),
@@ -595,12 +697,15 @@ class _UserMapScreenState extends State<UserMapScreen> {
aditionalAddress: '', aditionalAddress: '',
latitude: 0, latitude: 0,
longitude: 0, longitude: 0,
day: fechaSeleccionada.toString(), day: serviceDayParam(fechaSeleccionada!),
createdAt: DateTime.now().toIso8601String(), createdAt: DateTime.now().toIso8601String(),
description: _observationController.text, description: _observationController.text,
range1Hour1: horaSeleccionada!, range1Hour1: horaSeleccionada!,
range1Hour2: range1Hour2: SlotGenerator.endOf(
horaSeleccionada!.add(hour: 2), horaSeleccionada!,
profesionalSeleccionado!
.professionalInfo
.slotDurationMinutes),
rate: profesionalSeleccionado! rate: profesionalSeleccionado!
.professionalInfo.rate, .professionalInfo.rate,
location: serviceLocationPreference!, location: serviceLocationPreference!,
@@ -615,12 +720,15 @@ class _UserMapScreenState extends State<UserMapScreen> {
aditionalAddress: '', aditionalAddress: '',
latitude: 0, latitude: 0,
longitude: 0, longitude: 0,
day: fechaSeleccionada.toString(), day: serviceDayParam(fechaSeleccionada!),
createdAt: DateTime.now().toIso8601String(), createdAt: DateTime.now().toIso8601String(),
description: _observationController.text, description: _observationController.text,
range1Hour1: horaSeleccionada!, range1Hour1: horaSeleccionada!,
range1Hour2: range1Hour2: SlotGenerator.endOf(
horaSeleccionada!.add(hour: 2), horaSeleccionada!,
profesionalSeleccionado!
.professionalInfo
.slotDurationMinutes),
rate: '0', rate: '0',
location: serviceLocationPreference!, location: serviceLocationPreference!,
), ),
@@ -630,24 +738,12 @@ class _UserMapScreenState extends State<UserMapScreen> {
// TODO: error inesperado // TODO: error inesperado
} }
if (profesionalSeleccionado!.myUser.token != null) { // The form is NOT cleared here and the professional
LocalNotifications.sendPushNotification( // is NOT notified here: both used to run right after
profesionalSeleccionado!.myUser.token!, // dispatching the event, so a failed booking still
'Nuevo servicio', // sent "tienes una nueva solicitud" and wiped the
'Tienes una nueva solicitud de servicio pendiente', // patient's date, hour and notes. See the
); // CreateServiceSuccess branch in the listener.
}
fechaSeleccionada = null;
horaSeleccionada = null;
profesionalSeleccionado = null;
isClearButtonVisible = false;
_observationController.text = '';
serviceLocationPreference = null;
polylines.clear();
markers.clear();
setState(() {});
}, },
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary, backgroundColor: Theme.of(context).colorScheme.primary,
@@ -745,6 +841,25 @@ class _UserMapScreenState extends State<UserMapScreen> {
); );
}); });
_animateCameraToPosition(_currentP!);
} catch (e) {
log(e.toString());
await _locateByUserCity();
}
}
Future<void> _locateByUserCity() async {
try {
final city = Injector.appInstance.get<MyUserBloc>().state.user?.city;
if (city == null || city.isEmpty) return;
final coords = await NominatimGeocoder.forwardGeocodeCity(city);
if (coords == null) return;
setState(() {
_currentP = LatLng(coords.$1, coords.$2);
});
_animateCameraToPosition(_currentP!); _animateCameraToPosition(_currentP!);
} catch (e) { } catch (e) {
log(e.toString()); log(e.toString());
+33 -8
View File
@@ -57,7 +57,8 @@ class _UserServiceScreenState extends State<UserServiceScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<ServiceBloc>( return BlocProvider<ServiceBloc>(
create: (context) => Injector.appInstance.get<ServiceBloc>(), create: (context) => Injector.appInstance.get<ServiceBloc>()
..add(LoadService(widget.serviceId)),
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Servicio'), title: const Text('Servicio'),
@@ -275,12 +276,10 @@ class _UserServiceScreenState extends State<UserServiceScreen> {
} }
}, },
); );
} else if (state is CreateServiceFailure) {
return _loadErrorState(context);
} else { } else {
BlocProvider.of<ServiceBloc>(context) return const Center(child: CircularProgressIndicator());
.add(LoadService(widget.serviceId));
return const Center(
child: CircularProgressIndicator(),
);
} }
}, },
), ),
@@ -288,6 +287,32 @@ class _UserServiceScreenState extends State<UserServiceScreen> {
); );
} }
/// Dispatching LoadService from build() turned any failure into an endless
/// request loop: fail -> rebuild -> request -> fail. Errors now get an
/// explicit retry instead of a permanent spinner.
Widget _loadErrorState(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 40, color: Colors.grey),
const SizedBox(height: 12),
const Text('No se pudo cargar el servicio',
textAlign: TextAlign.center),
const SizedBox(height: 16),
OutlinedButton(
onPressed: () => BlocProvider.of<ServiceBloc>(context)
.add(LoadService(widget.serviceId)),
child: const Text('Reintentar'),
),
],
),
),
);
}
Widget customMessageStatus(ServiceEntity service) { Widget customMessageStatus(ServiceEntity service) {
DateTime serviceDate = DateTime.parse(service.day); DateTime serviceDate = DateTime.parse(service.day);
DateTime now = DateTime.now(); DateTime now = DateTime.now();
@@ -404,7 +429,7 @@ class _UserServiceScreenState extends State<UserServiceScreen> {
} }
if (service.status == ServiceStatus.completed && if (service.status == ServiceStatus.completed &&
service.userScored == false) { service.professionalScored == false) {
return Stack( return Stack(
alignment: AlignmentDirectional.topCenter, alignment: AlignmentDirectional.topCenter,
clipBehavior: Clip.none, clipBehavior: Clip.none,
@@ -479,7 +504,7 @@ class _UserServiceScreenState extends State<UserServiceScreen> {
} }
if (service.status == ServiceStatus.completed && if (service.status == ServiceStatus.completed &&
service.userScored == true) { service.professionalScored == true) {
return Stack( return Stack(
alignment: AlignmentDirectional.topCenter, alignment: AlignmentDirectional.topCenter,
clipBehavior: Clip.none, clipBehavior: Clip.none,
+239 -164
View File
@@ -1,204 +1,279 @@
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:prosappco/blocs/professional_list_bloc/professional_list_bloc.dart'; import 'package:prosappco/blocs/professional_list_bloc/professional_list_bloc.dart';
class UserViewProfileScreen extends StatelessWidget { const _kPrimary = Color(0xFF1565C0);
UserProfessional userProfessional;
UserViewProfileScreen({ extension _Th on BuildContext {
ThemeData get _t => Theme.of(this);
Color get bg => _t.scaffoldBackgroundColor;
Color get card => _t.cardColor;
Color get onSurface => _t.colorScheme.onSurface;
Color get muted => _t.colorScheme.onSurface.withOpacity(0.55);
Color get subtle => _t.colorScheme.onSurface.withOpacity(0.35);
bool get isDark => _t.brightness == Brightness.dark;
Color get shadowSm =>
isDark ? Colors.transparent : Colors.black.withOpacity(0.05);
}
class UserViewProfileScreen extends StatelessWidget {
final UserProfessional userProfessional;
const UserViewProfileScreen({
super.key, super.key,
required this.userProfessional, required this.userProfessional,
}); });
final double bannerHeight = 200;
final double profileHeight = 160;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( backgroundColor: context.bg,
title: const Text('Perfil Profesional'), body: CustomScrollView(
), slivers: [
body: ListView( _SliverHeader(userProfessional: userProfessional),
padding: EdgeInsets.zero, SliverPadding(
children: [ padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
buildTop(), sliver: SliverList(
buildContent(), delegate: SliverChildListDelegate([
_nameSection(context),
const SizedBox(height: 16),
if (userProfessional
.professionalInfo.specializations.isNotEmpty)
_sectionCard(
context,
icon: Icons.stars_outlined,
title: 'Especialidades',
child: Wrap(
spacing: 8,
runSpacing: 8,
children: userProfessional
.professionalInfo.specializations
.map((s) => _Chip(label: s))
.toList(),
),
),
if (userProfessional.professionalInfo.specializations.isNotEmpty)
const SizedBox(height: 12),
if (userProfessional.professionalInfo.rate.isNotEmpty)
_sectionCard(
context,
icon: Icons.payments_outlined,
title: 'Tarifa de consulta',
child: Row(
children: [
Text(
_formatCurrency(
int.tryParse(userProfessional
.professionalInfo.rate) ??
0),
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: context.onSurface),
),
const SizedBox(width: 6),
Text('por consulta',
style: TextStyle(
fontSize: 12, color: context.muted)),
],
),
),
if (userProfessional.professionalInfo.rate.isNotEmpty)
const SizedBox(height: 12),
if (userProfessional.professionalInfo.paymentMethods.datafono ||
userProfessional.professionalInfo.paymentMethods.nequi ||
userProfessional
.professionalInfo.paymentMethods.transferencia)
_sectionCard(
context,
icon: Icons.credit_card_outlined,
title: 'Métodos de pago',
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (userProfessional
.professionalInfo.paymentMethods.datafono)
_Chip(label: 'Datafono', icon: Icons.point_of_sale_outlined),
if (userProfessional
.professionalInfo.paymentMethods.nequi)
_Chip(label: 'Nequi', icon: Icons.phone_android_outlined),
if (userProfessional
.professionalInfo.paymentMethods.transferencia)
_Chip(
label: 'Transferencia',
icon: Icons.account_balance_outlined),
],
),
),
]),
),
),
], ],
), ),
); );
} }
Stack buildTop() { Widget _nameSection(BuildContext context) {
final top = bannerHeight - profileHeight / 2;
final bottom = profileHeight / 2;
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
Container(
margin: EdgeInsets.only(bottom: bottom),
child: buildBannerImage(),
),
Positioned(
top: top,
child: buildProfileImage(),
),
],
);
}
buildContent() {
return Column( return Column(
children: [ children: [
Text( Text(
userProfessional.myUser.name ?? '', userProfessional.myUser.name ?? '',
overflow: TextOverflow.ellipsis, textAlign: TextAlign.center,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold), style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: context.onSurface),
), ),
const SizedBox(height: 4),
Text( Text(
'${userProfessional.professionalInfo.profession}, ${userProfessional.myUser.city ?? ''}', '${userProfessional.professionalInfo.profession} · ${userProfessional.myUser.city ?? ''}',
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 18, color: Colors.black54), style: TextStyle(fontSize: 14, color: context.muted),
), ),
const Divider(),
const Text(
'Especialidades:',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
userProfessional.professionalInfo.specializations.isEmpty
? const Text(
'No posee especialidades',
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 15, color: Colors.black45),
)
: Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: userProfessional.professionalInfo.specializations
.map((specialization) {
return Chip(
label: Text(specialization),
);
}).toList(),
),
Visibility(
visible: userProfessional.professionalInfo.rate.isNotEmpty,
child: const Divider(),
),
Visibility(
visible: userProfessional.professionalInfo.rate.isNotEmpty,
child: const Text(
'Tarifa:',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
),
Visibility(
visible: userProfessional.professionalInfo.rate.isNotEmpty,
child: Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
Chip(label: Text('\$${userProfessional.professionalInfo.rate}')),
],
),
),
const Divider(),
const Text(
'Metodos de pago recibidos:',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
userProfessional.professionalInfo.paymentMethods.datafono ||
userProfessional.professionalInfo.paymentMethods.nequi ||
userProfessional.professionalInfo.paymentMethods.transferencia
? Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
Visibility(
visible: userProfessional
.professionalInfo.paymentMethods.datafono,
child: const Chip(label: Text('Datafono')),
),
Visibility(
visible:
userProfessional.professionalInfo.paymentMethods.nequi,
child: const Chip(label: Text('Nequi')),
),
Visibility(
visible: userProfessional
.professionalInfo.paymentMethods.transferencia,
child: const Chip(label: Text('Transferencia Bancaria')),
),
],
)
: const Text(
'No hay metodos de pago registrados',
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 15, color: Colors.black45),
),
const Divider(),
], ],
); );
} }
Container buildProfileImage() { Widget _sectionCard(BuildContext context,
{required IconData icon,
required String title,
required Widget child}) {
return Container( return Container(
width: 155, width: double.infinity,
height: 155, padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, color: context.card,
border: Border.all( borderRadius: BorderRadius.circular(14),
color: Colors.white, boxShadow: [
width: 5, BoxShadow(color: context.shadowSm, blurRadius: 10)
), ],
), ),
child: Container( child: Column(
width: 150, crossAxisAlignment: CrossAxisAlignment.start,
height: 150, children: [
decoration: BoxDecoration( Row(
color: Colors.grey.shade300, children: [
shape: BoxShape.circle, Icon(icon, size: 16, color: _kPrimary),
border: Border.all(width: 0), const SizedBox(width: 7),
image: userProfessional.myUser.picture == null Text(title,
? null style: const TextStyle(
: DecorationImage( fontSize: 13,
image: NetworkImage(userProfessional.myUser.picture ?? ''), fontWeight: FontWeight.w600,
fit: BoxFit.contain, color: _kPrimary)),
), ],
), ),
child: userProfessional.myUser.picture == null const SizedBox(height: 10),
? Icon( child,
CupertinoIcons.person, ],
color: Colors.grey.shade400,
size: 40,
)
: null,
), ),
); );
} }
Container buildBannerImage() { String _formatCurrency(int n) {
return Container( final formatter =
color: Colors.grey, NumberFormat.currency(locale: 'es_CO', decimalDigits: 0, symbol: '');
child: Image.network( return '\$${formatter.format(n)}';
userProfessional.professionalInfo.bannerPicture, }
fit: BoxFit.cover, }
width: double.infinity,
height: bannerHeight, class _SliverHeader extends StatelessWidget {
final UserProfessional userProfessional;
const _SliverHeader({required this.userProfessional});
@override
Widget build(BuildContext context) {
return SliverAppBar(
expandedHeight: 220,
pinned: true,
backgroundColor: _kPrimary,
foregroundColor: Colors.white,
elevation: 0,
flexibleSpace: FlexibleSpaceBar(
collapseMode: CollapseMode.parallax,
background: Stack(
fit: StackFit.expand,
children: [
// Banner
Image.network(
userProfessional.professionalInfo.bannerPicture,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Container(color: _kPrimary.withOpacity(0.6)),
),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.45),
],
),
),
),
// Avatar centered at bottom
Positioned(
bottom: 12,
left: 0,
right: 0,
child: Center(
child: Container(
width: 82,
height: 82,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
color: Colors.grey.shade300,
image: userProfessional.myUser.picture == null
? null
: DecorationImage(
image: NetworkImage(
userProfessional.myUser.picture!),
fit: BoxFit.cover),
),
child: userProfessional.myUser.picture == null
? Icon(CupertinoIcons.person,
color: Colors.grey.shade400, size: 40)
: null,
),
),
),
],
),
), ),
); );
} }
} }
class _Chip extends StatelessWidget {
final String label;
final IconData? icon;
const _Chip({required this.label, this.icon});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: _kPrimary.withOpacity(0.07),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: _kPrimary.withOpacity(0.2)),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
if (icon != null) ...[
Icon(icon, size: 13, color: _kPrimary),
const SizedBox(width: 4),
],
Text(label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _kPrimary)),
]),
);
}
}
+40
View File
@@ -0,0 +1,40 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class NominatimGeocoder {
static Future<String?> reverseGeocodeCity(double lat, double lng) async {
final uri = Uri.parse(
'https://nominatim.openstreetmap.org/reverse'
'?format=json&lat=$lat&lon=$lng&zoom=10&addressdetails=1',
);
final res = await http.get(
uri,
headers: {'User-Agent': 'ProsApp/1.0 (prosapp.co)'},
);
final data = jsonDecode(res.body) as Map<String, dynamic>;
final address = data['address'] as Map<String, dynamic>?;
return address?['city'] as String? ??
address?['town'] as String? ??
address?['municipality'] as String? ??
address?['county'] as String?;
}
static Future<(double lat, double lng)?> forwardGeocodeCity(
String cityName) async {
final uri = Uri.parse(
'https://nominatim.openstreetmap.org/search'
'?format=json&limit=1&q=${Uri.encodeQueryComponent(cityName)}',
);
final res = await http.get(
uri,
headers: {'User-Agent': 'ProsApp/1.0 (prosapp.co)'},
);
final data = jsonDecode(res.body) as List;
if (data.isEmpty) return null;
final first = data.first as Map<String, dynamic>;
final lat = double.tryParse(first['lat']?.toString() ?? '');
final lng = double.tryParse(first['lon']?.toString() ?? '');
if (lat == null || lng == null) return null;
return (lat, lng);
}
}
+64
View File
@@ -0,0 +1,64 @@
import 'package:professional_repository/professional_repository.dart';
import 'package:prosappco/utils/time_of_day_extension.dart';
const _dayNames = [
'Lunes',
'Martes',
'Miércoles',
'Jueves',
'Viernes',
'Sábado',
'Domingo',
];
/// Human-readable problems with a week of opening hours, empty when it is fine.
///
/// Nothing validated these before: an inverted or half-filled day saved
/// happily and then produced zero bookable slots, with no clue as to why.
List<String> validateSchedules(Schedules schedules) {
final days = [
schedules.monday,
schedules.tuesday,
schedules.wednesday,
schedules.thursday,
schedules.friday,
schedules.saturday,
schedules.sunday,
];
final problems = <String>[];
for (var i = 0; i < days.length; i++) {
final problem = _validateDay(days[i]);
if (problem != null) problems.add('${_dayNames[i]}: $problem');
}
return problems;
}
String? _validateDay(ScheduleEntity day) {
if (!day.enabled) return null;
if (day.continuousDay) {
if (day.range1Hour1 == null || day.range2Hour2 == null) {
return 'falta la hora de apertura o de cierre';
}
if (!day.range1Hour1!.isBefore(day.range2Hour2!)) {
return 'la hora de cierre debe ser posterior a la de apertura';
}
return null;
}
final r = [day.range1Hour1, day.range1Hour2, day.range2Hour1, day.range2Hour2];
if (r.any((t) => t == null)) {
return 'faltan horas en la jornada partida';
}
if (!r[0]!.isBefore(r[1]!)) {
return 'la primera jornada termina antes de empezar';
}
if (!r[2]!.isBefore(r[3]!)) {
return 'la segunda jornada termina antes de empezar';
}
if (r[2]!.isBefore(r[1]!)) {
return 'las dos jornadas se cruzan';
}
return null;
}
+14
View File
@@ -0,0 +1,14 @@
/// Whether a service's `day` field refers to the same calendar day as [day].
///
/// The backend returns ISO 8601 ("2026-07-13T00:00:00.000Z") while
/// `DateTime.toString()` produces a space separator ("2026-07-13 00:00:00.000Z"),
/// so comparing the two as raw strings never matched and every booked slot
/// rendered as free. Compare parsed calendar dates instead.
bool isSameServiceDay(DateTime day, String? serviceDay) {
if (serviceDay == null || serviceDay.isEmpty) return false;
final parsed = DateTime.tryParse(serviceDay);
if (parsed == null) return false;
return parsed.year == day.year &&
parsed.month == day.month &&
parsed.day == day.day;
}
+9
View File
@@ -0,0 +1,9 @@
/// The `day` value the services API expects: `yyyy-MM-dd`.
///
/// `DateTime.toString()` produces "2026-08-25 00:00:00.000Z", which happens to
/// slip past the backend validator but is not canonical ISO 8601 and differs
/// from what the web sends for the same table.
String serviceDayParam(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';
+74
View File
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:professional_repository/professional_repository.dart';
import 'package:prosappco/utils/time_of_day_extension.dart';
import 'package:prosappco/utils/time_of_day_utils.dart';
/// Minimum notice a patient has to give before an appointment.
///
/// It used to live only in the patient's calendar, so the professional's own
/// agenda generated a different list: at 15:00 a professional working
/// 08:00-18:00 with hourly slots saw "3 libres" (15:00, 16:00, 17:00) while
/// the patient could only book 18:00. Both screens now go through
/// [SlotGenerator], so the two lists always agree.
const Duration kBookingLeadTime = Duration(hours: 3);
/// The single source of truth for "which times exist on this day".
class SlotGenerator {
const SlotGenerator._();
/// Times offered on [day] for [schedule].
///
/// [notBefore] drops the slots that are already in the past (or inside the
/// lead time). Pass it as `DateTime.now().add(kBookingLeadTime)` for the
/// patient flow and `DateTime.now()` for the professional's own agenda,
/// where the professional may still block the next hour.
static List<TimeOfDay> forDay({
required ScheduleEntity? schedule,
required DateTime day,
required int slotDurationMinutes,
DateTime? notBefore,
}) {
final raw = _rawSlots(schedule, slotDurationMinutes);
if (notBefore == null) return raw;
return raw.where((t) {
final at = DateTime(day.year, day.month, day.day, t.hour, t.minute);
return !at.isBefore(notBefore);
}).toList();
}
/// Where a slot starting at [start] ends.
///
/// Clamped to 23:59: a 120-minute slot starting at 23:00 used to produce
/// "25:00", which is not a real time and the backend stored verbatim.
static TimeOfDay endOf(TimeOfDay start, int slotDurationMinutes) {
final total = start.hour * 60 + start.minute + slotDurationMinutes;
if (total >= 24 * 60) return const TimeOfDay(hour: 23, minute: 59);
return TimeOfDay(hour: total ~/ 60, minute: total % 60);
}
static List<TimeOfDay> _rawSlots(ScheduleEntity? s, int stepMinutes) {
if (s == null || !s.enabled) return [];
if (s.continuousDay) {
if (s.range1Hour1 == null || s.range2Hour2 == null) return [];
if (s.range1Hour1!.compareTo(s.range2Hour2!) >= 0) return [];
return TimeOfDayUtils.genRanges(s.range1Hour1!, s.range2Hour2!,
stepMinutes: stepMinutes);
}
if (s.range1Hour1 == null ||
s.range1Hour2 == null ||
s.range2Hour1 == null ||
s.range2Hour2 == null) {
return [];
}
if (s.range1Hour1!.compareTo(s.range1Hour2!) >= 0 ||
s.range2Hour1!.compareTo(s.range2Hour2!) >= 0) {
return [];
}
return [
...TimeOfDayUtils.genRanges(s.range1Hour1!, s.range1Hour2!,
stepMinutes: stepMinutes),
...TimeOfDayUtils.genRanges(s.range2Hour1!, s.range2Hour2!,
stepMinutes: stepMinutes),
];
}
}
+8 -1
View File
@@ -1,8 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
extension TimeOfDayExtension on TimeOfDay { extension TimeOfDayExtension on TimeOfDay {
/// Adds the given offset, carrying minutes into hours.
///
/// Without the carry, adding a minute-based step (e.g. 45) would leave the
/// hour untouched and produce values like "8:90", which makes [isBefore]
/// loops run forever. The hour is intentionally NOT wrapped at 24 so that
/// comparisons against an end-of-day bound still terminate.
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 totalMinutes = (this.hour + hour) * 60 + this.minute + minute;
return TimeOfDay(hour: totalMinutes ~/ 60, minute: totalMinutes % 60);
} }
int compareTo(TimeOfDay other) { int compareTo(TimeOfDay other) {
+4 -3
View File
@@ -2,13 +2,14 @@ import 'package:flutter/material.dart';
import 'package:prosappco/utils/time_of_day_extension.dart'; import 'package:prosappco/utils/time_of_day_extension.dart';
class TimeOfDayUtils { class TimeOfDayUtils {
static List<TimeOfDay> genRanges(TimeOfDay timeStart, TimeOfDay timeEnd) { static List<TimeOfDay> genRanges(TimeOfDay timeStart, TimeOfDay timeEnd,
{int stepMinutes = 30}) {
final step = stepMinutes.clamp(5, 480);
List<TimeOfDay> ranges = []; List<TimeOfDay> ranges = [];
TimeOfDay current = timeStart; TimeOfDay current = timeStart;
while (current.isBefore(timeEnd)) { while (current.isBefore(timeEnd)) {
ranges.add(current); ranges.add(current);
// Sumar 2 horas al objeto DateTime current = current.add(minute: step);
current = current.add(hour: 2);
} }
return ranges; return ranges;
} }
+48
View File
@@ -0,0 +1,48 @@
/// Compares dotted numeric versions, e.g. "1.0.14" vs "1.0.2".
///
/// Returns a negative number when [a] is older than [b], 0 when they are
/// equivalent, and a positive number when [a] is newer. Missing segments count
/// as 0, so "1.0" and "1.0.0" are equivalent. Returns null when either side
/// cannot be parsed.
int? compareVersions(String? a, String? b) {
final left = _segments(a);
final right = _segments(b);
if (left == null || right == null) return null;
final length = left.length > right.length ? left.length : right.length;
for (var i = 0; i < length; i++) {
final l = i < left.length ? left[i] : 0;
final r = i < right.length ? right[i] : 0;
if (l != r) return l - r;
}
return 0;
}
/// Whether [current] is strictly older than [minimum].
///
/// Deliberately fails open: a missing or unparseable value returns false, so a
/// backend misconfiguration can never lock users out of the app behind the
/// blocking "you must update" dialog. Equal or newer versions are fine too — a
/// build ahead of the configured minimum is not out of date.
bool isUpdateRequired(String? current, String? minimum) {
final result = compareVersions(current, minimum);
if (result == null) return false;
return result < 0;
}
List<int>? _segments(String? version) {
if (version == null) return null;
final trimmed = version.trim();
if (trimmed.isEmpty) return null;
// Tolerate build suffixes such as "1.0.14+14" or "1.0.14-beta".
final core = trimmed.split(RegExp(r'[+\-]')).first;
final parts = core.split('.');
final segments = <int>[];
for (final part in parts) {
final value = int.tryParse(part.trim());
if (value == null) return null;
segments.add(value);
}
return segments.isEmpty ? null : segments;
}
@@ -5,30 +5,22 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import cloud_firestore
import file_selector_macos import file_selector_macos
import firebase_auth
import firebase_core import firebase_core
import firebase_messaging import firebase_messaging
import firebase_storage
import flutter_local_notifications import flutter_local_notifications
import geolocator_apple import geolocator_apple
import google_sign_in_ios
import location import location
import package_info_plus import package_info_plus
import shared_preferences_foundation import shared_preferences_foundation
import url_launcher_macos import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FLTFirebaseStoragePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseStoragePlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
LocationPlugin.register(with: registry.registrar(forPlugin: "LocationPlugin")) LocationPlugin.register(with: registry.registrar(forPlugin: "LocationPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
@@ -4,6 +4,8 @@ import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:chat_repository/chat_repository.dart'; import 'package:chat_repository/chat_repository.dart';
const _kHttpTimeout = Duration(seconds: 20);
const _base = 'https://backend.prosapp.co/api/v1'; const _base = 'https://backend.prosapp.co/api/v1';
/// API-backed replacement for FirebaseChatRepository. /// API-backed replacement for FirebaseChatRepository.
@@ -27,8 +29,10 @@ class ApiChatRepository {
ChatEntity _chatFromApi(Map<String, dynamic> json) { ChatEntity _chatFromApi(Map<String, dynamic> json) {
final rawMessages = json['messages'] as List? ?? []; final rawMessages = json['messages'] as List? ?? [];
// _msgFromApi, not MessageEntity.fromDocument: the backend sends
// `sender_id` while fromDocument hard-casts `owner_id`.
final messages = rawMessages final messages = rawMessages
.map((m) => MessageEntity.fromDocument(m as Map<String, dynamic>)) .map((m) => _msgFromApi(m as Map<String, dynamic>))
.toList(); .toList();
return ChatEntity( return ChatEntity(
id: json['id']?.toString(), id: json['id']?.toString(),
@@ -50,68 +54,77 @@ class ApiChatRepository {
); );
} }
/// Get or create a chat session. Maps to POST /chat/start/:professionalUserId. /// Opens the conversation with a professional, creating it if needed.
/// [chatId] here is used as the professional's userId for the REST call. ///
Future<ChatEntity> createNewChat( /// `POST /chat/start/:professionalUserId` is idempotent: calling it again
String chatId, String userId, String professionalId) async { /// returns the same chat. The returned `id` is the **chat** id, which is what
final res = await http.post( /// every other chat endpoint is keyed by — not the service id.
Uri.parse('$_base/chat/start/$professionalId'), Future<ChatEntity> startChat(String professionalUserId) async {
headers: await _headers(), final res = await http
); .post(
final data = jsonDecode(res.body) as Map<String, dynamic>; Uri.parse('$_base/chat/start/$professionalUserId'),
return _chatFromApi(data); headers: await _headers(),
} )
.timeout(_kHttpTimeout);
/// Streams a single chat by its ID. Fetches once and emits. if (res.statusCode >= 400) {
Stream<ChatEntity?> getChatById(String chatId) { throw Exception('No se pudo abrir el chat (${res.statusCode})');
final controller = StreamController<ChatEntity?>();
_fetchChat(chatId).then((chat) {
controller.add(chat);
controller.close();
}).catchError((e) {
controller.add(null);
controller.close();
});
return controller.stream;
}
Future<ChatEntity?> _fetchChat(String chatId) async {
try {
// Try to get messages for this chat — if the chat exists it'll succeed
final res = await http.get(
Uri.parse('$_base/chat/$chatId/messages'),
headers: await _headers(),
);
if (res.statusCode == 404) return null;
final messages = jsonDecode(res.body) as List? ?? [];
return ChatEntity(
id: chatId,
userId: '',
professionalId: '',
messages: messages
.map((m) => _msgFromApi(m as Map<String, dynamic>))
.toList(),
);
} catch (_) {
return null;
} }
return _chatFromApi(jsonDecode(res.body) as Map<String, dynamic>);
} }
Future<void> sendMessage(String chatId, MessageEntity message) async { /// Loads the conversation plus its messages, ready to render.
await http.post( Future<ChatEntity> loadConversation(String professionalUserId) async {
Uri.parse('$_base/chat/$chatId/message'), final chat = await startChat(professionalUserId);
headers: await _headers(), final messages = await fetchMessages(chat.id ?? '');
body: jsonEncode({'content': message.content}), return ChatEntity(
id: chat.id,
userId: chat.userId,
professionalId: chat.professionalId,
messages: messages,
); );
} }
/// Messages come back wrapped as `{ "data": [...], "meta": {...} }`.
/// Reading the body as a bare list threw and surfaced as an empty chat.
Future<List<MessageEntity>> fetchMessages(String chatId) async {
final res = await http
.get(
Uri.parse('$_base/chat/$chatId/messages'),
headers: await _headers(),
)
.timeout(_kHttpTimeout);
if (res.statusCode >= 400) {
throw Exception('No se pudieron cargar los mensajes (${res.statusCode})');
}
final body = jsonDecode(res.body);
final raw = body is Map ? (body['data'] as List? ?? []) : (body as List? ?? []);
return raw
.map((m) => _msgFromApi(m as Map<String, dynamic>))
.toList();
}
/// Posts a message and returns it as stored by the backend.
Future<MessageEntity> sendMessage(String chatId, MessageEntity message) async {
final res = await http
.post(
Uri.parse('$_base/chat/$chatId/message'),
headers: await _headers(),
body: jsonEncode({'content': message.content}),
)
.timeout(_kHttpTimeout);
if (res.statusCode >= 400) {
throw Exception('No se pudo enviar el mensaje (${res.statusCode})');
}
return _msgFromApi(jsonDecode(res.body) as Map<String, dynamic>);
}
/// Get all chats for the current user. /// Get all chats for the current user.
Future<List<ChatEntity>> getMyChats() async { Future<List<ChatEntity>> getMyChats() async {
try { try {
final res = await http.get( final res = await http.get(
Uri.parse('$_base/chat/my'), Uri.parse('$_base/chat/my'),
headers: await _headers(), headers: await _headers(),
); ).timeout(_kHttpTimeout);
final data = jsonDecode(res.body) as List? ?? []; final data = jsonDecode(res.body) as List? ?? [];
return data.map((e) => _chatFromApi(e as Map<String, dynamic>)).toList(); return data.map((e) => _chatFromApi(e as Map<String, dynamic>)).toList();
} catch (_) { } catch (_) {
+141 -69
View File
@@ -1,14 +1,6 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
_flutterfire_internals:
dependency: transitive
description:
name: _flutterfire_internals
sha256: "4eec93681221723a686ad580c2e7d960e1017cf1a4e0a263c2573c2c6b0bf5cd"
url: "https://pub.dev"
source: hosted
version: "1.3.25"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -41,30 +33,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
cloud_firestore:
dependency: "direct main"
description:
name: cloud_firestore
sha256: "31cfa4d65d6e9ea837234fffe121304034c30c9214c06207b4a35867e3757900"
url: "https://pub.dev"
source: hosted
version: "4.15.8"
cloud_firestore_platform_interface:
dependency: transitive
description:
name: cloud_firestore_platform_interface
sha256: a0097a26569b015faf8142e159e855241609ea9a1738b5fd1c40bfe8411b41a0
url: "https://pub.dev"
source: hosted
version: "6.1.9"
cloud_firestore_web:
dependency: transitive
description:
name: cloud_firestore_web
sha256: ed680ece29a5750985119c09cdc276b460c3a2fa80e8c12f9b7241f6b4a7ca16
url: "https://pub.dev"
source: hosted
version: "3.10.8"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@@ -89,30 +57,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.1" version: "1.3.1"
firebase_core: ffi:
dependency: "direct main"
description:
name: firebase_core
sha256: "53316975310c8af75a96e365f9fccb67d1c544ef0acdbf0d88bbe30eedd1c4f9"
url: "https://pub.dev"
source: hosted
version: "2.27.0"
firebase_core_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: firebase_core_platform_interface name: ffi
sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63 sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "2.1.3"
firebase_core_web: file:
dependency: transitive dependency: transitive
description: description:
name: firebase_core_web name: file
sha256: c8e1d59385eee98de63c92f961d2a7062c5d9a65e7f45bdc7f1b0b205aab2492 sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.11.5" version: "7.0.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -136,30 +96,38 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
js: http:
dependency: transitive dependency: "direct main"
description: description:
name: js name: http
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.7" version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
url: "https://pub.dev"
source: hosted
version: "4.0.2"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.4" version: "10.0.5"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.5"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
@@ -188,18 +156,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.0" version: "0.11.1"
meta: meta:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.12.0" version: "1.15.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -208,6 +176,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.0" version: "1.9.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface: plugin_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -216,6 +216,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
url: "https://pub.dev"
source: hosted
version: "2.5.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "9f9f3d372d4304723e6136663bb291c0b93f5e4c8a4a6314347f481a33bda2b1"
url: "https://pub.dev"
source: hosted
version: "2.4.7"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -265,10 +321,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.0" version: "0.7.2"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -281,18 +345,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.2.1" version: "14.2.5"
web: web:
dependency: transitive dependency: transitive
description: description:
name: web name: web
sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.0" version: "1.1.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
sdks: sdks:
dart: ">=3.3.0 <4.0.0" dart: ">=3.5.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.24.0"
@@ -5,6 +5,8 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:city_repository/city_repository.dart'; import 'package:city_repository/city_repository.dart';
import 'city_repo.dart'; import 'city_repo.dart';
const _kHttpTimeout = Duration(seconds: 20);
const _base = 'https://backend.prosapp.co/api/v1'; const _base = 'https://backend.prosapp.co/api/v1';
class ApiCityRepository implements CityRepository { class ApiCityRepository implements CityRepository {
@@ -33,7 +35,7 @@ class ApiCityRepository implements CityRepository {
final countriesRes = await http.get( final countriesRes = await http.get(
Uri.parse('$_base/locations/countries'), Uri.parse('$_base/locations/countries'),
headers: await _headers(), headers: await _headers(),
); ).timeout(_kHttpTimeout);
final countries = jsonDecode(countriesRes.body) as List; final countries = jsonDecode(countriesRes.body) as List;
for (final country in countries) { for (final country in countries) {
@@ -43,7 +45,7 @@ class ApiCityRepository implements CityRepository {
final regionsRes = await http.get( final regionsRes = await http.get(
Uri.parse('$_base/locations/countries/$countryId/regions'), Uri.parse('$_base/locations/countries/$countryId/regions'),
headers: await _headers(), headers: await _headers(),
); ).timeout(_kHttpTimeout);
final regions = jsonDecode(regionsRes.body) as List; final regions = jsonDecode(regionsRes.body) as List;
for (final region in regions) { for (final region in regions) {
@@ -53,7 +55,7 @@ class ApiCityRepository implements CityRepository {
final citiesRes = await http.get( final citiesRes = await http.get(
Uri.parse('$_base/locations/regions/$regionId/cities'), Uri.parse('$_base/locations/regions/$regionId/cities'),
headers: await _headers(), headers: await _headers(),
); ).timeout(_kHttpTimeout);
final citiesList = jsonDecode(citiesRes.body) as List; final citiesList = jsonDecode(citiesRes.body) as List;
for (final city in citiesList) { for (final city in citiesList) {
+141 -69
View File
@@ -1,14 +1,6 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
_flutterfire_internals:
dependency: transitive
description:
name: _flutterfire_internals
sha256: "4eec93681221723a686ad580c2e7d960e1017cf1a4e0a263c2573c2c6b0bf5cd"
url: "https://pub.dev"
source: hosted
version: "1.3.25"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -41,30 +33,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
cloud_firestore:
dependency: "direct main"
description:
name: cloud_firestore
sha256: "31cfa4d65d6e9ea837234fffe121304034c30c9214c06207b4a35867e3757900"
url: "https://pub.dev"
source: hosted
version: "4.15.8"
cloud_firestore_platform_interface:
dependency: transitive
description:
name: cloud_firestore_platform_interface
sha256: a0097a26569b015faf8142e159e855241609ea9a1738b5fd1c40bfe8411b41a0
url: "https://pub.dev"
source: hosted
version: "6.1.9"
cloud_firestore_web:
dependency: transitive
description:
name: cloud_firestore_web
sha256: ed680ece29a5750985119c09cdc276b460c3a2fa80e8c12f9b7241f6b4a7ca16
url: "https://pub.dev"
source: hosted
version: "3.10.8"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@@ -89,30 +57,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.1" version: "1.3.1"
firebase_core: ffi:
dependency: "direct main"
description:
name: firebase_core
sha256: "53316975310c8af75a96e365f9fccb67d1c544ef0acdbf0d88bbe30eedd1c4f9"
url: "https://pub.dev"
source: hosted
version: "2.27.0"
firebase_core_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: firebase_core_platform_interface name: ffi
sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63 sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "2.1.3"
firebase_core_web: file:
dependency: transitive dependency: transitive
description: description:
name: firebase_core_web name: file
sha256: c8e1d59385eee98de63c92f961d2a7062c5d9a65e7f45bdc7f1b0b205aab2492 sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.11.5" version: "7.0.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -136,30 +96,38 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
js: http:
dependency: transitive dependency: "direct main"
description: description:
name: js name: http
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.7" version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
url: "https://pub.dev"
source: hosted
version: "4.0.2"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.4" version: "10.0.5"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.5"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
@@ -188,18 +156,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.0" version: "0.11.1"
meta: meta:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.12.0" version: "1.15.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -208,6 +176,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.0" version: "1.9.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface: plugin_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -216,6 +216,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
url: "https://pub.dev"
source: hosted
version: "2.5.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "9f9f3d372d4304723e6136663bb291c0b93f5e4c8a4a6314347f481a33bda2b1"
url: "https://pub.dev"
source: hosted
version: "2.4.7"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -265,10 +321,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.0" version: "0.7.2"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -281,18 +345,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.2.1" version: "14.2.5"
web: web:
dependency: transitive dependency: transitive
description: description:
name: web name: web
sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.0" version: "1.1.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
sdks: sdks:
dart: ">=3.3.0 <4.0.0" dart: ">=3.5.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.24.0"
@@ -1 +1,2 @@
export 'profession_ui.dart'; export 'profession_ui.dart';
export 'professions.dart';
@@ -0,0 +1,10 @@
import 'package:equatable/equatable.dart';
class Professions extends Equatable {
final List<String> professions;
const Professions(this.professions);
@override
List<Object?> get props => [professions];
}
@@ -5,6 +5,8 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:profession_repository/profession_repository.dart'; import 'package:profession_repository/profession_repository.dart';
import 'profession_repo.dart'; import 'profession_repo.dart';
const _kHttpTimeout = Duration(seconds: 20);
const _base = 'https://backend.prosapp.co/api/v1'; const _base = 'https://backend.prosapp.co/api/v1';
class ApiProfessionRepository implements ProfessionRepository { class ApiProfessionRepository implements ProfessionRepository {
@@ -27,7 +29,7 @@ class ApiProfessionRepository implements ProfessionRepository {
final res = await http.get( final res = await http.get(
Uri.parse('$_base/professions'), Uri.parse('$_base/professions'),
headers: await _headers(), headers: await _headers(),
); ).timeout(_kHttpTimeout);
final data = jsonDecode(res.body); final data = jsonDecode(res.body);
// Backend returns either a list of profession objects or a map with a professions key // Backend returns either a list of profession objects or a map with a professions key
if (data is List) { if (data is List) {
+141 -69
View File
@@ -1,14 +1,6 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
_flutterfire_internals:
dependency: transitive
description:
name: _flutterfire_internals
sha256: "4eec93681221723a686ad580c2e7d960e1017cf1a4e0a263c2573c2c6b0bf5cd"
url: "https://pub.dev"
source: hosted
version: "1.3.25"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -41,30 +33,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
cloud_firestore:
dependency: "direct main"
description:
name: cloud_firestore
sha256: "31cfa4d65d6e9ea837234fffe121304034c30c9214c06207b4a35867e3757900"
url: "https://pub.dev"
source: hosted
version: "4.15.8"
cloud_firestore_platform_interface:
dependency: transitive
description:
name: cloud_firestore_platform_interface
sha256: a0097a26569b015faf8142e159e855241609ea9a1738b5fd1c40bfe8411b41a0
url: "https://pub.dev"
source: hosted
version: "6.1.9"
cloud_firestore_web:
dependency: transitive
description:
name: cloud_firestore_web
sha256: ed680ece29a5750985119c09cdc276b460c3a2fa80e8c12f9b7241f6b4a7ca16
url: "https://pub.dev"
source: hosted
version: "3.10.8"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@@ -89,30 +57,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.1" version: "1.3.1"
firebase_core: ffi:
dependency: "direct main"
description:
name: firebase_core
sha256: "53316975310c8af75a96e365f9fccb67d1c544ef0acdbf0d88bbe30eedd1c4f9"
url: "https://pub.dev"
source: hosted
version: "2.27.0"
firebase_core_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: firebase_core_platform_interface name: ffi
sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63 sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "2.1.3"
firebase_core_web: file:
dependency: transitive dependency: transitive
description: description:
name: firebase_core_web name: file
sha256: c8e1d59385eee98de63c92f961d2a7062c5d9a65e7f45bdc7f1b0b205aab2492 sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.11.5" version: "7.0.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -136,30 +96,38 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
js: http:
dependency: transitive dependency: "direct main"
description: description:
name: js name: http
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.7" version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
url: "https://pub.dev"
source: hosted
version: "4.0.2"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.4" version: "10.0.5"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.5"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
@@ -188,18 +156,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.0" version: "0.11.1"
meta: meta:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.12.0" version: "1.15.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -208,6 +176,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.0" version: "1.9.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface: plugin_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -216,6 +216,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
url: "https://pub.dev"
source: hosted
version: "2.5.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "9f9f3d372d4304723e6136663bb291c0b93f5e4c8a4a6314347f481a33bda2b1"
url: "https://pub.dev"
source: hosted
version: "2.4.7"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -265,10 +321,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.0" version: "0.7.2"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -281,18 +345,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.2.1" version: "14.2.5"
web: web:
dependency: transitive dependency: transitive
description: description:
name: web name: web
sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.0" version: "1.1.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
sdks: sdks:
dart: ">=3.3.0 <4.0.0" dart: ">=3.5.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.24.0"
@@ -19,9 +19,9 @@ class PaymentMethodEntity extends Equatable {
static PaymentMethodEntity fromDocument(Map<String, dynamic> doc) { static PaymentMethodEntity fromDocument(Map<String, dynamic> doc) {
return PaymentMethodEntity( return PaymentMethodEntity(
nequi: doc['nequi'] as bool, nequi: doc['nequi'] as bool? ?? false,
datafono: doc['datafono'] as bool, datafono: doc['datafono'] as bool? ?? false,
transferencia: doc['transferencia'] as bool, transferencia: doc['transferencia'] as bool? ?? false,
); );
} }
@@ -19,6 +19,12 @@ class ProfessionalEntity extends Equatable {
final List<String> specializationsPictures; final List<String> specializationsPictures;
final Schedules schedules; final Schedules schedules;
final PaymentMethodEntity paymentMethods; final PaymentMethodEntity paymentMethods;
final int slotDurationMinutes;
/// Row id in the `professionals` table. Distinct from [id], which is the
/// owning user's id. Endpoints such as /services/public-calendar are keyed
/// by this one.
final String recordId;
const ProfessionalEntity({ const ProfessionalEntity({
required this.id, required this.id,
@@ -38,38 +44,17 @@ class ProfessionalEntity extends Equatable {
required this.specializationsPictures, required this.specializationsPictures,
required this.schedules, required this.schedules,
required this.paymentMethods, required this.paymentMethods,
this.slotDurationMinutes = 120,
this.recordId = '',
}); });
static ProfessionalEntity fromDocument(Map<String, dynamic> doc) {
return ProfessionalEntity(
id: doc['id'] as String,
identification: doc['identification'] as String,
address: doc['address'] as String,
aditionalAddress: doc['aditional_address'] as String,
profession: doc['profession'] as String,
ratePreferences: doc['rate_preferences'] as bool,
rate: doc['rate'] as String,
locationPreferences: intToEnum(doc['location_preferences'] as int),
bannerPicture: doc['banner_picture'] as String,
identificationPicture: doc['identification_picture'] as String,
certificatePicture: doc['certificate_picture'] as String,
latitude: double.parse(doc['latitude'].toString()),
longitude: double.parse(doc['longitude'].toString()),
specializations: List<String>.from(doc['specializations']),
specializationsPictures:
List<String>.from(doc['specializations_pictures']),
schedules: Schedules.fromDocument(doc['schedules']),
paymentMethods: PaymentMethodEntity.fromDocument(doc['payment_methods']),
);
}
ProfessionalEntity copyWith({ ProfessionalEntity copyWith({
String? id, String? id,
String? identification, String? identification,
String? address, String? address,
String? aditionalAddress, String? aditionalAddress,
String? profession, String? profession,
bool ratePreferences = false, bool? ratePreferences,
String? rate, String? rate,
LocationPreferences? locationPreferences, LocationPreferences? locationPreferences,
String? bannerPicture, String? bannerPicture,
@@ -81,6 +66,8 @@ class ProfessionalEntity extends Equatable {
List<String>? specializationsPictures, List<String>? specializationsPictures,
Schedules? schedules, Schedules? schedules,
PaymentMethodEntity? paymentMethods, PaymentMethodEntity? paymentMethods,
int? slotDurationMinutes,
String? recordId,
}) { }) {
return ProfessionalEntity( return ProfessionalEntity(
id: id ?? this.id, id: id ?? this.id,
@@ -88,7 +75,7 @@ class ProfessionalEntity extends Equatable {
address: address ?? this.address, address: address ?? this.address,
aditionalAddress: aditionalAddress ?? this.aditionalAddress, aditionalAddress: aditionalAddress ?? this.aditionalAddress,
profession: profession ?? this.profession, profession: profession ?? this.profession,
ratePreferences: ratePreferences, ratePreferences: ratePreferences ?? this.ratePreferences,
rate: rate ?? this.rate, rate: rate ?? this.rate,
locationPreferences: locationPreferences ?? this.locationPreferences, locationPreferences: locationPreferences ?? this.locationPreferences,
bannerPicture: bannerPicture ?? this.bannerPicture, bannerPicture: bannerPicture ?? this.bannerPicture,
@@ -102,6 +89,8 @@ class ProfessionalEntity extends Equatable {
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,
recordId: recordId ?? this.recordId,
); );
} }
@@ -110,11 +99,13 @@ class ProfessionalEntity extends Equatable {
'id': id, 'id': id,
'identification': identification, 'identification': identification,
'address': address, 'address': address,
'aditional_address': aditionalAddress, 'additional_address': aditionalAddress,
'profession': profession, 'profession': profession,
'rate_preferences': ratePreferences, 'rate_preferences': ratePreferences,
'rate': rate, 'rate': rate,
'location_preferences': enumToInt(locationPreferences), // String, not the enum index: the backend answers 400
// "location_preferences must be a string".
'location_preferences': enumToString(locationPreferences),
'banner_picture': bannerPicture, 'banner_picture': bannerPicture,
'identification_picture': identificationPicture, 'identification_picture': identificationPicture,
'certificate_picture': certificatePicture, 'certificate_picture': certificatePicture,
@@ -122,8 +113,8 @@ class ProfessionalEntity extends Equatable {
'longitude': longitude, 'longitude': longitude,
'specializations': specializations, 'specializations': specializations,
'specializations_pictures': specializationsPictures, 'specializations_pictures': specializationsPictures,
'schedules': schedules.toJson(),
'payment_methods': paymentMethods.toDocument(), 'payment_methods': paymentMethods.toDocument(),
'slot_duration_minutes': slotDurationMinutes,
}; };
} }
@@ -145,7 +136,9 @@ class ProfessionalEntity extends Equatable {
specializations, specializations,
specializationsPictures, specializationsPictures,
schedules, schedules,
paymentMethods paymentMethods,
slotDurationMinutes,
recordId,
]; ];
@override @override
@@ -28,41 +28,46 @@ class ScheduleEntity extends Equatable {
range2Hour2: null, range2Hour2: null,
); );
// copy func /// Marks "argument not given", so that passing an explicit `null` clears
/// the hour instead of being read as "leave it as it was" — that is why
/// going back to jornada continua used to keep the split hours around.
static const _unset = Object();
ScheduleEntity copyWith({ ScheduleEntity copyWith({
bool? enabled, bool? enabled,
bool? continuousDay, bool? continuousDay,
TimeOfDay? range1Hour1, Object? range1Hour1 = _unset,
TimeOfDay? range1Hour2, Object? range1Hour2 = _unset,
TimeOfDay? range2Hour1, Object? range2Hour1 = _unset,
TimeOfDay? range2Hour2, Object? range2Hour2 = _unset,
}) { }) {
return ScheduleEntity( return ScheduleEntity(
enabled: enabled ?? this.enabled, enabled: enabled ?? this.enabled,
continuousDay: continuousDay ?? this.continuousDay, continuousDay: continuousDay ?? this.continuousDay,
range1Hour1: range1Hour1 ?? this.range1Hour1, range1Hour1: identical(range1Hour1, _unset)
range1Hour2: range1Hour2 ?? this.range1Hour2, ? this.range1Hour1
range2Hour1: range2Hour1 ?? this.range2Hour1, : range1Hour1 as TimeOfDay?,
range2Hour2: range2Hour2 ?? this.range2Hour2, range1Hour2: identical(range1Hour2, _unset)
? this.range1Hour2
: range1Hour2 as TimeOfDay?,
range2Hour1: identical(range2Hour1, _unset)
? this.range2Hour1
: range2Hour1 as TimeOfDay?,
range2Hour2: identical(range2Hour2, _unset)
? this.range2Hour2
: range2Hour2 as TimeOfDay?,
); );
} }
static ScheduleEntity fromDocument(Map<String, dynamic> doc) { /// Accepts both "HH:MM" and the ISO8601 the backend returns
return ScheduleEntity( /// (e.g. "1970-01-01T08:30:00.000Z"). The time is read as wall clock —
enabled: doc['habilitado'] as bool, /// no timezone conversion — so 08:00 stays 08:00.
continuousDay: doc['continuous_day'] as bool, static TimeOfDay? parseTime(String? time) {
range1Hour1: _parseTime(doc['range1Hour1']),
range1Hour2: _parseTime(doc['range1Hour2']),
range2Hour1: _parseTime(doc['range2Hour1']),
range2Hour2: _parseTime(doc['range2Hour2']),
);
}
static TimeOfDay? _parseTime(String? time) {
try { try {
if (time == null) return null; if (time == null) return null;
final components = time.split(':'); final t = time.contains('T') ? time.split('T')[1] : time;
if (components.length != 2) { final components = t.split(':');
if (components.length < 2) {
return null; return null;
} }
final hour = int.parse(components[0]); final hour = int.parse(components[0]);
@@ -73,22 +78,26 @@ class ScheduleEntity extends Equatable {
} }
} }
Map<String, dynamic> toJson() { /// "08:05", never "8:5" — the backend matches /^\d{2}:\d{2}$/.
return { static String? formatTimePadded(TimeOfDay? time) {
'habilitado': enabled, if (time == null) return null;
'continuous_day': continuousDay, final h = time.hour.toString().padLeft(2, '0');
'range1Hour1': formatTimeOfDay(range1Hour1), final m = time.minute.toString().padLeft(2, '0');
'range1Hour2': formatTimeOfDay(range1Hour2), return '$h:$m';
'range2Hour1': formatTimeOfDay(range2Hour1),
'range2Hour2': formatTimeOfDay(range2Hour2),
};
} }
String? formatTimeOfDay(TimeOfDay? time) { /// The row shape PATCH /professionals/me/schedules expects.
if (time != null) { /// [dayOfWeek] is 0 = Monday … 6 = Sunday, the backend's own convention.
return "${time.hour.toString()}:${time.minute.toString()}"; Map<String, dynamic> toScheduleDto(int dayOfWeek) {
} return {
return null; '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),
};
} }
static String? getFormatTime(TimeOfDay? time) { static String? getFormatTime(TimeOfDay? time) {
@@ -51,28 +51,59 @@ class Schedules extends Equatable {
); );
} }
factory Schedules.fromDocument(Map<String, dynamic> doc) { /// Parses the array the backend returns, keyed by `day_of_week`
/// (0 = Monday … 6 = Sunday). Anything else yields [Schedules.empty]
/// rather than throwing, so one malformed row cannot blank a screen.
static Schedules fromApiArray(dynamic raw) {
if (raw is! List || raw.isEmpty) return Schedules.empty;
final byDay = <int, Map<String, dynamic>>{};
for (final s in raw) {
if (s is! Map) continue;
final day = (s['day_of_week'] as num?)?.toInt();
if (day != null) byDay[day] = s.cast<String, dynamic>();
}
ScheduleEntity entityFor(int day) {
final s = byDay[day];
if (s == null) return ScheduleEntity.empty;
return ScheduleEntity(
enabled: s['enabled'] as bool? ?? false,
continuousDay: s['continuous_day'] as bool? ?? false,
range1Hour1: ScheduleEntity.parseTime(s['range1_hour1']?.toString()),
range1Hour2: ScheduleEntity.parseTime(s['range1_hour2']?.toString()),
range2Hour1: ScheduleEntity.parseTime(s['range2_hour1']?.toString()),
range2Hour2: ScheduleEntity.parseTime(s['range2_hour2']?.toString()),
);
}
return Schedules( return Schedules(
monday: ScheduleEntity.fromDocument(doc['monday']), monday: entityFor(0),
tuesday: ScheduleEntity.fromDocument(doc['tuesday']), tuesday: entityFor(1),
wednesday: ScheduleEntity.fromDocument(doc['wednesday']), wednesday: entityFor(2),
thursday: ScheduleEntity.fromDocument(doc['thursday']), thursday: entityFor(3),
friday: ScheduleEntity.fromDocument(doc['friday']), friday: entityFor(4),
saturday: ScheduleEntity.fromDocument(doc['saturday']), saturday: entityFor(5),
sunday: ScheduleEntity.fromDocument(doc['sunday']), sunday: entityFor(6),
); );
} }
Map<String, dynamic> toJson() { /// The array PATCH /professionals/me/schedules expects, ordered
return { /// 0 = Monday … 6 = Sunday.
'monday': monday.toJson(), ///
'tuesday': tuesday.toJson(), /// The map form below is NOT accepted by the backend: sending it to
'wednesday': wednesday.toJson(), /// /professionals/me returns 200 and silently discards the schedules, which
'thursday': thursday.toJson(), /// is why editing opening hours appeared to work and never persisted.
'friday': friday.toJson(), List<Map<String, dynamic>> toSchedulesArray() {
'saturday': saturday.toJson(), return [
'sunday': sunday.toJson(), monday.toScheduleDto(0),
}; tuesday.toScheduleDto(1),
wednesday.toScheduleDto(2),
thursday.toScheduleDto(3),
friday.toScheduleDto(4),
saturday.toScheduleDto(5),
sunday.toScheduleDto(6),
];
} }
@override @override
@@ -7,5 +7,21 @@ int enumToInt(LocationPreferences state) {
} }
LocationPreferences intToEnum(int value) { LocationPreferences intToEnum(int value) {
if (value < 0 || value >= LocationPreferences.values.length) {
return LocationPreferences.office;
}
return LocationPreferences.values[value]; return LocationPreferences.values[value];
} }
/// The wire format the backend expects: it rejects the enum index with
/// "location_preferences must be a string".
String enumToString(LocationPreferences state) {
switch (state) {
case LocationPreferences.delivery:
return 'delivery';
case LocationPreferences.both:
return 'both';
case LocationPreferences.office:
return 'office';
}
}
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:developer';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:professional_repository/professional_repository.dart'; import 'package:professional_repository/professional_repository.dart';
@@ -19,6 +20,8 @@ class ApiProfessionalRepository {
String? _token; String? _token;
static const _timeout = Duration(seconds: 20);
ApiProfessionalRepository() { ApiProfessionalRepository() {
_isProModeActiveBroadcast.add(isProModeActive); _isProModeActiveBroadcast.add(isProModeActive);
} }
@@ -38,19 +41,72 @@ class ApiProfessionalRepository {
} }
Future<dynamic> _get(String path) async { Future<dynamic> _get(String path) async {
final res = await http.get(Uri.parse('$_base$path'), headers: await _headers()); final res = await http
.get(Uri.parse('$_base$path'), headers: await _headers())
.timeout(_timeout);
return jsonDecode(res.body); return jsonDecode(res.body);
} }
Future<dynamic> _patch(String path, Map<String, dynamic> body) async { Future<dynamic> _patch(String path, Map<String, dynamic> body) async {
final res = await http.patch( final res = await http
Uri.parse('$_base$path'), .patch(
headers: await _headers(), Uri.parse('$_base$path'),
body: jsonEncode(body), headers: await _headers(),
); body: jsonEncode(body),
)
.timeout(_timeout);
if (res.statusCode >= 400) {
throw Exception('No se pudo guardar la información (${res.statusCode})');
}
return jsonDecode(res.body); return jsonDecode(res.body);
} }
/// Uploads [file] to storage and returns its public URL.
/// Throws when the upload fails, so callers never treat a failure as success.
Future<String> _uploadFile(String file) async {
final token = await _getToken();
final req =
http.MultipartRequest('POST', Uri.parse('$_base/storage/upload'));
if (token != null) req.headers['Authorization'] = 'Bearer $token';
req.files.add(await http.MultipartFile.fromPath('file', file));
final streamed = await req.send().timeout(const Duration(seconds: 60));
final res = await http.Response.fromStream(streamed);
if (res.statusCode >= 400) {
throw Exception('No se pudo subir el archivo (${res.statusCode})');
}
final body = jsonDecode(res.body) as Map<String, dynamic>;
final url = body['url']?.toString();
if (url == null || url.isEmpty) {
throw Exception('El servidor no devolvió la URL del archivo');
}
return url;
}
Future<void> _delete(String path) async {
final res = await http
.delete(Uri.parse('$_base$path'), headers: await _headers())
.timeout(_timeout);
if (res.statusCode >= 400) {
throw Exception('Error al reiniciar la solicitud (${res.statusCode})');
}
}
Future<void> deleteProfessionalInfo() => _delete('/professionals/me');
/// Saves only the weekly opening hours.
///
/// Lets the schedule editor persist on its own instead of handing the object
/// back and hoping the profile screen gets saved afterwards, and keeps it
/// from overwriting address, rate or payment methods along the way.
Future<void> updateSchedules(Schedules schedules) async {
await _patch('/professionals/me/schedules', {
'schedules': schedules.toSchedulesArray(),
});
if (_proInfo != null) {
await updateFromFirebase(userId: _proInfo!.id);
}
}
ProfessionalEntity? lastProInfo() => _proInfo; ProfessionalEntity? lastProInfo() => _proInfo;
Stream<ProfessionalEntity?> streamProInfo() => _proInfoBroadcast.stream; Stream<ProfessionalEntity?> streamProInfo() => _proInfoBroadcast.stream;
@@ -62,34 +118,63 @@ class ApiProfessionalRepository {
_isProModeActiveBroadcast.add(isProModeActive); _isProModeActiveBroadcast.add(isProModeActive);
} }
/// The backend sends `location_preferences` as a string ('office' |
/// 'delivery' | 'both'); older payloads used the enum index.
LocationPreferences _locationPrefsFromValue(dynamic v) {
if (v is num) return intToEnum(v.toInt());
if (v is String) {
switch (v) {
case 'delivery':
return LocationPreferences.delivery;
case 'both':
return LocationPreferences.both;
default:
return LocationPreferences.office;
}
}
return LocationPreferences.office;
}
/// The backend sends `schedules` as an array of rows keyed by
/// `day_of_week` (0 = Monday … 6 = Sunday), not as a map of day names.
Schedules _schedulesFromApi(dynamic raw) => Schedules.fromApiArray(raw);
ProfessionalEntity _fromApi(Map<String, dynamic> json) { ProfessionalEntity _fromApi(Map<String, dynamic> json) {
return ProfessionalEntity( return ProfessionalEntity(
id: json['user_id']?.toString() ?? json['id']?.toString() ?? '', id: json['user_id']?.toString() ?? json['id']?.toString() ?? '',
identification: json['identification']?.toString() ?? '', identification: json['identification']?.toString() ?? '',
address: json['address']?.toString() ?? '', address: json['address']?.toString() ?? '',
aditionalAddress: json['aditional_address']?.toString() ?? '', aditionalAddress: (json['additional_address'] ?? json['aditional_address'])
?.toString() ??
'',
profession: json['profession']?.toString() ?? '', profession: json['profession']?.toString() ?? '',
ratePreferences: json['rate_preferences'] as bool? ?? false, ratePreferences: json['rate_preferences'] as bool? ?? false,
rate: json['rate']?.toString() ?? '', rate: json['rate']?.toString() ?? '',
locationPreferences: intToEnum((json['location_preferences'] as num?)?.toInt() ?? 0), locationPreferences:
_locationPrefsFromValue(json['location_preferences']),
bannerPicture: json['banner_picture']?.toString() ?? '', bannerPicture: json['banner_picture']?.toString() ?? '',
identificationPicture: json['identification_picture']?.toString() ?? '', identificationPicture: json['identification_picture']?.toString() ?? '',
certificatePicture: json['certificate_picture']?.toString() ?? '', certificatePicture: json['certificate_picture']?.toString() ?? '',
latitude: double.tryParse(json['latitude']?.toString() ?? '0') ?? 0.0, latitude: double.tryParse(json['latitude']?.toString() ?? '0') ?? 0.0,
longitude: double.tryParse(json['longitude']?.toString() ?? '0') ?? 0.0, longitude: double.tryParse(json['longitude']?.toString() ?? '0') ?? 0.0,
specializations: json['specializations'] != null specializations: json['specializations'] is List
? List<String>.from(json['specializations']) ? (json['specializations'] as List)
: [], .map((e) => e.toString())
specializationsPictures: json['specializations_pictures'] != null .toList()
? List<String>.from(json['specializations_pictures']) : <String>[],
: [], specializationsPictures: json['specializations_pictures'] is List
schedules: json['schedules'] != null ? (json['specializations_pictures'] as List)
? Schedules.fromDocument(json['schedules'] as Map<String, dynamic>) .map((e) => e.toString())
: Schedules.empty, .toList()
: <String>[],
schedules: _schedulesFromApi(json['schedules']),
paymentMethods: json['payment_methods'] != null paymentMethods: json['payment_methods'] != null
? PaymentMethodEntity.fromDocument( ? PaymentMethodEntity.fromDocument(
json['payment_methods'] as Map<String, dynamic>) json['payment_methods'] as Map<String, dynamic>)
: PaymentMethodEntity.empty, : PaymentMethodEntity.empty,
slotDurationMinutes:
(json['slot_duration_minutes'] as num?)?.toInt() ?? 120,
recordId: json['id']?.toString() ?? '',
); );
} }
@@ -109,7 +194,9 @@ class ApiProfessionalRepository {
final data = await _get('/professionals/$myUserId'); final data = await _get('/professionals/$myUserId');
if (data == null) return null; if (data == null) return null;
return _fromApi(data as Map<String, dynamic>); return _fromApi(data as Map<String, dynamic>);
} catch (_) { } catch (e) {
// Logged because a silent null here surfaces as an endless spinner.
log('getProInfo($myUserId) failed: $e');
return null; return null;
} }
} }
@@ -123,18 +210,26 @@ class ApiProfessionalRepository {
double latitude, double latitude,
double longitude, double longitude,
Schedules schedules, Schedules schedules,
PaymentMethodEntity paymentMethods, PaymentMethodEntity paymentMethods, {
) async { int slotDurationMinutes = 120,
}) async {
// Two calls on purpose: /professionals/me ignores a `schedules` key
// (returns 200 and drops it), and the schedules endpoint takes the array
// form. Sending them together is what made saving opening hours a no-op.
await _patch('/professionals/me', { await _patch('/professionals/me', {
'address': address, 'address': address,
'aditional_address': aditionalAddress, 'aditional_address': aditionalAddress,
'rate_preferences': ratePreferences, 'rate_preferences': ratePreferences,
'rate': rate, 'rate': rate,
'location_preferences': enumToInt(locationPreferences), 'location_preferences': enumToString(locationPreferences),
'latitude': latitude, 'latitude': latitude,
'longitude': longitude, 'longitude': longitude,
'schedules': schedules.toJson(),
'payment_methods': paymentMethods.toDocument(), 'payment_methods': paymentMethods.toDocument(),
'slot_duration_minutes': slotDurationMinutes,
});
await _patch('/professionals/me/schedules', {
'schedules': schedules.toSchedulesArray(),
}); });
if (_proInfo != null) { if (_proInfo != null) {
@@ -147,51 +242,21 @@ class ApiProfessionalRepository {
} }
Future<void> uploadBannerPicture(String file) async { Future<void> uploadBannerPicture(String file) async {
final token = await _getToken(); final url = await _uploadFile(file);
final req = http.MultipartRequest('POST', Uri.parse('$_base/storage/upload'));
if (token != null) req.headers['Authorization'] = 'Bearer $token';
req.files.add(await http.MultipartFile.fromPath('file', file));
final streamed = await req.send();
final res = await http.Response.fromStream(streamed);
final body = jsonDecode(res.body) as Map<String, dynamic>;
final url = body['url'] as String;
await _patch('/professionals/me', {'banner_picture': url}); await _patch('/professionals/me', {'banner_picture': url});
} }
Future<String> uploadPdfCedula(String file, String userId) async { Future<String> uploadPdfCedula(String file, String userId) =>
final token = await _getToken(); _uploadFile(file);
final req = http.MultipartRequest('POST', Uri.parse('$_base/storage/upload'));
if (token != null) req.headers['Authorization'] = 'Bearer $token';
req.files.add(await http.MultipartFile.fromPath('file', file));
final streamed = await req.send();
final res = await http.Response.fromStream(streamed);
final body = jsonDecode(res.body) as Map<String, dynamic>;
return body['url'] as String;
}
Future<String> uploadPdfCertificado(String file, String userId) async { Future<String> uploadPdfCertificado(String file, String userId) =>
final token = await _getToken(); _uploadFile(file);
final req = http.MultipartRequest('POST', Uri.parse('$_base/storage/upload'));
if (token != null) req.headers['Authorization'] = 'Bearer $token';
req.files.add(await http.MultipartFile.fromPath('file', file));
final streamed = await req.send();
final res = await http.Response.fromStream(streamed);
final body = jsonDecode(res.body) as Map<String, dynamic>;
return body['url'] as String;
}
Future<List<String>> uploadPdfsEspecializaciones( Future<List<String>> uploadPdfsEspecializaciones(
List<String> files, String userId) async { List<String> files, String userId) async {
final List<String> urls = []; final List<String> urls = [];
for (final file in files) { for (final file in files) {
final token = await _getToken(); urls.add(await _uploadFile(file));
final req = http.MultipartRequest('POST', Uri.parse('$_base/storage/upload'));
if (token != null) req.headers['Authorization'] = 'Bearer $token';
req.files.add(await http.MultipartFile.fromPath('file', file));
final streamed = await req.send();
final res = await http.Response.fromStream(streamed);
final body = jsonDecode(res.body) as Map<String, dynamic>;
urls.add(body['url'] as String);
} }
return urls; return urls;
} }
@@ -206,6 +271,26 @@ class ApiProfessionalRepository {
} }
} }
Future<List<ProfessionalEntity>> searchProfessionals({
String? search,
String? city,
double? lat,
double? lng,
}) async {
final params = <String, String>{};
if (search != null && search.isNotEmpty) params['search'] = search;
if (city != null && city.isNotEmpty) params['city'] = city;
if (lat != null) params['lat'] = lat.toString();
if (lng != null) params['lng'] = lng.toString();
final uri = Uri.parse('$_base/professionals')
.replace(queryParameters: params.isEmpty ? null : params);
final res = await http.get(uri, headers: await _headers());
final body = jsonDecode(res.body);
final List raw = body is Map ? (body['data'] as List? ?? []) : (body as List? ?? []);
return raw.map((e) => _fromApi(e as Map<String, dynamic>)).toList();
}
Future<List<ProfessionalEntity>> getProfessionalsFromIds( Future<List<ProfessionalEntity>> getProfessionalsFromIds(
Iterable<String> ids) async { Iterable<String> ids) async {
final result = <ProfessionalEntity>[]; final result = <ProfessionalEntity>[];
+121 -121
View File
@@ -1,14 +1,6 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
_flutterfire_internals:
dependency: transitive
description:
name: _flutterfire_internals
sha256: "4eec93681221723a686ad580c2e7d960e1017cf1a4e0a263c2573c2c6b0bf5cd"
url: "https://pub.dev"
source: hosted
version: "1.3.25"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -41,30 +33,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
cloud_firestore:
dependency: "direct main"
description:
name: cloud_firestore
sha256: "31cfa4d65d6e9ea837234fffe121304034c30c9214c06207b4a35867e3757900"
url: "https://pub.dev"
source: hosted
version: "4.15.8"
cloud_firestore_platform_interface:
dependency: transitive
description:
name: cloud_firestore_platform_interface
sha256: a0097a26569b015faf8142e159e855241609ea9a1738b5fd1c40bfe8411b41a0
url: "https://pub.dev"
source: hosted
version: "6.1.9"
cloud_firestore_web:
dependency: transitive
description:
name: cloud_firestore_web
sha256: ed680ece29a5750985119c09cdc276b460c3a2fa80e8c12f9b7241f6b4a7ca16
url: "https://pub.dev"
source: hosted
version: "3.10.8"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@@ -89,78 +57,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.1" version: "1.3.1"
firebase_auth: ffi:
dependency: "direct main"
description:
name: firebase_auth
sha256: "17b841e1b000c3441b8ffceca88f468e078d0443db9643e77541bdfb7a3fd16b"
url: "https://pub.dev"
source: hosted
version: "4.17.8"
firebase_auth_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: firebase_auth_platform_interface name: ffi
sha256: f294ceef40409a36c819a14280ca864fe487b44033e5276443377c66cb448310 sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.1.8" version: "2.1.3"
firebase_auth_web: file:
dependency: transitive dependency: transitive
description: description:
name: firebase_auth_web name: file
sha256: "1f231da900fe7ff9f2974f8adcbdb3363c410c24725978afa5dc33e1e7e62e06" sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.9.8" version: "7.0.1"
firebase_core:
dependency: "direct main"
description:
name: firebase_core
sha256: "53316975310c8af75a96e365f9fccb67d1c544ef0acdbf0d88bbe30eedd1c4f9"
url: "https://pub.dev"
source: hosted
version: "2.27.0"
firebase_core_platform_interface:
dependency: transitive
description:
name: firebase_core_platform_interface
sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63
url: "https://pub.dev"
source: hosted
version: "5.0.0"
firebase_core_web:
dependency: transitive
description:
name: firebase_core_web
sha256: c8e1d59385eee98de63c92f961d2a7062c5d9a65e7f45bdc7f1b0b205aab2492
url: "https://pub.dev"
source: hosted
version: "2.11.5"
firebase_storage:
dependency: "direct main"
description:
name: firebase_storage
sha256: ce1b0efe8dc111058c5f079b2f2ce84906d030d0fd2eef70c42ca1253c67039a
url: "https://pub.dev"
source: hosted
version: "11.6.9"
firebase_storage_platform_interface:
dependency: transitive
description:
name: firebase_storage_platform_interface
sha256: "4a2b64d4dac096390a0b7f2e7b6d086d0546c4a1bf7ee3fc4b5ae4cc41005c46"
url: "https://pub.dev"
source: hosted
version: "5.1.12"
firebase_storage_web:
dependency: transitive
description:
name: firebase_storage_web
sha256: "4153814db8d59138e816d9f016736e4095c45675a2c18f2868d11ffd8cc6a4ca"
url: "https://pub.dev"
source: hosted
version: "3.7.3"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -185,7 +97,7 @@ packages:
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
http: http:
dependency: transitive dependency: "direct main"
description: description:
name: http name: http
sha256: a2bbf9d017fcced29139daa8ed2bba4ece450ab222871df93ca9eec6f80c34ba sha256: a2bbf9d017fcced29139daa8ed2bba4ece450ab222871df93ca9eec6f80c34ba
@@ -204,34 +116,26 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: intl name: intl
sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.18.1" version: "0.19.0"
js:
dependency: transitive
description:
name: js
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev"
source: hosted
version: "0.6.7"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.4" version: "10.0.5"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.5"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
@@ -260,18 +164,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.0" version: "0.11.1"
meta: meta:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.12.0" version: "1.15.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -280,6 +184,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.0" version: "1.9.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface: plugin_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -288,6 +224,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180
url: "https://pub.dev"
source: hosted
version: "2.2.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "9f9f3d372d4304723e6136663bb291c0b93f5e4c8a4a6314347f481a33bda2b1"
url: "https://pub.dev"
source: hosted
version: "2.4.7"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: "7b15ffb9387ea3e237bb7a66b8a23d2147663d391cafc5c8f37b2e7b4bde5d21"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -337,10 +329,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.0" version: "0.7.2"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -361,10 +353,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.2.1" version: "14.2.5"
web: web:
dependency: transitive dependency: transitive
description: description:
@@ -373,6 +365,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.0" version: "0.3.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
sdks: sdks:
dart: ">=3.3.0 <4.0.0" dart: ">=3.5.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.24.0"
@@ -21,12 +21,16 @@ class CommentEntity extends Equatable {
static CommentEntity fromDocument(Map<String, dynamic> doc) { static CommentEntity fromDocument(Map<String, dynamic> doc) {
return CommentEntity( return CommentEntity(
authorId: doc['author_id'] as String, // Tolerant on purpose: a whole-number score arrives as `5`, and
destinationId: doc['destination_id'] as String, // Postgres numerics can arrive as strings. Hard casts here emptied the
serviceId: doc['service_id'] as String, // whole review list, which then read as "no reviews yet".
content: doc['content'] as String, authorId: doc['author_id']?.toString() ?? '',
score: doc['score'] as double, destinationId: doc['destination_id']?.toString() ?? '',
isFromUser: doc['is_from_user'] as bool, serviceId: doc['service_id']?.toString() ?? '',
content: doc['content']?.toString() ?? '',
score: double.tryParse(doc['score']?.toString() ?? '') ?? 0.0,
isFromUser: doc['is_from_user'] == true ||
doc['is_from_user']?.toString() == 'true',
createdAt: doc['created_at']?.toString() ?? DateTime.now().toIso8601String(), createdAt: doc['created_at']?.toString() ?? DateTime.now().toIso8601String(),
); );
} }
@@ -23,10 +23,12 @@ class ReputationEntity extends Equatable {
final averagePro = doc['average_pro'] ?? 0.0; final averagePro = doc['average_pro'] ?? 0.0;
return ReputationEntity( return ReputationEntity(
total: int.parse(total.toString()), // COUNT can come back as "5.0000"; parse as double then truncate so a
average: double.parse(average.toString()), // reputation of 0.0 never gets shown as if it were real.
totalPro: int.parse(totalPro.toString()), total: (double.tryParse(total.toString()) ?? 0).toInt(),
averagePro: double.parse(averagePro.toString()), average: double.tryParse(average.toString()) ?? 0.0,
totalPro: (double.tryParse(totalPro.toString()) ?? 0).toInt(),
averagePro: double.tryParse(averagePro.toString()) ?? 0.0,
); );
} }
@@ -5,6 +5,8 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:score_repository/score_repository.dart'; import 'package:score_repository/score_repository.dart';
import 'package:score_repository/src/entities/comment_entity.dart'; import 'package:score_repository/src/entities/comment_entity.dart';
const _kHttpTimeout = Duration(seconds: 20);
const _base = 'https://backend.prosapp.co/api/v1'; const _base = 'https://backend.prosapp.co/api/v1';
class ApiScoreRepository { class ApiScoreRepository {
@@ -43,7 +45,7 @@ class ApiScoreRepository {
final res = await http.get( final res = await http.get(
Uri.parse('$_base/comments/reputation/$userId'), Uri.parse('$_base/comments/reputation/$userId'),
headers: await _headers(), headers: await _headers(),
); ).timeout(_kHttpTimeout);
final data = jsonDecode(res.body) as Map<String, dynamic>; final data = jsonDecode(res.body) as Map<String, dynamic>;
final rep = ReputationEntity.fromDocument(data); final rep = ReputationEntity.fromDocument(data);
_reputation = rep; _reputation = rep;
@@ -73,25 +75,35 @@ class ApiScoreRepository {
} }
Future<List<CommentEntity>> _fetchComments(String path) async { Future<List<CommentEntity>> _fetchComments(String path) async {
final res = await http.get(Uri.parse('$_base$path'), headers: await _headers()); final res = await http.get(Uri.parse('$_base$path'), headers: await _headers()).timeout(_kHttpTimeout);
final body = jsonDecode(res.body); final body = jsonDecode(res.body);
final List raw = body is Map ? (body['data'] as List? ?? []) : (body as List? ?? []); final List raw = body is Map ? (body['data'] as List? ?? []) : (body as List? ?? []);
return raw.map((e) => CommentEntity.fromDocument(e as Map<String, dynamic>)).toList(); return raw.map((e) => CommentEntity.fromDocument(e as Map<String, dynamic>)).toList();
} }
/// Throws when the rating could not be stored.
///
/// This used to swallow every error in an empty catch, so a lost rating was
/// indistinguishable from a saved one — for the user *and* for us.
Future<void> addComment(CommentEntity comment) async { Future<void> addComment(CommentEntity comment) async {
final res = await http
.post(
Uri.parse('$_base/comments'),
headers: await _headers(),
body: jsonEncode({
'destination_id': comment.destinationId,
'service_id': comment.serviceId,
'content': comment.content,
'score': comment.score,
'is_from_user': comment.isFromUser,
}),
)
.timeout(_kHttpTimeout);
if (res.statusCode >= 400) {
throw Exception('No se pudo guardar la calificación (${res.statusCode})');
}
// Refreshing reputation is a nicety; never fail the rating over it.
try { try {
await http.post(
Uri.parse('$_base/comments'),
headers: await _headers(),
body: jsonEncode({
'destination_id': comment.destinationId,
'service_id': comment.serviceId,
'content': comment.content,
'score': comment.score,
'is_from_user': comment.isFromUser,
}),
);
await getReputationByUserId(comment.destinationId); await getReputationByUserId(comment.destinationId);
} catch (_) {} } catch (_) {}
} }
+128 -96
View File
@@ -1,14 +1,6 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
_flutterfire_internals:
dependency: transitive
description:
name: _flutterfire_internals
sha256: "4eec93681221723a686ad580c2e7d960e1017cf1a4e0a263c2573c2c6b0bf5cd"
url: "https://pub.dev"
source: hosted
version: "1.3.25"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -41,30 +33,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
cloud_firestore:
dependency: "direct main"
description:
name: cloud_firestore
sha256: "31cfa4d65d6e9ea837234fffe121304034c30c9214c06207b4a35867e3757900"
url: "https://pub.dev"
source: hosted
version: "4.15.8"
cloud_firestore_platform_interface:
dependency: transitive
description:
name: cloud_firestore_platform_interface
sha256: a0097a26569b015faf8142e159e855241609ea9a1738b5fd1c40bfe8411b41a0
url: "https://pub.dev"
source: hosted
version: "6.1.9"
cloud_firestore_web:
dependency: transitive
description:
name: cloud_firestore_web
sha256: ed680ece29a5750985119c09cdc276b460c3a2fa80e8c12f9b7241f6b4a7ca16
url: "https://pub.dev"
source: hosted
version: "3.10.8"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@@ -89,54 +57,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.1" version: "1.3.1"
firebase_auth: ffi:
dependency: "direct main"
description:
name: firebase_auth
sha256: "17b841e1b000c3441b8ffceca88f468e078d0443db9643e77541bdfb7a3fd16b"
url: "https://pub.dev"
source: hosted
version: "4.17.8"
firebase_auth_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: firebase_auth_platform_interface name: ffi
sha256: f294ceef40409a36c819a14280ca864fe487b44033e5276443377c66cb448310 sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.1.8" version: "2.1.3"
firebase_auth_web: file:
dependency: transitive dependency: transitive
description: description:
name: firebase_auth_web name: file
sha256: "1f231da900fe7ff9f2974f8adcbdb3363c410c24725978afa5dc33e1e7e62e06" sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.9.8" version: "7.0.1"
firebase_core:
dependency: "direct main"
description:
name: firebase_core
sha256: "53316975310c8af75a96e365f9fccb67d1c544ef0acdbf0d88bbe30eedd1c4f9"
url: "https://pub.dev"
source: hosted
version: "2.27.0"
firebase_core_platform_interface:
dependency: transitive
description:
name: firebase_core_platform_interface
sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63
url: "https://pub.dev"
source: hosted
version: "5.0.0"
firebase_core_web:
dependency: transitive
description:
name: firebase_core_web
sha256: c8e1d59385eee98de63c92f961d2a7062c5d9a65e7f45bdc7f1b0b205aab2492
url: "https://pub.dev"
source: hosted
version: "2.11.5"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -160,6 +96,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser: http_parser:
dependency: transitive dependency: transitive
description: description:
@@ -168,30 +112,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.2" version: "4.0.2"
js:
dependency: transitive
description:
name: js
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev"
source: hosted
version: "0.6.7"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.4" version: "10.0.5"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.5"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
@@ -220,18 +156,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.0" version: "0.11.1"
meta: meta:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.12.0" version: "1.15.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -240,6 +176,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.0" version: "1.9.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface: plugin_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -248,6 +216,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
url: "https://pub.dev"
source: hosted
version: "2.5.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "9f9f3d372d4304723e6136663bb291c0b93f5e4c8a4a6314347f481a33bda2b1"
url: "https://pub.dev"
source: hosted
version: "2.4.7"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -297,10 +321,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.0" version: "0.7.2"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -321,18 +345,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.2.1" version: "14.2.5"
web: web:
dependency: transitive dependency: transitive
description: description:
name: web name: web
sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.0" version: "1.1.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
sdks: sdks:
dart: ">=3.3.0 <4.0.0" dart: ">=3.5.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.24.0"
@@ -1 +1,2 @@
export '/src/entities/service_entity.dart'; export '/src/entities/service_entity.dart';
export 'public_calendar.dart';
@@ -0,0 +1,17 @@
import 'package:professional_repository/professional_repository.dart'
show Schedules;
import 'service_entity.dart';
/// What a client needs to draw another professional's availability: the
/// opening hours as they stand right now, plus the slots already taken.
///
/// The two travel together on purpose. Reading the hours from a list snapshot
/// and the appointments from the live endpoint let a patient be offered a slot
/// on a day the professional had already closed.
class PublicCalendar {
final Schedules schedules;
final List<ServiceEntity> services;
const PublicCalendar({required this.schedules, required this.services});
}
@@ -2,9 +2,13 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:professional_repository/professional_repository.dart'
show Schedules;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:service_repository/service_repository.dart'; import 'package:service_repository/service_repository.dart';
const _kHttpTimeout = Duration(seconds: 20);
const _base = 'https://backend.prosapp.co/api/v1'; const _base = 'https://backend.prosapp.co/api/v1';
// Backend uses string status; Flutter uses int-indexed enum // Backend uses string status; Flutter uses int-indexed enum
@@ -49,27 +53,55 @@ class ApiServiceRepository {
}; };
} }
Future<dynamic> _get(String path) async { /// Rejects error responses and tolerates an empty body.
final res = await http.get(Uri.parse('$_base$path'), headers: await _headers()); ///
/// Neither used to happen: a 4xx was parsed as if it had succeeded, and a
/// 204 with no body made `jsonDecode` throw, so a change that *did* apply
/// was reported to the user as a failure.
dynamic _decode(http.Response res, String action) {
if (res.statusCode >= 400) {
String detail = '';
try {
final body = jsonDecode(res.body);
if (body is Map && body['message'] != null) {
final m = body['message'];
detail = m is List ? m.join(', ') : m.toString();
}
} catch (_) {}
throw Exception(
'$action falló (${res.statusCode})${detail.isEmpty ? '' : ': $detail'}');
}
if (res.body.isEmpty) return null;
return jsonDecode(res.body); return jsonDecode(res.body);
} }
Future<dynamic> _get(String path) async {
final res = await http
.get(Uri.parse('$_base$path'), headers: await _headers())
.timeout(_kHttpTimeout);
return _decode(res, 'La consulta');
}
Future<dynamic> _post(String path, Map<String, dynamic> body) async { Future<dynamic> _post(String path, Map<String, dynamic> body) async {
final res = await http.post( final res = await http
Uri.parse('$_base$path'), .post(
headers: await _headers(), Uri.parse('$_base$path'),
body: jsonEncode(body), headers: await _headers(),
); body: jsonEncode(body),
return jsonDecode(res.body); )
.timeout(_kHttpTimeout);
return _decode(res, 'La operación');
} }
Future<dynamic> _patch(String path, Map<String, dynamic> body) async { Future<dynamic> _patch(String path, Map<String, dynamic> body) async {
final res = await http.patch( final res = await http
Uri.parse('$_base$path'), .patch(
headers: await _headers(), Uri.parse('$_base$path'),
body: jsonEncode(body), headers: await _headers(),
); body: jsonEncode(body),
return jsonDecode(res.body); )
.timeout(_kHttpTimeout);
return _decode(res, 'La actualización');
} }
ServiceEntity _fromApi(Map<String, dynamic> json) { ServiceEntity _fromApi(Map<String, dynamic> json) {
@@ -112,7 +144,13 @@ class ApiServiceRepository {
createdAt: json['created_at']?.toString() ?? DateTime.now().toIso8601String(), createdAt: json['created_at']?.toString() ?? DateTime.now().toIso8601String(),
description: json['description']?.toString() ?? '', description: json['description']?.toString() ?? '',
range1Hour1: parseTime(json['range1_hour1']?.toString()), range1Hour1: parseTime(json['range1_hour1']?.toString()),
range1Hour2: parseTime(json['range1_hour2']?.toString()), // The backend often leaves range1_hour2 null. Defaulting it to 00:00
// made every service look like it had ended at midnight, so the UI
// marked brand new appointments as "Caducado" and hid every action.
// Falling back to the start time keeps the comparison meaningful.
range1Hour2: json['range1_hour2'] != null
? parseTime(json['range1_hour2'].toString())
: parseTime(json['range1_hour1']?.toString()),
rate: json['rate']?.toString() ?? '0', rate: json['rate']?.toString() ?? '0',
status: intToEnumService(statusIndex), status: intToEnumService(statusIndex),
location: intToEnum(locationIndex), location: intToEnum(locationIndex),
@@ -140,7 +178,13 @@ class ApiServiceRepository {
'longitude': entity.longitude, 'longitude': entity.longitude,
'location_preference': _locationToString[entity.location.index], 'location_preference': _locationToString[entity.location.index],
}); });
return data['id']?.toString() ?? ''; final id = data is Map ? data['id']?.toString() : null;
if (id == null || id.isEmpty) {
// Returning '' here used to be reported as success, and the app then
// navigated to a service that did not exist.
throw Exception('El servidor no devolvió la cita creada');
}
return id;
} }
Future<void> updateServiceStatus(String serviceId, ServiceStatus newStatus) async { Future<void> updateServiceStatus(String serviceId, ServiceStatus newStatus) async {
@@ -149,6 +193,17 @@ class ApiServiceRepository {
}); });
} }
Future<String> blockSlot(String day, TimeOfDay hour1) async {
String fmt(TimeOfDay t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
final data = await _post('/services/block', {
'day': day,
'range1_hour1': fmt(hour1),
});
return data['id']?.toString() ?? '';
}
Stream<ServiceEntity> getService(String serviceId) { Stream<ServiceEntity> getService(String serviceId) {
final controller = StreamController<ServiceEntity>(); final controller = StreamController<ServiceEntity>();
_get('/services/$serviceId').then((data) { _get('/services/$serviceId').then((data) {
@@ -169,12 +224,30 @@ class ApiServiceRepository {
return _stream('/services/professional'); return _stream('/services/professional');
} }
Future<List<ServiceEntity>> getServicesForProfessionalforCalendar(String professionalId) async { /// Throws on failure: the caller must distinguish "no bookings" from
try { /// "could not load bookings", otherwise busy slots render as free.
return _parseList(await _get('/services/professional/calendar')); Future<List<ServiceEntity>> getServicesForProfessionalforCalendar(
} catch (_) { String professionalId) async {
return []; return _parseList(await _get('/services/professional/calendar'));
} }
/// Another professional's booked slots, for a client browsing availability.
///
/// [professionalRecordId] is the row id of the `professionals` table
/// (`ProfessionalEntity.recordId`), not the owning user's id. Clients used to
/// call the endpoint above, which returns the *caller's* own agenda, so every
/// slot looked free and two people could book the same hour.
Future<PublicCalendar> getPublicCalendar(String professionalRecordId) async {
final body = await _get('/services/public-calendar/$professionalRecordId');
final map = body is Map ? body : const {};
final raw = map['services'] as List? ?? <dynamic>[];
return PublicCalendar(
// The endpoint also returns the professional's current opening hours.
// Reading them here instead of the snapshot cached in the search list
// is what keeps the patient from booking against yesterday's agenda.
schedules: Schedules.fromApiArray(map['schedules']),
services: raw.map((e) => _fromApi(e as Map<String, dynamic>)).toList(),
);
} }
Stream<List<ServiceEntity>> getServicesHistoryForUser(String userId) { Stream<List<ServiceEntity>> getServicesHistoryForUser(String userId) {
+156 -69
View File
@@ -1,14 +1,6 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
_flutterfire_internals:
dependency: transitive
description:
name: _flutterfire_internals
sha256: "4eec93681221723a686ad580c2e7d960e1017cf1a4e0a263c2573c2c6b0bf5cd"
url: "https://pub.dev"
source: hosted
version: "1.3.25"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -41,30 +33,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
cloud_firestore:
dependency: "direct main"
description:
name: cloud_firestore
sha256: "31cfa4d65d6e9ea837234fffe121304034c30c9214c06207b4a35867e3757900"
url: "https://pub.dev"
source: hosted
version: "4.15.8"
cloud_firestore_platform_interface:
dependency: transitive
description:
name: cloud_firestore_platform_interface
sha256: a0097a26569b015faf8142e159e855241609ea9a1738b5fd1c40bfe8411b41a0
url: "https://pub.dev"
source: hosted
version: "6.1.9"
cloud_firestore_web:
dependency: transitive
description:
name: cloud_firestore_web
sha256: ed680ece29a5750985119c09cdc276b460c3a2fa80e8c12f9b7241f6b4a7ca16
url: "https://pub.dev"
source: hosted
version: "3.10.8"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@@ -89,30 +57,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.1" version: "1.3.1"
firebase_core: ffi:
dependency: "direct main"
description:
name: firebase_core
sha256: "53316975310c8af75a96e365f9fccb67d1c544ef0acdbf0d88bbe30eedd1c4f9"
url: "https://pub.dev"
source: hosted
version: "2.27.0"
firebase_core_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: firebase_core_platform_interface name: ffi
sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63 sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "2.1.3"
firebase_core_web: file:
dependency: transitive dependency: transitive
description: description:
name: firebase_core_web name: file
sha256: c8e1d59385eee98de63c92f961d2a7062c5d9a65e7f45bdc7f1b0b205aab2492 sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.11.5" version: "7.0.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -136,30 +96,46 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
js: http:
dependency: transitive dependency: "direct main"
description: description:
name: js name: http
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.7" version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
url: "https://pub.dev"
source: hosted
version: "4.0.2"
intl:
dependency: transitive
description:
name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
url: "https://pub.dev"
source: hosted
version: "0.19.0"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.4" version: "10.0.5"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.5"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
@@ -188,18 +164,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.0" version: "0.11.1"
meta: meta:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.12.0" version: "1.15.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -208,6 +184,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.0" version: "1.9.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface: plugin_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -216,6 +224,69 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
professional_repository:
dependency: "direct main"
description:
path: "../professional_repository"
relative: true
source: path
version: "1.0.11+11"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
url: "https://pub.dev"
source: hosted
version: "2.5.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "9f9f3d372d4304723e6136663bb291c0b93f5e4c8a4a6314347f481a33bda2b1"
url: "https://pub.dev"
source: hosted
version: "2.4.7"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -265,10 +336,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.0" version: "0.7.2"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -281,18 +360,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.2.1" version: "14.2.5"
web: web:
dependency: transitive dependency: transitive
description: description:
name: web name: web
sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.0" version: "1.1.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
sdks: sdks:
dart: ">=3.3.0 <4.0.0" dart: ">=3.5.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.24.0"
+2
View File
@@ -11,6 +11,8 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
equatable: ^2.0.5 equatable: ^2.0.5
professional_repository:
path: ../professional_repository
http: ^1.1.0 http: ^1.1.0
shared_preferences: ^2.0.10 shared_preferences: ^2.0.10
@@ -1 +1,2 @@
export '/src/entities/setting_entity.dart'; export '/src/entities/setting_entity.dart';
export '/src/entities/policies_entity.dart';
@@ -0,0 +1,21 @@
import 'package:equatable/equatable.dart';
class PoliciesEntity extends Equatable {
final String privacy;
final String terms;
const PoliciesEntity({
required this.privacy,
required this.terms,
});
static PoliciesEntity fromDocument(Map<String, dynamic> doc) {
return PoliciesEntity(
privacy: doc['privacy'] as String? ?? '',
terms: doc['terms'] as String? ?? '',
);
}
@override
List<Object?> get props => [privacy, terms];
}
@@ -19,6 +19,7 @@ class SettingEntity extends Equatable {
final String? terminosCondiciones; final String? terminosCondiciones;
final String? terminosCondicionesTitle; final String? terminosCondicionesTitle;
final String? terminosCondicionesBody; final String? terminosCondicionesBody;
final num? rejectionWaitDays;
const SettingEntity({ const SettingEntity({
required this.tarifas, required this.tarifas,
@@ -39,6 +40,7 @@ class SettingEntity extends Equatable {
required this.terminosCondiciones, required this.terminosCondiciones,
required this.terminosCondicionesTitle, required this.terminosCondicionesTitle,
required this.terminosCondicionesBody, required this.terminosCondicionesBody,
this.rejectionWaitDays,
}); });
static SettingEntity fromDocument(Map<String, dynamic> doc) { static SettingEntity fromDocument(Map<String, dynamic> doc) {
@@ -61,6 +63,7 @@ class SettingEntity extends Equatable {
terminosCondiciones: doc['terminos_condiciones'] as String?, terminosCondiciones: doc['terminos_condiciones'] as String?,
terminosCondicionesTitle: doc['terminos_condiciones_title'] as String?, terminosCondicionesTitle: doc['terminos_condiciones_title'] as String?,
terminosCondicionesBody: doc['terminos_condiciones_body'] as String?, terminosCondicionesBody: doc['terminos_condiciones_body'] as String?,
rejectionWaitDays: doc['rejection_wait_days'] as num?,
); );
} }
@@ -84,6 +87,7 @@ class SettingEntity extends Equatable {
terminosCondiciones, terminosCondiciones,
terminosCondicionesTitle, terminosCondicionesTitle,
terminosCondicionesBody, terminosCondicionesBody,
rejectionWaitDays,
]; ];
@override @override
@@ -5,6 +5,8 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:setting_repository/src/entities/entities.dart'; import 'package:setting_repository/src/entities/entities.dart';
import 'package:setting_repository/src/repositories/setting_repo.dart'; import 'package:setting_repository/src/repositories/setting_repo.dart';
const _kHttpTimeout = Duration(seconds: 20);
const _base = 'https://backend.prosapp.co/api/v1'; const _base = 'https://backend.prosapp.co/api/v1';
class ApiSettingRepository implements SettingRepository { class ApiSettingRepository implements SettingRepository {
@@ -27,22 +29,35 @@ class ApiSettingRepository implements SettingRepository {
final res = await http.get( final res = await http.get(
Uri.parse('$_base/settings'), Uri.parse('$_base/settings'),
headers: await _headers(), headers: await _headers(),
); ).timeout(_kHttpTimeout);
final data = jsonDecode(res.body); final data = jsonDecode(res.body) as Map<String, dynamic>;
// Backend returns {key: string, value: any}[] or a flat map // Backend returns nested structure: {version: {android, ios}, soporte: {...}, politicas: {...}}
Map<String, dynamic> doc = {}; final version = data['version'] as Map<String, dynamic>? ?? {};
if (data is List) { final soporte = data['soporte'] as Map<String, dynamic>? ?? {};
for (final entry in data) { final politicas = data['politicas'] as Map<String, dynamic>? ?? {};
if (entry is Map) {
final key = entry['key']?.toString(); final doc = <String, dynamic>{
final value = entry['value']; // Feature flags. They were missing from this hand-built map, so
if (key != null) doc[key] = value; // SettingEntity always saw null: the "Mostrar tarifa" and "Servicio a
} // domicilio" switches could never be turned on, and every appointment
} // was created with rate 0 and location "office".
} else if (data is Map) { 'tarifas': data['tarifas'],
doc = Map<String, dynamic>.from(data); 'domicilios': data['domicilios'],
} 'version_android': version['android'],
'version_ios': version['ios'],
'titulo_soporte': soporte['titulo'] ?? data['support_title'],
'parrafo_soporte': soporte['descripcion'] ??
soporte['parrafo'] ??
data['support_description'],
'email_soporte': soporte['email'] ?? data['support_email'],
'numero_soporte': soporte['telefono'] ?? data['support_phone'],
'dias_soporte': soporte['dias'] ?? data['support_days'],
'horas_soporte': soporte['horas'] ?? data['support_hours'],
'politicas_privacidad_title': politicas['titulo'],
'politicas_privacidad_body': politicas['body'],
'rejection_wait_days': data['rejection_wait_days'],
};
return SettingEntity.fromDocument(doc); return SettingEntity.fromDocument(doc);
} catch (_) { } catch (_) {
@@ -69,4 +84,14 @@ class ApiSettingRepository implements SettingRepository {
); );
} }
} }
@override
Future<PoliciesEntity> getPolicies() async {
final res = await http.get(
Uri.parse('$_base/settings/policies'),
headers: await _headers(),
).timeout(_kHttpTimeout);
final data = jsonDecode(res.body) as Map<String, dynamic>;
return PoliciesEntity.fromDocument(data);
}
} }
@@ -2,4 +2,5 @@ import 'package:setting_repository/src/entities/entities.dart';
abstract class SettingRepository { abstract class SettingRepository {
Future<SettingEntity> getSettings(); Future<SettingEntity> getSettings();
Future<PoliciesEntity> getPolicies();
} }
+141 -69
View File
@@ -1,14 +1,6 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
_flutterfire_internals:
dependency: transitive
description:
name: _flutterfire_internals
sha256: "4eec93681221723a686ad580c2e7d960e1017cf1a4e0a263c2573c2c6b0bf5cd"
url: "https://pub.dev"
source: hosted
version: "1.3.25"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -41,30 +33,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
cloud_firestore:
dependency: "direct main"
description:
name: cloud_firestore
sha256: "31cfa4d65d6e9ea837234fffe121304034c30c9214c06207b4a35867e3757900"
url: "https://pub.dev"
source: hosted
version: "4.15.8"
cloud_firestore_platform_interface:
dependency: transitive
description:
name: cloud_firestore_platform_interface
sha256: a0097a26569b015faf8142e159e855241609ea9a1738b5fd1c40bfe8411b41a0
url: "https://pub.dev"
source: hosted
version: "6.1.9"
cloud_firestore_web:
dependency: transitive
description:
name: cloud_firestore_web
sha256: ed680ece29a5750985119c09cdc276b460c3a2fa80e8c12f9b7241f6b4a7ca16
url: "https://pub.dev"
source: hosted
version: "3.10.8"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@@ -89,30 +57,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.1" version: "1.3.1"
firebase_core: ffi:
dependency: "direct main"
description:
name: firebase_core
sha256: "53316975310c8af75a96e365f9fccb67d1c544ef0acdbf0d88bbe30eedd1c4f9"
url: "https://pub.dev"
source: hosted
version: "2.27.0"
firebase_core_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: firebase_core_platform_interface name: ffi
sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63 sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "2.1.3"
firebase_core_web: file:
dependency: transitive dependency: transitive
description: description:
name: firebase_core_web name: file
sha256: c8e1d59385eee98de63c92f961d2a7062c5d9a65e7f45bdc7f1b0b205aab2492 sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.11.5" version: "7.0.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -136,30 +96,38 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
js: http:
dependency: transitive dependency: "direct main"
description: description:
name: js name: http
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.7" version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
url: "https://pub.dev"
source: hosted
version: "4.0.2"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.4" version: "10.0.5"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.5"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
@@ -188,18 +156,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.0" version: "0.11.1"
meta: meta:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.12.0" version: "1.15.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -208,6 +176,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.0" version: "1.9.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface: plugin_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -216,6 +216,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
url: "https://pub.dev"
source: hosted
version: "2.5.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "9f9f3d372d4304723e6136663bb291c0b93f5e4c8a4a6314347f481a33bda2b1"
url: "https://pub.dev"
source: hosted
version: "2.4.7"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -265,10 +321,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.0" version: "0.7.2"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -281,18 +345,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.2.1" version: "14.2.5"
web: web:
dependency: transitive dependency: transitive
description: description:
name: web name: web
sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.0" version: "1.1.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
sdks: sdks:
dart: ">=3.3.0 <4.0.0" dart: ">=3.5.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.24.0"
@@ -0,0 +1,36 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
const _kHttpTimeout = Duration(seconds: 20);
const _base = 'https://backend.prosapp.co/api/v1';
class ApiSuggestionRepository {
String? _token;
Future<String?> _getToken() async {
if (_token != null) return _token;
final prefs = await SharedPreferences.getInstance();
return _token = prefs.getString('token');
}
Future<Map<String, String>> _headers() async {
final t = await _getToken();
return {
'Content-Type': 'application/json',
if (t != null) 'Authorization': 'Bearer $t',
};
}
Future<void> createSuggestion(String message) async {
final res = await http.post(
Uri.parse('$_base/suggestions'),
headers: await _headers(),
body: jsonEncode({'message': message}),
).timeout(_kHttpTimeout);
if (res.statusCode >= 400) {
throw Exception('No se pudo enviar la sugerencia (${res.statusCode})');
}
}
}
@@ -0,0 +1,3 @@
library suggestion_repository;
export 'src/repositories/api_suggestion_repository.dart';
@@ -0,0 +1,24 @@
name: suggestion_repository
description: Dart package for the suggestion repository.
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ">=2.19.3 <3.0.0"
dependencies:
flutter:
sdk: flutter
equatable: ^2.0.5
http: ^1.1.0
shared_preferences: ^2.0.10
dev_dependencies:
flutter_lints: ^2.0.0
flutter_test:
sdk: flutter
flutter:
uses-material-design: true

Some files were not shown because too many files have changed in this diff Show More