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);
|
||||
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});
|
||||
|
||||
@@ -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!);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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