feat: 6 mejoras simultáneas en prosappweb
- web/index.html: lang=es (evita traductor) + cache busting con timestamp en flutter_bootstrap.js - profesional.dart: rate → número, location_preferences → string para coincidir con backend DTO - location_preferences.dart: funciones locationPrefsToString/locationPrefsFromValue - auth_provider.dart: _navigateAfterAuth redirige a setup-city si user.city vacío, método updateCity y linkEmailWithOtp - setup_city_view.dart: nueva vista con GPS + Nominatim para detectar ciudad, campo editable, omitir - email_view.dart: rediseño completo con flujo OTP (paso 1: email+contraseña → paso 2: código recibido en correo) - router + dashboard_handlers: ruta /dashboard/setup-city Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
f433bb2f6e
commit
0711d30f07
@@ -0,0 +1,253 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
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 SetupCityView extends StatefulWidget {
|
||||
const SetupCityView({super.key});
|
||||
|
||||
@override
|
||||
State<SetupCityView> createState() => _SetupCityViewState();
|
||||
}
|
||||
|
||||
class _SetupCityViewState extends State<SetupCityView> {
|
||||
final _controller = TextEditingController();
|
||||
bool _loading = false;
|
||||
bool _detecting = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_detectCity();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _detectCity() async {
|
||||
setState(() => _detecting = true);
|
||||
try {
|
||||
// Pedir permiso de ubicación
|
||||
LocationPermission perm = await Geolocator.checkPermission();
|
||||
if (perm == LocationPermission.denied) {
|
||||
perm = await Geolocator.requestPermission();
|
||||
}
|
||||
if (perm == LocationPermission.deniedForever ||
|
||||
perm == LocationPermission.denied) {
|
||||
setState(() => _detecting = false);
|
||||
return;
|
||||
}
|
||||
|
||||
final pos = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.low,
|
||||
timeLimit: Duration(seconds: 8),
|
||||
),
|
||||
);
|
||||
|
||||
// Reverse geocode con Nominatim (sin API key)
|
||||
final url = Uri.parse(
|
||||
'https://nominatim.openstreetmap.org/reverse'
|
||||
'?format=json&lat=${pos.latitude}&lon=${pos.longitude}&zoom=10&addressdetails=1',
|
||||
);
|
||||
final resp = await http.get(url,
|
||||
headers: {'User-Agent': 'ProsApp/1.0 (prosapp.co)'});
|
||||
|
||||
if (resp.statusCode == 200) {
|
||||
final json = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
final address = json['address'] as Map<String, dynamic>?;
|
||||
final city = address?['city'] as String? ??
|
||||
address?['town'] as String? ??
|
||||
address?['municipality'] as String? ??
|
||||
address?['county'] as String?;
|
||||
if (city != null && city.isNotEmpty) {
|
||||
_controller.text = city;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// GPS no disponible, el usuario ingresa manualmente
|
||||
} finally {
|
||||
if (mounted) setState(() => _detecting = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final city = _controller.text.trim();
|
||||
if (city.isEmpty) {
|
||||
setState(() => _error = 'Ingresa tu ciudad');
|
||||
return;
|
||||
}
|
||||
setState(() { _loading = true; _error = null; });
|
||||
try {
|
||||
await context.read<AuthProvider>().updateCity(city);
|
||||
NavigationService.replaceTo(Flurorouter.dashboardRoute);
|
||||
} catch (_) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _skip() => NavigationService.replaceTo(Flurorouter.dashboardRoute);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final textSec = isDark ? Colors.white54 : Colors.grey[600];
|
||||
|
||||
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: [
|
||||
// Ícono
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF42A4EF).withOpacity(0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.location_city_outlined,
|
||||
color: Color(0xFF42A4EF), size: 34),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
const Text('¿En qué ciudad estás?',
|
||||
style:
|
||||
TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Así podemos mostrarte profesionales cerca de ti.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 14, color: textSec),
|
||||
),
|
||||
|
||||
const SizedBox(height: 28),
|
||||
|
||||
if (_detecting)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Color(0xFF42A4EF))),
|
||||
const SizedBox(width: 10),
|
||||
Text('Detectando tu ubicación...',
|
||||
style:
|
||||
TextStyle(fontSize: 13, color: textSec)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Campo de ciudad
|
||||
TextField(
|
||||
controller: _controller,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
onSubmitted: (_) => _save(),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Ciudad',
|
||||
hintText: 'Ej: Bucaramanga',
|
||||
prefixIcon: const Icon(Icons.location_on_outlined,
|
||||
color: Color(0xFF42A4EF)),
|
||||
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),
|
||||
),
|
||||
suffixIcon: _detecting
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(14),
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Color(0xFF42A4EF)),
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.my_location,
|
||||
color: Color(0xFF42A4EF)),
|
||||
tooltip: 'Detectar de nuevo',
|
||||
onPressed: _detectCity,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Botón guardar
|
||||
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('Guardar ciudad',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600)),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Omitir
|
||||
TextButton(
|
||||
onPressed: _skip,
|
||||
child: Text('Omitir por ahora',
|
||||
style: TextStyle(color: textSec, fontSize: 13)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user