diff --git a/frontend/arah.app/lib/features/events/data/repositories/events_repository.dart b/frontend/arah.app/lib/features/events/data/repositories/events_repository.dart index 4d39002c..671ea901 100644 --- a/frontend/arah.app/lib/features/events/data/repositories/events_repository.dart +++ b/frontend/arah.app/lib/features/events/data/repositories/events_repository.dart @@ -1,4 +1,5 @@ import '../../../../core/config/constants.dart'; +import '../../../../core/network/api_exception.dart'; import '../../../../core/network/bff_client.dart'; import '../models/event_item.dart'; @@ -50,6 +51,39 @@ class EventsRepository { body: {'eventId': eventId, 'status': status}, ); } + + /// POST events/create-event — cria um evento no território ativo. + /// Datas em UTC (ISO 8601). Retorna o evento criado. + Future createEvent({ + required String territoryId, + required String title, + String? description, + required DateTime startsAtUtc, + DateTime? endsAtUtc, + double? latitude, + double? longitude, + String? locationLabel, + }) async { + final response = await _client.post( + 'events', + 'create-event', + body: { + 'territoryId': territoryId, + 'title': title, + if (description != null && description.isNotEmpty) 'description': description, + 'startsAtUtc': startsAtUtc.toUtc().toIso8601String(), + if (endsAtUtc != null) 'endsAtUtc': endsAtUtc.toUtc().toIso8601String(), + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + if (locationLabel != null && locationLabel.isNotEmpty) 'locationLabel': locationLabel, + }, + ); + final data = response.data; + if (data is Map) { + return EventItem.fromJson(data); + } + throw ApiException('Resposta inválida ao criar evento.', statusCode: response.statusCode); + } } class EventsPage { diff --git a/frontend/arah.app/lib/features/events/presentation/providers/territory_events_provider.dart b/frontend/arah.app/lib/features/events/presentation/providers/territory_events_provider.dart index eddbbe12..e766f59a 100644 --- a/frontend/arah.app/lib/features/events/presentation/providers/territory_events_provider.dart +++ b/frontend/arah.app/lib/features/events/presentation/providers/territory_events_provider.dart @@ -85,6 +85,30 @@ class TerritoryEventsNotifier extends StateNotifier { await loadPage(state.page + 1, append: true); } + /// Cria um evento no território ativo e recarrega a lista. + Future createEvent({ + required String title, + String? description, + required DateTime startsAtUtc, + DateTime? endsAtUtc, + String? locationLabel, + }) async { + final tid = territoryId ?? ''; + if (tid.isEmpty) { + throw StateError('Território ativo não definido.'); + } + final created = await _repo.createEvent( + territoryId: tid, + title: title, + description: description, + startsAtUtc: startsAtUtc, + endsAtUtc: endsAtUtc, + locationLabel: locationLabel, + ); + await loadPage(1, append: false); + return created; + } + /// Marca participação (INTERESTED ou CONFIRMED) e atualiza o item na lista. Future participate(EventItem item, String status) async { if (territoryId == null || territoryId!.isEmpty) return; diff --git a/frontend/arah.app/lib/features/events/presentation/screens/create_event_screen.dart b/frontend/arah.app/lib/features/events/presentation/screens/create_event_screen.dart new file mode 100644 index 00000000..4305546f --- /dev/null +++ b/frontend/arah.app/lib/features/events/presentation/screens/create_event_screen.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../../../core/config/constants.dart'; +import '../../../../core/network/api_exception.dart'; +import '../../../../core/widgets/app_snackbar.dart'; +import '../../../../core/widgets/arah_scaffold.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../providers/territory_events_provider.dart'; + +/// Formulário de criação de evento no território ativo (BFF events/create-event). +class CreateEventScreen extends ConsumerStatefulWidget { + const CreateEventScreen({super.key, required this.territoryId}); + + final String territoryId; + + @override + ConsumerState createState() => _CreateEventScreenState(); +} + +class _CreateEventScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _titleController = TextEditingController(); + final _descController = TextEditingController(); + final _locationController = TextEditingController(); + + DateTime? _startsAt; + DateTime? _endsAt; + bool _submitting = false; + + @override + void dispose() { + _titleController.dispose(); + _descController.dispose(); + _locationController.dispose(); + super.dispose(); + } + + Future _pickDateTime(DateTime? initial) async { + final now = DateTime.now(); + final base = initial ?? now; + final date = await showDatePicker( + context: context, + initialDate: base, + firstDate: now.subtract(const Duration(days: 1)), + lastDate: now.add(const Duration(days: 365 * 2)), + ); + if (date == null || !mounted) return null; + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(base), + ); + if (time == null) return null; + return DateTime(date.year, date.month, date.day, time.hour, time.minute); + } + + Future _submit() async { + final l10n = AppLocalizations.of(context)!; + if (!_formKey.currentState!.validate()) return; + if (_startsAt == null) { + showErrorSnackBar(context, l10n.eventStartRequired); + return; + } + if (_endsAt != null && _endsAt!.isBefore(_startsAt!)) { + showErrorSnackBar(context, l10n.eventEndBeforeStart); + return; + } + + setState(() => _submitting = true); + try { + await ref.read(territoryEventsProvider(widget.territoryId).notifier).createEvent( + title: _titleController.text.trim(), + description: _descController.text.trim(), + startsAtUtc: _startsAt!, + endsAtUtc: _endsAt, + locationLabel: _locationController.text.trim(), + ); + if (mounted) Navigator.of(context).pop(true); + } catch (e) { + if (mounted) { + final msg = e is ApiException ? e.userMessage : l10n.errorCreateEvent; + showErrorSnackBar(context, msg); + setState(() => _submitting = false); + } + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final dateFormat = DateFormat('dd/MM/yyyy HH:mm'); + + return ArahScaffold( + appBar: AppBar(title: Text(l10n.createEvent)), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(AppConstants.spacingMd), + children: [ + TextFormField( + controller: _titleController, + decoration: InputDecoration(labelText: l10n.eventTitleLabel), + textInputAction: TextInputAction.next, + validator: (v) => + (v == null || v.trim().isEmpty) ? l10n.requiredField : null, + ), + const SizedBox(height: AppConstants.spacingMd), + TextFormField( + controller: _descController, + decoration: InputDecoration(labelText: l10n.eventDescriptionLabel), + maxLines: 3, + ), + const SizedBox(height: AppConstants.spacingMd), + _DateTimeField( + label: l10n.eventStartLabel, + value: _startsAt, + placeholder: l10n.selectDateTime, + format: dateFormat, + onTap: () async { + final picked = await _pickDateTime(_startsAt); + if (picked != null) setState(() => _startsAt = picked); + }, + ), + const SizedBox(height: AppConstants.spacingSm), + _DateTimeField( + label: l10n.eventEndLabel, + value: _endsAt, + placeholder: l10n.selectDateTime, + format: dateFormat, + onClear: _endsAt == null ? null : () => setState(() => _endsAt = null), + onTap: () async { + final picked = await _pickDateTime(_endsAt ?? _startsAt); + if (picked != null) setState(() => _endsAt = picked); + }, + ), + const SizedBox(height: AppConstants.spacingMd), + TextFormField( + controller: _locationController, + decoration: InputDecoration(labelText: l10n.eventLocationLabel), + ), + const SizedBox(height: AppConstants.spacingLg), + FilledButton( + onPressed: _submitting ? null : _submit, + child: _submitting + ? const SizedBox( + height: AppConstants.loadingIndicatorSize, + width: AppConstants.loadingIndicatorSize, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.create), + ), + ], + ), + ), + ); + } +} + +class _DateTimeField extends StatelessWidget { + const _DateTimeField({ + required this.label, + required this.value, + required this.placeholder, + required this.format, + required this.onTap, + this.onClear, + }); + + final String label; + final DateTime? value; + final String placeholder; + final DateFormat format; + final VoidCallback onTap; + final VoidCallback? onClear; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppConstants.radiusSm), + child: InputDecorator( + decoration: InputDecoration( + labelText: label, + suffixIcon: onClear != null + ? IconButton(icon: const Icon(Icons.clear), onPressed: onClear) + : const Icon(Icons.calendar_today_outlined), + ), + child: Text( + value != null ? format.format(value!) : placeholder, + style: theme.textTheme.bodyLarge?.copyWith( + color: value != null + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } +} diff --git a/frontend/arah.app/lib/features/events/presentation/screens/events_screen.dart b/frontend/arah.app/lib/features/events/presentation/screens/events_screen.dart index e5942ea6..4a381fe6 100644 --- a/frontend/arah.app/lib/features/events/presentation/screens/events_screen.dart +++ b/frontend/arah.app/lib/features/events/presentation/screens/events_screen.dart @@ -10,6 +10,7 @@ import '../../../../l10n/app_localizations.dart'; import '../../../../core/providers/territory_provider.dart'; import '../../data/models/event_item.dart'; import '../providers/territory_events_provider.dart'; +import 'create_event_screen.dart'; /// Lista de eventos do território (BFF events/territory-events). Pull-to-refresh e carregar mais. class EventsScreen extends ConsumerWidget { @@ -30,6 +31,13 @@ class EventsScreen extends ConsumerWidget { appBar: AppBar( title: Text(AppLocalizations.of(context)!.events), ), + floatingActionButton: hasTerritory + ? FloatingActionButton.extended( + onPressed: () => _openCreate(context, effectiveTerritoryId), + icon: const Icon(Icons.add), + label: Text(AppLocalizations.of(context)!.createEvent), + ) + : null, body: !hasTerritory ? Center( child: Padding( @@ -59,6 +67,17 @@ class EventsScreen extends ConsumerWidget { ); } + Future _openCreate(BuildContext context, String territoryId) async { + final created = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => CreateEventScreen(territoryId: territoryId), + ), + ); + if (created == true && context.mounted) { + showSuccessSnackBar(context, AppLocalizations.of(context)!.eventCreated); + } + } + Widget _buildBody( BuildContext context, TerritoryEventsState state, diff --git a/frontend/arah.app/lib/l10n/app_en.arb b/frontend/arah.app/lib/l10n/app_en.arb index 7325a457..0a8bc248 100644 --- a/frontend/arah.app/lib/l10n/app_en.arb +++ b/frontend/arah.app/lib/l10n/app_en.arb @@ -343,5 +343,16 @@ "votingTypeCommunityPolicy": "Community policy", "votingVisibilityAllMembers": "All members", "votingVisibilityResidentsOnly": "Residents only", - "votingVisibilityCuratorsOnly": "Curators only" + "votingVisibilityCuratorsOnly": "Curators only", + "createEvent": "New event", + "eventTitleLabel": "Event title", + "eventDescriptionLabel": "Description", + "eventStartLabel": "Start", + "eventEndLabel": "End (optional)", + "eventLocationLabel": "Location (optional)", + "selectDateTime": "Select date and time", + "eventCreated": "Event created.", + "errorCreateEvent": "Error creating event.", + "eventStartRequired": "Set the start date and time.", + "eventEndBeforeStart": "End must be after start." } diff --git a/frontend/arah.app/lib/l10n/app_localizations.dart b/frontend/arah.app/lib/l10n/app_localizations.dart index e8af4bce..2ecbae8d 100644 --- a/frontend/arah.app/lib/l10n/app_localizations.dart +++ b/frontend/arah.app/lib/l10n/app_localizations.dart @@ -1828,6 +1828,72 @@ abstract class AppLocalizations { /// In pt, this message translates to: /// **'Apenas curadores'** String get votingVisibilityCuratorsOnly; + + /// No description provided for @createEvent. + /// + /// In pt, this message translates to: + /// **'Novo evento'** + String get createEvent; + + /// No description provided for @eventTitleLabel. + /// + /// In pt, this message translates to: + /// **'Título do evento'** + String get eventTitleLabel; + + /// No description provided for @eventDescriptionLabel. + /// + /// In pt, this message translates to: + /// **'Descrição'** + String get eventDescriptionLabel; + + /// No description provided for @eventStartLabel. + /// + /// In pt, this message translates to: + /// **'Início'** + String get eventStartLabel; + + /// No description provided for @eventEndLabel. + /// + /// In pt, this message translates to: + /// **'Término (opcional)'** + String get eventEndLabel; + + /// No description provided for @eventLocationLabel. + /// + /// In pt, this message translates to: + /// **'Local (opcional)'** + String get eventLocationLabel; + + /// No description provided for @selectDateTime. + /// + /// In pt, this message translates to: + /// **'Selecionar data e hora'** + String get selectDateTime; + + /// No description provided for @eventCreated. + /// + /// In pt, this message translates to: + /// **'Evento criado.'** + String get eventCreated; + + /// No description provided for @errorCreateEvent. + /// + /// In pt, this message translates to: + /// **'Erro ao criar evento.'** + String get errorCreateEvent; + + /// No description provided for @eventStartRequired. + /// + /// In pt, this message translates to: + /// **'Defina a data e hora de início.'** + String get eventStartRequired; + + /// No description provided for @eventEndBeforeStart. + /// + /// In pt, this message translates to: + /// **'O término deve ser após o início.'** + String get eventEndBeforeStart; } 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 6e9074aa..a6d00a6a 100644 --- a/frontend/arah.app/lib/l10n/app_localizations_en.dart +++ b/frontend/arah.app/lib/l10n/app_localizations_en.dart @@ -894,4 +894,37 @@ class AppLocalizationsEn extends AppLocalizations { @override String get votingVisibilityCuratorsOnly => 'Curators only'; + + @override + String get createEvent => 'New event'; + + @override + String get eventTitleLabel => 'Event title'; + + @override + String get eventDescriptionLabel => 'Description'; + + @override + String get eventStartLabel => 'Start'; + + @override + String get eventEndLabel => 'End (optional)'; + + @override + String get eventLocationLabel => 'Location (optional)'; + + @override + String get selectDateTime => 'Select date and time'; + + @override + String get eventCreated => 'Event created.'; + + @override + String get errorCreateEvent => 'Error creating event.'; + + @override + String get eventStartRequired => 'Set the start date and time.'; + + @override + String get eventEndBeforeStart => 'End must be after start.'; } diff --git a/frontend/arah.app/lib/l10n/app_localizations_pt.dart b/frontend/arah.app/lib/l10n/app_localizations_pt.dart index 627f2e53..9b75bcdc 100644 --- a/frontend/arah.app/lib/l10n/app_localizations_pt.dart +++ b/frontend/arah.app/lib/l10n/app_localizations_pt.dart @@ -894,4 +894,37 @@ class AppLocalizationsPt extends AppLocalizations { @override String get votingVisibilityCuratorsOnly => 'Apenas curadores'; + + @override + String get createEvent => 'Novo evento'; + + @override + String get eventTitleLabel => 'Título do evento'; + + @override + String get eventDescriptionLabel => 'Descrição'; + + @override + String get eventStartLabel => 'Início'; + + @override + String get eventEndLabel => 'Término (opcional)'; + + @override + String get eventLocationLabel => 'Local (opcional)'; + + @override + String get selectDateTime => 'Selecionar data e hora'; + + @override + String get eventCreated => 'Evento criado.'; + + @override + String get errorCreateEvent => 'Erro ao criar evento.'; + + @override + String get eventStartRequired => 'Defina a data e hora de início.'; + + @override + String get eventEndBeforeStart => 'O término deve ser após o início.'; } diff --git a/frontend/arah.app/lib/l10n/app_pt.arb b/frontend/arah.app/lib/l10n/app_pt.arb index bea0de41..fbfa8efe 100644 --- a/frontend/arah.app/lib/l10n/app_pt.arb +++ b/frontend/arah.app/lib/l10n/app_pt.arb @@ -343,5 +343,16 @@ "votingTypeCommunityPolicy": "Política comunitária", "votingVisibilityAllMembers": "Todos os membros", "votingVisibilityResidentsOnly": "Apenas moradores", - "votingVisibilityCuratorsOnly": "Apenas curadores" + "votingVisibilityCuratorsOnly": "Apenas curadores", + "createEvent": "Novo evento", + "eventTitleLabel": "Título do evento", + "eventDescriptionLabel": "Descrição", + "eventStartLabel": "Início", + "eventEndLabel": "Término (opcional)", + "eventLocationLabel": "Local (opcional)", + "selectDateTime": "Selecionar data e hora", + "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." } diff --git a/frontend/arah.app/test/features/events/presentation/create_event_test.dart b/frontend/arah.app/test/features/events/presentation/create_event_test.dart new file mode 100644 index 00000000..b1d77657 --- /dev/null +++ b/frontend/arah.app/test/features/events/presentation/create_event_test.dart @@ -0,0 +1,109 @@ +import 'package:arah_app/features/events/data/models/event_item.dart'; +import 'package:arah_app/features/events/data/repositories/events_repository.dart'; +import 'package:arah_app/features/events/presentation/providers/territory_events_provider.dart'; +import 'package:arah_app/features/events/presentation/screens/create_event_screen.dart'; +import 'package:arah_app/l10n/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Fake repository que registra a criação e devolve listas controladas, sem rede. +class _FakeEventsRepository implements EventsRepository { + EventItem? lastCreated; + Map? lastArgs; + + @override + Future getTerritoryEvents({ + required String territoryId, + int pageNumber = 1, + int pageSize = 20, + DateTime? from, + DateTime? to, + String? status, + }) async { + return EventsPage( + items: lastCreated != null ? [lastCreated!] : const [], + hasMore: false, + pageNumber: pageNumber, + ); + } + + @override + Future participate({required String eventId, required String status}) async {} + + @override + Future createEvent({ + required String territoryId, + required String title, + String? description, + required DateTime startsAtUtc, + DateTime? endsAtUtc, + double? latitude, + double? longitude, + String? locationLabel, + }) async { + lastArgs = { + 'territoryId': territoryId, + 'title': title, + 'startsAtUtc': startsAtUtc, + 'endsAtUtc': endsAtUtc, + 'locationLabel': locationLabel, + }; + lastCreated = EventItem( + id: 'e-new', + territoryId: territoryId, + title: title, + description: description, + startsAtUtc: startsAtUtc, + endsAtUtc: endsAtUtc ?? startsAtUtc, + status: 'SCHEDULED', + ); + return lastCreated!; + } +} + +void main() { + testWidgets('CreateEventScreen renders the form fields', (tester) async { + await tester.pumpWidget( + const ProviderScope( + child: MaterialApp( + locale: Locale('pt'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: CreateEventScreen(territoryId: 't1'), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Novo evento'), findsWidgets); + expect(find.text('Título do evento'), findsOneWidget); + expect(find.text('Início'), findsOneWidget); + expect(find.text('Criar'), findsOneWidget); + }); + + test('TerritoryEventsNotifier.createEvent calls repo and reloads', () async { + final fake = _FakeEventsRepository(); + final container = ProviderContainer( + overrides: [eventsRepositoryProvider.overrideWithValue(fake)], + ); + addTearDown(container.dispose); + + final notifier = container.read(territoryEventsProvider('t1').notifier); + final start = DateTime.utc(2026, 7, 1, 19, 0); + + final created = await notifier.createEvent( + title: 'Feira comunitária', + description: 'Descrição', + startsAtUtc: start, + locationLabel: 'Praça Central', + ); + + expect(created.id, 'e-new'); + expect(fake.lastArgs!['territoryId'], 't1'); + expect(fake.lastArgs!['title'], 'Feira comunitária'); + // Após criar, a lista é recarregada e passa a conter o evento criado. + expect(container.read(territoryEventsProvider('t1')).items.single.title, + 'Feira comunitária'); + }); +}