Files
prosappweb/lib/ui/views/setup_city_view.dart
T
Lizandro GuarnizoandClaude Sonnet 4.6 0711d30f07 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>
2026-06-28 11:34:51 -05:00

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