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
@@ -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';

Expand Down Expand Up @@ -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<EventItem> 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<String, dynamic>) {
return EventItem.fromJson(data);
}
throw ApiException('Resposta inválida ao criar evento.', statusCode: response.statusCode);
}
}

class EventsPage {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,30 @@ class TerritoryEventsNotifier extends StateNotifier<TerritoryEventsState> {
await loadPage(state.page + 1, append: true);
}

/// Cria um evento no território ativo e recarrega a lista.
Future<EventItem> 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<void> participate(EventItem item, String status) async {
if (territoryId == null || territoryId!.isEmpty) return;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CreateEventScreen> createState() => _CreateEventScreenState();
}

class _CreateEventScreenState extends ConsumerState<CreateEventScreen> {
final _formKey = GlobalKey<FormState>();
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<DateTime?> _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<void> _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,
),
),
),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(
Expand Down Expand Up @@ -59,6 +67,17 @@ class EventsScreen extends ConsumerWidget {
);
}

Future<void> _openCreate(BuildContext context, String territoryId) async {
final created = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) => CreateEventScreen(territoryId: territoryId),
),
);
if (created == true && context.mounted) {
showSuccessSnackBar(context, AppLocalizations.of(context)!.eventCreated);
}
}

Widget _buildBody(
BuildContext context,
TerritoryEventsState state,
Expand Down
13 changes: 12 additions & 1 deletion frontend/arah.app/lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
Loading
Loading