diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..3dd96aaa --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,48 @@ +# AGENTS.md + +## Cursor Cloud specific instructions + +This monorepo has four products: a .NET 8 backend API + BFF (`backend/`), three web apps +(`frontend/wiki`, `frontend/portal`, `frontend/devportal`), and a Flutter app (`frontend/arah.app`). +The VM snapshot already has the toolchains installed (.NET 8 SDK at `/usr/local/dotnet`, +Flutter 3.27.4 + Dart at `/usr/local/flutter`, both symlinked into `/usr/local/bin`), and the +startup update script refreshes dependencies. Standard build/test/run commands live in +`.github/workflows/ci.yml`, `docs/SETUP.md`, `docs/DEVELOPMENT.md`, and the per-app +`package.json`/`pubspec.yaml`. Notes below are the non-obvious gotchas. + +### Backend (.NET 8) — `backend/`, solution `Arah.sln` +- Use **`Arah.sln`**, not the legacy `Araponga.sln`. All code/namespaces are `Arah.*` even though + prose docs still say "Araponga". +- The API **requires** a JWT signing key to boot, even in Development. Without it `Program.cs` + throws `JWT SigningKey must be configured...`. Set env `JWT__SIGNINGKEY=...` before running + (any value works in Development/Testing; the literal `dev-only-change-me` is tolerated outside + Production with a warning). +- The API defaults to the **InMemory** persistence provider (`appsettings.json` → + `Persistence:Provider`), so it runs with **no Postgres/Redis/MinIO** for local dev and seeds + canonical territories on boot. Redis and MinIO are optional (the app falls back to in-memory cache + and local-disk media). For realistic persistence set `Persistence__Provider=Postgres` + + `ConnectionStrings__Postgres=...` (see `docker-compose.yml`). +- Run the API: `dotnet run --project backend/Arah.Api` → `http://localhost:5178` (Swagger `/swagger`, + health `/health`). `/health` reporting `Degraded` in dev is expected (storage health check with the + Local media provider). +- Run the BFF (needed by the Flutter app): `dotnet run --project backend/Arah.Api.Bff` → + `http://localhost:5005`. Point it at the API with `Bff__ApiBaseUrl=http://localhost:5178`. The BFF + has no `/health` route; its proxied routes live under `/bff/journeys` and `/api/v2/journeys/*`. +- Tests: `dotnet test backend/Tests/Arah.Tests/Arah.Tests.csproj` and + `dotnet test backend/Tests/Arah.Tests.Bff/Arah.Tests.Bff.csproj`. A couple of tests under + `Arah.Tests/Performance` assert latency SLAs (e.g. `< 700ms`) and can flake on a slow/loaded VM — + those failures are timing-related, not code regressions. + +### Web frontends — `frontend/{wiki,portal,devportal}` (npm) +- `npm run lint` is **broken** in `wiki` and `portal`: they call `next lint`, which Next.js 16 + removed, so `next` treats `lint` as a directory and errors. Use `npm run type-check` and + `npm test` (jest) instead for those apps. +- `wiki`: `npm run dev` serves on port **3001** under the **`/wiki`** base path + (`http://localhost:3001/wiki`); `/` returns 404. Checks: `npm run type-check`, `npm test`. +- `portal`: `npm run dev` serves on port **3000** at `/`. +- `devportal`: static HTML; no dev server. Validate with `npm test` (jest). It is also served by the + API at `/devportal`. + +### Flutter app — `frontend/arah.app` +- `flutter pub get`, then `flutter analyze --no-fatal-infos` (CI tolerates info-level lints) and + `flutter test`. It talks only to the **BFF**, not the API directly (`--dart-define=BFF_BASE_URL=...`). diff --git a/backend/Arah.Api.Bff/Journeys/BffJourneyRegistry.cs b/backend/Arah.Api.Bff/Journeys/BffJourneyRegistry.cs index be064675..5ff46666 100644 --- a/backend/Arah.Api.Bff/Journeys/BffJourneyRegistry.cs +++ b/backend/Arah.Api.Bff/Journeys/BffJourneyRegistry.cs @@ -60,6 +60,9 @@ public static class BffJourneyRegistry /// Jornada de moderação (work-items, cases, evidências por território). public const string Moderation = "moderation"; + /// Jornada de governança (votações do território: listar, criar, votar, fechar, resultados). + public const string Governance = "governance"; + /// Jornada de chat (conversas, mensagens, participantes). public const string Chat = "chat"; @@ -90,6 +93,7 @@ public static class BffJourneyRegistry [Notifications] = "api/v1/notifications", [MarketplaceV1] = "api/v1", [Moderation] = "api/v1/territories", + [Governance] = "api/v1/territories", [Chat] = "api/v1/chat", [Alerts] = "api/v1/alerts", [Admin] = "api/v1/admin" @@ -258,6 +262,15 @@ public static string GetApiPathBase(string journeyName) new("{territoryId}/moderation/cases", "GET", "Casos de moderação do território."), new("{territoryId}/evidences", "GET", "Evidências do território.") }, + [Governance] = new List + { + new("{territoryId}/votings", "GET", "Lista votações do território. Query: status (Open, Closed, Cancelled)."), + new("{territoryId}/votings", "POST", "Cria votação no território. Body: type, title, description, options, visibility, startsAtUtc, endsAtUtc."), + new("{territoryId}/votings/{votingId}", "GET", "Detalhe de uma votação."), + new("{territoryId}/votings/{votingId}/vote", "POST", "Registra um voto. Body: selectedOption."), + new("{territoryId}/votings/{votingId}/close", "POST", "Fecha a votação (criador ou curador)."), + new("{territoryId}/votings/{votingId}/results", "GET", "Resultados da votação (contagem por opção).") + }, [Chat] = new List { new("conversations/{conversationId}", "GET", "Detalhe da conversa."), @@ -384,6 +397,12 @@ public static string GetApiPathBase(string journeyName) new("{territoryId}/moderation/cases", "GET", "Casos de moderação."), new("{territoryId}/evidences", "GET", "Evidências.") }, + [Governance] = new List + { + new("{territoryId}/votings", "GET", "Votações do território."), + new("{territoryId}/votings/{votingId}", "GET", "Detalhe da votação."), + new("{territoryId}/votings/{votingId}/results", "GET", "Resultados da votação.") + }, [Chat] = new List { new("conversations/{conversationId}", "GET", "Conversa."), @@ -422,6 +441,7 @@ public static string GetApiPathBase(string journeyName) Notifications, MarketplaceV1, Moderation, + Governance, Chat, Alerts, Admin diff --git a/backend/Arah.Api.Bff/appsettings.json b/backend/Arah.Api.Bff/appsettings.json index efb37f6a..eaf881fc 100644 --- a/backend/Arah.Api.Bff/appsettings.json +++ b/backend/Arah.Api.Bff/appsettings.json @@ -27,6 +27,7 @@ "notifications": 30, "marketplace-v1": 60, "moderation": 45, + "governance": 45, "chat": 30, "alerts": 45, "admin": 30 diff --git a/backend/Tests/Arah.Tests.Bff/Journeys/BffJourneyRegistryTests.cs b/backend/Tests/Arah.Tests.Bff/Journeys/BffJourneyRegistryTests.cs index f2a9b984..3f49bf32 100644 --- a/backend/Tests/Arah.Tests.Bff/Journeys/BffJourneyRegistryTests.cs +++ b/backend/Tests/Arah.Tests.Bff/Journeys/BffJourneyRegistryTests.cs @@ -63,6 +63,7 @@ public void PathAndQuery_ReturnsExpectedPath(string journey, string subPath, str [InlineData("notifications", "api/v1/notifications")] [InlineData("marketplace-v1", "api/v1")] [InlineData("moderation", "api/v1/territories")] + [InlineData("governance", "api/v1/territories")] [InlineData("chat", "api/v1/chat")] [InlineData("alerts", "api/v1/alerts")] [InlineData("admin", "api/v1/admin")] @@ -235,4 +236,28 @@ public void CacheableGetEndpoints_MarketplaceV1_HasCart() var list = BffJourneyRegistry.CacheableGetEndpoints[BffJourneyRegistry.MarketplaceV1]; Assert.Contains(list, e => e.Path == "cart"); } + + [Fact] + public void AllEndpoints_Governance_ContainsVotingsLifecycle() + { + var list = BffJourneyRegistry.AllEndpoints[BffJourneyRegistry.Governance]; + var paths = list.Select(e => e.Path).ToHashSet(); + Assert.Contains("{territoryId}/votings", paths); + Assert.Contains("{territoryId}/votings/{votingId}", paths); + Assert.Contains("{territoryId}/votings/{votingId}/vote", paths); + Assert.Contains("{territoryId}/votings/{votingId}/close", paths); + Assert.Contains("{territoryId}/votings/{votingId}/results", paths); + Assert.Contains(list, e => e.Method == "GET"); + Assert.Contains(list, e => e.Method == "POST"); + } + + [Fact] + public void CacheableGetEndpoints_Governance_HasVotingsReads() + { + var list = BffJourneyRegistry.CacheableGetEndpoints[BffJourneyRegistry.Governance]; + var paths = list.Select(e => e.Path).ToHashSet(); + Assert.Contains("{territoryId}/votings", paths); + Assert.Contains("{territoryId}/votings/{votingId}/results", paths); + Assert.All(list, e => Assert.Equal("GET", e.Method)); + } } diff --git a/frontend/arah.app/lib/app_router.dart b/frontend/arah.app/lib/app_router.dart index 7c97f941..0b33e3f7 100644 --- a/frontend/arah.app/lib/app_router.dart +++ b/frontend/arah.app/lib/app_router.dart @@ -18,6 +18,7 @@ import 'features/marketplace/presentation/screens/marketplace_screen.dart'; import 'features/chat/presentation/screens/chat_list_screen.dart'; import 'features/chat/presentation/screens/chat_conversation_screen.dart'; import 'features/moderation/presentation/screens/moderation_screen.dart'; +import 'features/governance/presentation/screens/governance_screen.dart'; import 'features/assets/presentation/screens/assets_screen.dart'; import 'features/subscriptions/presentation/screens/subscriptions_screen.dart'; import 'features/onboarding/presentation/screens/onboarding_screen.dart'; @@ -119,6 +120,13 @@ final goRouterProvider = Provider((ref) { path: '/moderation', builder: (_, __) => const ModerationScreen(), ), + GoRoute( + path: '/governance', + builder: (_, state) { + final territoryId = state.uri.queryParameters['territoryId']; + return GovernanceScreen(territoryId: territoryId); + }, + ), GoRoute( path: '/assets', builder: (_, __) => const AssetsScreen(), diff --git a/frontend/arah.app/lib/features/explore/presentation/screens/explore_screen.dart b/frontend/arah.app/lib/features/explore/presentation/screens/explore_screen.dart index 237965ab..4dc1bfdb 100644 --- a/frontend/arah.app/lib/features/explore/presentation/screens/explore_screen.dart +++ b/frontend/arah.app/lib/features/explore/presentation/screens/explore_screen.dart @@ -49,6 +49,11 @@ class ExploreScreen extends ConsumerWidget { tooltip: l10n.chat, onPressed: () => context.push('/chat'), ), + IconButton( + icon: const Icon(Icons.how_to_vote_outlined), + tooltip: l10n.governance, + onPressed: () => context.push('/governance?territoryId=$territoryId'), + ), ], ], ), diff --git a/frontend/arah.app/lib/features/governance/data/models/voting.dart b/frontend/arah.app/lib/features/governance/data/models/voting.dart new file mode 100644 index 00000000..1eeab6f6 --- /dev/null +++ b/frontend/arah.app/lib/features/governance/data/models/voting.dart @@ -0,0 +1,82 @@ +/// Votação de governança do território (BFF governance/{territoryId}/votings). +/// Alinhado a VotingResponse da API. +class Voting { + const Voting({ + required this.id, + required this.territoryId, + required this.createdByUserId, + required this.type, + required this.title, + required this.description, + required this.options, + required this.visibility, + required this.status, + this.startsAtUtc, + this.endsAtUtc, + required this.createdAtUtc, + required this.updatedAtUtc, + }); + + final String id; + final String territoryId; + final String createdByUserId; + final String type; + final String title; + final String description; + final List options; + final String visibility; + final String status; // Open | Closed | Cancelled + final DateTime? startsAtUtc; + final DateTime? endsAtUtc; + final DateTime createdAtUtc; + final DateTime updatedAtUtc; + + bool get isOpen => status.toLowerCase() == 'open'; + + factory Voting.fromJson(Map json) { + final optionsRaw = json['options'] as List? ?? const []; + return Voting( + id: (json['id'] ?? '').toString(), + territoryId: (json['territoryId'] ?? '').toString(), + createdByUserId: (json['createdByUserId'] ?? '').toString(), + type: json['type'] as String? ?? '', + title: json['title'] as String? ?? '', + description: json['description'] as String? ?? '', + options: optionsRaw.map((e) => e.toString()).toList(), + visibility: json['visibility'] as String? ?? '', + status: json['status'] as String? ?? 'Open', + startsAtUtc: _parseDate(json['startsAtUtc']), + endsAtUtc: _parseDate(json['endsAtUtc']), + createdAtUtc: _parseDate(json['createdAtUtc']) ?? DateTime.now(), + updatedAtUtc: _parseDate(json['updatedAtUtc']) ?? DateTime.now(), + ); + } + + static DateTime? _parseDate(dynamic value) { + if (value == null) return null; + return DateTime.tryParse(value.toString()); + } +} + +/// Resultados de uma votação: contagem de votos por opção. +/// Alinhado a VotingResultsResponse da API. +class VotingResults { + const VotingResults({required this.votingId, required this.counts}); + + final String votingId; + final Map counts; + + int get totalVotes => counts.values.fold(0, (sum, c) => sum + c); + + factory VotingResults.fromJson(Map json) { + final resultsRaw = json['results'] as Map? ?? const {}; + final counts = {}; + resultsRaw.forEach((key, value) { + counts[key] = (value as num?)?.toInt() ?? 0; + }); + return VotingResults( + votingId: (json['votingId'] ?? '').toString(), + counts: counts, + ); + } +} diff --git a/frontend/arah.app/lib/features/governance/data/repositories/governance_repository.dart b/frontend/arah.app/lib/features/governance/data/repositories/governance_repository.dart new file mode 100644 index 00000000..69869853 --- /dev/null +++ b/frontend/arah.app/lib/features/governance/data/repositories/governance_repository.dart @@ -0,0 +1,112 @@ +import '../../../../core/network/api_exception.dart'; +import '../../../../core/network/bff_client.dart'; +import '../models/voting.dart'; + +/// Repositório da jornada BFF governance (votações por território). +/// Proxy para api/v1/territories/{territoryId}/votings na API. +class GovernanceRepository { + GovernanceRepository({required BffClient client}) : _client = client; + + final BffClient _client; + + static const String _journey = 'governance'; + + /// GET governance/{territoryId}/votings?status= + Future> listVotings( + String territoryId, { + String? status, + }) async { + var path = '$territoryId/votings'; + if (status != null && status.isNotEmpty) { + path += '?status=$status'; + } + final response = await _client.get(_journey, path); + _ensureSuccess(response.statusCode); + final data = response.data; + if (data is! List) return const []; + return data + .whereType>() + .map(Voting.fromJson) + .toList(); + } + + /// GET governance/{territoryId}/votings/{votingId}/results + Future getResults({ + required String territoryId, + required String votingId, + }) async { + final response = + await _client.get(_journey, '$territoryId/votings/$votingId/results'); + _ensureSuccess(response.statusCode); + final data = response.data; + if (data is! Map) { + return VotingResults(votingId: votingId, counts: const {}); + } + return VotingResults.fromJson(data); + } + + /// POST governance/{territoryId}/votings/{votingId}/vote + Future vote({ + required String territoryId, + required String votingId, + required String selectedOption, + }) async { + final response = await _client.post( + _journey, + '$territoryId/votings/$votingId/vote', + body: {'selectedOption': selectedOption}, + ); + _ensureSuccess(response.statusCode); + } + + /// POST governance/{territoryId}/votings + Future createVoting({ + required String territoryId, + required String type, + required String title, + required String description, + required List options, + required String visibility, + DateTime? startsAtUtc, + DateTime? endsAtUtc, + }) async { + final response = await _client.post( + _journey, + '$territoryId/votings', + body: { + 'type': type, + 'title': title, + 'description': description, + 'options': options, + 'visibility': visibility, + if (startsAtUtc != null) 'startsAtUtc': startsAtUtc.toIso8601String(), + if (endsAtUtc != null) 'endsAtUtc': endsAtUtc.toIso8601String(), + }, + ); + _ensureSuccess(response.statusCode); + final data = response.data; + if (data is! Map) { + throw ApiException('Resposta inválida ao criar votação.', + statusCode: response.statusCode); + } + return Voting.fromJson(data); + } + + /// POST governance/{territoryId}/votings/{votingId}/close + Future closeVoting({ + required String territoryId, + required String votingId, + }) async { + final response = await _client.post( + _journey, + '$territoryId/votings/$votingId/close', + ); + _ensureSuccess(response.statusCode); + } + + void _ensureSuccess(int statusCode) { + if (statusCode < 200 || statusCode >= 300) { + throw ApiException('HTTP $statusCode', statusCode: statusCode); + } + } +} diff --git a/frontend/arah.app/lib/features/governance/presentation/providers/governance_provider.dart b/frontend/arah.app/lib/features/governance/presentation/providers/governance_provider.dart new file mode 100644 index 00000000..e5eac037 --- /dev/null +++ b/frontend/arah.app/lib/features/governance/presentation/providers/governance_provider.dart @@ -0,0 +1,138 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/providers/app_providers.dart'; +import '../../data/models/voting.dart'; +import '../../data/repositories/governance_repository.dart'; + +final governanceRepositoryProvider = Provider((ref) { + return GovernanceRepository(client: ref.watch(bffClientProvider)); +}); + +/// Estado da lista de votações do território. +class GovernanceState { + const GovernanceState({ + this.votings = const [], + this.statusFilter, + this.votedIds = const {}, + this.isLoading = false, + this.error, + }); + + final List votings; + final String? statusFilter; // null = todas; Open | Closed + final Set votedIds; + final bool isLoading; + final Object? error; + + static const initial = GovernanceState(); + + GovernanceState copyWith({ + List? votings, + String? statusFilter, + bool clearStatusFilter = false, + Set? votedIds, + bool? isLoading, + Object? error, + bool clearError = false, + }) { + return GovernanceState( + votings: votings ?? this.votings, + statusFilter: + clearStatusFilter ? null : (statusFilter ?? this.statusFilter), + votedIds: votedIds ?? this.votedIds, + isLoading: isLoading ?? this.isLoading, + error: clearError ? null : (error ?? this.error), + ); + } +} + +class GovernanceNotifier extends StateNotifier { + GovernanceNotifier(this._ref, this.territoryId) + : super(GovernanceState.initial) { + if (territoryId != null && territoryId!.isNotEmpty) { + load(); + } + } + + final Ref _ref; + final String? territoryId; + + GovernanceRepository get _repo => _ref.read(governanceRepositoryProvider); + + Future load() async { + final tid = territoryId ?? ''; + if (tid.isEmpty) return; + + state = state.copyWith(isLoading: true, clearError: true); + try { + final votings = + await _repo.listVotings(tid, status: state.statusFilter); + state = state.copyWith(votings: votings, isLoading: false); + } catch (e) { + state = state.copyWith(isLoading: false, error: e); + } + } + + Future refresh() => load(); + + Future setStatusFilter(String? status) async { + if (status == state.statusFilter) return; + state = status == null + ? state.copyWith(clearStatusFilter: true) + : state.copyWith(statusFilter: status); + await load(); + } + + /// Registra um voto e marca a votação como votada localmente. + Future vote(Voting voting, String option) async { + final tid = territoryId ?? ''; + if (tid.isEmpty) return; + await _repo.vote( + territoryId: tid, + votingId: voting.id, + selectedOption: option, + ); + state = state.copyWith(votedIds: {...state.votedIds, voting.id}); + } + + Future results(Voting voting) { + final tid = territoryId ?? ''; + return _repo.getResults(territoryId: tid, votingId: voting.id); + } + + Future closeVoting(Voting voting) async { + final tid = territoryId ?? ''; + if (tid.isEmpty) return; + await _repo.closeVoting(territoryId: tid, votingId: voting.id); + await load(); + } + + Future createVoting({ + required String type, + required String title, + required String description, + required List options, + required String visibility, + DateTime? startsAtUtc, + DateTime? endsAtUtc, + }) async { + final tid = territoryId ?? ''; + final created = await _repo.createVoting( + territoryId: tid, + type: type, + title: title, + description: description, + options: options, + visibility: visibility, + startsAtUtc: startsAtUtc, + endsAtUtc: endsAtUtc, + ); + await load(); + return created; + } +} + +final governanceProvider = StateNotifierProvider.autoDispose + .family((ref, territoryId) { + return GovernanceNotifier(ref, territoryId); +}); diff --git a/frontend/arah.app/lib/features/governance/presentation/screens/create_voting_screen.dart b/frontend/arah.app/lib/features/governance/presentation/screens/create_voting_screen.dart new file mode 100644 index 00000000..4cd1ae78 --- /dev/null +++ b/frontend/arah.app/lib/features/governance/presentation/screens/create_voting_screen.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.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/governance_provider.dart'; +import 'voting_labels.dart'; + +/// Formulário de criação de votação no território. +class CreateVotingScreen extends ConsumerStatefulWidget { + const CreateVotingScreen({super.key, required this.territoryId}); + + final String territoryId; + + @override + ConsumerState createState() => _CreateVotingScreenState(); +} + +class _CreateVotingScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _titleController = TextEditingController(); + final _descController = TextEditingController(); + final List _optionControllers = [ + TextEditingController(), + TextEditingController(), + ]; + + String _type = votingTypes.first; + String _visibility = votingVisibilities.first; + bool _submitting = false; + + @override + void dispose() { + _titleController.dispose(); + _descController.dispose(); + for (final c in _optionControllers) { + c.dispose(); + } + super.dispose(); + } + + void _addOption() { + setState(() => _optionControllers.add(TextEditingController())); + } + + void _removeOption(int index) { + if (_optionControllers.length <= 2) return; + setState(() { + _optionControllers.removeAt(index).dispose(); + }); + } + + Future _submit() async { + final l10n = AppLocalizations.of(context)!; + if (!_formKey.currentState!.validate()) return; + + final options = _optionControllers + .map((c) => c.text.trim()) + .where((t) => t.isNotEmpty) + .toList(); + if (options.length < 2) { + showErrorSnackBar(context, l10n.votingNeedsTwoOptions); + return; + } + + setState(() => _submitting = true); + try { + await ref + .read(governanceProvider(widget.territoryId).notifier) + .createVoting( + type: _type, + title: _titleController.text.trim(), + description: _descController.text.trim(), + options: options, + visibility: _visibility, + ); + if (mounted) Navigator.of(context).pop(true); + } catch (e) { + if (mounted) { + final msg = + e is ApiException ? e.userMessage : l10n.errorCreateVoting; + showErrorSnackBar(context, msg); + setState(() => _submitting = false); + } + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return ArahScaffold( + appBar: AppBar(title: Text(l10n.createVoting)), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(AppConstants.spacingMd), + children: [ + TextFormField( + controller: _titleController, + decoration: InputDecoration(labelText: l10n.votingTitleLabel), + 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.votingDescriptionLabel), + maxLines: 3, + ), + const SizedBox(height: AppConstants.spacingMd), + DropdownButtonFormField( + value: _type, + decoration: InputDecoration(labelText: l10n.votingTypeLabel), + items: votingTypes + .map((t) => DropdownMenuItem( + value: t, + child: Text(votingTypeLabel(l10n, t)), + )) + .toList(), + onChanged: (v) => setState(() => _type = v ?? _type), + ), + const SizedBox(height: AppConstants.spacingMd), + DropdownButtonFormField( + value: _visibility, + decoration: + InputDecoration(labelText: l10n.votingVisibilityLabel), + items: votingVisibilities + .map((v) => DropdownMenuItem( + value: v, + child: Text(votingVisibilityLabel(l10n, v)), + )) + .toList(), + onChanged: (v) => setState(() => _visibility = v ?? _visibility), + ), + const SizedBox(height: AppConstants.spacingLg), + Text(l10n.votingOptionsLabel, + style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: AppConstants.spacingSm), + ..._buildOptionFields(l10n), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: _addOption, + icon: const Icon(Icons.add), + label: Text(l10n.addOption), + ), + ), + 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), + ), + ], + ), + ), + ); + } + + List _buildOptionFields(AppLocalizations l10n) { + return List.generate(_optionControllers.length, (index) { + return Padding( + padding: const EdgeInsets.only(bottom: AppConstants.spacingSm), + child: Row( + children: [ + Expanded( + child: TextFormField( + controller: _optionControllers[index], + decoration: InputDecoration( + labelText: '${l10n.optionLabel} ${index + 1}', + ), + ), + ), + if (_optionControllers.length > 2) + IconButton( + icon: const Icon(Icons.remove_circle_outline), + tooltip: l10n.removeOption, + onPressed: () => _removeOption(index), + ), + ], + ), + ); + }); + } +} diff --git a/frontend/arah.app/lib/features/governance/presentation/screens/governance_screen.dart b/frontend/arah.app/lib/features/governance/presentation/screens/governance_screen.dart new file mode 100644 index 00000000..a44e9242 --- /dev/null +++ b/frontend/arah.app/lib/features/governance/presentation/screens/governance_screen.dart @@ -0,0 +1,502 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/config/constants.dart'; +import '../../../../core/network/api_exception.dart'; +import '../../../../core/providers/territory_provider.dart'; +import '../../../../core/widgets/app_snackbar.dart'; +import '../../../../core/widgets/arah_list_skeleton.dart'; +import '../../../../core/widgets/arah_scaffold.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../territories/presentation/widgets/territory_indicator_bar.dart'; +import '../../data/models/voting.dart'; +import '../providers/governance_provider.dart'; +import 'create_voting_screen.dart'; +import 'voting_labels.dart'; + +/// Governança: votações do território (BFF governance/{territoryId}/votings). +class GovernanceScreen extends ConsumerWidget { + const GovernanceScreen({super.key, this.territoryId}); + + final String? territoryId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + final effectiveTerritoryId = + territoryId ?? ref.watch(selectedTerritoryIdValueProvider); + final hasTerritory = + effectiveTerritoryId != null && effectiveTerritoryId.isNotEmpty; + + if (!hasTerritory) { + return ArahScaffold( + appBar: AppBar(title: Text(l10n.governance)), + body: Center( + child: Padding( + padding: const EdgeInsets.all(AppConstants.spacingLg), + child: Text( + l10n.chooseTerritoryForGovernance, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ), + ); + } + + final state = ref.watch(governanceProvider(effectiveTerritoryId)); + final notifier = ref.read(governanceProvider(effectiveTerritoryId).notifier); + + return ArahScaffold( + appBar: AppBar(title: Text(l10n.governance)), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _openCreate(context, effectiveTerritoryId), + icon: const Icon(Icons.how_to_vote_outlined), + label: Text(l10n.createVoting), + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const TerritoryIndicatorBar(), + _StatusFilterBar( + selected: state.statusFilter, + onSelected: notifier.setStatusFilter, + ), + Expanded( + child: RefreshIndicator( + onRefresh: () => notifier.refresh(), + child: _buildBody(context, ref, state, notifier), + ), + ), + ], + ), + ); + } + + Future _openCreate(BuildContext context, String territoryId) async { + final created = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => CreateVotingScreen(territoryId: territoryId), + ), + ); + if (created == true && context.mounted) { + showSuccessSnackBar(context, AppLocalizations.of(context)!.votingCreated); + } + } + + Widget _buildBody( + BuildContext context, + WidgetRef ref, + GovernanceState state, + GovernanceNotifier notifier, + ) { + final l10n = AppLocalizations.of(context)!; + + if (state.isLoading && state.votings.isEmpty) { + return const ArahListSkeleton(); + } + + if (state.error != null && state.votings.isEmpty) { + final message = state.error is ApiException + ? (state.error as ApiException).userMessage + : l10n.errorLoadVotings; + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + Padding( + padding: const EdgeInsets.all(AppConstants.spacingLg), + child: Column( + children: [ + Icon(Icons.lock_outline, + size: AppConstants.iconSizeLg, + color: Theme.of(context).colorScheme.error), + const SizedBox(height: AppConstants.spacingMd), + Text(message, textAlign: TextAlign.center), + const SizedBox(height: AppConstants.spacingMd), + FilledButton.tonal( + onPressed: () => notifier.refresh(), + child: Text(l10n.tryAgain)), + ], + ), + ), + ], + ); + } + + if (state.votings.isEmpty) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: MediaQuery.of(context).size.height * 0.5, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.how_to_vote_outlined, + size: AppConstants.avatarSizeLg, + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.5), + ), + const SizedBox(height: AppConstants.spacingMd), + Text(l10n.noVotings, + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: AppConstants.spacingXs), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingLg), + child: Text( + l10n.governanceSubtitle, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + + return ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingMd, vertical: AppConstants.spacingSm), + itemCount: state.votings.length, + itemBuilder: (context, index) { + final voting = state.votings[index]; + return _VotingCard( + voting: voting, + alreadyVoted: state.votedIds.contains(voting.id), + onVote: (option) async { + try { + await notifier.vote(voting, option); + if (context.mounted) { + showSuccessSnackBar(context, l10n.voteRegistered); + } + } catch (e) { + if (context.mounted) { + final msg = e is ApiException ? e.userMessage : l10n.errorVote; + showErrorSnackBar(context, msg); + } + } + }, + onShowResults: () => _showResults(context, notifier, voting), + onClose: () async { + try { + await notifier.closeVoting(voting); + if (context.mounted) { + showSuccessSnackBar(context, l10n.votingClosedMsg); + } + } catch (e) { + if (context.mounted) { + final msg = + e is ApiException ? e.userMessage : l10n.errorCloseVoting; + showErrorSnackBar(context, msg); + } + } + }, + ); + }, + ); + } + + Future _showResults( + BuildContext context, + GovernanceNotifier notifier, + Voting voting, + ) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (ctx) => _VotingResultsSheet( + voting: voting, + load: () => notifier.results(voting), + ), + ); + } +} + +class _StatusFilterBar extends StatelessWidget { + const _StatusFilterBar({required this.selected, required this.onSelected}); + + final String? selected; + final void Function(String?) onSelected; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingMd, vertical: AppConstants.spacingSm), + child: Wrap( + spacing: AppConstants.spacingSm, + children: [ + ChoiceChip( + label: Text(l10n.filterAllVotings), + selected: selected == null, + onSelected: (_) => onSelected(null), + ), + ChoiceChip( + label: Text(l10n.filterOpenVotings), + selected: selected == 'Open', + onSelected: (_) => onSelected('Open'), + ), + ChoiceChip( + label: Text(l10n.filterClosedVotings), + selected: selected == 'Closed', + onSelected: (_) => onSelected('Closed'), + ), + ], + ), + ); + } +} + +class _VotingCard extends StatefulWidget { + const _VotingCard({ + required this.voting, + required this.alreadyVoted, + required this.onVote, + required this.onShowResults, + required this.onClose, + }); + + final Voting voting; + final bool alreadyVoted; + final void Function(String option) onVote; + final VoidCallback onShowResults; + final VoidCallback onClose; + + @override + State<_VotingCard> createState() => _VotingCardState(); +} + +class _VotingCardState extends State<_VotingCard> { + String? _selectedOption; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final voting = widget.voting; + final theme = Theme.of(context); + final canVote = voting.isOpen && !widget.alreadyVoted; + + return Card( + margin: const EdgeInsets.only(bottom: AppConstants.spacingMd), + child: Padding( + padding: const EdgeInsets.all(AppConstants.spacingMd), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + voting.title, + style: theme.textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + _StatusBadge(status: voting.status), + PopupMenuButton( + onSelected: (value) { + if (value == 'results') widget.onShowResults(); + if (value == 'close') widget.onClose(); + }, + itemBuilder: (ctx) => [ + PopupMenuItem( + value: 'results', child: Text(l10n.viewResults)), + if (voting.isOpen) + PopupMenuItem( + value: 'close', child: Text(l10n.closeVoting)), + ], + ), + ], + ), + Text( + '${votingTypeLabel(l10n, voting.type)} · ${votingVisibilityLabel(l10n, voting.visibility)}', + style: theme.textTheme.labelSmall + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + if (voting.description.isNotEmpty) ...[ + const SizedBox(height: AppConstants.spacingSm), + Text(voting.description, style: theme.textTheme.bodyMedium), + ], + const SizedBox(height: AppConstants.spacingSm), + if (canVote) ...[ + ...voting.options.map( + (option) => RadioListTile( + value: option, + groupValue: _selectedOption, + onChanged: (value) => + setState(() => _selectedOption = value), + title: Text(option), + contentPadding: EdgeInsets.zero, + dense: true, + ), + ), + const SizedBox(height: AppConstants.spacingXs), + Align( + alignment: Alignment.centerRight, + child: FilledButton( + onPressed: _selectedOption == null + ? null + : () => widget.onVote(_selectedOption!), + child: Text(l10n.voteAction), + ), + ), + ] else ...[ + if (widget.alreadyVoted) + Row( + children: [ + Icon(Icons.check_circle, + size: AppConstants.iconSizeSm, + color: theme.colorScheme.primary), + const SizedBox(width: AppConstants.spacingXs), + Text(l10n.alreadyVoted, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant)), + ], + ), + const SizedBox(height: AppConstants.spacingXs), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: widget.onShowResults, + icon: const Icon(Icons.bar_chart_outlined), + label: Text(l10n.viewResults), + ), + ), + ], + ], + ), + ), + ); + } +} + +class _StatusBadge extends StatelessWidget { + const _StatusBadge({required this.status}); + + final String status; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + final isOpen = status.toLowerCase() == 'open'; + final color = isOpen ? theme.colorScheme.primary : theme.colorScheme.outline; + return Container( + margin: const EdgeInsets.only(left: AppConstants.spacingSm), + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingSm, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppConstants.radiusSm), + ), + child: Text( + votingStatusLabel(l10n, status), + style: theme.textTheme.labelSmall?.copyWith(color: color), + ), + ); + } +} + +class _VotingResultsSheet extends StatelessWidget { + const _VotingResultsSheet({required this.voting, required this.load}); + + final Voting voting; + final Future Function() load; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB( + AppConstants.spacingLg, + 0, + AppConstants.spacingLg, + AppConstants.spacingLg, + ), + child: FutureBuilder( + future: load(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Padding( + padding: EdgeInsets.all(AppConstants.spacingXl), + child: Center(child: CircularProgressIndicator()), + ); + } + if (snapshot.hasError) { + final err = snapshot.error; + final msg = err is ApiException ? err.userMessage : l10n.errorLoad; + return Padding( + padding: const EdgeInsets.all(AppConstants.spacingLg), + child: Text(msg, textAlign: TextAlign.center), + ); + } + final results = snapshot.data ?? VotingResults(votingId: voting.id, counts: const {}); + final total = results.totalVotes; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(voting.title, + style: theme.textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600)), + const SizedBox(height: AppConstants.spacingXs), + Text(l10n.totalVotesLabel(total), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant)), + const SizedBox(height: AppConstants.spacingMd), + ...voting.options.map((option) { + final count = results.counts[option] ?? 0; + final fraction = total == 0 ? 0.0 : count / total; + final percent = (fraction * 100).round(); + return Padding( + padding: + const EdgeInsets.only(bottom: AppConstants.spacingMd), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded(child: Text(option)), + Text('$count · $percent%', + style: theme.textTheme.bodySmall), + ], + ), + const SizedBox(height: AppConstants.spacingXs), + ClipRRect( + borderRadius: + BorderRadius.circular(AppConstants.radiusSm), + child: LinearProgressIndicator( + value: fraction, + minHeight: 8, + backgroundColor: theme + .colorScheme.surfaceContainerHighest, + ), + ), + ], + ), + ); + }), + ], + ); + }, + ), + ); + } +} diff --git a/frontend/arah.app/lib/features/governance/presentation/screens/voting_labels.dart b/frontend/arah.app/lib/features/governance/presentation/screens/voting_labels.dart new file mode 100644 index 00000000..482eeb98 --- /dev/null +++ b/frontend/arah.app/lib/features/governance/presentation/screens/voting_labels.dart @@ -0,0 +1,60 @@ +import '../../../../l10n/app_localizations.dart'; + +/// Tipos de votação suportados pela API (Domain.Governance.VotingType). +const List votingTypes = [ + 'CommunityPolicy', + 'ThemePrioritization', + 'TerritoryCharacterization', + 'ModerationRule', + 'FeatureFlag', +]; + +/// Visibilidades suportadas pela API (Domain.Governance.VotingVisibility). +const List votingVisibilities = [ + 'AllMembers', + 'ResidentsOnly', + 'CuratorsOnly', +]; + +String votingTypeLabel(AppLocalizations l10n, String type) { + switch (type) { + case 'ThemePrioritization': + return l10n.votingTypeThemePrioritization; + case 'ModerationRule': + return l10n.votingTypeModerationRule; + case 'FeatureFlag': + return l10n.votingTypeFeatureFlag; + case 'TerritoryCharacterization': + return l10n.votingTypeTerritoryCharacterization; + case 'CommunityPolicy': + return l10n.votingTypeCommunityPolicy; + default: + return type; + } +} + +String votingVisibilityLabel(AppLocalizations l10n, String visibility) { + switch (visibility) { + case 'AllMembers': + return l10n.votingVisibilityAllMembers; + case 'ResidentsOnly': + return l10n.votingVisibilityResidentsOnly; + case 'CuratorsOnly': + return l10n.votingVisibilityCuratorsOnly; + default: + return visibility; + } +} + +String votingStatusLabel(AppLocalizations l10n, String status) { + switch (status.toLowerCase()) { + case 'open': + return l10n.statusOpen; + case 'closed': + return l10n.statusClosed; + case 'cancelled': + return l10n.statusCancelled; + default: + return status; + } +} diff --git a/frontend/arah.app/lib/l10n/app_en.arb b/frontend/arah.app/lib/l10n/app_en.arb index 3d0b37ad..7325a457 100644 --- a/frontend/arah.app/lib/l10n/app_en.arb +++ b/frontend/arah.app/lib/l10n/app_en.arb @@ -297,5 +297,51 @@ "useSystemTheme": "Use system theme", "viewDetails": "View details", "createFirstPost": "Create first post", - "appearance": "Appearance" + "appearance": "Appearance", + "governance": "Governance", + "governanceSubtitle": "Collective decisions of the territory.", + "chooseTerritoryForGovernance": "Choose a territory to see the votings.", + "noVotings": "No votings right now.", + "errorLoadVotings": "Error loading votings.", + "createVoting": "New voting", + "filterAllVotings": "All", + "filterOpenVotings": "Open", + "filterClosedVotings": "Closed", + "voteAction": "Vote", + "voteRegistered": "Vote recorded.", + "errorVote": "Error recording vote.", + "alreadyVoted": "You already voted.", + "viewResults": "View results", + "closeVoting": "Close voting", + "votingClosedMsg": "Voting closed.", + "errorCloseVoting": "Error closing voting.", + "votingCreated": "Voting created.", + "errorCreateVoting": "Error creating voting.", + "votingTitleLabel": "Voting title", + "votingDescriptionLabel": "Description", + "votingTypeLabel": "Type", + "votingVisibilityLabel": "Who can vote", + "votingOptionsLabel": "Options", + "optionLabel": "Option", + "addOption": "Add option", + "removeOption": "Remove option", + "votingNeedsTwoOptions": "Provide at least 2 options.", + "requiredField": "Required field.", + "totalVotesLabel": "{count} votes", + "@totalVotesLabel": { + "placeholders": { + "count": {"type": "int"} + } + }, + "statusOpen": "Open", + "statusClosed": "Closed", + "statusCancelled": "Cancelled", + "votingTypeThemePrioritization": "Theme prioritization", + "votingTypeModerationRule": "Moderation rule", + "votingTypeFeatureFlag": "Feature flag", + "votingTypeTerritoryCharacterization": "Territory characterization", + "votingTypeCommunityPolicy": "Community policy", + "votingVisibilityAllMembers": "All members", + "votingVisibilityResidentsOnly": "Residents only", + "votingVisibilityCuratorsOnly": "Curators only" } diff --git a/frontend/arah.app/lib/l10n/app_localizations.dart b/frontend/arah.app/lib/l10n/app_localizations.dart index 62431a70..e8af4bce 100644 --- a/frontend/arah.app/lib/l10n/app_localizations.dart +++ b/frontend/arah.app/lib/l10n/app_localizations.dart @@ -18,7 +18,7 @@ import 'app_localizations_pt.dart'; /// `supportedLocales` list. For example: /// /// ```dart -/// import 'l10n/app_localizations.dart'; +/// import 'gen_l10n/app_localizations.dart'; /// /// return MaterialApp( /// localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -62,8 +62,7 @@ import 'app_localizations_pt.dart'; /// be consistent with the languages listed in the AppLocalizations.supportedLocales /// property. abstract class AppLocalizations { - AppLocalizations(String locale) - : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString()); final String localeName; @@ -71,8 +70,7 @@ abstract class AppLocalizations { return Localizations.of(context, AppLocalizations); } - static const LocalizationsDelegate delegate = - _AppLocalizationsDelegate(); + static const LocalizationsDelegate delegate = _AppLocalizationsDelegate(); /// A list of this localizations delegate along with the default localizations /// delegates. @@ -84,8 +82,7 @@ abstract class AppLocalizations { /// Additional delegates can be added by appending to this list in /// MaterialApp. This list does not have to be used at all if a custom list /// of delegates is preferred or required. - static const List> localizationsDelegates = - >[ + static const List> localizationsDelegates = >[ delegate, GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, @@ -1585,10 +1582,255 @@ abstract class AppLocalizations { /// In pt, this message translates to: /// **'Aparência'** String get appearance; + + /// No description provided for @governance. + /// + /// In pt, this message translates to: + /// **'Governança'** + String get governance; + + /// No description provided for @governanceSubtitle. + /// + /// In pt, this message translates to: + /// **'Decisões coletivas do território.'** + String get governanceSubtitle; + + /// No description provided for @chooseTerritoryForGovernance. + /// + /// In pt, this message translates to: + /// **'Escolha um território para ver as votações.'** + String get chooseTerritoryForGovernance; + + /// No description provided for @noVotings. + /// + /// In pt, this message translates to: + /// **'Nenhuma votação no momento.'** + String get noVotings; + + /// No description provided for @errorLoadVotings. + /// + /// In pt, this message translates to: + /// **'Erro ao carregar votações.'** + String get errorLoadVotings; + + /// No description provided for @createVoting. + /// + /// In pt, this message translates to: + /// **'Nova votação'** + String get createVoting; + + /// No description provided for @filterAllVotings. + /// + /// In pt, this message translates to: + /// **'Todas'** + String get filterAllVotings; + + /// No description provided for @filterOpenVotings. + /// + /// In pt, this message translates to: + /// **'Abertas'** + String get filterOpenVotings; + + /// No description provided for @filterClosedVotings. + /// + /// In pt, this message translates to: + /// **'Fechadas'** + String get filterClosedVotings; + + /// No description provided for @voteAction. + /// + /// In pt, this message translates to: + /// **'Votar'** + String get voteAction; + + /// No description provided for @voteRegistered. + /// + /// In pt, this message translates to: + /// **'Voto registrado.'** + String get voteRegistered; + + /// No description provided for @errorVote. + /// + /// In pt, this message translates to: + /// **'Erro ao registrar voto.'** + String get errorVote; + + /// No description provided for @alreadyVoted. + /// + /// In pt, this message translates to: + /// **'Você já votou.'** + String get alreadyVoted; + + /// No description provided for @viewResults. + /// + /// In pt, this message translates to: + /// **'Ver resultados'** + String get viewResults; + + /// No description provided for @closeVoting. + /// + /// In pt, this message translates to: + /// **'Fechar votação'** + String get closeVoting; + + /// No description provided for @votingClosedMsg. + /// + /// In pt, this message translates to: + /// **'Votação fechada.'** + String get votingClosedMsg; + + /// No description provided for @errorCloseVoting. + /// + /// In pt, this message translates to: + /// **'Erro ao fechar votação.'** + String get errorCloseVoting; + + /// No description provided for @votingCreated. + /// + /// In pt, this message translates to: + /// **'Votação criada.'** + String get votingCreated; + + /// No description provided for @errorCreateVoting. + /// + /// In pt, this message translates to: + /// **'Erro ao criar votação.'** + String get errorCreateVoting; + + /// No description provided for @votingTitleLabel. + /// + /// In pt, this message translates to: + /// **'Título da votação'** + String get votingTitleLabel; + + /// No description provided for @votingDescriptionLabel. + /// + /// In pt, this message translates to: + /// **'Descrição'** + String get votingDescriptionLabel; + + /// No description provided for @votingTypeLabel. + /// + /// In pt, this message translates to: + /// **'Tipo'** + String get votingTypeLabel; + + /// No description provided for @votingVisibilityLabel. + /// + /// In pt, this message translates to: + /// **'Quem pode votar'** + String get votingVisibilityLabel; + + /// No description provided for @votingOptionsLabel. + /// + /// In pt, this message translates to: + /// **'Opções'** + String get votingOptionsLabel; + + /// No description provided for @optionLabel. + /// + /// In pt, this message translates to: + /// **'Opção'** + String get optionLabel; + + /// No description provided for @addOption. + /// + /// In pt, this message translates to: + /// **'Adicionar opção'** + String get addOption; + + /// No description provided for @removeOption. + /// + /// In pt, this message translates to: + /// **'Remover opção'** + String get removeOption; + + /// No description provided for @votingNeedsTwoOptions. + /// + /// In pt, this message translates to: + /// **'Informe pelo menos 2 opções.'** + String get votingNeedsTwoOptions; + + /// No description provided for @requiredField. + /// + /// In pt, this message translates to: + /// **'Campo obrigatório.'** + String get requiredField; + + /// No description provided for @totalVotesLabel. + /// + /// In pt, this message translates to: + /// **'{count} votos'** + String totalVotesLabel(int count); + + /// No description provided for @statusOpen. + /// + /// In pt, this message translates to: + /// **'Aberta'** + String get statusOpen; + + /// No description provided for @statusClosed. + /// + /// In pt, this message translates to: + /// **'Fechada'** + String get statusClosed; + + /// No description provided for @statusCancelled. + /// + /// In pt, this message translates to: + /// **'Cancelada'** + String get statusCancelled; + + /// No description provided for @votingTypeThemePrioritization. + /// + /// In pt, this message translates to: + /// **'Priorização de temas'** + String get votingTypeThemePrioritization; + + /// No description provided for @votingTypeModerationRule. + /// + /// In pt, this message translates to: + /// **'Regra de moderação'** + String get votingTypeModerationRule; + + /// No description provided for @votingTypeFeatureFlag. + /// + /// In pt, this message translates to: + /// **'Funcionalidade'** + String get votingTypeFeatureFlag; + + /// No description provided for @votingTypeTerritoryCharacterization. + /// + /// In pt, this message translates to: + /// **'Caracterização do território'** + String get votingTypeTerritoryCharacterization; + + /// No description provided for @votingTypeCommunityPolicy. + /// + /// In pt, this message translates to: + /// **'Política comunitária'** + String get votingTypeCommunityPolicy; + + /// No description provided for @votingVisibilityAllMembers. + /// + /// In pt, this message translates to: + /// **'Todos os membros'** + String get votingVisibilityAllMembers; + + /// No description provided for @votingVisibilityResidentsOnly. + /// + /// In pt, this message translates to: + /// **'Apenas moradores'** + String get votingVisibilityResidentsOnly; + + /// No description provided for @votingVisibilityCuratorsOnly. + /// + /// In pt, this message translates to: + /// **'Apenas curadores'** + String get votingVisibilityCuratorsOnly; } -class _AppLocalizationsDelegate - extends LocalizationsDelegate { +class _AppLocalizationsDelegate extends LocalizationsDelegate { const _AppLocalizationsDelegate(); @override @@ -1597,25 +1839,25 @@ class _AppLocalizationsDelegate } @override - bool isSupported(Locale locale) => - ['en', 'pt'].contains(locale.languageCode); + bool isSupported(Locale locale) => ['en', 'pt'].contains(locale.languageCode); @override bool shouldReload(_AppLocalizationsDelegate old) => false; } AppLocalizations lookupAppLocalizations(Locale locale) { + + // Lookup logic when only language code is specified. switch (locale.languageCode) { - case 'en': - return AppLocalizationsEn(); - case 'pt': - return AppLocalizationsPt(); + case 'en': return AppLocalizationsEn(); + case 'pt': return AppLocalizationsPt(); } throw FlutterError( - 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' - 'an issue with the localizations generation tool. Please file an issue ' - 'on GitHub with a reproducible sample app and the gen-l10n configuration ' - 'that was used.'); + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.' + ); } diff --git a/frontend/arah.app/lib/l10n/app_localizations_en.dart b/frontend/arah.app/lib/l10n/app_localizations_en.dart index 556c6dd6..6e9074aa 100644 --- a/frontend/arah.app/lib/l10n/app_localizations_en.dart +++ b/frontend/arah.app/lib/l10n/app_localizations_en.dart @@ -1,5 +1,3 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -54,8 +52,7 @@ class AppLocalizationsEn extends AppLocalizations { String get territories => 'Territories'; @override - String get territoriesSubtitle => - 'Tap a territory to see its feed or switch region.'; + String get territoriesSubtitle => 'Tap a territory to see its feed or switch region.'; @override String get noTerritoryAvailable => 'No territory available'; @@ -64,8 +61,7 @@ class AppLocalizationsEn extends AppLocalizations { String get onboardingTitle => 'Choose your territory'; @override - String get onboardingDescription => - 'To see the feed and join the community, choose a territory near you.'; + String get onboardingDescription => 'To see the feed and join the community, choose a territory near you.'; @override String get useMyLocation => 'Use my location'; @@ -74,8 +70,7 @@ class AppLocalizationsEn extends AppLocalizations { String get enableLocationHint => 'Enable location to see nearby territories.'; @override - String get noTerritoryInRegion => - 'No territory found in this region. Try a larger radius or enable location.'; + String get noTerritoryInRegion => 'No territory found in this region. Try a larger radius or enable location.'; @override String get onboardingNearbyTitle => 'Near you'; @@ -84,19 +79,16 @@ class AppLocalizationsEn extends AppLocalizations { String get onboardingAllTerritoriesTitle => 'All territories'; @override - String get onboardingOrChooseFromList => - 'Or choose a territory from the list below'; + String get onboardingOrChooseFromList => 'Or choose a territory from the list below'; @override String get onboardingLocationEnabled => 'Location enabled'; @override - String get onboardingLocationPrivacy => - 'Your location is private and not visible to other users.'; + String get onboardingLocationPrivacy => 'Your location is private and not visible to other users.'; @override - String get onboardingAllowLocationToCenter => - 'Allow location to center the map and see nearby territories.'; + String get onboardingAllowLocationToCenter => 'Allow location to center the map and see nearby territories.'; @override String onboardingContinueWith(Object name) { @@ -104,8 +96,7 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get onboardingVisitorOnContinue => - 'When you continue, you will enter as a visitor in this territory and will be able to see the feed.'; + String get onboardingVisitorOnContinue => 'When you continue, you will enter as a visitor in this territory and will be able to see the feed.'; @override String get onboardingGettingLocation => 'Getting location...'; @@ -195,8 +186,7 @@ class AppLocalizationsEn extends AppLocalizations { String get noTerritorySelected => 'No territory selected'; @override - String get chooseTerritoryInExplore => - 'Tap Explore, choose a territory, and come back here to post.'; + String get chooseTerritoryInExplore => 'Tap Explore, choose a territory, and come back here to post.'; @override String get comingSoon => 'Coming soon'; @@ -208,8 +198,7 @@ class AppLocalizationsEn extends AppLocalizations { String get sessionExpired => 'Session expired. Please log in again.'; @override - String get enterToAccess => - 'Sign in to access profile, post, and notifications.'; + String get enterToAccess => 'Sign in to access profile, post, and notifications.'; @override String get map => 'Map'; @@ -617,8 +606,7 @@ class AppLocalizationsEn extends AppLocalizations { String get errorSearch => 'Search error.'; @override - String get searchMinCharsHint => - 'Type at least 2 characters or see suggestions above.'; + String get searchMinCharsHint => 'Type at least 2 characters or see suggestions above.'; @override String get connectionRequestIncoming => 'Incoming request'; @@ -679,8 +667,7 @@ class AppLocalizationsEn extends AppLocalizations { String get errorLoadAlerts => 'Could not load alerts.'; @override - String get alertsRequireResidency => - 'Territory alerts require residency or curator role.'; + String get alertsRequireResidency => 'Territory alerts require residency or curator role.'; @override String get filterAll => 'All'; @@ -782,4 +769,129 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appearance => 'Appearance'; + + @override + String get governance => 'Governance'; + + @override + String get governanceSubtitle => 'Collective decisions of the territory.'; + + @override + String get chooseTerritoryForGovernance => 'Choose a territory to see the votings.'; + + @override + String get noVotings => 'No votings right now.'; + + @override + String get errorLoadVotings => 'Error loading votings.'; + + @override + String get createVoting => 'New voting'; + + @override + String get filterAllVotings => 'All'; + + @override + String get filterOpenVotings => 'Open'; + + @override + String get filterClosedVotings => 'Closed'; + + @override + String get voteAction => 'Vote'; + + @override + String get voteRegistered => 'Vote recorded.'; + + @override + String get errorVote => 'Error recording vote.'; + + @override + String get alreadyVoted => 'You already voted.'; + + @override + String get viewResults => 'View results'; + + @override + String get closeVoting => 'Close voting'; + + @override + String get votingClosedMsg => 'Voting closed.'; + + @override + String get errorCloseVoting => 'Error closing voting.'; + + @override + String get votingCreated => 'Voting created.'; + + @override + String get errorCreateVoting => 'Error creating voting.'; + + @override + String get votingTitleLabel => 'Voting title'; + + @override + String get votingDescriptionLabel => 'Description'; + + @override + String get votingTypeLabel => 'Type'; + + @override + String get votingVisibilityLabel => 'Who can vote'; + + @override + String get votingOptionsLabel => 'Options'; + + @override + String get optionLabel => 'Option'; + + @override + String get addOption => 'Add option'; + + @override + String get removeOption => 'Remove option'; + + @override + String get votingNeedsTwoOptions => 'Provide at least 2 options.'; + + @override + String get requiredField => 'Required field.'; + + @override + String totalVotesLabel(int count) { + return '$count votes'; + } + + @override + String get statusOpen => 'Open'; + + @override + String get statusClosed => 'Closed'; + + @override + String get statusCancelled => 'Cancelled'; + + @override + String get votingTypeThemePrioritization => 'Theme prioritization'; + + @override + String get votingTypeModerationRule => 'Moderation rule'; + + @override + String get votingTypeFeatureFlag => 'Feature flag'; + + @override + String get votingTypeTerritoryCharacterization => 'Territory characterization'; + + @override + String get votingTypeCommunityPolicy => 'Community policy'; + + @override + String get votingVisibilityAllMembers => 'All members'; + + @override + String get votingVisibilityResidentsOnly => 'Residents only'; + + @override + String get votingVisibilityCuratorsOnly => 'Curators only'; } diff --git a/frontend/arah.app/lib/l10n/app_localizations_pt.dart b/frontend/arah.app/lib/l10n/app_localizations_pt.dart index 7a58ee14..627f2e53 100644 --- a/frontend/arah.app/lib/l10n/app_localizations_pt.dart +++ b/frontend/arah.app/lib/l10n/app_localizations_pt.dart @@ -1,5 +1,3 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -48,15 +46,13 @@ class AppLocalizationsPt extends AppLocalizations { String get profile => 'Perfil'; @override - String get chooseTerritory => - 'Escolha um território para ver o feed da região'; + String get chooseTerritory => 'Escolha um território para ver o feed da região'; @override String get territories => 'Territórios'; @override - String get territoriesSubtitle => - 'Toque em um território para ver o feed da região ou trocar de região.'; + String get territoriesSubtitle => 'Toque em um território para ver o feed da região ou trocar de região.'; @override String get noTerritoryAvailable => 'Nenhum território disponível'; @@ -65,19 +61,16 @@ class AppLocalizationsPt extends AppLocalizations { String get onboardingTitle => 'Escolha seu território'; @override - String get onboardingDescription => - 'Para ver o feed e participar da comunidade, escolha um território próximo a você.'; + String get onboardingDescription => 'Para ver o feed e participar da comunidade, escolha um território próximo a você.'; @override String get useMyLocation => 'Usar minha localização'; @override - String get enableLocationHint => - 'Ative a localização para ver territórios próximos.'; + String get enableLocationHint => 'Ative a localização para ver territórios próximos.'; @override - String get noTerritoryInRegion => - 'Nenhum território encontrado nesta região. Tente aumentar o raio ou ative a localização.'; + String get noTerritoryInRegion => 'Nenhum território encontrado nesta região. Tente aumentar o raio ou ative a localização.'; @override String get onboardingNearbyTitle => 'Próximos a você'; @@ -86,19 +79,16 @@ class AppLocalizationsPt extends AppLocalizations { String get onboardingAllTerritoriesTitle => 'Todos os territórios'; @override - String get onboardingOrChooseFromList => - 'Ou escolha um território na lista abaixo'; + String get onboardingOrChooseFromList => 'Ou escolha um território na lista abaixo'; @override String get onboardingLocationEnabled => 'Localização ativa'; @override - String get onboardingLocationPrivacy => - 'Sua localização é privada e não fica visível para outros usuários.'; + String get onboardingLocationPrivacy => 'Sua localização é privada e não fica visível para outros usuários.'; @override - String get onboardingAllowLocationToCenter => - 'Permita a localização para centralizar o mapa e ver territórios próximos.'; + String get onboardingAllowLocationToCenter => 'Permita a localização para centralizar o mapa e ver territórios próximos.'; @override String onboardingContinueWith(Object name) { @@ -106,8 +96,7 @@ class AppLocalizationsPt extends AppLocalizations { } @override - String get onboardingVisitorOnContinue => - 'Ao continuar, você entrará como visitante neste território e poderá ver o feed da região.'; + String get onboardingVisitorOnContinue => 'Ao continuar, você entrará como visitante neste território e poderá ver o feed da região.'; @override String get onboardingGettingLocation => 'Obtendo localização...'; @@ -197,8 +186,7 @@ class AppLocalizationsPt extends AppLocalizations { String get noTerritorySelected => 'Nenhum território selecionado'; @override - String get chooseTerritoryInExplore => - 'Toque em Explorar, escolha um território e volte aqui para publicar.'; + String get chooseTerritoryInExplore => 'Toque em Explorar, escolha um território e volte aqui para publicar.'; @override String get comingSoon => 'Em breve'; @@ -210,8 +198,7 @@ class AppLocalizationsPt extends AppLocalizations { String get sessionExpired => 'Sessão expirada. Faça login novamente.'; @override - String get enterToAccess => - 'Entre na sua conta para acessar perfil, publicar e notificações.'; + String get enterToAccess => 'Entre na sua conta para acessar perfil, publicar e notificações.'; @override String get map => 'Mapa'; @@ -433,8 +420,7 @@ class AppLocalizationsPt extends AppLocalizations { String get reportAlert => 'Reportar alerta'; @override - String get chooseTerritoryForAlerts => - 'Escolha um território para ver alertas.'; + String get chooseTerritoryForAlerts => 'Escolha um território para ver alertas.'; @override String get pending => 'Pendentes'; @@ -611,8 +597,7 @@ class AppLocalizationsPt extends AppLocalizations { String get errorLoadConnections => 'Não foi possível carregar conexões.'; @override - String get noConnectionsYet => - 'Nenhuma conexão ainda. Toque em Adicionar para buscar pessoas.'; + String get noConnectionsYet => 'Nenhuma conexão ainda. Toque em Adicionar para buscar pessoas.'; @override String get errorLoadSuggestions => 'Erro ao carregar sugestões.'; @@ -621,8 +606,7 @@ class AppLocalizationsPt extends AppLocalizations { String get errorSearch => 'Erro na busca.'; @override - String get searchMinCharsHint => - 'Digite ao menos 2 caracteres ou veja sugestões acima.'; + String get searchMinCharsHint => 'Digite ao menos 2 caracteres ou veja sugestões acima.'; @override String get connectionRequestIncoming => 'Solicitação recebida'; @@ -683,8 +667,7 @@ class AppLocalizationsPt extends AppLocalizations { String get errorLoadAlerts => 'Não foi possível carregar alertas.'; @override - String get alertsRequireResidency => - 'Alertas do território exigem residência ou curadoria.'; + String get alertsRequireResidency => 'Alertas do território exigem residência ou curadoria.'; @override String get filterAll => 'Todos'; @@ -693,8 +676,7 @@ class AppLocalizationsPt extends AppLocalizations { String get postDefaultTitle => 'Post'; @override - String get chooseTerritoryBeforePost => - 'Escolha um território antes de publicar.'; + String get chooseTerritoryBeforePost => 'Escolha um território antes de publicar.'; @override String get addImage => 'Adicionar imagem'; @@ -787,4 +769,129 @@ class AppLocalizationsPt extends AppLocalizations { @override String get appearance => 'Aparência'; + + @override + String get governance => 'Governança'; + + @override + String get governanceSubtitle => 'Decisões coletivas do território.'; + + @override + String get chooseTerritoryForGovernance => 'Escolha um território para ver as votações.'; + + @override + String get noVotings => 'Nenhuma votação no momento.'; + + @override + String get errorLoadVotings => 'Erro ao carregar votações.'; + + @override + String get createVoting => 'Nova votação'; + + @override + String get filterAllVotings => 'Todas'; + + @override + String get filterOpenVotings => 'Abertas'; + + @override + String get filterClosedVotings => 'Fechadas'; + + @override + String get voteAction => 'Votar'; + + @override + String get voteRegistered => 'Voto registrado.'; + + @override + String get errorVote => 'Erro ao registrar voto.'; + + @override + String get alreadyVoted => 'Você já votou.'; + + @override + String get viewResults => 'Ver resultados'; + + @override + String get closeVoting => 'Fechar votação'; + + @override + String get votingClosedMsg => 'Votação fechada.'; + + @override + String get errorCloseVoting => 'Erro ao fechar votação.'; + + @override + String get votingCreated => 'Votação criada.'; + + @override + String get errorCreateVoting => 'Erro ao criar votação.'; + + @override + String get votingTitleLabel => 'Título da votação'; + + @override + String get votingDescriptionLabel => 'Descrição'; + + @override + String get votingTypeLabel => 'Tipo'; + + @override + String get votingVisibilityLabel => 'Quem pode votar'; + + @override + String get votingOptionsLabel => 'Opções'; + + @override + String get optionLabel => 'Opção'; + + @override + String get addOption => 'Adicionar opção'; + + @override + String get removeOption => 'Remover opção'; + + @override + String get votingNeedsTwoOptions => 'Informe pelo menos 2 opções.'; + + @override + String get requiredField => 'Campo obrigatório.'; + + @override + String totalVotesLabel(int count) { + return '$count votos'; + } + + @override + String get statusOpen => 'Aberta'; + + @override + String get statusClosed => 'Fechada'; + + @override + String get statusCancelled => 'Cancelada'; + + @override + String get votingTypeThemePrioritization => 'Priorização de temas'; + + @override + String get votingTypeModerationRule => 'Regra de moderação'; + + @override + String get votingTypeFeatureFlag => 'Funcionalidade'; + + @override + String get votingTypeTerritoryCharacterization => 'Caracterização do território'; + + @override + String get votingTypeCommunityPolicy => 'Política comunitária'; + + @override + String get votingVisibilityAllMembers => 'Todos os membros'; + + @override + String get votingVisibilityResidentsOnly => 'Apenas moradores'; + + @override + String get votingVisibilityCuratorsOnly => 'Apenas curadores'; } diff --git a/frontend/arah.app/lib/l10n/app_pt.arb b/frontend/arah.app/lib/l10n/app_pt.arb index fada1814..bea0de41 100644 --- a/frontend/arah.app/lib/l10n/app_pt.arb +++ b/frontend/arah.app/lib/l10n/app_pt.arb @@ -297,5 +297,51 @@ "useSystemTheme": "Usar tema do sistema", "viewDetails": "Ver detalhes", "createFirstPost": "Criar primeiro post", - "appearance": "Aparência" + "appearance": "Aparência", + "governance": "Governança", + "governanceSubtitle": "Decisões coletivas do território.", + "chooseTerritoryForGovernance": "Escolha um território para ver as votações.", + "noVotings": "Nenhuma votação no momento.", + "errorLoadVotings": "Erro ao carregar votações.", + "createVoting": "Nova votação", + "filterAllVotings": "Todas", + "filterOpenVotings": "Abertas", + "filterClosedVotings": "Fechadas", + "voteAction": "Votar", + "voteRegistered": "Voto registrado.", + "errorVote": "Erro ao registrar voto.", + "alreadyVoted": "Você já votou.", + "viewResults": "Ver resultados", + "closeVoting": "Fechar votação", + "votingClosedMsg": "Votação fechada.", + "errorCloseVoting": "Erro ao fechar votação.", + "votingCreated": "Votação criada.", + "errorCreateVoting": "Erro ao criar votação.", + "votingTitleLabel": "Título da votação", + "votingDescriptionLabel": "Descrição", + "votingTypeLabel": "Tipo", + "votingVisibilityLabel": "Quem pode votar", + "votingOptionsLabel": "Opções", + "optionLabel": "Opção", + "addOption": "Adicionar opção", + "removeOption": "Remover opção", + "votingNeedsTwoOptions": "Informe pelo menos 2 opções.", + "requiredField": "Campo obrigatório.", + "totalVotesLabel": "{count} votos", + "@totalVotesLabel": { + "placeholders": { + "count": {"type": "int"} + } + }, + "statusOpen": "Aberta", + "statusClosed": "Fechada", + "statusCancelled": "Cancelada", + "votingTypeThemePrioritization": "Priorização de temas", + "votingTypeModerationRule": "Regra de moderação", + "votingTypeFeatureFlag": "Funcionalidade", + "votingTypeTerritoryCharacterization": "Caracterização do território", + "votingTypeCommunityPolicy": "Política comunitária", + "votingVisibilityAllMembers": "Todos os membros", + "votingVisibilityResidentsOnly": "Apenas moradores", + "votingVisibilityCuratorsOnly": "Apenas curadores" } diff --git a/frontend/arah.app/test/features/governance/data/models/voting_test.dart b/frontend/arah.app/test/features/governance/data/models/voting_test.dart new file mode 100644 index 00000000..2a4f10f2 --- /dev/null +++ b/frontend/arah.app/test/features/governance/data/models/voting_test.dart @@ -0,0 +1,65 @@ +import 'package:arah_app/features/governance/data/models/voting.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Voting.fromJson', () { + test('parses full json', () { + final voting = Voting.fromJson({ + 'id': 'v1', + 'territoryId': 't1', + 'createdByUserId': 'u1', + 'type': 'CommunityPolicy', + 'title': 'Horário do silêncio', + 'description': 'Definir horário de silêncio.', + 'options': ['22h', '23h'], + 'visibility': 'AllMembers', + 'status': 'Open', + 'createdAtUtc': '2026-01-01T10:00:00Z', + 'updatedAtUtc': '2026-01-02T10:00:00Z', + }); + + expect(voting.id, 'v1'); + expect(voting.territoryId, 't1'); + expect(voting.type, 'CommunityPolicy'); + expect(voting.title, 'Horário do silêncio'); + expect(voting.options, ['22h', '23h']); + expect(voting.visibility, 'AllMembers'); + expect(voting.status, 'Open'); + expect(voting.isOpen, isTrue); + }); + + test('isOpen is case-insensitive and false when closed', () { + final closed = Voting.fromJson({'id': 'v', 'status': 'Closed'}); + expect(closed.isOpen, isFalse); + }); + + test('applies defaults for missing fields', () { + final voting = Voting.fromJson({'id': 'v2'}); + expect(voting.id, 'v2'); + expect(voting.title, ''); + expect(voting.options, isEmpty); + expect(voting.status, 'Open'); + expect(voting.startsAtUtc, isNull); + }); + }); + + group('VotingResults.fromJson', () { + test('parses counts and computes total', () { + final results = VotingResults.fromJson({ + 'votingId': 'v1', + 'results': {'22h': 3, '23h': 5}, + }); + + expect(results.votingId, 'v1'); + expect(results.counts['22h'], 3); + expect(results.counts['23h'], 5); + expect(results.totalVotes, 8); + }); + + test('handles empty results', () { + final results = VotingResults.fromJson({'votingId': 'v1'}); + expect(results.counts, isEmpty); + expect(results.totalVotes, 0); + }); + }); +} diff --git a/frontend/arah.app/test/features/governance/presentation/governance_screen_test.dart b/frontend/arah.app/test/features/governance/presentation/governance_screen_test.dart new file mode 100644 index 00000000..a375b361 --- /dev/null +++ b/frontend/arah.app/test/features/governance/presentation/governance_screen_test.dart @@ -0,0 +1,107 @@ +import 'package:arah_app/core/providers/current_territory_name_provider.dart'; +import 'package:arah_app/features/governance/data/models/voting.dart'; +import 'package:arah_app/features/governance/data/repositories/governance_repository.dart'; +import 'package:arah_app/features/governance/presentation/providers/governance_provider.dart'; +import 'package:arah_app/features/governance/presentation/screens/governance_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 que devolve uma lista controlada de votações, sem rede. +class _FakeGovernanceRepository implements GovernanceRepository { + _FakeGovernanceRepository(this.votings); + + final List votings; + + @override + Future> listVotings(String territoryId, {String? status}) async { + return votings; + } + + @override + Future getResults({ + required String territoryId, + required String votingId, + }) async { + return const VotingResults(votingId: 'v', counts: {}); + } + + @override + Future vote({ + required String territoryId, + required String votingId, + required String selectedOption, + }) async {} + + @override + Future createVoting({ + required String territoryId, + required String type, + required String title, + required String description, + required List options, + required String visibility, + DateTime? startsAtUtc, + DateTime? endsAtUtc, + }) async { + throw UnimplementedError(); + } + + @override + Future closeVoting({ + required String territoryId, + required String votingId, + }) async {} +} + +void main() { + Widget buildApp(List votings) { + return ProviderScope( + overrides: [ + governanceRepositoryProvider + .overrideWithValue(_FakeGovernanceRepository(votings)), + currentTerritoryNameProvider.overrideWithValue('Camburi'), + ], + child: MaterialApp( + locale: const Locale('pt'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const GovernanceScreen(territoryId: 't1'), + ), + ); + } + + testWidgets('GovernanceScreen shows empty state when there are no votings', + (WidgetTester tester) async { + await tester.pumpWidget(buildApp(const [])); + await tester.pumpAndSettle(); + + expect(find.text('Governança'), findsWidgets); + expect(find.text('Nenhuma votação no momento.'), findsOneWidget); + }); + + testWidgets('GovernanceScreen lists a voting with vote action', + (WidgetTester tester) async { + final voting = Voting( + id: 'v1', + territoryId: 't1', + createdByUserId: 'u1', + type: 'CommunityPolicy', + title: 'Horário do silêncio', + description: 'Definir horário.', + options: const ['22h', '23h'], + visibility: 'AllMembers', + status: 'Open', + createdAtUtc: DateTime(2026, 1, 1), + updatedAtUtc: DateTime(2026, 1, 1), + ); + + await tester.pumpWidget(buildApp([voting])); + await tester.pumpAndSettle(); + + expect(find.text('Horário do silêncio'), findsOneWidget); + expect(find.text('22h'), findsOneWidget); + expect(find.text('Votar'), findsOneWidget); + }); +}