feat: pedir nombre al primer login + avatar con iniciales
- Detectar usuario nuevo (nombre == telefono) y redirigir a pantalla setup-name antes del dashboard - SetupNameView: bienvenida con campo de nombre obligatorio - AuthProvider.updateName() para guardar via PATCH /users/me - NavbarAvatar: iniciales con color por inicial del nombre en lugar de no-image.jpg cuando no hay foto - Perfil: mismo avatar de iniciales en la pantalla de perfil Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
7349d4e3fd
commit
bfa494b7fe
@@ -81,7 +81,14 @@ class AuthProvider extends ChangeNotifier {
|
|||||||
userAverageScore = await _loadAverageScore(user!.id);
|
userAverageScore = await _loadAverageScore(user!.id);
|
||||||
authStatus = AuthStatus.authenticated;
|
authStatus = AuthStatus.authenticated;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
NavigationService.replaceTo(Flurorouter.dashboardRoute);
|
// Usuario nuevo: el backend usa el teléfono como nombre por defecto
|
||||||
|
final name = user!.name.trim();
|
||||||
|
final isNewUser = name.isEmpty || name == phoneNumber || name == phoneNumber.replaceAll('+57', '');
|
||||||
|
if (isNewUser) {
|
||||||
|
NavigationService.replaceTo(Flurorouter.setupNameRoute);
|
||||||
|
} else {
|
||||||
|
NavigationService.replaceTo(Flurorouter.dashboardRoute);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
authStatus = AuthStatus.notAuthenticated;
|
authStatus = AuthStatus.notAuthenticated;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -89,6 +96,17 @@ class AuthProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updateName(String name) async {
|
||||||
|
try {
|
||||||
|
await _api.patch('/users/me', {'name': name.trim()});
|
||||||
|
user = await _fetchMe();
|
||||||
|
notifyListeners();
|
||||||
|
} catch (_) {
|
||||||
|
NotificationsService.showSnackBarError('Error al guardar el nombre');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> verifyPhoneNumberForLink(String phoneNumber) async {
|
Future<void> verifyPhoneNumberForLink(String phoneNumber) async {
|
||||||
try {
|
try {
|
||||||
await _api.post('/auth/send-otp', {'phone': phoneNumber});
|
await _api.post('/auth/send-otp', {'phone': phoneNumber});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import 'package:prosapp_web_app/ui/views/service_view.dart';
|
|||||||
import 'package:prosapp_web_app/ui/views/services_history_view.dart';
|
import 'package:prosapp_web_app/ui/views/services_history_view.dart';
|
||||||
import 'package:prosapp_web_app/ui/views/services_requests_view.dart';
|
import 'package:prosapp_web_app/ui/views/services_requests_view.dart';
|
||||||
import 'package:prosapp_web_app/ui/views/services_view.dart';
|
import 'package:prosapp_web_app/ui/views/services_view.dart';
|
||||||
|
import 'package:prosapp_web_app/ui/views/setup_name_view.dart';
|
||||||
import 'package:prosapp_web_app/ui/views/support_view.dart';
|
import 'package:prosapp_web_app/ui/views/support_view.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
@@ -40,6 +41,17 @@ class DashboardHandlers {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static Handler setupName = Handler(
|
||||||
|
handlerFunc: (context, params) {
|
||||||
|
final authProvider = Provider.of<AuthProvider>(context!);
|
||||||
|
if (authProvider.authStatus == AuthStatus.authenticated) {
|
||||||
|
return const SetupNameView();
|
||||||
|
} else {
|
||||||
|
return const LoginView();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
static Handler support = Handler(
|
static Handler support = Handler(
|
||||||
handlerFunc: (context, params) {
|
handlerFunc: (context, params) {
|
||||||
final authProvider = Provider.of<AuthProvider>(context!);
|
final authProvider = Provider.of<AuthProvider>(context!);
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ class Flurorouter {
|
|||||||
static String dashboardRoute = "/dashboard";
|
static String dashboardRoute = "/dashboard";
|
||||||
static String supportRoute = "/dashboard/support";
|
static String supportRoute = "/dashboard/support";
|
||||||
|
|
||||||
|
// Onboarding
|
||||||
|
static String setupNameRoute = "/dashboard/setup-name";
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
static String profileRoute = "/dashboard/profile";
|
static String profileRoute = "/dashboard/profile";
|
||||||
static String phoneRoute = "/dashboard/phone";
|
static String phoneRoute = "/dashboard/phone";
|
||||||
@@ -68,6 +71,13 @@ class Flurorouter {
|
|||||||
transitionType: TransitionType.none,
|
transitionType: TransitionType.none,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Onboarding
|
||||||
|
router.define(
|
||||||
|
setupNameRoute,
|
||||||
|
handler: DashboardHandlers.setupName,
|
||||||
|
transitionType: TransitionType.none,
|
||||||
|
);
|
||||||
|
|
||||||
// Dashboard Routes
|
// Dashboard Routes
|
||||||
router.define(
|
router.define(
|
||||||
dashboardRoute,
|
dashboardRoute,
|
||||||
|
|||||||
@@ -10,23 +10,68 @@ class NavbarAvatar extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final user = Provider.of<AuthProvider>(context).user!;
|
final user = Provider.of<AuthProvider>(context).user!;
|
||||||
|
final hasPicture = user.picture != null && user.picture!.isNotEmpty;
|
||||||
final image = (user.picture == '' || user.picture == null)
|
|
||||||
? const Image(image: AssetImage('no-image.jpg'))
|
|
||||||
: FadeInImage.assetNetwork(
|
|
||||||
placeholder: 'loader.gif',
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
image: user.picture!,
|
|
||||||
);
|
|
||||||
|
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
cursor: SystemMouseCursors.click,
|
cursor: SystemMouseCursors.click,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => NavigationService.replaceTo(Flurorouter.profileRoute),
|
onTap: () => NavigationService.replaceTo(Flurorouter.profileRoute),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 40,
|
width: 36,
|
||||||
height: 40,
|
height: 36,
|
||||||
child: ClipOval(child: image),
|
child: ClipOval(
|
||||||
|
child: hasPicture
|
||||||
|
? Image.network(
|
||||||
|
user.picture!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => _InitialsAvatar(name: user.name),
|
||||||
|
)
|
||||||
|
: _InitialsAvatar(name: user.name),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InitialsAvatar extends StatelessWidget {
|
||||||
|
final String name;
|
||||||
|
const _InitialsAvatar({required this.name});
|
||||||
|
|
||||||
|
String get _initials {
|
||||||
|
final parts = name.trim().split(RegExp(r'\s+'));
|
||||||
|
if (parts.isEmpty || parts.first.isEmpty) return '?';
|
||||||
|
if (parts.length == 1) return parts.first[0].toUpperCase();
|
||||||
|
return (parts.first[0] + parts.last[0]).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
Color get _bgColor {
|
||||||
|
final colors = [
|
||||||
|
const Color(0xFF6366F1), // indigo
|
||||||
|
const Color(0xFF8B5CF6), // violet
|
||||||
|
const Color(0xFFEC4899), // pink
|
||||||
|
const Color(0xFF14B8A6), // teal
|
||||||
|
const Color(0xFFF59E0B), // amber
|
||||||
|
const Color(0xFF10B981), // emerald
|
||||||
|
const Color(0xFFEF4444), // red
|
||||||
|
const Color(0xFF3B82F6), // blue
|
||||||
|
];
|
||||||
|
final index = name.isEmpty ? 0 : name.codeUnitAt(0) % colors.length;
|
||||||
|
return colors[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
color: _bgColor,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
_initials,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -250,6 +250,27 @@ class _ProfileViewForm extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildInitials(String name) {
|
||||||
|
final parts = name.trim().split(RegExp(r'\s+'));
|
||||||
|
final initials = parts.length >= 2
|
||||||
|
? '${parts.first[0]}${parts.last[0]}'.toUpperCase()
|
||||||
|
: (parts.first.isEmpty ? '?' : parts.first[0].toUpperCase());
|
||||||
|
final colors = [
|
||||||
|
const Color(0xFF6366F1), const Color(0xFF8B5CF6),
|
||||||
|
const Color(0xFFEC4899), const Color(0xFF14B8A6),
|
||||||
|
const Color(0xFFF59E0B), const Color(0xFF10B981),
|
||||||
|
const Color(0xFFEF4444), const Color(0xFF3B82F6),
|
||||||
|
];
|
||||||
|
final bg = name.isEmpty ? colors[0] : colors[name.codeUnitAt(0) % colors.length];
|
||||||
|
return Container(
|
||||||
|
color: bg,
|
||||||
|
child: Center(
|
||||||
|
child: Text(initials,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 48, fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class _AvatarContainer extends StatelessWidget {
|
class _AvatarContainer extends StatelessWidget {
|
||||||
final bool containerFull;
|
final bool containerFull;
|
||||||
|
|
||||||
@@ -260,14 +281,18 @@ class _AvatarContainer extends StatelessWidget {
|
|||||||
final user = Provider.of<AuthProvider>(context).user!;
|
final user = Provider.of<AuthProvider>(context).user!;
|
||||||
final profileFormProvider = Provider.of<ProfileFormProvider>(context);
|
final profileFormProvider = Provider.of<ProfileFormProvider>(context);
|
||||||
|
|
||||||
final image = (profileFormProvider.user!.picture == '' ||
|
final picUrl = profileFormProvider.user!.picture;
|
||||||
profileFormProvider.user!.picture == null)
|
final hasPicture = picUrl != null && picUrl.isNotEmpty;
|
||||||
? const Image(image: AssetImage('no-image.jpg'))
|
final userName = profileFormProvider.user!.name;
|
||||||
: FadeInImage.assetNetwork(
|
|
||||||
placeholder: 'loader.gif',
|
Widget avatarImage;
|
||||||
fit: BoxFit.cover,
|
if (hasPicture) {
|
||||||
image: profileFormProvider.user!.picture!,
|
avatarImage = Image.network(picUrl, fit: BoxFit.cover,
|
||||||
);
|
errorBuilder: (_, __, ___) => _buildInitials(userName));
|
||||||
|
} else {
|
||||||
|
avatarImage = _buildInitials(userName);
|
||||||
|
}
|
||||||
|
final image = avatarImage;
|
||||||
|
|
||||||
return WhiteCard(
|
return WhiteCard(
|
||||||
width: containerFull ? null : 250,
|
width: containerFull ? null : 250,
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:prosapp_web_app/providers/auth_provider.dart';
|
||||||
|
import 'package:prosapp_web_app/router/router.dart';
|
||||||
|
import 'package:prosapp_web_app/services/navigation_service.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
class SetupNameView extends StatefulWidget {
|
||||||
|
const SetupNameView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SetupNameView> createState() => _SetupNameViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SetupNameViewState extends State<SetupNameView> {
|
||||||
|
final _nameController = TextEditingController();
|
||||||
|
bool _loading = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
final name = _nameController.text.trim();
|
||||||
|
if (name.length < 3) {
|
||||||
|
setState(() => _error = 'Ingresa al menos 3 caracteres');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() { _loading = true; _error = null; });
|
||||||
|
try {
|
||||||
|
await context.read<AuthProvider>().updateName(name);
|
||||||
|
NavigationService.replaceTo(Flurorouter.dashboardRoute);
|
||||||
|
} catch (_) {
|
||||||
|
setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF42A4EF).withOpacity(0.12),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.waving_hand_rounded,
|
||||||
|
color: Color(0xFF42A4EF), size: 34),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
const Text(
|
||||||
|
'¡Bienvenido a ProsApp!',
|
||||||
|
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'¿Cómo te llamas? Usaremos tu nombre para personalizar tu experiencia.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: isDark ? Colors.white54 : Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
TextField(
|
||||||
|
controller: _nameController,
|
||||||
|
autofocus: true,
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
onSubmitted: (_) => _save(),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Tu nombre completo',
|
||||||
|
hintText: 'Ej: Juan Pérez',
|
||||||
|
prefixIcon: const Icon(Icons.person_outline),
|
||||||
|
errorText: _error,
|
||||||
|
filled: true,
|
||||||
|
fillColor: isDark
|
||||||
|
? const Color(0xFF1E293B)
|
||||||
|
: const Color(0xFFF5F8FF),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: isDark
|
||||||
|
? const Color(0xFF334155)
|
||||||
|
: const Color(0xFFE0E7FF),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(
|
||||||
|
color: Color(0xFF42A4EF), width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _loading ? null : _save,
|
||||||
|
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: _loading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.white, strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('Continuar',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15, fontWeight: FontWeight.w600)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user