diff --git a/frontend/arah.app/lib/core/providers/main_shell_tab_provider.dart b/frontend/arah.app/lib/core/providers/main_shell_tab_provider.dart new file mode 100644 index 00000000..8753c94a --- /dev/null +++ b/frontend/arah.app/lib/core/providers/main_shell_tab_provider.dart @@ -0,0 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Índice da aba ativa no shell principal (bottom navigation). +/// Permite que outras telas (ex.: deep-link do mapa para o feed) solicitem +/// a troca de aba antes de navegar de volta para `/home`. +/// +/// 0 = Feed · 1 = Explorar · 2 = Publicar · 3 = Notificações · 4 = Perfil +final mainShellTabProvider = StateProvider((ref) => 0); diff --git a/frontend/arah.app/lib/features/home/presentation/screens/main_shell_screen.dart b/frontend/arah.app/lib/features/home/presentation/screens/main_shell_screen.dart index 9203b79f..ebdfe78c 100644 --- a/frontend/arah.app/lib/features/home/presentation/screens/main_shell_screen.dart +++ b/frontend/arah.app/lib/features/home/presentation/screens/main_shell_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/geo/geo_location_provider.dart'; +import '../../../../core/providers/main_shell_tab_provider.dart'; import '../../../../core/widgets/arah_scaffold.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../feed/presentation/screens/feed_screen.dart'; @@ -19,7 +20,8 @@ class MainShellScreen extends ConsumerStatefulWidget { } class _MainShellScreenState extends ConsumerState { - int _currentIndex = 0; + void _setTab(int index) => + ref.read(mainShellTabProvider.notifier).state = index; @override void initState() { @@ -30,24 +32,25 @@ class _MainShellScreenState extends ConsumerState { } List _buildScreens() => [ - FeedScreen(onGoToCreatePost: () => setState(() => _currentIndex = 2)), + FeedScreen(onGoToCreatePost: () => _setTab(2)), const ExploreScreen(), - CreatePostScreen(onSuccess: () => setState(() => _currentIndex = 0)), + CreatePostScreen(onSuccess: () => _setTab(0)), const NotificationsScreen(), const ProfileScreen(), ]; @override Widget build(BuildContext context) { + final currentIndex = ref.watch(mainShellTabProvider); return ArahScaffold( extendBody: true, body: IndexedStack( - index: _currentIndex, + index: currentIndex, children: _buildScreens(), ), bottomNavigationBar: NavigationBar( - selectedIndex: _currentIndex, - onDestinationSelected: (index) => setState(() => _currentIndex = index), + selectedIndex: currentIndex, + onDestinationSelected: _setTab, destinations: [ NavigationDestination( icon: const Icon(Icons.home_outlined), diff --git a/frontend/arah.app/lib/features/map/presentation/screens/map_deep_link.dart b/frontend/arah.app/lib/features/map/presentation/screens/map_deep_link.dart new file mode 100644 index 00000000..abcb7c0c --- /dev/null +++ b/frontend/arah.app/lib/features/map/presentation/screens/map_deep_link.dart @@ -0,0 +1,24 @@ +import '../../data/models/map_pin.dart'; + +/// Resolve a rota de detalhe (deep-link) para um pin do mapa. +/// Retorna `null` quando o tipo do pin não possui tela de detalhe navegável. +/// +/// - `event` → `/events` (com `territoryId` quando disponível) +/// - `asset` → `/assets` +/// - `alert` → `/alerts` +/// - `post` → `/home` (feed; não há rota de detalhe de post) +String? mapPinDeepLink(MapPin pin, {String? territoryId}) { + switch (pin.pinType.toLowerCase()) { + case 'event': + final hasTid = territoryId != null && territoryId.isNotEmpty; + return hasTid ? '/events?territoryId=$territoryId' : '/events'; + case 'asset': + return '/assets'; + case 'alert': + return '/alerts'; + case 'post': + return '/home'; + default: + return null; + } +} diff --git a/frontend/arah.app/lib/features/map/presentation/screens/map_screen.dart b/frontend/arah.app/lib/features/map/presentation/screens/map_screen.dart index c6e19d70..a2a189d0 100644 --- a/frontend/arah.app/lib/features/map/presentation/screens/map_screen.dart +++ b/frontend/arah.app/lib/features/map/presentation/screens/map_screen.dart @@ -1,9 +1,13 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:latlong2/latlong.dart'; import '../../../../core/config/constants.dart'; +import '../../../../core/providers/main_shell_tab_provider.dart'; import '../../../../core/providers/territory_provider.dart'; import '../../../../core/theme/app_design_tokens.dart'; import '../../../../core/widgets/arah_glass_card.dart'; @@ -15,6 +19,7 @@ import '../../../territories/data/repositories/territories_repository.dart'; import '../../../territories/presentation/providers/territories_list_provider.dart'; import '../providers/map_pins_provider.dart'; import '../../data/models/map_pin.dart'; +import 'map_deep_link.dart'; /// Centro padrão do mapa (Brasil) quando não há geo nem pins. const LatLng _defaultCenter = LatLng(-14.2, -51.9); @@ -97,6 +102,12 @@ class _MapScreenState extends ConsumerState { interactionOptions: const InteractionOptions( flags: InteractiveFlag.all, ), + onTap: (_, point) => _handleMapTap( + context, + point, + pinsAsync.valueOrNull, + territoryId, + ), ), children: [ TileLayer( @@ -131,7 +142,7 @@ class _MapScreenState extends ConsumerState { error: (_, __) => const SizedBox.shrink(), ), pinsAsync.when( - data: (pins) => _buildPinsLayer(context, pins), + data: (pins) => _buildPinsLayer(context, pins, territoryId), loading: () => const SizedBox.shrink(), error: (_, __) => const SizedBox.shrink(), ), @@ -192,30 +203,29 @@ class _MapScreenState extends ConsumerState { return _defaultZoom; } - Widget _buildPinsLayer(BuildContext context, List pins) { + Widget _buildPinsLayer(BuildContext context, List pins, String? territoryId) { return MarkerLayer( markers: pins.map((pin) { return Marker( point: LatLng(pin.latitude, pin.longitude), - width: 36, - height: 36, - child: GestureDetector( - onTap: () => _onPinTap(context, pin), - child: Container( - decoration: BoxDecoration( - color: AppDesignTokens.pinColorForType(pin.pinType).withValues(alpha: 0.25), - shape: BoxShape.circle, - border: Border.all( - color: AppDesignTokens.pinColorForType(pin.pinType), - width: 2, - ), - ), - child: Icon( - _iconForPinType(pin.pinType), - size: 20, + width: AppConstants.minTouchTargetSize, + height: AppConstants.minTouchTargetSize, + // O toque é tratado no nível do mapa (MapOptions.onTap) por ser mais + // confiável na web do que GestureDetector dentro do marcador. + child: Container( + decoration: BoxDecoration( + color: AppDesignTokens.pinColorForType(pin.pinType).withValues(alpha: 0.25), + shape: BoxShape.circle, + border: Border.all( color: AppDesignTokens.pinColorForType(pin.pinType), + width: 2, ), ), + child: Icon( + _iconForPinType(pin.pinType), + size: 20, + color: AppDesignTokens.pinColorForType(pin.pinType), + ), ), ); }).toList(), @@ -241,8 +251,44 @@ class _MapScreenState extends ConsumerState { } } - void _onPinTap(BuildContext context, MapPin pin) { + /// Distância máxima (px) entre o toque e o pin para considerar um acerto. + /// Generosa o suficiente para toques de dedo próximos ao marcador. + static const double _pinTapTolerancePx = 48; + + /// Trata o toque no mapa: localiza o pin mais próximo (em pixels) do ponto + /// tocado e, se dentro da tolerância, abre a folha de detalhes. + void _handleMapTap( + BuildContext context, + LatLng tapPoint, + List? pins, + String? territoryId, + ) { + if (pins == null || pins.isEmpty) return; + final camera = _mapController.camera; + final tapPx = camera.latLngToScreenOffset(tapPoint); + + MapPin? nearest; + double nearestDistance = double.infinity; + for (final pin in pins) { + final pinPx = camera.latLngToScreenOffset(LatLng(pin.latitude, pin.longitude)); + final dx = pinPx.dx - tapPx.dx; + final dy = pinPx.dy - tapPx.dy; + final distance = math.sqrt(dx * dx + dy * dy); + if (distance < nearestDistance) { + nearestDistance = distance; + nearest = pin; + } + } + + if (nearest != null && nearestDistance <= _pinTapTolerancePx) { + _onPinTap(context, nearest, territoryId); + } + } + + void _onPinTap(BuildContext context, MapPin pin, String? territoryId) { final l10n = AppLocalizations.of(context)!; + final target = mapPinDeepLink(pin, territoryId: territoryId); + final hasTarget = target != null; showModalBottomSheet( context: context, showDragHandle: true, @@ -266,8 +312,13 @@ class _MapScreenState extends ConsumerState { ), const SizedBox(height: AppConstants.spacingLg), ArahButton( - label: l10n.viewDetails, - onPressed: () => Navigator.pop(ctx), + label: hasTarget ? l10n.viewDetails : l10n.close, + onPressed: () { + Navigator.pop(ctx); + if (target != null) { + _navigateToPinTarget(context, target); + } + }, expand: true, ), ], @@ -277,6 +328,17 @@ class _MapScreenState extends ConsumerState { ); } + /// Navega para a rota de detalhe correspondente ao pin (deep-link). + void _navigateToPinTarget(BuildContext context, String route) { + if (route == '/home') { + // Não há rota de detalhe de post; seleciona a aba do feed e volta ao shell. + ref.read(mainShellTabProvider.notifier).state = 0; + context.go('/home'); + } else { + context.push(route); + } + } + String _subtitleForPinType(BuildContext context, MapPin pin) { final l10n = AppLocalizations.of(context)!; switch (pin.pinType.toLowerCase()) { diff --git a/frontend/arah.app/lib/l10n/app_en.arb b/frontend/arah.app/lib/l10n/app_en.arb index 0a8bc248..7646b93c 100644 --- a/frontend/arah.app/lib/l10n/app_en.arb +++ b/frontend/arah.app/lib/l10n/app_en.arb @@ -354,5 +354,6 @@ "eventCreated": "Event created.", "errorCreateEvent": "Error creating event.", "eventStartRequired": "Set the start date and time.", - "eventEndBeforeStart": "End must be after start." + "eventEndBeforeStart": "End must be after start.", + "close": "Close" } diff --git a/frontend/arah.app/lib/l10n/app_localizations.dart b/frontend/arah.app/lib/l10n/app_localizations.dart index 2ecbae8d..b5e0ec05 100644 --- a/frontend/arah.app/lib/l10n/app_localizations.dart +++ b/frontend/arah.app/lib/l10n/app_localizations.dart @@ -1894,6 +1894,12 @@ abstract class AppLocalizations { /// In pt, this message translates to: /// **'O término deve ser após o início.'** String get eventEndBeforeStart; + + /// No description provided for @close. + /// + /// In pt, this message translates to: + /// **'Fechar'** + String get close; } class _AppLocalizationsDelegate extends LocalizationsDelegate { diff --git a/frontend/arah.app/lib/l10n/app_localizations_en.dart b/frontend/arah.app/lib/l10n/app_localizations_en.dart index a6d00a6a..11bc615e 100644 --- a/frontend/arah.app/lib/l10n/app_localizations_en.dart +++ b/frontend/arah.app/lib/l10n/app_localizations_en.dart @@ -927,4 +927,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get eventEndBeforeStart => 'End must be after start.'; + + @override + String get close => 'Close'; } diff --git a/frontend/arah.app/lib/l10n/app_localizations_pt.dart b/frontend/arah.app/lib/l10n/app_localizations_pt.dart index 9b75bcdc..5012b856 100644 --- a/frontend/arah.app/lib/l10n/app_localizations_pt.dart +++ b/frontend/arah.app/lib/l10n/app_localizations_pt.dart @@ -927,4 +927,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get eventEndBeforeStart => 'O término deve ser após o início.'; + + @override + String get close => 'Fechar'; } diff --git a/frontend/arah.app/lib/l10n/app_pt.arb b/frontend/arah.app/lib/l10n/app_pt.arb index fbfa8efe..f5d1f560 100644 --- a/frontend/arah.app/lib/l10n/app_pt.arb +++ b/frontend/arah.app/lib/l10n/app_pt.arb @@ -354,5 +354,6 @@ "eventCreated": "Evento criado.", "errorCreateEvent": "Erro ao criar evento.", "eventStartRequired": "Defina a data e hora de início.", - "eventEndBeforeStart": "O término deve ser após o início." + "eventEndBeforeStart": "O término deve ser após o início.", + "close": "Fechar" } diff --git a/frontend/arah.app/test/features/map/presentation/map_deep_link_test.dart b/frontend/arah.app/test/features/map/presentation/map_deep_link_test.dart new file mode 100644 index 00000000..6dec2e1e --- /dev/null +++ b/frontend/arah.app/test/features/map/presentation/map_deep_link_test.dart @@ -0,0 +1,41 @@ +import 'package:arah_app/features/map/data/models/map_pin.dart'; +import 'package:arah_app/features/map/presentation/screens/map_deep_link.dart'; +import 'package:flutter_test/flutter_test.dart'; + +MapPin _pin(String type) => MapPin( + pinType: type, + latitude: -23.35, + longitude: -44.89, + title: 'Pin', + ); + +void main() { + group('mapPinDeepLink', () { + test('event pin links to /events with territoryId', () { + final route = mapPinDeepLink(_pin('event'), territoryId: 't1'); + expect(route, '/events?territoryId=t1'); + }); + + test('event pin without territoryId links to /events', () { + expect(mapPinDeepLink(_pin('EVENT')), '/events'); + }); + + test('asset pin links to /assets', () { + expect(mapPinDeepLink(_pin('asset')), '/assets'); + }); + + test('alert pin links to /alerts', () { + expect(mapPinDeepLink(_pin('alert')), '/alerts'); + }); + + test('post pin links to /home (feed)', () { + expect(mapPinDeepLink(_pin('post')), '/home'); + }); + + test('entity and unknown pins have no deep-link target', () { + expect(mapPinDeepLink(_pin('entity')), isNull); + expect(mapPinDeepLink(_pin('media')), isNull); + expect(mapPinDeepLink(_pin('whatever')), isNull); + }); + }); +}