diff --git a/.github/workflows/ci-waydowntown-full-stack.yml b/.github/workflows/ci-waydowntown-full-stack.yml index 80abc7e4..97d88608 100644 --- a/.github/workflows/ci-waydowntown-full-stack.yml +++ b/.github/workflows/ci-waydowntown-full-stack.yml @@ -93,7 +93,7 @@ jobs: api-level: 24 arch: x86_64 profile: Nexus 6 - script: flutter test integration_test/token_refresh_test.dart integration_test/string_collector_test.dart integration_test/fill_in_the_blank_test.dart --flavor local --dart-define=API_BASE_URL=http://10.0.2.2:4001 --timeout none --file-reporter json:test-results.json + script: flutter test integration_test/token_refresh_test.dart integration_test/string_collector_test.dart integration_test/fill_in_the_blank_test.dart integration_test/team_game_test.dart --flavor local --dart-define=API_BASE_URL=http://10.0.2.2:4001 --timeout none --file-reporter json:test-results.json - name: Publish test results uses: EnricoMi/publish-unit-test-result-action@v2 diff --git a/registrations/lib/registrations/waydowntown.ex b/registrations/lib/registrations/waydowntown.ex index 632f9c55..49532ada 100644 --- a/registrations/lib/registrations/waydowntown.ex +++ b/registrations/lib/registrations/waydowntown.ex @@ -18,6 +18,12 @@ defmodule Registrations.Waydowntown do |> Repo.update() end + def update_user_details(user, attrs) do + user + |> User.details_changeset(attrs) + |> Repo.update() + end + defp concepts_yaml do ConCache.get_or_store(:registrations_cache, :concepts_yaml, fn -> YamlElixir.read_from_file!(Path.join(:code.priv_dir(:registrations), "concepts.yaml")) @@ -366,7 +372,7 @@ defmodule Registrations.Waydowntown do run = get_run!(run_id) case result do - {:ok, _} when not is_nil(run.winner_submission_id) -> + {:ok, _} -> broadcast_run_update(run, conn) _ -> diff --git a/registrations/lib/registrations_web/controllers/api/user_controller.ex b/registrations/lib/registrations_web/controllers/api/user_controller.ex index 73bc139b..30d292ba 100644 --- a/registrations/lib/registrations_web/controllers/api/user_controller.ex +++ b/registrations/lib/registrations_web/controllers/api/user_controller.ex @@ -6,10 +6,19 @@ defmodule RegistrationsWeb.ApiUserController do action_fallback(RegistrationsWeb.FallbackController) + @team_fields ~w(team_emails proposed_team_name risk_aversion) + def update(conn, params) do user = Pow.Plug.current_user(conn) - case Waydowntown.update_user(user, params) do + result = + if has_team_fields?(params) do + Waydowntown.update_user_details(user, params) + else + Waydowntown.update_user(user, params) + end + + case result do {:ok, updated_user} -> conn |> put_view(UserView) @@ -31,4 +40,8 @@ defmodule RegistrationsWeb.ApiUserController do }) end end + + defp has_team_fields?(params) do + Enum.any?(@team_fields, fn field -> Map.has_key?(params, field) end) + end end diff --git a/registrations/lib/registrations_web/controllers/team_negotiation_controller.ex b/registrations/lib/registrations_web/controllers/team_negotiation_controller.ex new file mode 100644 index 00000000..6a36af98 --- /dev/null +++ b/registrations/lib/registrations_web/controllers/team_negotiation_controller.ex @@ -0,0 +1,25 @@ +defmodule RegistrationsWeb.TeamNegotiationController do + use RegistrationsWeb, :controller + + alias Registrations.Repo + alias RegistrationsWeb.TeamFinder + alias RegistrationsWeb.User + alias RegistrationsWeb.JSONAPI.TeamNegotiationView + + action_fallback(RegistrationsWeb.FallbackController) + + def show(conn, _params) do + current_user = Pow.Plug.current_user(conn) + users = Repo.all(User) + current_user_with_team = Repo.preload(current_user, team: [:users]) + relationships = TeamFinder.relationships(current_user, users) + + conn + |> put_view(TeamNegotiationView) + |> render("show.json", + data: current_user_with_team, + relationships: relationships, + conn: conn + ) + end +end diff --git a/registrations/lib/registrations_web/controllers/test_controller.ex b/registrations/lib/registrations_web/controllers/test_controller.ex index 73f6494e..314f7307 100644 --- a/registrations/lib/registrations_web/controllers/test_controller.ex +++ b/registrations/lib/registrations_web/controllers/test_controller.ex @@ -41,6 +41,10 @@ defmodule RegistrationsWeb.TestController do game_data = create_orientation_memory_game() Map.merge(base_response, game_data) + "string_collector_team" -> + game_data = create_string_collector_team_game() + Map.merge(base_response, game_data) + _ -> base_response end @@ -129,31 +133,63 @@ defmodule RegistrationsWeb.TestController do } end + defp create_string_collector_team_game do + region = Repo.insert!(%Region{name: "Test Region"}) + + specification = + Repo.insert!(%Specification{ + concept: "string_collector", + task_description: "Find all the hidden words", + start_description: "Look around for words", + region: region, + duration: 300 + }) + + answer1 = Repo.insert!(%Answer{answer: "apple", specification_id: specification.id}) + answer2 = Repo.insert!(%Answer{answer: "banana", specification_id: specification.id}) + answer3 = Repo.insert!(%Answer{answer: "cherry", specification_id: specification.id}) + + # Create second test user + user2 = create_or_reset_user("test2@example.com", @test_password, "Test User 2") + + %{ + specification_id: specification.id, + correct_answers: ["apple", "banana", "cherry"], + total_answers: 3, + answer_ids: [answer1.id, answer2.id, answer3.id], + user2_email: "test2@example.com", + user2_password: @test_password + } + end + defp create_or_reset_test_user do - case Repo.get_by(RegistrationsWeb.User, email: @test_email) do + create_or_reset_user(@test_email, @test_password, "Test User") + end + + defp create_or_reset_user(email, password, name) do + case Repo.get_by(RegistrationsWeb.User, email: email) do nil -> %RegistrationsWeb.User{} |> RegistrationsWeb.User.changeset(%{ - email: @test_email, - password: @test_password, - password_confirmation: @test_password + email: email, + password: password, + password_confirmation: password }) |> Repo.insert!() - |> Ecto.Changeset.change(%{name: "Test User"}) + |> Ecto.Changeset.change(%{name: name}) |> Repo.update!() existing_user -> - # Delete and recreate to ensure clean state Repo.delete!(existing_user) %RegistrationsWeb.User{} |> RegistrationsWeb.User.changeset(%{ - email: @test_email, - password: @test_password, - password_confirmation: @test_password + email: email, + password: password, + password_confirmation: password }) |> Repo.insert!() - |> Ecto.Changeset.change(%{name: "Test User"}) + |> Ecto.Changeset.change(%{name: name}) |> Repo.update!() end end diff --git a/registrations/lib/registrations_web/router.ex b/registrations/lib/registrations_web/router.ex index 9af0946f..95137d63 100644 --- a/registrations/lib/registrations_web/router.ex +++ b/registrations/lib/registrations_web/router.ex @@ -149,6 +149,8 @@ defmodule RegistrationsWeb.Router do scope "/waydowntown", RegistrationsWeb do pipe_through([:pow_json_api_protected]) + get("/team-negotiation", TeamNegotiationController, :show) + resources "/participations", ParticipationController, only: [:create, :update] resources "/reveals", RevealController, only: [:create] diff --git a/registrations/lib/registrations_web/views/team_negotiation_view.ex b/registrations/lib/registrations_web/views/team_negotiation_view.ex new file mode 100644 index 00000000..51922fb1 --- /dev/null +++ b/registrations/lib/registrations_web/views/team_negotiation_view.ex @@ -0,0 +1,110 @@ +defmodule RegistrationsWeb.JSONAPI.TeamNegotiationView do + def render("show.json", %{data: user, relationships: relationships, conn: _conn}) do + included = build_included(user, relationships) + + %{ + data: %{ + id: user.id, + type: "team-negotiations", + attributes: %{ + team_emails: user.team_emails, + proposed_team_name: user.proposed_team_name, + risk_aversion: user.risk_aversion, + empty: relationships[:empty?], + only_mutuals: relationships[:only_mutuals?] + }, + relationships: build_relationships(user, relationships) + }, + included: included + } + end + + defp build_relationships(user, relationships) do + base = %{ + mutuals: %{data: Enum.map(relationships[:mutuals], &member_ref/1)}, + proposers: %{data: Enum.map(relationships[:proposers], &member_ref/1)}, + proposees: %{data: Enum.map(relationships[:proposees], &proposee_ref/1)}, + invalids: %{data: Enum.map(relationships[:invalids], fn email -> %{type: "invalids", id: email} end)} + } + + if user.team do + Map.put(base, :team, %{data: %{type: "teams", id: user.team.id}}) + else + base + end + end + + defp build_included(user, relationships) do + team_included = + if user.team do + [build_team(user.team)] + else + [] + end + + mutuals_included = Enum.map(relationships[:mutuals], &build_member/1) + proposers_included = Enum.map(relationships[:proposers], &build_member/1) + proposees_included = Enum.map(relationships[:proposees], &build_proposee/1) + invalids_included = Enum.map(relationships[:invalids], &build_invalid/1) + + team_included ++ mutuals_included ++ proposers_included ++ proposees_included ++ invalids_included + end + + defp member_ref(user) do + %{type: "team-members", id: user.id} + end + + defp proposee_ref(proposee) do + %{type: "proposees", id: proposee.email} + end + + defp build_team(team) do + %{ + id: team.id, + type: "teams", + attributes: %{ + name: team.name, + risk_aversion: team.risk_aversion, + notes: team.notes + }, + relationships: %{ + members: %{data: Enum.map(team.users, &member_ref/1)} + } + } + end + + defp build_member(user) do + %{ + id: user.id, + type: "team-members", + attributes: %{ + email: user.email, + name: user.name, + risk_aversion: user.risk_aversion, + proposed_team_name: user.proposed_team_name + } + } + end + + defp build_proposee(proposee) do + %{ + id: proposee.email, + type: "proposees", + attributes: %{ + email: proposee.email, + invited: proposee.invited, + registered: Map.get(proposee, :registered, false) + } + } + end + + defp build_invalid(email) do + %{ + id: email, + type: "invalids", + attributes: %{ + value: email + } + } + end +end diff --git a/registrations/lib/registrations_web/views/user_view_json.ex b/registrations/lib/registrations_web/views/user_view_json.ex index 5895a09a..d561d33b 100644 --- a/registrations/lib/registrations_web/views/user_view_json.ex +++ b/registrations/lib/registrations_web/views/user_view_json.ex @@ -2,6 +2,6 @@ defmodule RegistrationsWeb.JSONAPI.UserView do use JSONAPI.View, type: "users" def fields do - [:admin, :email, :name] + [:admin, :email, :name, :team_emails, :proposed_team_name, :risk_aversion, :team_id] end end diff --git a/waydowntown_app/integration_test/team_game_test.dart b/waydowntown_app/integration_test/team_game_test.dart new file mode 100644 index 00000000..cef1d894 --- /dev/null +++ b/waydowntown_app/integration_test/team_game_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:waydowntown/app.dart'; +import 'package:waydowntown/services/user_service.dart'; + +import 'helpers.dart'; +import 'test_backend_client.dart'; +import 'test_config.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + late TestBackendClient testClient; + + setUp(() async { + FlutterSecureStorage.setMockInitialValues({}); + dotenv.testLoad(fileInput: 'API_ROOT=${TestConfig.apiBaseUrl}'); + testClient = TestBackendClient(); + }); + + testWidgets('team game: teammate submissions appear via WebSocket', + (tester) async { + // 1. Reset DB with team game + final resetData = + await testClient.resetDatabase(game: 'string_collector_team'); + final email = resetData['email'] as String; + + // 2. Login user 1, set tokens, pump widget + final tokens1 = await testClient.login(email, resetData['password'] as String); + await UserService.setTokens(tokens1.accessToken, tokens1.renewalToken); + + // 3. Login user 2 via API + final user2Email = resetData['user2_email'] as String; + final user2Password = resetData['user2_password'] as String; + final tokens2 = await testClient.login(user2Email, user2Password); + final dio2 = testClient.createAuthenticatedDio(tokens2.accessToken); + + // 4. User 1 launches the app + await tester.pumpWidget(const Waydowntown()); + await waitFor(tester, find.text(email)); + + // 5. User 1 taps String Collector button + final gameButton = find.text('String\nCollector'); + await waitFor(tester, gameButton); + await tester.ensureVisible(gameButton); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tap(gameButton); + + // 6. Wait for RunLaunchRoute to appear (confirms run exists + WebSocket) + await waitFor(tester, find.text('String Collector'), + timeout: const Duration(seconds: 120), + failOn: find.textContaining('Error')); + + // 7. User 2 lists runs and finds the newly created run + final runs = await testClient.listRuns(dio2); + expect(runs, isNotEmpty, reason: 'User 2 should see at least one run'); + final runId = runs.first['id'] as String; + + // 8. User 2 joins the run + final participation = await testClient.joinRun(dio2, runId); + final participationId = participation['id'] as String; + + // 9. Wait for user 1 to see 2 players (broadcast_participation_update) + await waitFor(tester, find.textContaining('2'), + timeout: const Duration(seconds: 15)); + + // 10. User 1 scrolls to and taps "I'm ready" + await tester.scrollUntilVisible( + find.textContaining('ready'), + 200.0, + scrollable: find.byType(Scrollable).last, + ); + await tester.tap(find.textContaining('ready')); + + // 11. User 2 marks ready via API + await testClient.markReady(dio2, participationId); + + // 12. Wait for game screen (auto-start after countdown) + await waitFor( + tester, + find.text('Enter a string!'), + timeout: const Duration(seconds: 30), + ); + + // 13. User 2 submits "apple" (correct) via API + await testClient.submitAnswer(dio2, runId, 'apple'); + + // 14. User 1 should see "apple" and "Teammate" appear in timeline + await waitFor(tester, find.text('apple'), + timeout: const Duration(seconds: 15)); + expect(find.text('Teammate'), findsOneWidget); + + // 15. User 2 submits "banana" (correct) via API + await testClient.submitAnswer(dio2, runId, 'banana'); + + // 16. User 1 should see "banana" and two "Teammate" labels + await waitFor(tester, find.text('banana'), + timeout: const Duration(seconds: 15)); + expect(find.text('Teammate'), findsNWidgets(2)); + + // 17. User 2 submits "cherry" (correct, triggers win) + await testClient.submitAnswer(dio2, runId, 'cherry'); + + // 18. User 1 should see Congratulations + await waitFor(tester, find.textContaining('Congratulations'), + timeout: const Duration(seconds: 15)); + + // 19. Let confetti animation timers fire + await tester.pump(const Duration(seconds: 1)); + }); +} diff --git a/waydowntown_app/integration_test/test_backend_client.dart b/waydowntown_app/integration_test/test_backend_client.dart index ccc4bc9c..fec1bb21 100644 --- a/waydowntown_app/integration_test/test_backend_client.dart +++ b/waydowntown_app/integration_test/test_backend_client.dart @@ -38,4 +38,72 @@ class TestBackendClient { renewalToken: data['renewal_token'] as String, ); } + + /// Creates a Dio instance with Bearer auth and JSON:API headers. + Dio createAuthenticatedDio(String accessToken) { + return Dio(BaseOptions( + baseUrl: TestConfig.apiBaseUrl, + headers: { + 'Content-Type': 'application/vnd.api+json', + 'Accept': 'application/vnd.api+json', + 'Authorization': 'Bearer $accessToken', + }, + )); + } + + /// Lists all runs. Returns the list of run resource objects. + Future> listRuns(Dio dio) async { + final response = await dio.get('/waydowntown/runs'); + return response.data['data'] as List; + } + + /// Joins a run by creating a participation. Returns the participation data. + Future> joinRun(Dio dio, String runId) async { + final response = await dio.post('/waydowntown/participations', data: { + 'data': { + 'type': 'participations', + 'relationships': { + 'run': { + 'data': {'type': 'runs', 'id': runId}, + }, + }, + }, + }); + return response.data['data'] as Map; + } + + /// Marks a participation as ready. + Future markReady(Dio dio, String participationId) async { + await dio.patch('/waydowntown/participations/$participationId', data: { + 'data': { + 'type': 'participations', + 'id': participationId, + 'attributes': { + 'ready': true, + }, + }, + }); + } + + /// Submits an answer for a run. Returns the submission data. + Future> submitAnswer( + Dio dio, + String runId, + String submission, + ) async { + final response = await dio.post('/waydowntown/submissions', data: { + 'data': { + 'type': 'submissions', + 'attributes': { + 'submission': submission, + }, + 'relationships': { + 'run': { + 'data': {'type': 'runs', 'id': runId}, + }, + }, + }, + }); + return response.data['data'] as Map; + } } diff --git a/waydowntown_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/waydowntown_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 15cada48..e3773d42 100644 --- a/waydowntown_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/waydowntown_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -26,6 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> json) { + final attributes = json['attributes']; + + return Proposee( + email: attributes['email'], + invited: attributes['invited'] ?? false, + registered: attributes['registered'] ?? false, + ); + } +} diff --git a/waydowntown_app/lib/models/team.dart b/waydowntown_app/lib/models/team.dart new file mode 100644 index 00000000..a5d3f02e --- /dev/null +++ b/waydowntown_app/lib/models/team.dart @@ -0,0 +1,48 @@ +import 'package:waydowntown/models/team_member.dart'; + +class Team { + final String id; + final String name; + final int? riskAversion; + final String? notes; + final List members; + + Team({ + required this.id, + required this.name, + this.riskAversion, + this.notes, + this.members = const [], + }); + + factory Team.fromJson(Map json, List included) { + final attributes = json['attributes']; + final relationships = json['relationships']; + + List members = []; + if (relationships != null && relationships['members'] != null) { + final membersData = relationships['members']['data'] as List?; + if (membersData != null) { + for (var memberRef in membersData) { + final memberJson = included.firstWhere( + (item) => + item['type'] == 'team-members' && + item['id'] == memberRef['id'], + orElse: () => {}, + ); + if (memberJson.isNotEmpty) { + members.add(TeamMember.fromJson(memberJson)); + } + } + } + } + + return Team( + id: json['id'], + name: attributes['name'], + riskAversion: attributes['risk_aversion'], + notes: attributes['notes'], + members: members, + ); + } +} diff --git a/waydowntown_app/lib/models/team_member.dart b/waydowntown_app/lib/models/team_member.dart new file mode 100644 index 00000000..3f42ef58 --- /dev/null +++ b/waydowntown_app/lib/models/team_member.dart @@ -0,0 +1,27 @@ +class TeamMember { + final String id; + final String email; + final String? name; + final int? riskAversion; + final String? proposedTeamName; + + TeamMember({ + required this.id, + required this.email, + this.name, + this.riskAversion, + this.proposedTeamName, + }); + + factory TeamMember.fromJson(Map json) { + final attributes = json['attributes']; + + return TeamMember( + id: json['id'], + email: attributes['email'], + name: attributes['name'], + riskAversion: attributes['risk_aversion'], + proposedTeamName: attributes['proposed_team_name'], + ); + } +} diff --git a/waydowntown_app/lib/models/team_negotiation.dart b/waydowntown_app/lib/models/team_negotiation.dart new file mode 100644 index 00000000..c6680d0d --- /dev/null +++ b/waydowntown_app/lib/models/team_negotiation.dart @@ -0,0 +1,135 @@ +import 'package:waydowntown/models/proposee.dart'; +import 'package:waydowntown/models/team.dart'; +import 'package:waydowntown/models/team_member.dart'; + +class TeamNegotiation { + final String id; + final String? teamEmails; + final String? proposedTeamName; + final int? riskAversion; + final bool isEmpty; + final bool onlyMutuals; + final Team? team; + final List mutuals; + final List proposers; + final List proposees; + final List invalids; + + TeamNegotiation({ + required this.id, + this.teamEmails, + this.proposedTeamName, + this.riskAversion, + required this.isEmpty, + required this.onlyMutuals, + this.team, + this.mutuals = const [], + this.proposers = const [], + this.proposees = const [], + this.invalids = const [], + }); + + factory TeamNegotiation.fromJson(Map apiResponse) { + final data = apiResponse['data']; + final attributes = data['attributes']; + final relationships = data['relationships']; + final List included = apiResponse['included'] ?? []; + + // Parse team if present + Team? team; + if (relationships != null && relationships['team'] != null) { + final teamData = relationships['team']['data']; + if (teamData != null) { + final teamJson = included.firstWhere( + (item) => item['type'] == 'teams' && item['id'] == teamData['id'], + orElse: () => {}, + ); + if (teamJson.isNotEmpty) { + team = Team.fromJson(teamJson, included); + } + } + } + + // Parse mutuals + List mutuals = _parseMembers(relationships, 'mutuals', included); + + // Parse proposers + List proposers = _parseMembers(relationships, 'proposers', included); + + // Parse proposees + List proposees = []; + if (relationships != null && relationships['proposees'] != null) { + final proposeesData = + relationships['proposees']['data'] as List?; + if (proposeesData != null) { + for (var proposeeRef in proposeesData) { + final proposeeJson = included.firstWhere( + (item) => + item['type'] == 'proposees' && item['id'] == proposeeRef['id'], + orElse: () => {}, + ); + if (proposeeJson.isNotEmpty) { + proposees.add(Proposee.fromJson(proposeeJson)); + } + } + } + } + + // Parse invalids + List invalids = []; + if (relationships != null && relationships['invalids'] != null) { + final invalidsData = relationships['invalids']['data'] as List?; + if (invalidsData != null) { + for (var invalidRef in invalidsData) { + final invalidJson = included.firstWhere( + (item) => + item['type'] == 'invalids' && item['id'] == invalidRef['id'], + orElse: () => {}, + ); + if (invalidJson.isNotEmpty) { + invalids.add(invalidJson['attributes']['value']); + } + } + } + } + + return TeamNegotiation( + id: data['id'], + teamEmails: attributes['team_emails'], + proposedTeamName: attributes['proposed_team_name'], + riskAversion: attributes['risk_aversion'], + isEmpty: attributes['empty'] ?? false, + onlyMutuals: attributes['only_mutuals'] ?? false, + team: team, + mutuals: mutuals, + proposers: proposers, + proposees: proposees, + invalids: invalids, + ); + } + + static List _parseMembers( + Map? relationships, + String key, + List included, + ) { + List members = []; + if (relationships != null && relationships[key] != null) { + final membersData = relationships[key]['data'] as List?; + if (membersData != null) { + for (var memberRef in membersData) { + final memberJson = included.firstWhere( + (item) => + item['type'] == 'team-members' && + item['id'] == memberRef['id'], + orElse: () => {}, + ); + if (memberJson.isNotEmpty) { + members.add(TeamMember.fromJson(memberJson)); + } + } + } + } + return members; + } +} diff --git a/waydowntown_app/lib/routes/team_negotiation_route.dart b/waydowntown_app/lib/routes/team_negotiation_route.dart new file mode 100644 index 00000000..f4ac2d41 --- /dev/null +++ b/waydowntown_app/lib/routes/team_negotiation_route.dart @@ -0,0 +1,185 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:waydowntown/app.dart'; +import 'package:waydowntown/models/team_negotiation.dart'; +import 'package:waydowntown/widgets/team_form_widget.dart'; +import 'package:waydowntown/widgets/team_status_widget.dart'; + +class TeamNegotiationRoute extends StatefulWidget { + final Dio dio; + + const TeamNegotiationRoute({super.key, required this.dio}); + + @override + State createState() => _TeamNegotiationRouteState(); +} + +class _TeamNegotiationRouteState extends State { + TeamNegotiation? _negotiation; + bool _isLoading = true; + String? _error; + final TextEditingController _teamEmailsController = TextEditingController(); + + @override + void initState() { + super.initState(); + _fetchTeamNegotiation(); + } + + @override + void dispose() { + _teamEmailsController.dispose(); + super.dispose(); + } + + void _addEmailToTeam(String email) { + final currentText = _teamEmailsController.text.trim(); + final emails = currentText.isEmpty ? [] : currentText.split(RegExp(r'\s+')); + if (!emails.contains(email)) { + emails.add(email); + _teamEmailsController.text = emails.join(' '); + } + } + + Future _fetchTeamNegotiation() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final response = await widget.dio.get('/waydowntown/team-negotiation'); + if (response.statusCode == 200) { + final negotiation = TeamNegotiation.fromJson(response.data); + _teamEmailsController.text = negotiation.teamEmails ?? ''; + setState(() { + _negotiation = negotiation; + _isLoading = false; + }); + } else { + throw Exception('Failed to load team negotiation data'); + } + } catch (e) { + talker.error('Error fetching team negotiation: $e'); + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Team'), + ), + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Error: $_error'), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _fetchTeamNegotiation, + child: const Text('Retry'), + ), + ], + ), + ); + } + + if (_negotiation == null) { + return const Center(child: Text('No data available')); + } + + // If user has an assigned team, show team details + if (_negotiation!.team != null) { + return _buildTeamDetails(); + } + + // Otherwise show negotiation UI + return RefreshIndicator( + onRefresh: _fetchTeamNegotiation, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TeamStatusWidget( + negotiation: _negotiation!, + onAddEmail: _addEmailToTeam, + ), + const SizedBox(height: 24), + TeamFormWidget( + dio: widget.dio, + negotiation: _negotiation!, + teamEmailsController: _teamEmailsController, + onSaved: _fetchTeamNegotiation, + ), + ], + ), + ), + ); + } + + Widget _buildTeamDetails() { + final team = _negotiation!.team!; + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Your Team', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + team.name, + style: Theme.of(context).textTheme.titleLarge, + ), + if (team.notes != null && team.notes!.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(team.notes!), + ], + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + Text( + 'Members', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + ...team.members.map((member) => ListTile( + leading: const Icon(Icons.person), + title: Text(member.name ?? member.email), + subtitle: + member.name != null ? Text(member.email) : null, + )), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/waydowntown_app/lib/widgets/session_widget.dart b/waydowntown_app/lib/widgets/session_widget.dart index d14ebfa8..6dbfca2b 100644 --- a/waydowntown_app/lib/widgets/session_widget.dart +++ b/waydowntown_app/lib/widgets/session_widget.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:waydowntown/app.dart'; +import 'package:waydowntown/routes/team_negotiation_route.dart'; import 'package:waydowntown/services/user_service.dart'; import 'package:waydowntown/tools/auth_form.dart'; import 'package:waydowntown/tools/my_specifications_table.dart'; @@ -239,6 +240,15 @@ class _SessionWidgetState extends State { ), child: const Text('My specifications'), ), + ElevatedButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => TeamNegotiationRoute(dio: widget.dio), + ), + ), + child: const Text('Team'), + ), ], ), ); diff --git a/waydowntown_app/lib/widgets/team_form_widget.dart b/waydowntown_app/lib/widgets/team_form_widget.dart new file mode 100644 index 00000000..19f039b2 --- /dev/null +++ b/waydowntown_app/lib/widgets/team_form_widget.dart @@ -0,0 +1,142 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:waydowntown/app.dart'; +import 'package:waydowntown/models/team_negotiation.dart'; +import 'package:waydowntown/services/user_service.dart'; + +class TeamFormWidget extends StatefulWidget { + final Dio dio; + final TeamNegotiation negotiation; + final TextEditingController teamEmailsController; + final VoidCallback onSaved; + + const TeamFormWidget({ + super.key, + required this.dio, + required this.negotiation, + required this.teamEmailsController, + required this.onSaved, + }); + + @override + State createState() => _TeamFormWidgetState(); +} + +class _TeamFormWidgetState extends State { + late TextEditingController _proposedTeamNameController; + bool _isSaving = false; + String? _error; + + @override + void initState() { + super.initState(); + _proposedTeamNameController = + TextEditingController(text: widget.negotiation.proposedTeamName ?? ''); + } + + @override + void dispose() { + _proposedTeamNameController.dispose(); + super.dispose(); + } + + Future _save() async { + setState(() { + _isSaving = true; + _error = null; + }); + + try { + final userId = await UserService.getUserId(); + final response = await widget.dio.post( + '/fixme/me', + data: { + 'data': { + 'type': 'users', + 'id': userId, + 'attributes': { + 'team_emails': widget.teamEmailsController.text, + 'proposed_team_name': _proposedTeamNameController.text, + } + } + }, + options: Options(headers: { + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json', + }), + ); + + if (response.statusCode == 200) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Team preferences saved')), + ); + widget.onSaved(); + } + } + } catch (e) { + talker.error('Error saving team preferences: $e'); + setState(() { + _error = 'Failed to save team preferences'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Team Preferences', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + TextFormField( + controller: widget.teamEmailsController, + decoration: const InputDecoration( + labelText: 'Team member emails', + helperText: 'Enter email addresses separated by spaces', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + const SizedBox(height: 16), + TextFormField( + controller: _proposedTeamNameController, + decoration: const InputDecoration( + labelText: 'Proposed team name', + border: OutlineInputBorder(), + ), + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isSaving ? null : _save, + child: _isSaving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save'), + ), + ), + ], + ); + } +} diff --git a/waydowntown_app/lib/widgets/team_status_widget.dart b/waydowntown_app/lib/widgets/team_status_widget.dart new file mode 100644 index 00000000..381e2bd0 --- /dev/null +++ b/waydowntown_app/lib/widgets/team_status_widget.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; +import 'package:waydowntown/models/team_negotiation.dart'; + +class TeamStatusWidget extends StatelessWidget { + final TeamNegotiation negotiation; + final void Function(String email)? onAddEmail; + + const TeamStatusWidget({ + super.key, + required this.negotiation, + this.onAddEmail, + }); + + @override + Widget build(BuildContext context) { + if (negotiation.isEmpty) { + return const Card( + child: Padding( + padding: EdgeInsets.all(16), + child: Text( + 'Enter email addresses below to propose team members.', + style: TextStyle(fontStyle: FontStyle.italic), + ), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Team Status', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + if (negotiation.mutuals.isNotEmpty) ...[ + _buildSection( + context, + title: 'Confirmed Team Members', + icon: Icons.check_circle, + iconColor: Colors.green, + items: negotiation.mutuals + .map((m) => _MemberDisplay( + name: m.name ?? m.email, + email: m.name != null ? m.email : null, + )) + .toList(), + ), + ], + if (negotiation.proposers.isNotEmpty) ...[ + _buildSection( + context, + title: 'Want to Team With You', + icon: Icons.person_add, + iconColor: Colors.blue, + items: negotiation.proposers + .map((m) => _MemberDisplay( + name: m.name ?? m.email, + email: m.name != null ? m.email : null, + tappableEmail: m.email, + )) + .toList(), + hint: onAddEmail != null + ? 'Tap to add their email to your team.' + : 'Add their email to your team emails to confirm the connection.', + onTap: onAddEmail, + ), + ], + if (negotiation.proposees.isNotEmpty) ...[ + _buildSection( + context, + title: 'Waiting for Confirmation', + icon: Icons.hourglass_empty, + iconColor: Colors.orange, + items: negotiation.proposees + .map((p) => _MemberDisplay( + name: p.email, + subtitle: p.invited + ? 'Invited' + : (p.registered ? 'Registered' : 'Not registered'), + )) + .toList(), + hint: 'These people need to add your email to their team.', + ), + ], + if (negotiation.invalids.isNotEmpty) ...[ + _buildSection( + context, + title: 'Invalid Entries', + icon: Icons.error, + iconColor: Colors.red, + items: negotiation.invalids + .map((i) => _MemberDisplay(name: i)) + .toList(), + hint: 'These entries are not valid email addresses.', + ), + ], + ], + ); + } + + Widget _buildSection( + BuildContext context, { + required String title, + required IconData icon, + required Color iconColor, + required List<_MemberDisplay> items, + String? hint, + void Function(String email)? onTap, + }) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: iconColor, size: 20), + const SizedBox(width: 8), + Text( + title, + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + if (hint != null) ...[ + const SizedBox(height: 4), + Text( + hint, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontStyle: FontStyle.italic, + color: Colors.grey[600], + ), + ), + ], + const SizedBox(height: 8), + ...items.map((item) { + final isTappable = onTap != null && item.tappableEmail != null; + final content = Row( + children: [ + Icon( + isTappable ? Icons.add_circle_outline : Icons.person, + size: 16, + color: isTappable ? Colors.blue : null, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + style: isTappable + ? const TextStyle(color: Colors.blue) + : null, + ), + if (item.email != null) + Text( + item.email!, + style: Theme.of(context).textTheme.bodySmall, + ), + if (item.subtitle != null) + Text( + item.subtitle!, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Colors.grey[600]), + ), + ], + ), + ), + ], + ); + + if (isTappable) { + return InkWell( + onTap: () => onTap(item.tappableEmail!), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: content, + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: content, + ); + }), + ], + ), + ), + ); + } +} + +class _MemberDisplay { + final String name; + final String? email; + final String? subtitle; + final String? tappableEmail; + + _MemberDisplay({ + required this.name, + this.email, + this.subtitle, + this.tappableEmail, + }); +}