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:
Lizandro Guarnizo
2026-06-27 09:29:58 -05:00
co-authored by Claude Sonnet 4.6
parent 7349d4e3fd
commit bfa494b7fe
6 changed files with 272 additions and 20 deletions
+19 -1
View File
@@ -81,7 +81,14 @@ class AuthProvider extends ChangeNotifier {
userAverageScore = await _loadAverageScore(user!.id);
authStatus = AuthStatus.authenticated;
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) {
authStatus = AuthStatus.notAuthenticated;
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 {
try {
await _api.post('/auth/send-otp', {'phone': phoneNumber});
+12
View File
@@ -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_requests_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: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(
handlerFunc: (context, params) {
final authProvider = Provider.of<AuthProvider>(context!);
+10
View File
@@ -17,6 +17,9 @@ class Flurorouter {
static String dashboardRoute = "/dashboard";
static String supportRoute = "/dashboard/support";
// Onboarding
static String setupNameRoute = "/dashboard/setup-name";
// Users
static String profileRoute = "/dashboard/profile";
static String phoneRoute = "/dashboard/phone";
@@ -68,6 +71,13 @@ class Flurorouter {
transitionType: TransitionType.none,
);
// Onboarding
router.define(
setupNameRoute,
handler: DashboardHandlers.setupName,
transitionType: TransitionType.none,
);
// Dashboard Routes
router.define(
dashboardRoute,
+56 -11
View File
@@ -10,23 +10,68 @@ class NavbarAvatar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final user = Provider.of<AuthProvider>(context).user!;
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!,
);
final hasPicture = user.picture != null && user.picture!.isNotEmpty;
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => NavigationService.replaceTo(Flurorouter.profileRoute),
child: SizedBox(
width: 40,
height: 40,
child: ClipOval(child: image),
width: 36,
height: 36,
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,
),
),
),
);
+33 -8
View File
@@ -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 {
final bool containerFull;
@@ -260,14 +281,18 @@ class _AvatarContainer extends StatelessWidget {
final user = Provider.of<AuthProvider>(context).user!;
final profileFormProvider = Provider.of<ProfileFormProvider>(context);
final image = (profileFormProvider.user!.picture == '' ||
profileFormProvider.user!.picture == null)
? const Image(image: AssetImage('no-image.jpg'))
: FadeInImage.assetNetwork(
placeholder: 'loader.gif',
fit: BoxFit.cover,
image: profileFormProvider.user!.picture!,
);
final picUrl = profileFormProvider.user!.picture;
final hasPicture = picUrl != null && picUrl.isNotEmpty;
final userName = profileFormProvider.user!.name;
Widget avatarImage;
if (hasPicture) {
avatarImage = Image.network(picUrl, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _buildInitials(userName));
} else {
avatarImage = _buildInitials(userName);
}
final image = avatarImage;
return WhiteCard(
width: containerFull ? null : 250,
+142
View File
@@ -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)),
),
),
],
),
),
),
);
}
}