diff --git a/registrations/lib/registrations/waydowntown.ex b/registrations/lib/registrations/waydowntown.ex index 3bc5896b..331ffa5d 100644 --- a/registrations/lib/registrations/waydowntown.ex +++ b/registrations/lib/registrations/waydowntown.ex @@ -91,6 +91,7 @@ defmodule Registrations.Waydowntown do |> Repo.preload(run_preloads()) |> Repo.preload(participations: [run: run_preloads()]) |> Repo.preload([participations: [:user]], prefix: "public") + |> Repo.preload([submissions: [:creator]], prefix: "public") end def create_run(current_user, attrs \\ %{}, specification_filter \\ nil) do @@ -312,7 +313,12 @@ defmodule Registrations.Waydowntown do |> Repo.update() end - def get_submission!(id), do: Submission |> Repo.get!(id) |> Repo.preload(submission_preloads()) + def get_submission!(id), + do: + Submission + |> Repo.get!(id) + |> Repo.preload(submission_preloads()) + |> Repo.preload([:creator, run: [submissions: [:creator]]], prefix: "public") def create_submission(conn, %{"submission" => submission_text, "run_id" => run_id} = params) do current_user_id = conn.assigns[:current_user].id diff --git a/registrations/lib/registrations_web/views/run_view.ex b/registrations/lib/registrations_web/views/run_view.ex index df8fa517..e43b6143 100644 --- a/registrations/lib/registrations_web/views/run_view.ex +++ b/registrations/lib/registrations_web/views/run_view.ex @@ -4,7 +4,7 @@ defmodule RegistrationsWeb.RunView do alias Registrations.Waydowntown def fields do - [:complete, :correct_submissions, :total_answers, :task_description, :started_at, :competitors] + [:complete, :correct_submissions, :total_answers, :task_description, :started_at, :competitors, :winner_submission_id] end def hidden(run) do diff --git a/registrations/lib/registrations_web/views/submission_view.ex b/registrations/lib/registrations_web/views/submission_view.ex index 953d8965..6c48fccf 100644 --- a/registrations/lib/registrations_web/views/submission_view.ex +++ b/registrations/lib/registrations_web/views/submission_view.ex @@ -6,6 +6,10 @@ defmodule RegistrationsWeb.SubmissionView do end def relationships do - [answer: {RegistrationsWeb.AnswerView, :include}, run: {RegistrationsWeb.RunView, :include}] + [ + answer: {RegistrationsWeb.AnswerView, :include}, + run: {RegistrationsWeb.RunView, :include}, + creator: {RegistrationsWeb.JSONAPI.UserView, :include} + ] end end diff --git a/registrations/test/registrations_web/controllers/submission_controller_test.exs b/registrations/test/registrations_web/controllers/submission_controller_test.exs index 9c904e48..6a4227cc 100644 --- a/registrations/test/registrations_web/controllers/submission_controller_test.exs +++ b/registrations/test/registrations_web/controllers/submission_controller_test.exs @@ -104,10 +104,10 @@ defmodule RegistrationsWeb.SubmissionControllerTest do } end - test "creates correct submission", %{conn: conn, run: run, answer: answer} do + test "creates correct submission", %{conn: conn, user: user, run: run, answer: answer} do conn = conn - |> setup_conn() + |> setup_conn(user) |> post( Routes.submission_path(conn, :create), %{ @@ -147,6 +147,9 @@ defmodule RegistrationsWeb.SubmissionControllerTest do sideloaded_answer = Enum.find(included, &(&1["type"] == "answers")) assert sideloaded_answer["id"] == answer.id + sideloaded_user = Enum.find(included, &(&1["type"] == "users")) + assert sideloaded_user["id"] == user.id + assert_broadcast "run_update", payload assert payload.data.type == "runs" diff --git a/waydowntown_app/lib/mixins/run_state_mixin.dart b/waydowntown_app/lib/mixins/run_state_mixin.dart index 9a68a86e..f0dda8f7 100644 --- a/waydowntown_app/lib/mixins/run_state_mixin.dart +++ b/waydowntown_app/lib/mixins/run_state_mixin.dart @@ -3,11 +3,20 @@ import 'package:flutter/material.dart'; import 'package:phoenix_socket/phoenix_socket.dart'; import 'package:waydowntown/app.dart'; import 'package:waydowntown/models/run.dart'; -import 'package:waydowntown/widgets/completion_animation.dart'; +import 'package:waydowntown/models/submission.dart'; +import 'package:waydowntown/services/user_service.dart'; +import 'package:waydowntown/widgets/losing_animation.dart'; +import 'package:waydowntown/widgets/winning_animation.dart'; + +enum GameState { + inProgress, + won, + lost, +} mixin RunStateMixin on State { late Run currentRun; - bool isGameComplete = false; + GameState gameState = GameState.inProgress; Dio get dio; Run get initialRun; @@ -20,8 +29,14 @@ mixin RunStateMixin on State { currentRun = initialRun; } + bool get isGameComplete => gameState != GameState.inProgress; + void _showCompletionAnimation() { - CompletionAnimation.show(context); + if (gameState == GameState.won) { + WinningAnimation.show(context); + } else { + LosingAnimation.show(context); + } } Future submitSubmission(String submission, {String? answerId}) async { @@ -60,12 +75,9 @@ mixin RunStateMixin on State { currentRun = Run.fromJson( {'data': runData, 'included': response.data['included']}, existingSpecification: currentRun.specification); - - if (currentRun.isComplete) { - isGameComplete = true; - _showCompletionAnimation(); - } }); + + await checkForCompletion(); } } @@ -76,23 +88,35 @@ mixin RunStateMixin on State { } } - void initializeChannel(PhoenixChannel gameChannel) { + void initializeChannel(PhoenixChannel gameChannel) async { channel = gameChannel; - channel!.messages.listen((message) { + channel!.messages.listen((message) async { if (message.event == const PhoenixChannelEvent.custom('run_update')) { setState(() { currentRun = Run.fromJson(message.payload!, existingSpecification: currentRun.specification); - - if (currentRun.isComplete && !isGameComplete) { - isGameComplete = true; - _showCompletionAnimation(); - } }); + + await checkForCompletion(); } }); } + Future checkForCompletion() async { + if (currentRun.isComplete && + gameState == GameState.inProgress && + currentRun.winnerSubmissionId != null) { + final String? winnerSubmissionId = currentRun.winnerSubmissionId; + final Submission winningSubmission = currentRun.submissions + .firstWhere((submission) => submission.id == winnerSubmissionId); + final bool isUserSubmission = + winningSubmission.creatorId == await UserService.getUserId(); + + gameState = isUserSubmission ? GameState.won : GameState.lost; + _showCompletionAnimation(); + } + } + @override void dispose() { channel?.leave(); diff --git a/waydowntown_app/lib/models/run.dart b/waydowntown_app/lib/models/run.dart index 80d70729..f3a2ced8 100644 --- a/waydowntown_app/lib/models/run.dart +++ b/waydowntown_app/lib/models/run.dart @@ -1,5 +1,6 @@ import 'package:waydowntown/models/participation.dart'; import 'package:waydowntown/models/specification.dart'; +import 'package:waydowntown/models/submission.dart'; class Run { final String id; @@ -10,6 +11,8 @@ class Run { final String? taskDescription; final bool isComplete; final List participations; + final String? winnerSubmissionId; + final List submissions; Run({ required this.id, @@ -20,6 +23,8 @@ class Run { this.taskDescription, this.isComplete = false, required this.participations, + this.winnerSubmissionId, + required this.submissions, }); factory Run.fromJson(Map json, @@ -71,6 +76,25 @@ class Run { .toList(); } + List submissions = []; + if (included != null && + data['relationships'] != null && + data['relationships']['submissions'] != null) { + final submissionsData = + data['relationships']['submissions']['data'] as List; + + submissions = submissionsData + .map((submissionData) => Submission.fromJson(included.firstWhere( + (item) => + item['type'] == 'submissions' && + item['id'] == submissionData['id'] && + // FIXME serialisation crisis + item['relationships']['creator'] != null, + orElse: () => {}, + ))) + .toList(); + } + return Run( id: data['id'], specification: specification ?? @@ -83,6 +107,8 @@ class Run { : null, isComplete: data['attributes']['complete'] ?? false, participations: participations, + submissions: submissions, + winnerSubmissionId: data['attributes']['winner_submission_id'], ); } } diff --git a/waydowntown_app/lib/models/submission.dart b/waydowntown_app/lib/models/submission.dart new file mode 100644 index 00000000..e483ee69 --- /dev/null +++ b/waydowntown_app/lib/models/submission.dart @@ -0,0 +1,27 @@ +class Submission { + final String id; + final String submission; + final bool correct; + final DateTime insertedAt; + final String? creatorId; + + Submission({ + required this.id, + required this.submission, + required this.correct, + required this.insertedAt, + this.creatorId, + }); + + factory Submission.fromJson(Map json) { + print('submission json'); + print(json); + return Submission( + id: json['id'], + submission: json['attributes']['submission'], + correct: json['attributes']['correct'], + insertedAt: DateTime.parse(json['attributes']['inserted_at']), + creatorId: json['relationships']['creator']['data']['id'], + ); + } +} diff --git a/waydowntown_app/lib/widgets/losing_animation.dart b/waydowntown_app/lib/widgets/losing_animation.dart new file mode 100644 index 00000000..6bdc7f08 --- /dev/null +++ b/waydowntown_app/lib/widgets/losing_animation.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; + +class LosingAnimation extends StatefulWidget { + const LosingAnimation({super.key}); + + static void show(BuildContext context) { + showDialog( + context: context, + barrierDismissible: true, + barrierColor: Colors.transparent, + builder: (context) => const LosingAnimation(), + ); + } + + @override + State createState() => _LosingAnimationState(); +} + +class _LosingAnimationState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 1000), + vsync: this, + ); + + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + )); + + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _fadeAnimation, + child: Container( + color: Colors.black, + child: Stack( + children: [ + const Center( + child: Text( + 'You lost!', + style: TextStyle( + color: Colors.white, + fontSize: 48, + fontWeight: FontWeight.bold, + ), + ), + ), + Positioned( + top: 40, + right: 16, + child: IconButton( + icon: const Icon( + Icons.close, + color: Colors.white, + size: 32, + ), + onPressed: () => Navigator.of(context).pop(), + ), + ), + ], + ), + ), + ); + } +} diff --git a/waydowntown_app/lib/widgets/completion_animation.dart b/waydowntown_app/lib/widgets/winning_animation.dart similarity index 97% rename from waydowntown_app/lib/widgets/completion_animation.dart rename to waydowntown_app/lib/widgets/winning_animation.dart index 0db0c0bd..5acb06dd 100644 --- a/waydowntown_app/lib/widgets/completion_animation.dart +++ b/waydowntown_app/lib/widgets/winning_animation.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_confetti/flutter_confetti.dart'; -class CompletionAnimation { +class WinningAnimation { static void show(BuildContext context) { const options = ConfettiOptions( spread: 360,