diff --git a/.github/workflows/ci-waydowntown-app.yml b/.github/workflows/ci-waydowntown-app.yml index 28f2133f..00a4e81f 100644 --- a/.github/workflows/ci-waydowntown-app.yml +++ b/.github/workflows/ci-waydowntown-app.yml @@ -39,6 +39,11 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + - uses: subosito/flutter-action@v2 with: channel: "stable" diff --git a/.github/workflows/ci-waydowntown-full-stack.yml b/.github/workflows/ci-waydowntown-full-stack.yml new file mode 100644 index 00000000..80abc7e4 --- /dev/null +++ b/.github/workflows/ci-waydowntown-full-stack.yml @@ -0,0 +1,103 @@ +name: waydowntown full-stack tests + +on: + push: + branches: [main] + pull_request: + paths: + - "waydowntown_app/**" + - "registrations/**" + - ".github/workflows/ci-waydowntown-full-stack.yml" + +jobs: + waydowntown-full-stack-test: + runs-on: ubuntu-latest + services: + db: + image: postgis/postgis:16-3.4 + ports: ["5432:5432"] + env: + POSTGRES_PASSWORD: postgres + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + # Setup Elixir/Erlang + - name: Setup Erlang/OTP environment + uses: erlef/setup-beam@v1 + with: + version-file: .tool-versions + version-type: strict + + - name: Retrieve cached Elixir dependencies + uses: actions/cache@v4 + id: mix-cache + with: + path: | + registrations/deps + registrations/_build + key: ${{ runner.os }}-mix-${{ hashFiles('registrations/mix.lock') }} + + - name: Install Elixir dependencies + working-directory: registrations + run: | + mix local.rebar --force + mix local.hex --force + mix deps.get + mix deps.compile + + # Setup and start Phoenix + - name: Setup database + working-directory: registrations + run: MIX_ENV=test mix ecto.reset + + - name: Start Phoenix server + working-directory: registrations + run: | + MIX_ENV=test mix phx.server & + # Wait for server to be ready + for i in {1..30}; do + if curl -s http://localhost:4001/powapi/session > /dev/null 2>&1; then + echo "Phoenix server is ready" + break + fi + echo "Waiting for Phoenix server... ($i)" + sleep 1 + done + + # Setup Java 17 (required for AGP 8.x) + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + # Setup Flutter + - uses: subosito/flutter-action@v2 + with: + channel: "stable" + + - name: Create Flutter env file + run: touch .env.local + working-directory: waydowntown_app + + - name: Get Flutter dependencies + run: flutter pub get + working-directory: waydowntown_app + + # Run integration tests on Android emulator + - name: Run full-stack integration tests + uses: reactivecircus/android-emulator-runner@v2 + with: + working-directory: waydowntown_app + 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 + + - name: Publish test results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + check_name: "Full-stack integration test results" + files: waydowntown_app/test-results.json diff --git a/registrations/config/test.exs b/registrations/config/test.exs index 07e66577..cbedc831 100644 --- a/registrations/config/test.exs +++ b/registrations/config/test.exs @@ -26,5 +26,6 @@ config :registrations, Registrations.Repo, pool: Ecto.Adapters.SQL.Sandbox config :registrations, RegistrationsWeb.Endpoint, - http: [port: 4001], - server: true + http: [ip: {0, 0, 0, 0}, port: 4001], + server: true, + check_origin: false diff --git a/registrations/lib/registrations_web/controllers/test_controller.ex b/registrations/lib/registrations_web/controllers/test_controller.ex new file mode 100644 index 00000000..73f6494e --- /dev/null +++ b/registrations/lib/registrations_web/controllers/test_controller.ex @@ -0,0 +1,160 @@ +defmodule RegistrationsWeb.TestController do + @moduledoc """ + Controller for test-only endpoints. Only available in test environment. + Provides database reset functionality for integration testing. + """ + use RegistrationsWeb, :controller + + alias Registrations.Repo + alias Registrations.Waydowntown.Answer + alias Registrations.Waydowntown.Region + alias Registrations.Waydowntown.Specification + + @test_email "test@example.com" + @test_password "TestPassword1234" + + def reset(conn, params) do + # Truncate all waydowntown tables - CASCADE handles foreign key dependencies + Repo.query!("TRUNCATE waydowntown.reveals, waydowntown.submissions, waydowntown.participations, waydowntown.runs, waydowntown.answers, waydowntown.specifications, waydowntown.regions CASCADE") + + response = + if params["create_user"] == "true" do + user = create_or_reset_test_user() + + base_response = %{ + user_id: user.id, + email: @test_email, + password: @test_password + } + + # Optionally create game data based on concept parameter + case params["game"] do + "fill_in_the_blank" -> + game_data = create_fill_in_the_blank_game() + Map.merge(base_response, game_data) + + "string_collector" -> + game_data = create_string_collector_game() + Map.merge(base_response, game_data) + + "orientation_memory" -> + game_data = create_orientation_memory_game() + Map.merge(base_response, game_data) + + _ -> + base_response + end + else + %{message: "Database reset complete"} + end + + conn + |> put_status(:ok) + |> json(response) + end + + defp create_fill_in_the_blank_game do + region = Repo.insert!(%Region{name: "Test Region"}) + + specification = + Repo.insert!(%Specification{ + concept: "fill_in_the_blank", + task_description: "What is the answer to this test?", + region: region, + duration: 300 + }) + + # Insert answer separately (has_many relationship) + answer = + Repo.insert!(%Answer{ + label: "The answer is ____", + answer: "correct", + specification_id: specification.id + }) + + %{ + specification_id: specification.id, + answer_id: answer.id, + correct_answer: "correct" + } + end + + defp create_string_collector_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 + }) + + # Insert answers separately (has_many relationship) + 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}) + + %{ + specification_id: specification.id, + correct_answers: ["apple", "banana", "cherry"], + total_answers: 3, + answer_ids: [answer1.id, answer2.id, answer3.id] + } + end + + defp create_orientation_memory_game do + region = Repo.insert!(%Region{name: "Test Region"}) + + specification = + Repo.insert!(%Specification{ + concept: "orientation_memory", + task_description: "Remember the sequence of directions", + start_description: "Watch the pattern carefully", + region: region, + duration: 300 + }) + + # Insert ordered answers (order field is required for orientation_memory) + answer1 = Repo.insert!(%Answer{answer: "north", order: 1, specification_id: specification.id}) + answer2 = Repo.insert!(%Answer{answer: "east", order: 2, specification_id: specification.id}) + answer3 = Repo.insert!(%Answer{answer: "south", order: 3, specification_id: specification.id}) + + %{ + specification_id: specification.id, + ordered_answers: ["north", "east", "south"], + total_answers: 3, + answer_ids: [answer1.id, answer2.id, answer3.id] + } + end + + defp create_or_reset_test_user do + case Repo.get_by(RegistrationsWeb.User, email: @test_email) do + nil -> + %RegistrationsWeb.User{} + |> RegistrationsWeb.User.changeset(%{ + email: @test_email, + password: @test_password, + password_confirmation: @test_password + }) + |> Repo.insert!() + |> Ecto.Changeset.change(%{name: "Test User"}) + |> 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 + }) + |> Repo.insert!() + |> Ecto.Changeset.change(%{name: "Test User"}) + |> Repo.update!() + end + end +end diff --git a/registrations/lib/registrations_web/router.ex b/registrations/lib/registrations_web/router.ex index 0c2877a9..9af0946f 100644 --- a/registrations/lib/registrations_web/router.ex +++ b/registrations/lib/registrations_web/router.ex @@ -168,4 +168,12 @@ defmodule RegistrationsWeb.Router do get("/session", SessionController, :show) post("/me", ApiUserController, :update) end + + if Mix.env() == :test do + scope "/test", RegistrationsWeb do + pipe_through(:pow_api) + + post("/reset", TestController, :reset) + end + end end diff --git a/waydowntown_app/android/app/build.gradle b/waydowntown_app/android/app/build.gradle index 0887f662..b02b1fea 100644 --- a/waydowntown_app/android/app/build.gradle +++ b/waydowntown_app/android/app/build.gradle @@ -54,8 +54,12 @@ android { ndkVersion = flutter.ndkVersion compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" } defaultConfig { diff --git a/waydowntown_app/android/build.gradle b/waydowntown_app/android/build.gradle index d2ffbffa..ff31a160 100644 --- a/waydowntown_app/android/build.gradle +++ b/waydowntown_app/android/build.gradle @@ -3,6 +3,40 @@ allprojects { google() mavenCentral() } + + // Fix for AGP 8.x: auto-set namespace for library plugins + pluginManager.withPlugin("com.android.library") { + def androidExtension = project.extensions.getByType(com.android.build.gradle.LibraryExtension) + + // Set namespace from manifest if not specified + if (androidExtension.namespace == null) { + def manifestFile = project.file("src/main/AndroidManifest.xml") + if (manifestFile.exists()) { + def manifest = new XmlSlurper().parse(manifestFile) + def packageName = manifest.@package.toString() + if (packageName) { + androidExtension.namespace = packageName + } + } + } + } +} + +// Force Java 17 and Kotlin JVM target 17 for all subprojects AFTER they're evaluated +gradle.afterProject { project -> + if (project.plugins.hasPlugin("com.android.library")) { + project.android { + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + } + project.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions { + jvmTarget = "17" + } + } + } } rootProject.buildDir = "../build" diff --git a/waydowntown_app/android/gradle/wrapper/gradle-wrapper.properties b/waydowntown_app/android/gradle/wrapper/gradle-wrapper.properties index e1ca574e..3c85cfe0 100644 --- a/waydowntown_app/android/gradle/wrapper/gradle-wrapper.properties +++ b/waydowntown_app/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip diff --git a/waydowntown_app/android/settings.gradle b/waydowntown_app/android/settings.gradle index 4905f4ae..b58537ff 100644 --- a/waydowntown_app/android/settings.gradle +++ b/waydowntown_app/android/settings.gradle @@ -18,7 +18,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.3.0" apply false + id "com.android.application" version "8.6.0" apply false id "org.jetbrains.kotlin.android" version "2.0.20" apply false } diff --git a/waydowntown_app/integration_test/fill_in_the_blank_test.dart b/waydowntown_app/integration_test/fill_in_the_blank_test.dart new file mode 100644 index 00000000..df7f6569 --- /dev/null +++ b/waydowntown_app/integration_test/fill_in_the_blank_test.dart @@ -0,0 +1,85 @@ +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('fill_in_the_blank: play complete game through UI', + (tester) async { + final resetData = + await testClient.resetDatabase(game: 'fill_in_the_blank'); + final email = resetData['email'] as String; + final tokens = await testClient.login( + email, + resetData['password'] as String, + ); + await UserService.setTokens(tokens.accessToken, tokens.renewalToken); + + await tester.pumpWidget(const Waydowntown()); + + // Wait for session check to complete + await waitFor(tester, find.text(email)); + + // Scroll to and tap the game button on the home screen + final gameButton = find.text('Fill in the\nBlank'); + await waitFor(tester, gameButton); + await tester.ensureVisible(gameButton); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tap(gameButton); + + // Wait for run creation and RunLaunchRoute to appear + // WebSocket connection can be slow on CI emulators without hardware acceleration + await waitFor(tester, find.text('Fill in the Blank'), + timeout: const Duration(seconds: 120), + failOn: find.textContaining('Error')); + + // The ready button may be below the viewport on small screens. + // Use scrollUntilVisible to scroll the ListView until the button appears. + await tester.scrollUntilVisible( + find.textContaining('ready'), + 200.0, + scrollable: find.byType(Scrollable).last, + ); + + // Tap the ready button to start the game + await tester.tap(find.textContaining('ready')); + + // Wait for WebSocket countdown (~5s) and navigation to game widget. + // The answer label appears once we're in the game. + await waitFor( + tester, + find.text('The answer is ____'), + timeout: const Duration(seconds: 30), + ); + + // Submit a wrong answer + await tester.enterText(find.byType(TextFormField), 'wrong'); + await tester.tap(find.text('Submit')); + await waitFor(tester, find.text('Wrong')); + + // Submit the correct answer + await tester.enterText(find.byType(TextFormField), 'correct'); + await tester.tap(find.text('Submit')); + await waitFor(tester, find.textContaining('Congratulations')); + + // Let confetti animation timers fire while widget tree is still alive + await tester.pump(const Duration(seconds: 1)); + }); +} diff --git a/waydowntown_app/integration_test/helpers.dart b/waydowntown_app/integration_test/helpers.dart new file mode 100644 index 00000000..a26889dc --- /dev/null +++ b/waydowntown_app/integration_test/helpers.dart @@ -0,0 +1,30 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Pumps frames until [finder] matches at least one widget, or throws on timeout. +Future waitFor( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 15), + Finder? failOn, +}) async { + final end = DateTime.now().add(timeout); + while (DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 100)); + if (finder.evaluate().isNotEmpty) return; + if (failOn != null && failOn.evaluate().isNotEmpty) { + throw TestFailure( + 'Found fail-on widget while waiting for $finder: $failOn'); + } + } + // Dump all visible text on timeout for diagnostics + final texts = []; + for (final element in find.byType(Text).evaluate()) { + final widget = element.widget as Text; + if (widget.data != null) texts.add(widget.data!); + } + print('waitFor TIMEOUT: all visible texts = $texts'); + throw TimeoutException('Timed out waiting for $finder', timeout); +} diff --git a/waydowntown_app/integration_test/string_collector_test.dart b/waydowntown_app/integration_test/string_collector_test.dart new file mode 100644 index 00000000..0630c1a1 --- /dev/null +++ b/waydowntown_app/integration_test/string_collector_test.dart @@ -0,0 +1,88 @@ +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('string_collector: collect all items to win', (tester) async { + final resetData = + await testClient.resetDatabase(game: 'string_collector'); + final email = resetData['email'] as String; + final tokens = await testClient.login( + email, + resetData['password'] as String, + ); + await UserService.setTokens(tokens.accessToken, tokens.renewalToken); + + await tester.pumpWidget(const Waydowntown()); + await waitFor(tester, find.text(email)); + + // Scroll to and tap the game 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); + + // Wait for run creation and RunLaunchRoute to appear + // WebSocket connection can be slow on CI emulators without hardware acceleration + await waitFor(tester, find.text('String Collector'), + timeout: const Duration(seconds: 120), + failOn: find.textContaining('Error')); + + // The ready button may be below the viewport on small screens due to + // Instructions, Players, Starting point, Goal, Duration, and Location cards. + // Use scrollUntilVisible to scroll the ListView until the button appears. + await tester.scrollUntilVisible( + find.textContaining('ready'), + 200.0, + scrollable: find.byType(Scrollable).last, + ); + await tester.tap(find.textContaining('ready')); + + // Wait for countdown and navigation to game + await waitFor( + tester, + find.text('Enter a string!'), + timeout: const Duration(seconds: 30), + ); + + // Verify initial progress shows 0/3 + expect(find.text('0/3'), findsOneWidget); + + // Submit first item + await tester.enterText(find.byType(TextField), 'apple'); + await tester.tap(find.text('Submit')); + await waitFor(tester, find.text('1/3')); + + // Submit second item + await tester.enterText(find.byType(TextField), 'banana'); + await tester.tap(find.text('Submit')); + await waitFor(tester, find.text('2/3')); + + // Submit third item - should win + await tester.enterText(find.byType(TextField), 'cherry'); + await tester.tap(find.text('Submit')); + await waitFor(tester, find.textContaining('Congratulations')); + + // Let confetti animation timers fire while widget tree is still alive + 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 new file mode 100644 index 00000000..ccc4bc9c --- /dev/null +++ b/waydowntown_app/integration_test/test_backend_client.dart @@ -0,0 +1,41 @@ +import 'package:dio/dio.dart'; + +import 'test_config.dart'; + +/// HTTP client for test-only backend endpoints. +class TestBackendClient { + final Dio _dio; + + TestBackendClient() + : _dio = Dio(BaseOptions( + baseUrl: TestConfig.apiBaseUrl, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + )); + + /// Resets the database, creates a test user, and optionally seeds game data. + Future> resetDatabase({String? game}) async { + final response = await _dio.post('/test/reset', data: { + 'create_user': 'true', + if (game != null) 'game': game, + }); + return response.data as Map; + } + + /// Logs in and returns access_token and renewal_token. + Future<({String accessToken, String renewalToken})> login( + String email, + String password, + ) async { + final response = await _dio.post('/powapi/session', data: { + 'user': {'email': email, 'password': password}, + }); + final data = response.data['data']; + return ( + accessToken: data['access_token'] as String, + renewalToken: data['renewal_token'] as String, + ); + } +} diff --git a/waydowntown_app/integration_test/test_config.dart b/waydowntown_app/integration_test/test_config.dart new file mode 100644 index 00000000..a4eab1f4 --- /dev/null +++ b/waydowntown_app/integration_test/test_config.dart @@ -0,0 +1,10 @@ +/// Configuration for integration tests that run against a real backend. +class TestConfig { + /// API base URL for the test backend. + /// Can be overridden via --dart-define=API_BASE_URL= + /// Default: http://localhost:4001 + static const String apiBaseUrl = String.fromEnvironment( + 'API_BASE_URL', + defaultValue: 'http://localhost:4001', + ); +} diff --git a/waydowntown_app/integration_test/token_refresh_test.dart b/waydowntown_app/integration_test/token_refresh_test.dart new file mode 100644 index 00000000..63248571 --- /dev/null +++ b/waydowntown_app/integration_test/token_refresh_test.dart @@ -0,0 +1,41 @@ +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('refreshes expired token and shows user email', (tester) async { + final resetData = await testClient.resetDatabase(); + final email = resetData['email'] as String; + final tokens = await testClient.login( + email, + resetData['password'] as String, + ); + + // Store an invalid access token with a valid renewal token. + // When SessionWidget checks the session, it will get a 401, + // the RefreshTokenInterceptor will use the renewal token to get + // a fresh access token, and retry the request. + await UserService.setTokens('expired_invalid_token', tokens.renewalToken); + + await tester.pumpWidget(const Waydowntown()); + + await waitFor(tester, find.text(email)); + }); +} diff --git a/waydowntown_app/ios/Podfile.lock b/waydowntown_app/ios/Podfile.lock index 613c1f10..c76f0fbd 100644 --- a/waydowntown_app/ios/Podfile.lock +++ b/waydowntown_app/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - dchs_motion_sensors (0.0.1): + - Flutter - Flutter (1.0.0) - flutter_blue_plus (0.0.1): - Flutter @@ -59,8 +61,6 @@ PODS: - mobile_scanner (5.2.3): - Flutter - GoogleMLKit/BarcodeScanning (~> 6.0.0) - - motion_sensors (0.0.1): - - Flutter - nanopb (2.30910.0): - nanopb/decode (= 2.30910.0) - nanopb/encode (= 2.30910.0) @@ -99,6 +99,7 @@ PODS: - sqlite3/rtree DEPENDENCIES: + - dchs_motion_sensors (from `.symlinks/plugins/dchs_motion_sensors/ios`) - Flutter (from `Flutter`) - flutter_blue_plus (from `.symlinks/plugins/flutter_blue_plus/ios`) - flutter_compass (from `.symlinks/plugins/flutter_compass/ios`) @@ -107,7 +108,6 @@ DEPENDENCIES: - geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`) - - motion_sensors (from `.symlinks/plugins/motion_sensors/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`) @@ -132,6 +132,8 @@ SPEC REPOS: - sqlite3 EXTERNAL SOURCES: + dchs_motion_sensors: + :path: ".symlinks/plugins/dchs_motion_sensors/ios" Flutter: :path: Flutter flutter_blue_plus: @@ -148,8 +150,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/integration_test/ios" mobile_scanner: :path: ".symlinks/plugins/mobile_scanner/ios" - motion_sensors: - :path: ".symlinks/plugins/motion_sensors/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: @@ -162,6 +162,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/sqlite3_flutter_libs/ios" SPEC CHECKSUMS: + dchs_motion_sensors: 9cef816635a39345cda9f0c4943e061f6429f453 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_blue_plus: 4837da7d00cf5d441fdd6635b3a57f936778ea96 flutter_compass: cbbd285cea1584c7ac9c4e0c3e1f17cbea55e855 @@ -180,7 +181,6 @@ SPEC CHECKSUMS: MLKitCommon: afec63980417d29ffbb4790529a1b0a2291699e1 MLKitVision: e858c5f125ecc288e4a31127928301eaba9ae0c1 mobile_scanner: 96e91f2e1fb396bb7df8da40429ba8dfad664740 - motion_sensors: 03f55b7c637a7e365a0b5f9697a449f9059d5d91 nanopb: 438bc412db1928dac798aa6fd75726007be04262 package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 diff --git a/waydowntown_app/lib/games/cardinal_memory.dart b/waydowntown_app/lib/games/cardinal_memory.dart index e71850b7..6c7f65f0 100644 --- a/waydowntown_app/lib/games/cardinal_memory.dart +++ b/waydowntown_app/lib/games/cardinal_memory.dart @@ -3,7 +3,7 @@ import 'dart:math' as math; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; -import 'package:motion_sensors/motion_sensors.dart'; +import 'package:dchs_motion_sensors/dchs_motion_sensors.dart'; import 'package:phoenix_socket/phoenix_socket.dart'; import 'package:waydowntown/mixins/run_state_mixin.dart'; import 'package:waydowntown/models/run.dart'; diff --git a/waydowntown_app/lib/games/orientation_memory.dart b/waydowntown_app/lib/games/orientation_memory.dart index 6795e10e..d3dd69ec 100644 --- a/waydowntown_app/lib/games/orientation_memory.dart +++ b/waydowntown_app/lib/games/orientation_memory.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; -import 'package:motion_sensors/motion_sensors.dart'; +import 'package:dchs_motion_sensors/dchs_motion_sensors.dart'; import 'package:phoenix_socket/phoenix_socket.dart'; import 'package:waydowntown/mixins/run_state_mixin.dart'; import 'package:waydowntown/models/run.dart'; diff --git a/waydowntown_app/lib/routes/run_launch_route.dart b/waydowntown_app/lib/routes/run_launch_route.dart index d5d62fca..c2bbe993 100644 --- a/waydowntown_app/lib/routes/run_launch_route.dart +++ b/waydowntown_app/lib/routes/run_launch_route.dart @@ -44,6 +44,7 @@ class _RunLaunchRouteState extends State { DateTime? startTime; Timer? countdownTimer; late Future connectionFuture; + Future>? _gameInfoFuture; String? _currentUserId; List get opponents { @@ -80,9 +81,24 @@ class _RunLaunchRouteState extends State { connectionFuture = _initializeConnection(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _gameInfoFuture ??= _loadGameInfo(context); + } + Future _initializeConnection() async { _currentUserId = await UserService.getUserId(); - await _connectToSocket(); + // Retry connection up to 3 times to handle transient failures + for (var attempt = 1; attempt <= 3; attempt++) { + try { + await _connectToSocket(); + return; + } catch (e) { + if (attempt == 3) rethrow; + await Future.delayed(Duration(seconds: attempt * 2)); + } + } } Future _connectToSocket() async { @@ -96,10 +112,16 @@ class _RunLaunchRouteState extends State { PhoenixSocket('$apiRoot/socket/websocket', socketOptions: socketOptions); - await socket!.connect(); + await socket!.connect().timeout( + const Duration(seconds: 15), + onTimeout: () => throw TimeoutException('WebSocket connection timed out'), + ); channel = socket!.addChannel(topic: 'run:${widget.run.id}'); - await channel!.join().future; + await channel!.join().future.timeout( + const Duration(seconds: 15), + onTimeout: () => throw TimeoutException('Channel join timed out'), + ); channel!.messages.listen((message) { if (message.event == const PhoenixChannelEvent.custom('run_update')) { @@ -185,7 +207,7 @@ class _RunLaunchRouteState extends State { } return FutureBuilder>( - future: _loadGameInfo(context), + future: _gameInfoFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Scaffold( diff --git a/waydowntown_app/lib/tools/motion_sensors_route.dart b/waydowntown_app/lib/tools/motion_sensors_route.dart index 74c69de3..4eff3729 100644 --- a/waydowntown_app/lib/tools/motion_sensors_route.dart +++ b/waydowntown_app/lib/tools/motion_sensors_route.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:motion_sensors/motion_sensors.dart'; +import 'package:dchs_motion_sensors/dchs_motion_sensors.dart'; import 'package:vector_math/vector_math_64.dart' hide Colors; void main() { diff --git a/waydowntown_app/pubspec.lock b/waydowntown_app/pubspec.lock index 6e5e1dbf..b14e6f73 100644 --- a/waydowntown_app/pubspec.lock +++ b/waydowntown_app/pubspec.lock @@ -225,6 +225,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.6" + dchs_motion_sensors: + dependency: "direct main" + description: + name: dchs_motion_sensors + sha256: "2a6eec0c47fd59d0f923aa758286ded0d74ae79519dec560d003e6d0797877e2" + url: "https://pub.dev" + source: hosted + version: "2.0.1" dio: dependency: "direct main" description: @@ -759,15 +767,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.4.4" - motion_sensors: - dependency: "direct main" - description: - path: "." - ref: "6dafc3639b3e96460fabc639768a60b431b53610" - resolved-ref: "6dafc3639b3e96460fabc639768a60b431b53610" - url: "https://github.com/zesage/motion_sensors.git" - source: git - version: "0.1.0" nested: dependency: transitive description: diff --git a/waydowntown_app/pubspec.yaml b/waydowntown_app/pubspec.yaml index 2894237a..0cd37757 100644 --- a/waydowntown_app/pubspec.yaml +++ b/waydowntown_app/pubspec.yaml @@ -47,10 +47,7 @@ dependencies: mobile_scanner: ^5.1.1 latlong2: ^0.9.1 flutter_native_splash: ^2.4.1 - motion_sensors: - git: - url: https://github.com/zesage/motion_sensors.git - ref: 6dafc3639b3e96460fabc639768a60b431b53610 + dchs_motion_sensors: ^2.0.1 vector_math: ^2.1.4 yaml: ^3.1.2 flutter_confetti: ^0.3.0 diff --git a/waydowntown_app/test/games/cardinal_memory_test.dart b/waydowntown_app/test/games/cardinal_memory_test.dart index 612aea20..dd7d8af1 100644 --- a/waydowntown_app/test/games/cardinal_memory_test.dart +++ b/waydowntown_app/test/games/cardinal_memory_test.dart @@ -8,7 +8,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http_mock_adapter/http_mock_adapter.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; -import 'package:motion_sensors/motion_sensors.dart'; +import 'package:dchs_motion_sensors/dchs_motion_sensors.dart'; import 'package:pretty_dio_logger/pretty_dio_logger.dart'; import 'package:waydowntown/games/cardinal_memory.dart'; import 'package:waydowntown/models/answer.dart'; diff --git a/waydowntown_app/test/games/cardinal_memory_test.mocks.dart b/waydowntown_app/test/games/cardinal_memory_test.mocks.dart index 7c8644c6..d3ebaa21 100644 --- a/waydowntown_app/test/games/cardinal_memory_test.mocks.dart +++ b/waydowntown_app/test/games/cardinal_memory_test.mocks.dart @@ -5,9 +5,9 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i5; +import 'package:dchs_motion_sensors/dchs_motion_sensors.dart' as _i4; import 'package:flutter/services.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; -import 'package:motion_sensors/motion_sensors.dart' as _i4; import 'package:vector_math/vector_math_64.dart' as _i3; // ignore_for_file: type=lint diff --git a/waydowntown_app/test/games/orientation_memory_test.dart b/waydowntown_app/test/games/orientation_memory_test.dart index b237ccb6..d05e71a7 100644 --- a/waydowntown_app/test/games/orientation_memory_test.dart +++ b/waydowntown_app/test/games/orientation_memory_test.dart @@ -8,7 +8,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http_mock_adapter/http_mock_adapter.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; -import 'package:motion_sensors/motion_sensors.dart'; +import 'package:dchs_motion_sensors/dchs_motion_sensors.dart'; import 'package:pretty_dio_logger/pretty_dio_logger.dart'; import 'package:waydowntown/games/orientation_memory.dart'; import 'package:waydowntown/models/answer.dart'; diff --git a/waydowntown_app/test/games/orientation_memory_test.mocks.dart b/waydowntown_app/test/games/orientation_memory_test.mocks.dart index 532f007c..3a333ac4 100644 --- a/waydowntown_app/test/games/orientation_memory_test.mocks.dart +++ b/waydowntown_app/test/games/orientation_memory_test.mocks.dart @@ -5,9 +5,9 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i5; +import 'package:dchs_motion_sensors/dchs_motion_sensors.dart' as _i4; import 'package:flutter/services.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; -import 'package:motion_sensors/motion_sensors.dart' as _i4; import 'package:vector_math/vector_math_64.dart' as _i3; // ignore_for_file: type=lint