Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<int>((ref) => 0);
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -19,7 +20,8 @@ class MainShellScreen extends ConsumerStatefulWidget {
}

class _MainShellScreenState extends ConsumerState<MainShellScreen> {
int _currentIndex = 0;
void _setTab(int index) =>
ref.read(mainShellTabProvider.notifier).state = index;

@override
void initState() {
Expand All @@ -30,24 +32,25 @@ class _MainShellScreenState extends ConsumerState<MainShellScreen> {
}

List<Widget> _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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
Expand Down Expand Up @@ -97,6 +102,12 @@ class _MapScreenState extends ConsumerState<MapScreen> {
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.all,
),
onTap: (_, point) => _handleMapTap(
context,
point,
pinsAsync.valueOrNull,
territoryId,
),
),
children: [
TileLayer(
Expand Down Expand Up @@ -131,7 +142,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
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(),
),
Expand Down Expand Up @@ -192,30 +203,29 @@ class _MapScreenState extends ConsumerState<MapScreen> {
return _defaultZoom;
}

Widget _buildPinsLayer(BuildContext context, List<MapPin> pins) {
Widget _buildPinsLayer(BuildContext context, List<MapPin> 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(),
Expand All @@ -241,8 +251,44 @@ class _MapScreenState extends ConsumerState<MapScreen> {
}
}

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<MapPin>? 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,
Expand All @@ -266,8 +312,13 @@ class _MapScreenState extends ConsumerState<MapScreen> {
),
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,
),
],
Expand All @@ -277,6 +328,17 @@ class _MapScreenState extends ConsumerState<MapScreen> {
);
}

/// 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()) {
Expand Down
3 changes: 2 additions & 1 deletion frontend/arah.app/lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
6 changes: 6 additions & 0 deletions frontend/arah.app/lib/l10n/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppLocalizations> {
Expand Down
3 changes: 3 additions & 0 deletions frontend/arah.app/lib/l10n/app_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -927,4 +927,7 @@ class AppLocalizationsEn extends AppLocalizations {

@override
String get eventEndBeforeStart => 'End must be after start.';

@override
String get close => 'Close';
}
3 changes: 3 additions & 0 deletions frontend/arah.app/lib/l10n/app_localizations_pt.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
3 changes: 2 additions & 1 deletion frontend/arah.app/lib/l10n/app_pt.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
Loading