From 5016d9193ef94598c52085273c5d2e75c21f5a87 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 4 Feb 2026 21:15:36 -0600 Subject: [PATCH 1/5] Add preliminary app team UI --- .../lib/registrations/waydowntown.ex | 6 + .../controllers/api/user_controller.ex | 15 +- .../team_negotiation_controller.ex | 25 +++ registrations/lib/registrations_web/router.ex | 2 + .../views/team_negotiation_view.ex | 110 +++++++++++ .../registrations_web/views/user_view_json.ex | 2 +- waydowntown_app/lib/models/proposee.dart | 21 +++ waydowntown_app/lib/models/team.dart | 48 +++++ waydowntown_app/lib/models/team_member.dart | 27 +++ .../lib/models/team_negotiation.dart | 135 ++++++++++++++ .../lib/routes/team_negotiation_route.dart | 167 +++++++++++++++++ .../lib/widgets/session_widget.dart | 10 + .../lib/widgets/team_form_widget.dart | 175 ++++++++++++++++++ .../lib/widgets/team_status_widget.dart | 173 +++++++++++++++++ 14 files changed, 914 insertions(+), 2 deletions(-) create mode 100644 registrations/lib/registrations_web/controllers/team_negotiation_controller.ex create mode 100644 registrations/lib/registrations_web/views/team_negotiation_view.ex create mode 100644 waydowntown_app/lib/models/proposee.dart create mode 100644 waydowntown_app/lib/models/team.dart create mode 100644 waydowntown_app/lib/models/team_member.dart create mode 100644 waydowntown_app/lib/models/team_negotiation.dart create mode 100644 waydowntown_app/lib/routes/team_negotiation_route.dart create mode 100644 waydowntown_app/lib/widgets/team_form_widget.dart create mode 100644 waydowntown_app/lib/widgets/team_status_widget.dart diff --git a/registrations/lib/registrations/waydowntown.ex b/registrations/lib/registrations/waydowntown.ex index 632f9c55..0dbb9eb8 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")) 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/router.ex b/registrations/lib/registrations_web/router.ex index 0c2877a9..ab585b2d 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/lib/models/proposee.dart b/waydowntown_app/lib/models/proposee.dart new file mode 100644 index 00000000..7be883a0 --- /dev/null +++ b/waydowntown_app/lib/models/proposee.dart @@ -0,0 +1,21 @@ +class Proposee { + final String email; + final bool invited; + final bool registered; + + Proposee({ + required this.email, + required this.invited, + required this.registered, + }); + + factory Proposee.fromJson(Map 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..bb16d667 --- /dev/null +++ b/waydowntown_app/lib/routes/team_negotiation_route.dart @@ -0,0 +1,167 @@ +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; + + @override + void initState() { + super.initState(); + _fetchTeamNegotiation(); + } + + Future _fetchTeamNegotiation() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final response = await widget.dio.get('/waydowntown/team-negotiation'); + if (response.statusCode == 200) { + setState(() { + _negotiation = TeamNegotiation.fromJson(response.data); + _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!), + const SizedBox(height: 24), + TeamFormWidget( + dio: widget.dio, + negotiation: _negotiation!, + 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!), + ], + if (team.riskAversion != null) ...[ + const SizedBox(height: 8), + Text('Risk aversion: ${team.riskAversion}'), + ], + 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..164e7c9b --- /dev/null +++ b/waydowntown_app/lib/widgets/team_form_widget.dart @@ -0,0 +1,175 @@ +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 VoidCallback onSaved; + + const TeamFormWidget({ + super.key, + required this.dio, + required this.negotiation, + required this.onSaved, + }); + + @override + State createState() => _TeamFormWidgetState(); +} + +class _TeamFormWidgetState extends State { + late TextEditingController _teamEmailsController; + late TextEditingController _proposedTeamNameController; + int? _riskAversion; + bool _isSaving = false; + String? _error; + + @override + void initState() { + super.initState(); + _teamEmailsController = + TextEditingController(text: widget.negotiation.teamEmails ?? ''); + _proposedTeamNameController = + TextEditingController(text: widget.negotiation.proposedTeamName ?? ''); + _riskAversion = widget.negotiation.riskAversion; + } + + @override + void dispose() { + _teamEmailsController.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': _teamEmailsController.text, + 'proposed_team_name': _proposedTeamNameController.text, + 'risk_aversion': _riskAversion, + } + } + }, + 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: _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(), + ), + ), + const SizedBox(height: 16), + Text( + 'Risk aversion', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + RadioListTile( + title: const Text('Low risk (1)'), + subtitle: + const Text('Prefer challenges that are quicker and easier'), + value: 1, + groupValue: _riskAversion, + onChanged: (value) => setState(() => _riskAversion = value), + ), + RadioListTile( + title: const Text('Medium risk (2)'), + subtitle: const Text('Balanced mix of challenge and accessibility'), + value: 2, + groupValue: _riskAversion, + onChanged: (value) => setState(() => _riskAversion = value), + ), + RadioListTile( + title: const Text('High risk (3)'), + subtitle: const Text('Prefer more challenging and involved tasks'), + value: 3, + groupValue: _riskAversion, + onChanged: (value) => setState(() => _riskAversion = value), + ), + 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..e2c145d7 --- /dev/null +++ b/waydowntown_app/lib/widgets/team_status_widget.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:waydowntown/models/team_negotiation.dart'; + +class TeamStatusWidget extends StatelessWidget { + final TeamNegotiation negotiation; + + const TeamStatusWidget({super.key, required this.negotiation}); + + @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, + )) + .toList(), + hint: + 'Add their email to your team emails to confirm the connection.', + ), + ], + 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, + }) { + 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) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + const Icon(Icons.person, size: 16), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.name), + 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]), + ), + ], + ), + ), + ], + ), + )), + ], + ), + ), + ); + } +} + +class _MemberDisplay { + final String name; + final String? email; + final String? subtitle; + + _MemberDisplay({required this.name, this.email, this.subtitle}); +} From 931ee159fce19f65005d248fb9f06535b782161b Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 4 Feb 2026 21:19:20 -0600 Subject: [PATCH 2/5] Remove risk aversion Does waydowntown have this concept? Unclear --- .../lib/routes/team_negotiation_route.dart | 4 --- .../lib/widgets/team_form_widget.dart | 31 ------------------- 2 files changed, 35 deletions(-) diff --git a/waydowntown_app/lib/routes/team_negotiation_route.dart b/waydowntown_app/lib/routes/team_negotiation_route.dart index bb16d667..f3fc8b4b 100644 --- a/waydowntown_app/lib/routes/team_negotiation_route.dart +++ b/waydowntown_app/lib/routes/team_negotiation_route.dart @@ -138,10 +138,6 @@ class _TeamNegotiationRouteState extends State { const SizedBox(height: 8), Text(team.notes!), ], - if (team.riskAversion != null) ...[ - const SizedBox(height: 8), - Text('Risk aversion: ${team.riskAversion}'), - ], const SizedBox(height: 16), const Divider(), const SizedBox(height: 8), diff --git a/waydowntown_app/lib/widgets/team_form_widget.dart b/waydowntown_app/lib/widgets/team_form_widget.dart index 164e7c9b..11030f78 100644 --- a/waydowntown_app/lib/widgets/team_form_widget.dart +++ b/waydowntown_app/lib/widgets/team_form_widget.dart @@ -23,7 +23,6 @@ class TeamFormWidget extends StatefulWidget { class _TeamFormWidgetState extends State { late TextEditingController _teamEmailsController; late TextEditingController _proposedTeamNameController; - int? _riskAversion; bool _isSaving = false; String? _error; @@ -34,7 +33,6 @@ class _TeamFormWidgetState extends State { TextEditingController(text: widget.negotiation.teamEmails ?? ''); _proposedTeamNameController = TextEditingController(text: widget.negotiation.proposedTeamName ?? ''); - _riskAversion = widget.negotiation.riskAversion; } @override @@ -61,7 +59,6 @@ class _TeamFormWidgetState extends State { 'attributes': { 'team_emails': _teamEmailsController.text, 'proposed_team_name': _proposedTeamNameController.text, - 'risk_aversion': _riskAversion, } } }, @@ -120,34 +117,6 @@ class _TeamFormWidgetState extends State { border: OutlineInputBorder(), ), ), - const SizedBox(height: 16), - Text( - 'Risk aversion', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - RadioListTile( - title: const Text('Low risk (1)'), - subtitle: - const Text('Prefer challenges that are quicker and easier'), - value: 1, - groupValue: _riskAversion, - onChanged: (value) => setState(() => _riskAversion = value), - ), - RadioListTile( - title: const Text('Medium risk (2)'), - subtitle: const Text('Balanced mix of challenge and accessibility'), - value: 2, - groupValue: _riskAversion, - onChanged: (value) => setState(() => _riskAversion = value), - ), - RadioListTile( - title: const Text('High risk (3)'), - subtitle: const Text('Prefer more challenging and involved tasks'), - value: 3, - groupValue: _riskAversion, - onChanged: (value) => setState(() => _riskAversion = value), - ), if (_error != null) ...[ const SizedBox(height: 8), Text( From f36c6c8646d04fb9f6ff637563bbc1214a8ef30f Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 4 Feb 2026 21:24:24 -0600 Subject: [PATCH 3/5] Add shortcut for accepting invitation --- .../lib/routes/team_negotiation_route.dart | 26 ++++- .../lib/widgets/team_form_widget.dart | 10 +- .../lib/widgets/team_status_widget.dart | 103 ++++++++++++------ 3 files changed, 99 insertions(+), 40 deletions(-) diff --git a/waydowntown_app/lib/routes/team_negotiation_route.dart b/waydowntown_app/lib/routes/team_negotiation_route.dart index f3fc8b4b..f4ac2d41 100644 --- a/waydowntown_app/lib/routes/team_negotiation_route.dart +++ b/waydowntown_app/lib/routes/team_negotiation_route.dart @@ -18,6 +18,7 @@ class _TeamNegotiationRouteState extends State { TeamNegotiation? _negotiation; bool _isLoading = true; String? _error; + final TextEditingController _teamEmailsController = TextEditingController(); @override void initState() { @@ -25,6 +26,21 @@ class _TeamNegotiationRouteState extends State { _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; @@ -34,8 +50,10 @@ class _TeamNegotiationRouteState extends State { 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 = TeamNegotiation.fromJson(response.data); + _negotiation = negotiation; _isLoading = false; }); } else { @@ -99,11 +117,15 @@ class _TeamNegotiationRouteState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - TeamStatusWidget(negotiation: _negotiation!), + TeamStatusWidget( + negotiation: _negotiation!, + onAddEmail: _addEmailToTeam, + ), const SizedBox(height: 24), TeamFormWidget( dio: widget.dio, negotiation: _negotiation!, + teamEmailsController: _teamEmailsController, onSaved: _fetchTeamNegotiation, ), ], diff --git a/waydowntown_app/lib/widgets/team_form_widget.dart b/waydowntown_app/lib/widgets/team_form_widget.dart index 11030f78..19f039b2 100644 --- a/waydowntown_app/lib/widgets/team_form_widget.dart +++ b/waydowntown_app/lib/widgets/team_form_widget.dart @@ -7,12 +7,14 @@ 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, }); @@ -21,7 +23,6 @@ class TeamFormWidget extends StatefulWidget { } class _TeamFormWidgetState extends State { - late TextEditingController _teamEmailsController; late TextEditingController _proposedTeamNameController; bool _isSaving = false; String? _error; @@ -29,15 +30,12 @@ class _TeamFormWidgetState extends State { @override void initState() { super.initState(); - _teamEmailsController = - TextEditingController(text: widget.negotiation.teamEmails ?? ''); _proposedTeamNameController = TextEditingController(text: widget.negotiation.proposedTeamName ?? ''); } @override void dispose() { - _teamEmailsController.dispose(); _proposedTeamNameController.dispose(); super.dispose(); } @@ -57,7 +55,7 @@ class _TeamFormWidgetState extends State { 'type': 'users', 'id': userId, 'attributes': { - 'team_emails': _teamEmailsController.text, + 'team_emails': widget.teamEmailsController.text, 'proposed_team_name': _proposedTeamNameController.text, } } @@ -101,7 +99,7 @@ class _TeamFormWidgetState extends State { ), const SizedBox(height: 16), TextFormField( - controller: _teamEmailsController, + controller: widget.teamEmailsController, decoration: const InputDecoration( labelText: 'Team member emails', helperText: 'Enter email addresses separated by spaces', diff --git a/waydowntown_app/lib/widgets/team_status_widget.dart b/waydowntown_app/lib/widgets/team_status_widget.dart index e2c145d7..381e2bd0 100644 --- a/waydowntown_app/lib/widgets/team_status_widget.dart +++ b/waydowntown_app/lib/widgets/team_status_widget.dart @@ -3,8 +3,13 @@ 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}); + const TeamStatusWidget({ + super.key, + required this.negotiation, + this.onAddEmail, + }); @override Widget build(BuildContext context) { @@ -52,10 +57,13 @@ class TeamStatusWidget extends StatelessWidget { .map((m) => _MemberDisplay( name: m.name ?? m.email, email: m.name != null ? m.email : null, + tappableEmail: m.email, )) .toList(), - hint: - 'Add their email to your team emails to confirm the connection.', + 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) ...[ @@ -98,6 +106,7 @@ class TeamStatusWidget extends StatelessWidget { required Color iconColor, required List<_MemberDisplay> items, String? hint, + void Function(String email)? onTap, }) { return Card( margin: const EdgeInsets.only(bottom: 12), @@ -127,36 +136,60 @@ class TeamStatusWidget extends StatelessWidget { ), ], const SizedBox(height: 8), - ...items.map((item) => Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - const Icon(Icons.person, size: 16), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(item.name), - 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]), - ), - ], + ...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, + ); + }), ], ), ), @@ -168,6 +201,12 @@ class _MemberDisplay { final String name; final String? email; final String? subtitle; + final String? tappableEmail; - _MemberDisplay({required this.name, this.email, this.subtitle}); + _MemberDisplay({ + required this.name, + this.email, + this.subtitle, + this.tappableEmail, + }); } From b1877cbbcb8853117940a08172a265507b01c52a Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 23 Feb 2026 21:58:39 -0700 Subject: [PATCH 4/5] Update iOS runner config --- .../ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme | 2 ++ 1 file changed, 2 insertions(+) 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"> Date: Mon, 23 Feb 2026 22:20:21 -0700 Subject: [PATCH 5/5] Add preliminary team test --- .../workflows/ci-waydowntown-full-stack.yml | 2 +- .../lib/registrations/waydowntown.ex | 2 +- .../controllers/test_controller.ex | 56 +++++++-- .../integration_test/team_game_test.dart | 114 ++++++++++++++++++ .../integration_test/test_backend_client.dart | 68 +++++++++++ 5 files changed, 230 insertions(+), 12 deletions(-) create mode 100644 waydowntown_app/integration_test/team_game_test.dart 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 0dbb9eb8..49532ada 100644 --- a/registrations/lib/registrations/waydowntown.ex +++ b/registrations/lib/registrations/waydowntown.ex @@ -372,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/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/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; + } }