- 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>
254 lines
8.8 KiB
Dart
254 lines
8.8 KiB
Dart
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)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|