diff --git a/.gitignore b/.gitignore index f9e186c..8b3e939 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,5 @@ yarn.lock !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages /graalvm_test/build /graalvm_test_native_interop/build +crossword_companion/firebase.json +crossword_companion/lib/firebase_options.dart diff --git a/crossword_companion/.gitignore b/crossword_companion/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/crossword_companion/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/crossword_companion/.metadata b/crossword_companion/.metadata new file mode 100644 index 0000000..b9fd747 --- /dev/null +++ b/crossword_companion/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + - platform: ios + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/crossword_companion/GEMINI.md b/crossword_companion/GEMINI.md new file mode 100644 index 0000000..2dcce8f --- /dev/null +++ b/crossword_companion/GEMINI.md @@ -0,0 +1,250 @@ +# Gemini Code-Gen Best Practices for This Project + +This document outlines the best practices and coding standards to be followed +during the development of this Flutter project. Adhering to these guidelines +will ensure the codebase is clean, maintainable, and scalable. + +## Architectural Principles + +- **DRY (Don’t Repeat Yourself)** – eliminate duplicated logic by extracting + shared utilities and modules. +- **Separation of Concerns** – each module should handle one distinct + responsibility. +- **Single Responsibility Principle (SRP)** – every class/module/function/file + should have exactly one reason to change. +- **Clear Abstractions & Contracts** – expose intent through small, stable + interfaces and hide implementation details. +- **Low Coupling, High Cohesion** – keep modules self-contained, minimize + cross-dependencies. +- **Scalability & Statelessness** – design components to scale horizontally and + prefer stateless services when possible. +- **Observability & Testability** – build in logging, metrics, tracing, and + ensure components can be unit/integration tested. +- **KISS (Keep It Simple, Sir)** - keep solutions as simple as possible. +- **YAGNI (You're Not Gonna Need It)** – avoid speculative complexity or + over-engineering. + +## Coding Standards + +### Linting +This project uses the standard set of lints provided by the `flutter_lints` +package. Ensure that all code adheres to these rules to maintain code quality +and consistency. Run `flutter analyze` frequently to check for linting issues. + +### Naming Conventions +- **Files:** Use `snake_case` for file names (e.g., `user_profile.dart`). +- **Classes:** Use `PascalCase` for classes (e.g., `UserProfile`). +- **Methods and Variables:** Use `camelCase` for methods and variables (e.g., + `getUserProfile`). +- **Constants:** Use `camelCase` for constants (e.g., `defaultTimeout`). + +### Cross-Platform Compatibility +This application targets Android, iOS, web, and macOS. All code must be written +to be platform-agnostic. + +- **Avoid Platform-Specific APIs:** Do not use platform-specific libraries or + APIs directly (e.g., `dart:io`'s `File` class for UI rendering). When + platform-specific code is unavoidable, it is abstracted away behind a common + interface using an adapter pattern, as seen in the `lib/platform` directory. +- **Use Flutter-Native Solutions:** Prefer Flutter's built-in, cross-platform + widgets and utilities (e.g., `Image.memory` with byte data for displaying + images from `image_picker`, which works on all platforms). +- **Verify Plugin Compatibility:** Before using a new package, ensure it + supports all target platforms (Android, iOS, web). + +### Don't Swallow Errors +- **Don't Swallow Errors** by catching expections, silently filling in required + but missing values or adding timeouts when something hangs unexpectedly. All + of those are exceptions that should be thrown so that the errors can be seen, + root causes can be found and fixes can be applied. +- **Use Assertions for Invariants:** Use `assert` statements to validate + assumptions and logical invariants in your code. For example, if a function + requires a list to be non-empty before proceeding, assert that condition at + the beginning of the function. This practice turns potential silent failures + into loud, immediate errors during development, making complex bugs + significantly easier to track down. + +### Null Value Handling +- Prefer using required parameters in constructors and methods when a value is + not expected to be null. +- When the compiler requires a non-null value and you are certain a value is not + null at that point, use the `!` (bang) operator. This turns invalid null + assumptions into runtime exceptions, making them easier to find and fix. +- Avoid providing default values for nullable types simply to satisfy the + compiler, as this can hide underlying data issues. + +### Widget Development +- **`const` Constructors:** Use `const` constructors for widgets whenever + possible to improve performance by allowing Flutter to cache and reuse widget + instances. +- **Break Down Large Widgets:** Decompose large widget build methods into + smaller, more manageable widgets. This improves readability, reusability, and + performance. + +### No Placeholder Code +- We're building production code here, not toys. Avoid placeholder code. + +### No Comments for Removed Functionality +- The source is not the place to keep a history of what's changed; it's the + place to implement the current requirements only. Use version control for + history. + +## Styling and Theming + +### Avoid Hardcoded Values +- **Do not** hardcode colors, dimensions, text styles, or other style values + directly in widgets. +- All centralized style-related code should be consolidated into + `lib/styles.dart`. +- Create descriptive, `camelCase` constants in a dedicated `lib/styles.dart` + file for any reusable style values that are not part of the main theme. + +### Theme Architecture +- The app uses Material Design 3 with a centralized theme defined in + `main.dart`. +- All UI components should inherit styles from this central theme. Avoid custom, + one-off styling for individual widgets. +- Only use per-widget theme or style overrides when a particular widget requires + a value that is explicitly different from the application-wide theme (e.g., a + special-purpose button with a unique color). + +#### Prioritize Blame Correctly +When debugging, assume the bug is in the local, new, application-specific code +before assuming a bug in a mature framework. + +## State Management +- **Provider:** use the provider package for state management + +## Testing +- Write unit tests for business logic (e.g., services, state management + controllers). +- Write widget tests to verify the UI and interactions of your widgets. +- Aim for a reasonable level of test coverage to ensure application stability + and prevent regressions. + +## Project Structure +- **`lib/`**: Contains all Dart code. + - **`main.dart`**: The application entry point and theme definition. + - **`styles.dart`**: Centralized file for style constants. + - **`models/`**: Directory for data model classes. + - `clue_answer.dart`: Model for a clue and its answer. + - `clue.dart`: Model for a single clue. + - `crossword_data.dart`: Model for the entire crossword puzzle data. + - `crossword_grid.dart`: Model for the crossword grid. + - `crossword_state.dart`: State management for the crossword puzzle. + - `grid_cell.dart`: Model for a single cell in the grid. + - `todo_item.dart`: (likely unused example code) + - **`platform/`**: Platform-specific implementations. + - `platform_io.dart`: IO-specific implementation. + - `platform_web.dart`: Web-specific implementation. + - `platform.dart`: Common platform interface. + - **`screens/`**: Top-level screen widgets. + - `crossword_screen.dart`: The main screen of the application. + - **`services/`**: Business logic services. + - `gemini_service.dart`: Service for interacting with the Gemini API. + - `image_picker_service.dart`: Service for picking images. + - `puzzle_solver.dart`: Service for solving the puzzle. + - **`widgets/`**: Reusable, shared widgets. + - `clue_list.dart`: Widget for displaying the list of clues. + - `grid_view.dart`: Widget for displaying the crossword grid. + - `step_state_base.dart`: Base class for step state management. + - `step1_select_image.dart`: Widget for the first step (selecting an image). + - `step2_verify_grid_size.dart`: Widget for the second step (verifying grid + size). + - `step3_verify_grid_contents.dart`: Widget for the third step (verifying + grid contents). + - `step4_verify_clue_text.dart`: Widget for the fourth step (verifying clue + text). + - `step5_solve_puzzle.dart`: Widget for the fifth step (solving the puzzle). + - `todo_list_widget.dart`: (likely unused example code) +- **`assets/`**: Contains static assets like images and fonts. +- **`test/`**: Contains tests for the application. +- **`web/`**: Contains web-specific files. +- **`macos/`**: Contains macOS-specific files. +- **`specs/`**: Contains project specifications and design documents. + +## Technical Accuracy and Verification + +To ensure the highest level of accuracy, the following verification steps are +mandatory when dealing with technical details like API names, library versions, +or other critical identifiers. + +1. **Prioritize Primary Sources:** Official documentation, API references, and + the project's own source code are the highest authority. Information from + secondary sources (e.g., blog posts, forum answers) must be cross-verified + against a primary source before being used. When a user provides a link to + official documentation, it must be treated as the ground truth. + +2. **Mandate Exact Identifier Verification:** When using a specific + identifier—such as a model name, package version, or function name—you must + find and use the **exact, literal string** from the primary source. Do not + shorten, paraphrase, or infer the name from surrounding text or titles. + +3. **Quote Before Use:** Before implementing a critical identifier obtained + from documentation, you must first quote the specific line or code block + from the source that confirms the identifier. This acts as a final + verification step to ensure you have found the precise value. + +## Project-Specific Implementation + +This Crossword Companion project serves as a practical example of the principles +outlined above: + +- **State Management:** The application uses the `provider` package for state + management, with a central `CrosswordState` class that acts as a + `ChangeNotifier`. This single source of truth manages the application's + data, such as the puzzle details and solver status. + +- **Event-Driven Navigation:** Step transitions are handled by a robust + two-phase state machine (`enteringStep`/`enteredStep`) within + `CrosswordState`. This allows each step widget to listen for when it is + being entered and run its own initialization logic in a self-contained + manner. + +- **Abstracted State Management:** To adhere to the DRY principle, the common + state management logic for each stepper page is encapsulated in a + `StepStateBase` abstract class. This base class handles the listener + registration and the two-phase state machine logic for entering a step. Each + step's state class then extends this base class and provides its `stepIndex` + and the specific logic to execute when the step is entered. + +- **Widget Decomposition:** The UI is broken down into small, single-purpose + widgets. For example, the main `CrosswordScreen` is composed of a `Stepper` + widget, which in turn uses a series of `Step...Content` widgets for each + step in the process. This makes the code more readable, reusable, and easier + to test. + +- **Centralized Theme:** The application's theme is defined in `main.dart` and + applied to the entire `MaterialApp`. This ensures a consistent look and feel + across all widgets and avoids hardcoded style values. + +- **Services:** Business logic is separated into a `GeminiService`. This + service is configured with a detailed system prompt that instructs the + `gemini-2.5-flash` model to act as a crossword-solving expert. This + decouples the UI from the underlying AI logic, making the code more modular + and easier to maintain. + +- **App-Driven Solving:** The puzzle-solving logic is not a simple API call + but an intelligent, app-driven loop managed by a dedicated `PuzzleSolver` + service, which is coordinated by `CrosswordState`. For each clue, the app + calculates the word's length and current letter pattern from the grid. It + then sends a highly focused prompt to the expert model. The app validates + the model's response, updates the grid, and automatically retries clues that + were answered incorrectly, creating a robust and resilient solving process. + +## Verification and Maintenance + +### Post-Change Verification +After any significant refactoring or feature addition, the following steps are +required to maintain code quality: + +1. **Run Static Analysis:** Execute `dart analyze` and fix all reported issues. +2. **Audit Against Best Practices:** Review the changes against the principles + outlined in the "Architectural Principles" and "Coding Standards" sections + of this document to ensure the code remains clean, robust, and maintainable. + +## Git Workflow + +- **Committing Changes:** After the changes are complete and verified, I will not + commit them to the repository. You, the user, are responsible for all git + commits. \ No newline at end of file diff --git a/crossword_companion/README.md b/crossword_companion/README.md new file mode 100644 index 0000000..244487f --- /dev/null +++ b/crossword_companion/README.md @@ -0,0 +1,90 @@ +# Crossword Companion + +The Crossword Companion is a Flutter sample app demonstrating an intelligent, +app-driven workflow using Flutter and the Google Gemini API through Firebase. +The app allows users to take or upload a picture of a crossword puzzle, verifies +the puzzle's structure and clues with the user, and then uses Gemini to solve it + in real-time. + +This project is an open-source sample intended to showcase how easy it is to +build an AI-powered app in Flutter beyond simple chat, allowing the user to step +in and direct the model as appropriate. + +The Crossword Companion app is supported where Firebase is support: Android, +iOS, web and macOS. + +## How It Works + +The application uses a multi-modal Gemini model (`gemini-2.5-pro`) to analyze an +image of a crossword puzzle. It then uses a separate model (`gemini-2.5-flash`), +configured with a detailed system prompt to act as a crossword "expert", to +solve the puzzle. Additionally, the app integrates with an external dictionary +API [dictionaryapi.dev](https://dictionaryapi.dev) to provide word metadata +(e.g., part of speech) when requested by the Gemini model during the solving +process. This integration allows the Gemini model to verify grammatical +constraints, such as part of speech, for potential answers, thereby improving +the accuracy and relevance of its solutions. + +The app itself drives the solving process. For each clue, it determines the +required word length and the current known letter pattern from the grid. It then +sends this focused context to the expert model. The app validates the answer, +updates the grid, and automatically retries clues that were answered +incorrectly, creating a robust feedback loop. + + + +## Getting Started + +### Prerequisites + +- The [Flutter SDK](https://docs.flutter.dev/install) installed. + +- A [Firebase project enabled for + Generative AI](https://firebase.google.com/docs/ai-logic/get-started?api=dev). + +### Installation + +1. Clone the repository. +2. Configure your Firebase project by running the following command at the + project root and following the instructions: + + ```bash + flutterfire config + ``` + + This will connect your Flutter application to your Firebase project, which + is necessary to use the Gemini API. + +3. Run the application on your desired platform: + + ```bash + flutter run + ``` + +## Functionality + +This application guides the user through a step-by-step workflow to solve a +crossword puzzle from an image. + +1. **Select Crossword Image:** The user can select an image of a crossword + puzzle from their device's gallery or by taking a photo. + +2. **Verify Grid Size:** The application uses Gemini to infer the information + about the crossword. On this step, the app shows the inferred grid + dimensions (width and height) and allows the user to make corrections. + +3. **Verify Grid Contents:** The app displays the inferred grid and the user + can tap on cells to toggle them between inactive, blank or numbered. + +4. **Verify Clue Text:** The inferred "Across" and "Down" clues are displayed, + and the user can edit them for accuracy. After this step, the app validates + that the user's edits on the grid have resulted in a consistent puzzle, e.g. + there are numbers on the grid that match the clues, etc. + +5. **LLM-based Solving:** The application uses Gemini model to solve the + puzzle. The app manages the solving loop, sending focused prompts for each + clue. The UI displays the model's confidence and color-codes letters to show + conflicts, allowing the user to watch the puzzle being solved in real-time. + + The user may pause or resume the solving process as well as start over with + a new puzzle as they choose. \ No newline at end of file diff --git a/crossword_companion/analysis_options.yaml b/crossword_companion/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/crossword_companion/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/crossword_companion/android/.gitignore b/crossword_companion/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/crossword_companion/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/crossword_companion/android/app/build.gradle.kts b/crossword_companion/android/app/build.gradle.kts new file mode 100644 index 0000000..066839f --- /dev/null +++ b/crossword_companion/android/app/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + id("com.android.application") + // START: FlutterFire Configuration + id("com.google.gms.google-services") + // END: FlutterFire Configuration + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.crossword_companion" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.crossword_companion" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/crossword_companion/android/app/google-services.json b/crossword_companion/android/app/google-services.json new file mode 100644 index 0000000..f8111d5 --- /dev/null +++ b/crossword_companion/android/app/google-services.json @@ -0,0 +1,67 @@ +{ + "project_info": { + "project_number": "775552889844", + "project_id": "crossword-companion-b7759", + "storage_bucket": "crossword-companion-b7759.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:775552889844:android:cde9d25b42e6c936f03fa1", + "android_client_info": { + "package_name": "com.example.crossword_companion" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyDU7JpCA_1eLvlYHa_Y208J3ei46KpkHx8" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:775552889844:android:45ff33e8fd39589af03fa1", + "android_client_info": { + "package_name": "com.example.flutter_crosswo" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyDU7JpCA_1eLvlYHa_Y208J3ei46KpkHx8" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:775552889844:android:8ab2c0fdc70779c3f03fa1", + "android_client_info": { + "package_name": "com.example.flutter_crossword_companion" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyDU7JpCA_1eLvlYHa_Y208J3ei46KpkHx8" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/crossword_companion/android/app/src/debug/AndroidManifest.xml b/crossword_companion/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/crossword_companion/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/crossword_companion/android/app/src/main/AndroidManifest.xml b/crossword_companion/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..37d06a4 --- /dev/null +++ b/crossword_companion/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crossword_companion/android/app/src/main/kotlin/com/example/crossword_companion/MainActivity.kt b/crossword_companion/android/app/src/main/kotlin/com/example/crossword_companion/MainActivity.kt new file mode 100644 index 0000000..ec33e45 --- /dev/null +++ b/crossword_companion/android/app/src/main/kotlin/com/example/crossword_companion/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.crossword_companion + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/crossword_companion/android/app/src/main/res/drawable-v21/launch_background.xml b/crossword_companion/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/crossword_companion/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/crossword_companion/android/app/src/main/res/drawable/launch_background.xml b/crossword_companion/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/crossword_companion/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/crossword_companion/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/crossword_companion/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/crossword_companion/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/crossword_companion/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/crossword_companion/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/crossword_companion/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/crossword_companion/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/crossword_companion/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/crossword_companion/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/crossword_companion/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/crossword_companion/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/crossword_companion/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/crossword_companion/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/crossword_companion/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/crossword_companion/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/crossword_companion/android/app/src/main/res/values-night/styles.xml b/crossword_companion/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/crossword_companion/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/crossword_companion/android/app/src/main/res/values/styles.xml b/crossword_companion/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/crossword_companion/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/crossword_companion/android/app/src/profile/AndroidManifest.xml b/crossword_companion/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/crossword_companion/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/crossword_companion/android/build.gradle.kts b/crossword_companion/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/crossword_companion/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/crossword_companion/android/gradle.properties b/crossword_companion/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /dev/null +++ b/crossword_companion/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/crossword_companion/android/gradle/wrapper/gradle-wrapper.properties b/crossword_companion/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ac3b479 --- /dev/null +++ b/crossword_companion/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/crossword_companion/android/settings.gradle.kts b/crossword_companion/android/settings.gradle.kts new file mode 100644 index 0000000..ff284ff --- /dev/null +++ b/crossword_companion/android/settings.gradle.kts @@ -0,0 +1,29 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.9.1" apply false + // START: FlutterFire Configuration + id("com.google.gms.google-services") version("4.3.15") apply false + // END: FlutterFire Configuration + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/crossword_companion/assets/cc-title.svg b/crossword_companion/assets/cc-title.svg new file mode 100644 index 0000000..c91ad57 --- /dev/null +++ b/crossword_companion/assets/cc-title.svg @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crossword_companion/assets/cc-title.svg.vec b/crossword_companion/assets/cc-title.svg.vec new file mode 100644 index 0000000..51f7b8a Binary files /dev/null and b/crossword_companion/assets/cc-title.svg.vec differ diff --git a/crossword_companion/devtools_options.yaml b/crossword_companion/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/crossword_companion/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/crossword_companion/ios/.gitignore b/crossword_companion/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/crossword_companion/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/crossword_companion/ios/Flutter/AppFrameworkInfo.plist b/crossword_companion/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..1dc6cf7 --- /dev/null +++ b/crossword_companion/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/crossword_companion/ios/Flutter/Debug.xcconfig b/crossword_companion/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/crossword_companion/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/crossword_companion/ios/Flutter/Release.xcconfig b/crossword_companion/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/crossword_companion/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/crossword_companion/ios/Podfile b/crossword_companion/ios/Podfile new file mode 100644 index 0000000..6649374 --- /dev/null +++ b/crossword_companion/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +platform :ios, '15.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/crossword_companion/ios/Runner.xcodeproj/project.pbxproj b/crossword_companion/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..24df49d --- /dev/null +++ b/crossword_companion/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,732 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 33F1E93A641EBEF19662D5CA /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 148FB525A99CE069C75EB9DD /* GoogleService-Info.plist */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + DD24B70BA1B2F3B717DAFB8D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DF9FA8C80473229BBAA9EC81 /* Pods_Runner.framework */; }; + FB8FC6480B266E596C250441 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 87A3FBBBAD55B721A9BAFB8C /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 02631963DF074D98C4D94B61 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 0B9E48F289134B8B815A06A0 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 148FB525A99CE069C75EB9DD /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 488047A4CA7CF75335BC1CB9 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 5DC9EA2F2AA2C12CCDA79937 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 87A3FBBBAD55B721A9BAFB8C /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DF9FA8C80473229BBAA9EC81 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + FAA2B9F9FC65E2A60DB742E6 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + FF9D88EF78D7FAA4AD37DEB7 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DD24B70BA1B2F3B717DAFB8D /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A89B19E69C1759B1F05BD73E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FB8FC6480B266E596C250441 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 6EED3AB89C7B6B6E569913C4 /* Frameworks */ = { + isa = PBXGroup; + children = ( + DF9FA8C80473229BBAA9EC81 /* Pods_Runner.framework */, + 87A3FBBBAD55B721A9BAFB8C /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + 148FB525A99CE069C75EB9DD /* GoogleService-Info.plist */, + B5A79047352A064715ED3039 /* Pods */, + 6EED3AB89C7B6B6E569913C4 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + B5A79047352A064715ED3039 /* Pods */ = { + isa = PBXGroup; + children = ( + 5DC9EA2F2AA2C12CCDA79937 /* Pods-Runner.debug.xcconfig */, + FF9D88EF78D7FAA4AD37DEB7 /* Pods-Runner.release.xcconfig */, + FAA2B9F9FC65E2A60DB742E6 /* Pods-Runner.profile.xcconfig */, + 02631963DF074D98C4D94B61 /* Pods-RunnerTests.debug.xcconfig */, + 488047A4CA7CF75335BC1CB9 /* Pods-RunnerTests.release.xcconfig */, + 0B9E48F289134B8B815A06A0 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 6016E37099C15589CB841864 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + A89B19E69C1759B1F05BD73E /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 044B34897A7E5A4B1954EC6C /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 9ED9CD583A81BE10B3A5029B /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + 33F1E93A641EBEF19662D5CA /* GoogleService-Info.plist in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 044B34897A7E5A4B1954EC6C /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 6016E37099C15589CB841864 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + 9ED9CD583A81BE10B3A5029B /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.crosswordCompanion; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 02631963DF074D98C4D94B61 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.crosswordCompanion.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 488047A4CA7CF75335BC1CB9 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.crosswordCompanion.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0B9E48F289134B8B815A06A0 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.crosswordCompanion.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.crosswordCompanion; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.crosswordCompanion; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/crossword_companion/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/crossword_companion/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/crossword_companion/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/crossword_companion/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/crossword_companion/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/crossword_companion/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/crossword_companion/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/crossword_companion/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/crossword_companion/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/crossword_companion/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/crossword_companion/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/crossword_companion/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crossword_companion/ios/Runner.xcworkspace/contents.xcworkspacedata b/crossword_companion/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/crossword_companion/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/crossword_companion/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/crossword_companion/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/crossword_companion/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/crossword_companion/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/crossword_companion/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/crossword_companion/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/crossword_companion/ios/Runner/AppDelegate.swift b/crossword_companion/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/crossword_companion/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/crossword_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/crossword_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/crossword_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/crossword_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/crossword_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/crossword_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/crossword_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/crossword_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/crossword_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/crossword_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/crossword_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/crossword_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/crossword_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/crossword_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/crossword_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/crossword_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/crossword_companion/ios/Runner/Base.lproj/LaunchScreen.storyboard b/crossword_companion/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/crossword_companion/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crossword_companion/ios/Runner/Base.lproj/Main.storyboard b/crossword_companion/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/crossword_companion/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crossword_companion/ios/Runner/GoogleService-Info.plist b/crossword_companion/ios/Runner/GoogleService-Info.plist new file mode 100644 index 0000000..337c158 --- /dev/null +++ b/crossword_companion/ios/Runner/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyDxmtUAXptAZ4ioCP0XK8qwal__JIc_cuQ + GCM_SENDER_ID + 775552889844 + PLIST_VERSION + 1 + BUNDLE_ID + com.example.crosswordCompanion + PROJECT_ID + crossword-companion-b7759 + STORAGE_BUCKET + crossword-companion-b7759.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:775552889844:ios:7ff0fbf45b8bfd98f03fa1 + + \ No newline at end of file diff --git a/crossword_companion/ios/Runner/Info.plist b/crossword_companion/ios/Runner/Info.plist new file mode 100644 index 0000000..b19f7f1 --- /dev/null +++ b/crossword_companion/ios/Runner/Info.plist @@ -0,0 +1,53 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Crossword Companion + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + crossword_companion + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + NSCameraUsageDescription + This app needs access to your camera to take pictures of crossword puzzles. + NSPhotoLibraryUsageDescription + This app needs access to your photo library to select images of crossword puzzles. + + diff --git a/crossword_companion/ios/Runner/Runner-Bridging-Header.h b/crossword_companion/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/crossword_companion/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/crossword_companion/ios/RunnerTests/RunnerTests.swift b/crossword_companion/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/crossword_companion/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/crossword_companion/lib/main.dart b/crossword_companion/lib/main.dart new file mode 100644 index 0000000..20f804c --- /dev/null +++ b/crossword_companion/lib/main.dart @@ -0,0 +1,55 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'firebase_options.dart'; +import 'screens/crossword_screen.dart'; +import 'services/gemini_service.dart'; +import 'state/app_step_state.dart'; +import 'state/puzzle_data_state.dart'; +import 'state/puzzle_solver_state.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + runApp(const MainApp()); +} + +class MainApp extends StatelessWidget { + const MainApp({super.key}); + + @override + Widget build(BuildContext context) { + // Create instances of the services and state notifiers. + final geminiService = GeminiService(); + final appStepState = AppStepState(); + final puzzleDataState = PuzzleDataState(geminiService: geminiService); + final puzzleSolverState = PuzzleSolverState( + puzzleDataState: puzzleDataState, + geminiService: geminiService, + ); + + // Wire up the dependency between data changes and solver initialization. + puzzleDataState.onDataChanged = puzzleSolverState.initializeTodos; + + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: appStepState), + ChangeNotifierProvider.value(value: puzzleDataState), + ChangeNotifierProvider.value(value: puzzleSolverState), + ], + child: MaterialApp( + theme: ThemeData.from( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), + useMaterial3: true, + ), + home: const CrosswordScreen(), + debugShowCheckedModeBanner: false, + ), + ); + } +} diff --git a/crossword_companion/lib/models/clue.dart b/crossword_companion/lib/models/clue.dart new file mode 100644 index 0000000..7ec05a1 --- /dev/null +++ b/crossword_companion/lib/models/clue.dart @@ -0,0 +1,44 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:uuid/uuid.dart'; + +enum ClueDirection { across, down } + +class Clue { + Clue({required this.number, required this.direction, required this.text}) + : id = const Uuid().v4(); + + Clue.private({ + required this.id, + required this.number, + required this.direction, + required this.text, + }); + + factory Clue.fromJson(Map json) => Clue( + number: json['number'], + direction: ClueDirection.values.byName(json['direction']), + text: json['text'], + ); + String id; + int number; + ClueDirection direction; + String text; + + Clue copyWith({int? number, ClueDirection? direction, String? text}) => + Clue.private( + id: id, + number: number ?? this.number, + direction: direction ?? this.direction, + text: text ?? this.text, + ); + + Map toJson() => { + 'id': id, + 'number': number, + 'direction': direction.toString().split('.').last, + 'text': text, + }; +} diff --git a/crossword_companion/lib/models/clue_answer.dart b/crossword_companion/lib/models/clue_answer.dart new file mode 100644 index 0000000..cf21873 --- /dev/null +++ b/crossword_companion/lib/models/clue_answer.dart @@ -0,0 +1,14 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +class ClueAnswer { + ClueAnswer({required this.answer, required this.confidence}); + final String answer; + final double confidence; + + ClueAnswer copyWith({String? answer, double? confidence}) => ClueAnswer( + answer: answer ?? this.answer, + confidence: confidence ?? this.confidence, + ); +} diff --git a/crossword_companion/lib/models/crossword_data.dart b/crossword_companion/lib/models/crossword_data.dart new file mode 100644 index 0000000..b121e4a --- /dev/null +++ b/crossword_companion/lib/models/crossword_data.dart @@ -0,0 +1,47 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'clue.dart'; +import 'crossword_grid.dart'; + +class CrosswordData { + CrosswordData({ + required this.width, + required this.height, + required this.grid, + required this.clues, + }); + + factory CrosswordData.fromJson(Map json) => CrosswordData( + width: json['width'], + height: json['height'], + grid: CrosswordGrid.fromJson(json['grid']), + clues: (json['clues'] as List) + .map((clueJson) => Clue.fromJson(clueJson)) + .toList(), + ); + final int width; + final int height; + final CrosswordGrid grid; + final List clues; + + Map toJson() => { + 'width': width, + 'height': height, + 'grid': grid.toJson(), + 'clues': clues.map((clue) => clue.toJson()).toList(), + }; + + CrosswordData copyWith({ + int? width, + int? height, + CrosswordGrid? grid, + List? clues, + }) => CrosswordData( + width: width ?? this.width, + height: height ?? this.height, + grid: grid ?? this.grid, + clues: clues ?? this.clues, + ); +} diff --git a/crossword_companion/lib/models/crossword_grid.dart b/crossword_companion/lib/models/crossword_grid.dart new file mode 100644 index 0000000..5246953 --- /dev/null +++ b/crossword_companion/lib/models/crossword_grid.dart @@ -0,0 +1,37 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'grid_cell.dart'; + +class CrosswordGrid { + CrosswordGrid({ + required this.width, + required this.height, + required this.cells, + }); + + factory CrosswordGrid.fromJson(Map json) => CrosswordGrid( + width: json['width'], + height: json['height'], + cells: (json['cells'] as List) + .map((cellJson) => GridCell.fromJson(cellJson)) + .toList(), + ); + final int width; + final int height; + final List cells; + + Map toJson() => { + 'width': width, + 'height': height, + 'cells': cells.map((cell) => cell.toJson()).toList(), + }; + + CrosswordGrid copyWith({int? width, int? height, List? cells}) => + CrosswordGrid( + width: width ?? this.width, + height: height ?? this.height, + cells: cells ?? this.cells, + ); +} diff --git a/crossword_companion/lib/models/grid_cell.dart b/crossword_companion/lib/models/grid_cell.dart new file mode 100644 index 0000000..ff4c7f1 --- /dev/null +++ b/crossword_companion/lib/models/grid_cell.dart @@ -0,0 +1,68 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +enum GridCellType { inactive, empty, numbered } + +class GridCell { + GridCell({ + this.type = GridCellType.empty, + this.clueNumber, + this.acrossLetter, + this.downLetter, + this.userLetter, + }); + + factory GridCell.fromJson(Map json) { + final typeString = json['type'] as String?; + GridCellType type; + switch (typeString) { + case 'inactive': + type = GridCellType.inactive; + case 'numbered': + type = GridCellType.numbered; + case 'empty': + default: + type = GridCellType.empty; + } + + return GridCell( + type: type, + clueNumber: json['clueNumber'] as int?, + acrossLetter: json['acrossLetter'] as String?, + downLetter: json['downLetter'] as String?, + userLetter: json['userLetter'] as String?, + ); + } + final GridCellType type; + final int? clueNumber; + final String? acrossLetter; + final String? downLetter; + final String? userLetter; + + Map toJson() => { + 'type': type.toString().split('.').last, + 'clueNumber': clueNumber, + 'acrossLetter': acrossLetter, + 'downLetter': downLetter, + 'userLetter': userLetter, + }; + + GridCell copyWith({ + GridCellType? type, + int? clueNumber, + bool clearClueNumber = false, + String? acrossLetter, + bool clearAcrossLetter = false, + String? downLetter, + bool clearDownLetter = false, + String? userLetter, + bool clearUserLetter = false, + }) => GridCell( + type: type ?? this.type, + clueNumber: clearClueNumber ? null : clueNumber ?? this.clueNumber, + acrossLetter: clearAcrossLetter ? null : acrossLetter ?? this.acrossLetter, + downLetter: clearDownLetter ? null : downLetter ?? this.downLetter, + userLetter: clearUserLetter ? null : userLetter ?? this.userLetter, + ); +} diff --git a/crossword_companion/lib/models/todo_item.dart b/crossword_companion/lib/models/todo_item.dart new file mode 100644 index 0000000..0b70ab5 --- /dev/null +++ b/crossword_companion/lib/models/todo_item.dart @@ -0,0 +1,22 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'clue_answer.dart'; + +enum TodoStatus { notDone, inProgress, done } + +class TodoItem { + TodoItem({ + required this.id, + required this.description, + this.status = TodoStatus.notDone, + this.answer, + this.isWrong = false, + }); + final String id; + final String description; + final TodoStatus status; + final ClueAnswer? answer; + final bool isWrong; +} diff --git a/crossword_companion/lib/platform/platform.dart b/crossword_companion/lib/platform/platform.dart new file mode 100644 index 0000000..ea8fa91 --- /dev/null +++ b/crossword_companion/lib/platform/platform.dart @@ -0,0 +1,5 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +export 'platform_web.dart' if (dart.library.io) 'platform_io.dart'; diff --git a/crossword_companion/lib/platform/platform_io.dart b/crossword_companion/lib/platform/platform_io.dart new file mode 100644 index 0000000..9efae90 --- /dev/null +++ b/crossword_companion/lib/platform/platform_io.dart @@ -0,0 +1,8 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +bool isMobile() => Platform.isIOS || Platform.isAndroid; +bool isDesktop() => Platform.isWindows || Platform.isMacOS || Platform.isLinux; diff --git a/crossword_companion/lib/platform/platform_web.dart b/crossword_companion/lib/platform/platform_web.dart new file mode 100644 index 0000000..436a2b0 --- /dev/null +++ b/crossword_companion/lib/platform/platform_web.dart @@ -0,0 +1,6 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +bool isMobile() => false; +bool isDesktop() => true; diff --git a/crossword_companion/lib/screens/crossword_screen.dart b/crossword_companion/lib/screens/crossword_screen.dart new file mode 100644 index 0000000..e545022 --- /dev/null +++ b/crossword_companion/lib/screens/crossword_screen.dart @@ -0,0 +1,104 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:provider/provider.dart'; +import 'package:vector_graphics/vector_graphics.dart'; + +import '../state/app_step_state.dart'; +import '../widgets/step1_select_image.dart'; +import '../widgets/step2_verify_grid_size.dart'; +import '../widgets/step3_verify_grid_contents.dart'; +import '../widgets/step4_verify_clue_text.dart'; +import '../widgets/step5_solve_puzzle.dart'; + +const _showScreenWidth = false; + +class CrosswordScreen extends StatelessWidget { + const CrosswordScreen({super.key}); + + @override + Widget build(BuildContext context) => Consumer( + builder: (context, appStepState, child) { + final steps = [ + Step( + title: const Text('Select crossword image'), + content: StepOneSelectImage(isActive: appStepState.currentStep == 0), + ), + Step( + title: const Text('Verify grid size'), + content: StepTwoVerifyGridSize( + isActive: appStepState.currentStep == 1, + ), + ), + Step( + title: const Text('Verify grid contents'), + content: StepThreeVerifyGridContents( + isActive: appStepState.currentStep == 2, + ), + ), + Step( + title: const Text('Verify grid clues'), + content: StepFourVerifyClueText( + isActive: appStepState.currentStep == 3, + ), + ), + Step( + title: const Text('Solve the puzzle'), + content: StepFiveSolvePuzzle(isActive: appStepState.currentStep == 4), + ), + ]; + + return Scaffold( + body: Column( + children: [ + const Padding( + padding: EdgeInsets.only(left: 32, right: 32, top: 64), + child: SvgPicture( + AssetBytesLoader('assets/cc-title.svg.vec'), + height: 100, + ), + ), + Expanded( + child: Stepper( + currentStep: appStepState.currentStep, + onStepTapped: null, + onStepContinue: null, + onStepCancel: null, + // Hide the default buttons + controlsBuilder: (_, _) => const SizedBox.shrink(), + steps: steps.asMap().entries.map((entry) { + final index = entry.key; + final step = entry.value; + return Step( + title: step.title, + content: step.content, + state: appStepState.currentStep > index + ? StepState.complete + : StepState.indexed, + isActive: appStepState.currentStep == index, + ); + }).toList(), + ), + ), + if (_showScreenWidth) + Container( + color: Colors.grey[200], + padding: const EdgeInsets.all(8), + child: LayoutBuilder( + builder: (context, constraints) => Center( + child: Text( + 'Screen width: ' + '${constraints.maxWidth.toStringAsFixed(0)}px', + ), + ), + ), + ), + ], + ), + ); + }, + ); +} diff --git a/crossword_companion/lib/services/gemini_service.dart b/crossword_companion/lib/services/gemini_service.dart new file mode 100644 index 0000000..c96a6a3 --- /dev/null +++ b/crossword_companion/lib/services/gemini_service.dart @@ -0,0 +1,357 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: avoid_dynamic_calls + +import 'dart:async'; +import 'dart:convert'; + +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:image_picker/image_picker.dart'; + +import '../models/clue.dart'; +import '../models/clue_answer.dart'; +import '../models/crossword_data.dart'; +import '../models/crossword_grid.dart'; +import '../models/grid_cell.dart'; + +class GeminiService { + GeminiService() { + // The model for inferring crossword data from images. + _crosswordModel = FirebaseAI.googleAI().generativeModel( + model: 'gemini-2.5-pro', + generationConfig: GenerationConfig( + responseMimeType: 'application/json', + responseSchema: _crosswordSchema, + ), + ); + + // The model for solving clues. + _clueSolverModel = FirebaseAI.googleAI().generativeModel( + model: 'gemini-2.5-flash', + systemInstruction: Content.text(clueSolverSystemInstruction), + tools: [ + Tool.functionDeclarations([ + _getWordMetadataFunction, + _returnResultFunction, + ]), + ], + ); + } + + late final GenerativeModel _crosswordModel; + late final GenerativeModel _clueSolverModel; + StreamSubscription? _clueSolverSubscription; + + Future cancelCurrentSolve() async { + await _clueSolverSubscription?.cancel(); + _clueSolverSubscription = null; + } + + static final _getWordMetadataFunction = FunctionDeclaration( + 'getWordMetadata', + 'Gets grammatical metadata for a word, like its part of speech. ' + 'Best used to verify a candidate answer against a clue that implies a ' + 'grammatical constraint.', + parameters: { + 'word': Schema(SchemaType.string, description: 'The word to look up.'), + }, + ); + + static final _returnResultFunction = FunctionDeclaration( + 'returnResult', + 'Returns the final result of the clue solving process.', + parameters: { + 'answer': Schema( + SchemaType.string, + description: 'The answer to the clue.', + ), + 'confidence': Schema( + SchemaType.number, + description: 'The confidence score in the answer from 0.0 to 1.0.', + ), + }, + ); + + static String get clueSolverSystemInstruction => + ''' +You are an expert crossword puzzle solver. + +**Follow these rules at all times:** +1. **Prefer Common Words:** Prioritize common English words and proper nouns. Avoid obscure, archaic, or highly technical terms unless the clue strongly implies them. +2. **Match the Clue:** Ensure your answer strictly matches the clue's tense, plurality (singular vs. plural), and part of speech. +3. **Verify Grammatically:** If a clue implies a specific part of speech (e.g., it's a verb, adverb, or plural), it's a good idea to use the `getWordMetadata` tool to verify your candidate answer matches. However, avoid using it for every clue. +4. **Be Confident:** Provide a confidence score from 0.0 to 1.0 indicating your certainty. +5. **Trust the Clue Over the Pattern:** The provided letter pattern is only a suggestion based on other potentially incorrect answers. Your primary goal is to find the best word that fits the **clue text**. If you are confident in an answer that contradicts the provided pattern, you should use that answer. +6. **Format Correctly:** You must return your answer in the specified JSON format. + +--- + +### Tool: `getWordMetadata` + +You have a tool to get grammatical information about a word. + +**When to use:** +- This tool is most helpful as a verification step after you have a likely answer. +- Consider using this tool when a clue contains a grammatical hint that could be ambiguous. +- **Good candidates for verification:** + - Clues that seem to be verbs (e.g., "To run," "Waving"). + - Clues that are adverbs (e.g., "Happily," "Quickly"). + - Clues that specify a plural form. +- **Try to avoid using the tool for:** + - Simple definitions (e.g., "A small dog"). + - Fill-in-the-blank clues (e.g., "___ and flow"). + - Proper nouns (e.g., "Capital of France"). + +**Function signature:** +```json +${jsonEncode(_getWordMetadataFunction.toJson())} +``` + +### Tool: `returnResult` + +You have a tool to return the final result of the clue solving process. + +**When to use:** +- Use this tool when you have a final answer and confidence score to return. You + must use this tool exactly once, and only once, to return the final result. + +**Function signature:** +```json +${jsonEncode(_returnResultFunction.toJson())} +``` +'''; + + static final _crosswordSchema = Schema( + SchemaType.object, + properties: { + 'width': Schema(SchemaType.integer), + 'height': Schema(SchemaType.integer), + 'grid': Schema( + SchemaType.array, + items: Schema( + SchemaType.array, + items: Schema( + SchemaType.object, + properties: { + 'color': Schema(SchemaType.string), + 'clueNumber': Schema(SchemaType.integer, nullable: true), + }, + ), + ), + ), + 'clues': Schema( + SchemaType.object, + properties: { + 'across': Schema( + SchemaType.array, + items: Schema( + SchemaType.object, + properties: { + 'number': Schema(SchemaType.integer), + 'text': Schema(SchemaType.string), + }, + ), + ), + 'down': Schema( + SchemaType.array, + items: Schema( + SchemaType.object, + properties: { + 'number': Schema(SchemaType.integer), + 'text': Schema(SchemaType.string), + }, + ), + ), + }, + ), + }, + ); + + Future inferCrosswordData(List images) async { + final imageParts = []; + for (final image in images) { + final imageBytes = await image.readAsBytes(); + imageParts.add(InlineDataPart('image/jpeg', imageBytes)); + } + + final content = [ + Content.multi([ + TextPart(''' +Analyze the following crossword puzzle images and return a JSON object +representing the grid size, contents, and clues. The images may contain +different parts of the same puzzle (e.g., the grid the across clues, the down +clues). Combine them to form a complete puzzle. +The JSON schema is as follows: ${jsonEncode(_crosswordSchema.toJson())} + '''), + ...imageParts, + ]), + ]; + + final response = await _crosswordModel.generateContent(content); + + final json = jsonDecode(response.text!); + + final width = json['width'] as int; + final height = json['height'] as int; + final gridData = json['grid'] as List; + final cluesData = json['clues'] as Map; + + final cells = gridData + .expand( + (row) => (row as List).map((cellData) { + final isBlack = cellData['color'] == 'black'; + final type = isBlack ? GridCellType.inactive : GridCellType.empty; + final clueNumber = isBlack ? null : cellData['clueNumber'] as int?; + return GridCell(type: type, clueNumber: clueNumber); + }), + ) + .toList(); + + final grid = CrosswordGrid(width: width, height: height, cells: cells); + + final acrossClues = (cluesData['across'] as List).map( + (clueData) => Clue( + number: clueData['number'], + direction: ClueDirection.across, + text: clueData['text'], + ), + ); + + final downClues = (cluesData['down'] as List).map( + (clueData) => Clue( + number: clueData['number'], + direction: ClueDirection.down, + text: clueData['text'], + ), + ); + + final clues = [...acrossClues, ...downClues]; + + return CrosswordData( + width: width, + height: height, + grid: grid, + clues: clues, + ); + } + + // Buffer for the result of the clue solving process. + final _returnResult = {}; + + Future solveClue(Clue clue, int length, String pattern) async { + // Cancel any previous, in-flight request. + await cancelCurrentSolve(); + + // Clear the return result cache; this is where the result will be stored. + _returnResult.clear(); + + // Generate JSON response with functions and schema. + await _clueSolverModel.generateContentWithFunctions( + prompt: getSolverPrompt(clue, length, pattern), + onFunctionCall: (functionCall) async => switch (functionCall.name) { + 'getWordMetadata' => await _getWordMetadataFromApi( + functionCall.args['word'] as String, + ), + 'returnResult' => _cacheReturnResult(functionCall.args), + _ => throw Exception('Unknown function call: ${functionCall.name}'), + }, + ); + + assert(_returnResult.isNotEmpty, 'The return result cache is empty.'); + return ClueAnswer( + answer: _returnResult['answer'] as String, + confidence: (_returnResult['confidence'] as num).toDouble(), + ); + } + + // Look up the metadata for a word in the dictionary API. + Future> _getWordMetadataFromApi(String word) async { + debugPrint('Looking up metadata for word: "$word"'); + final url = Uri.parse( + 'https://api.dictionaryapi.dev/api/v2/entries/en/${Uri.encodeComponent(word)}', + ); + + final response = await http.get(url); + return response.statusCode == 200 + ? {'result': jsonDecode(response.body)} + : {'error': 'Could not find a definition for "$word".'}; + } + + // Cache the return result of the clue solving process via a function call. + // This is how we get JSON responses from the model with functions, since the + // model cannot return JSON directly when tools are used. + Map _cacheReturnResult(Map returnResult) { + debugPrint('Caching return result: ${jsonEncode(returnResult)}'); + assert(_returnResult.isEmpty, 'The return result cache is not empty.'); + _returnResult.addAll(returnResult); + return {'status': 'success'}; + } + + String getSolverPrompt(Clue clue, int length, String pattern) => + buildSolverPrompt(clue, length, pattern); + + String buildSolverPrompt(Clue clue, int length, String pattern) => + ''' +Your task is to solve the following crossword clue. + +**Clue:** "${clue.text}" + +**Constraints:** +- The answer is a **$length-letter** word. +- The current letter pattern is `$pattern`, where `_` represents an unknown letter. + +Return your answer and confidence score in the required JSON format. +'''; +} + +extension on GenerativeModel { + Future generateContentWithFunctions({ + required String prompt, + required Future> Function(FunctionCall) onFunctionCall, + }) async { + // Use a chat session to support multiple request/response pairs, which is + // needed to support function calls. + final chat = startChat(); + final buffer = StringBuffer(); + var response = await chat.sendMessage(Content.text(prompt)); + + while (true) { + // Append the response text to the buffer. + buffer.write(response.text ?? ''); + + // If no function calls were collected, we're done + if (response.functionCalls.isEmpty) break; + + // Append a newline to separate responses. + buffer.write('\n'); + + // Execute all function calls + final functionResponses = []; + for (final functionCall in response.functionCalls) { + try { + functionResponses.add( + FunctionResponse( + functionCall.name, + await onFunctionCall(functionCall), + ), + ); + } catch (ex) { + functionResponses.add( + FunctionResponse(functionCall.name, {'error': ex.toString()}), + ); + } + } + + // Get the next response stream with function results + response = await chat.sendMessage( + Content.functionResponses(functionResponses), + ); + } + + return buffer.toString(); + } +} diff --git a/crossword_companion/lib/services/image_picker_service.dart b/crossword_companion/lib/services/image_picker_service.dart new file mode 100644 index 0000000..ec62648 --- /dev/null +++ b/crossword_companion/lib/services/image_picker_service.dart @@ -0,0 +1,19 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:image_picker/image_picker.dart'; + +class ImagePickerService { + ImagePickerService({ImagePicker? picker}) : _picker = picker ?? ImagePicker(); + final ImagePicker _picker; + + Future pickImageFromGallery() async => + _picker.pickImage(source: ImageSource.gallery); + + Future pickImageFromCamera() async => + _picker.pickImage(source: ImageSource.camera); + + Future> pickMultipleImagesFromGallery() async => + _picker.pickMultiImage(); +} diff --git a/crossword_companion/lib/services/puzzle_solver.dart b/crossword_companion/lib/services/puzzle_solver.dart new file mode 100644 index 0000000..5cee9de --- /dev/null +++ b/crossword_companion/lib/services/puzzle_solver.dart @@ -0,0 +1,164 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import '../models/clue.dart'; +import '../models/grid_cell.dart'; +import '../models/todo_item.dart'; +import '../state/puzzle_data_state.dart'; +import '../state/puzzle_solver_state.dart'; +import 'gemini_service.dart'; + +class PuzzleSolver { + Future solve( + PuzzleSolverState solverState, + PuzzleDataState dataState, + GeminiService geminiService, { + bool isResuming = false, + }) async { + assert( + solverState.todos.isNotEmpty, + 'The list of todos should not be empty when calling solvePuzzle', + ); + if (!isResuming) { + assert(dataState.isGridClear); + } + if (dataState.crosswordData == null) return; + + solverState.isSolving = true; + + while (solverState.isSolving && + solverState.todos.any((todo) => todo.status != TodoStatus.done)) { + for (final todo in solverState.todos) { + if (!solverState.isSolving) break; + + if (todo.status == TodoStatus.done) continue; + + solverState.updateTodoStatus(todo.id, TodoStatus.inProgress); + + final clue = dataState.crosswordData!.clues.firstWhere( + (clue) => clue.id == todo.id, + ); + + final expectedLength = _getClueLength(dataState, clue); + final pattern = _getCluePattern(dataState, clue); + + final clueAnswer = await geminiService.solveClue( + clue, + expectedLength, + pattern, + ); + + if (!solverState.isSolving) break; + + if (clueAnswer != null) { + if (clueAnswer.answer.length == expectedLength) { + final conflicts = _getConflicts(dataState, clue, clueAnswer.answer); + if (conflicts.isEmpty) { + dataState.setSolution( + clue.number, + clue.direction, + clueAnswer.answer, + ); + solverState.updateTodoStatus( + todo.id, + TodoStatus.done, + answer: clueAnswer, + ); + } else { + solverState.updateTodoStatus( + todo.id, + TodoStatus.notDone, + answer: clueAnswer.copyWith( + answer: '-- CONFLICTS WITH YOUR LETTER', + ), + isWrong: true, + ); + } + } else { + solverState.updateTodoStatus( + todo.id, + TodoStatus.notDone, + answer: clueAnswer, + isWrong: true, + ); + } + } else { + solverState.updateTodoStatus(todo.id, TodoStatus.notDone); + } + } + } + + solverState.isSolving = false; + } + + // Helper method to execute a callback for each cell in a clue. + void _traverseClue( + PuzzleDataState dataState, + Clue clue, + void Function(GridCell) onCell, + ) { + final grid = dataState.crosswordData!.grid; + final startIndex = grid.cells.indexWhere( + (c) => c.clueNumber == clue.number, + ); + if (startIndex == -1) return; + + if (clue.direction == ClueDirection.across) { + final startRow = startIndex ~/ grid.width; + for (var i = startIndex; i < grid.cells.length; i++) { + final currentRow = i ~/ grid.width; + if (currentRow != startRow || + grid.cells[i].type == GridCellType.inactive) { + break; + } + onCell(grid.cells[i]); + } + } else { + for (var i = startIndex; i < grid.cells.length; i += grid.width) { + if (grid.cells[i].type == GridCellType.inactive) { + break; + } + onCell(grid.cells[i]); + } + } + } + + int _getClueLength(PuzzleDataState dataState, Clue clue) { + var length = 0; + _traverseClue(dataState, clue, (cell) { + if (cell.type != GridCellType.inactive) { + length++; + } + }); + return length; + } + + String _getCluePattern(PuzzleDataState dataState, Clue clue) { + var pattern = ''; + _traverseClue(dataState, clue, (cell) { + if (cell.userLetter != null) { + pattern += cell.userLetter!; + } else if (clue.direction == ClueDirection.across) { + pattern += cell.downLetter ?? '_'; + } else { + pattern += cell.acrossLetter ?? '_'; + } + }); + return pattern; + } + + List _getConflicts(PuzzleDataState dataState, Clue clue, String answer) { + final conflicts = []; + var i = 0; + _traverseClue(dataState, clue, (cell) { + if (cell.userLetter != null && + i < answer.length && + cell.userLetter != answer[i]) { + conflicts.add(i); + } + i++; + }); + return conflicts; + } +} diff --git a/crossword_companion/lib/state/app_step_state.dart b/crossword_companion/lib/state/app_step_state.dart new file mode 100644 index 0000000..628accd --- /dev/null +++ b/crossword_companion/lib/state/app_step_state.dart @@ -0,0 +1,29 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; + +class AppStepState with ChangeNotifier { + int _currentStep = 0; + int get currentStep => _currentStep; + + void nextStep() { + if (_currentStep < 4) { + _currentStep++; + notifyListeners(); + } + } + + void previousStep() { + if (_currentStep > 0) { + _currentStep--; + notifyListeners(); + } + } + + void reset() { + _currentStep = 0; + notifyListeners(); + } +} diff --git a/crossword_companion/lib/state/puzzle_data_state.dart b/crossword_companion/lib/state/puzzle_data_state.dart new file mode 100644 index 0000000..f38af0a --- /dev/null +++ b/crossword_companion/lib/state/puzzle_data_state.dart @@ -0,0 +1,276 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../models/clue.dart'; +import '../models/crossword_data.dart'; +import '../models/grid_cell.dart'; +import '../services/gemini_service.dart'; + +class PuzzleDataState with ChangeNotifier { + PuzzleDataState({required GeminiService geminiService}) + : _geminiService = geminiService; + + final GeminiService _geminiService; + + VoidCallback? onDataChanged; + + final List _selectedCrosswordImages = []; + List get selectedCrosswordImages => _selectedCrosswordImages; + + final List _selectedCrosswordImagesData = []; + List get selectedCrosswordImagesData => + _selectedCrosswordImagesData; + + CrosswordData? _crosswordData; + CrosswordData? get crosswordData => _crosswordData; + + bool get isGridClear => + _crosswordData?.grid.cells.every( + (cell) => cell.acrossLetter == null && cell.downLetter == null, + ) ?? + true; + + void updateCrosswordData(CrosswordData? newData) { + if (newData != null && + _crosswordData != null && + (newData.width != _crosswordData!.width || + newData.height != _crosswordData!.height)) { + final oldGrid = _crosswordData!.grid; + final newCells = []; + + for (var y = 0; y < newData.height; y++) { + for (var x = 0; x < newData.width; x++) { + if (x < oldGrid.width && y < oldGrid.height) { + // This cell existed in the old grid, so copy it. + final oldIndex = y * oldGrid.width + x; + newCells.add(oldGrid.cells[oldIndex]); + } else { + // This is a new cell, create a default one. + newCells.add(GridCell()); + } + } + } + + final newGrid = _crosswordData!.grid.copyWith( + width: newData.width, + height: newData.height, + cells: newCells, + ); + _crosswordData = newData.copyWith(grid: newGrid); + } else { + _crosswordData = newData; + } + onDataChanged?.call(); + notifyListeners(); + } + + List validateGridAndClues() { + if (_crosswordData == null) return []; + + final errors = []; + + // Rule 1: Unique Grid Numbers + final gridNumbers = _crosswordData!.grid.cells + .where((c) => c.clueNumber != null) + .map((c) => c.clueNumber!) + .toList(); + final uniqueGridNumbers = gridNumbers.toSet(); + if (gridNumbers.length != uniqueGridNumbers.length) { + final duplicates = gridNumbers + .fold>({}, (map, n) { + map[n] = (map[n] ?? 0) + 1; + return map; + }) + .entries + .where((e) => e.value > 1) + .map((e) => e.key) + .toList(); + for (final duplicate in duplicates) { + errors.add('Duplicate number in grid: #$duplicate'); + } + } + + // Rule 2 & 3: Parity between clues and grid numbers + final clueNumbers = _crosswordData!.clues.map((c) => c.number).toSet(); + + final cluesWithoutGridEntry = clueNumbers.difference(uniqueGridNumbers); + for (final number in cluesWithoutGridEntry) { + final missingClues = _crosswordData!.clues.where( + (c) => c.number == number, + ); + for (final clue in missingClues) { + errors.add( + "Clue '${clue.number} ${clue.direction.name}' exists, " + 'but #${clue.number} is not in the grid.', + ); + } + } + + final gridEntriesWithoutClue = uniqueGridNumbers.difference(clueNumbers); + for (final number in gridEntriesWithoutClue) { + errors.add('Grid contains #$number, but there is no clue for it.'); + } + + return errors; + } + + void updateClue(Clue updatedClue) { + if (_crosswordData == null) return; + + final newClues = List.from(_crosswordData!.clues); + final index = newClues.indexWhere((c) => c.id == updatedClue.id); + if (index != -1) { + newClues[index] = updatedClue; + _crosswordData = _crosswordData!.copyWith(clues: newClues); + onDataChanged?.call(); + notifyListeners(); + } + } + + bool _isInferringCrosswordData = false; + bool get isInferringCrosswordData => _isInferringCrosswordData; + + String? _inferenceError; + String? get inferenceError => _inferenceError; + + Future inferCrosswordData() async { + if (_selectedCrosswordImages.isEmpty) { + return; + } + + _isInferringCrosswordData = true; + _inferenceError = null; + notifyListeners(); + + try { + _crosswordData = await _geminiService.inferCrosswordData( + _selectedCrosswordImages, + ); + onDataChanged?.call(); + } on Exception catch (e) { + _inferenceError = e.toString(); + } + + _isInferringCrosswordData = false; + notifyListeners(); + } + + Future addSelectedCrosswordImages(List images) async { + _selectedCrosswordImages.addAll(images); + _crosswordData = null; // Clear old crossword data + + for (final image in images) { + _selectedCrosswordImagesData.add(await image.readAsBytes()); + } + onDataChanged?.call(); + notifyListeners(); + } + + void removeSelectedCrosswordImage(int index) { + _selectedCrosswordImages.removeAt(index); + _selectedCrosswordImagesData.removeAt(index); + _crosswordData = null; // Clear old crossword data + onDataChanged?.call(); + notifyListeners(); + } + + void setCellType( + int index, + GridCellType type, { + int? clueNumber, + bool clearClueNumber = false, + }) { + if (_crosswordData != null) { + final newCells = List.from(_crosswordData!.grid.cells); + final oldCell = newCells[index]; + + newCells[index] = GridCell( + type: type, + clueNumber: clearClueNumber ? null : clueNumber ?? oldCell.clueNumber, + acrossLetter: oldCell.acrossLetter, + downLetter: oldCell.downLetter, + userLetter: oldCell.userLetter, + ); + + final newGrid = _crosswordData!.grid.copyWith(cells: newCells); + _crosswordData = _crosswordData!.copyWith(grid: newGrid); + notifyListeners(); + } + } + + void updateCellLetter(int index, String letter) { + if (_crosswordData != null) { + final newCells = List.from(_crosswordData!.grid.cells); + final oldCell = newCells[index]; + + if (oldCell.type == GridCellType.inactive) return; + + if (letter.isEmpty) { + newCells[index] = oldCell.copyWith( + clearUserLetter: true, + clearAcrossLetter: true, + clearDownLetter: true, + ); + } else { + newCells[index] = oldCell.copyWith( + userLetter: letter.toUpperCase(), + clearAcrossLetter: true, + clearDownLetter: true, + ); + } + + final newGrid = _crosswordData!.grid.copyWith(cells: newCells); + _crosswordData = _crosswordData!.copyWith(grid: newGrid); + notifyListeners(); + } + } + + void setSolution(int clueNumber, ClueDirection direction, String? answer) { + if (_crosswordData == null) return; + + final newCells = List.from(_crosswordData!.grid.cells); + final startIndex = newCells.indexWhere( + (cell) => cell.clueNumber == clueNumber, + ); + + if (startIndex != -1 && answer != null) { + for (var i = 0; i < answer.length; i++) { + int cellIndex; + if (direction == ClueDirection.across) { + cellIndex = startIndex + i; + } else { + cellIndex = startIndex + (i * _crosswordData!.width); + } + + if (cellIndex < newCells.length && + newCells[cellIndex].type != GridCellType.inactive && + newCells[cellIndex].userLetter == null) { + final oldCell = newCells[cellIndex]; + newCells[cellIndex] = direction == ClueDirection.across + ? oldCell.copyWith(acrossLetter: answer[i].toUpperCase()) + : oldCell.copyWith(downLetter: answer[i].toUpperCase()); + } + } + } + + final newGrid = _crosswordData!.grid.copyWith(cells: newCells); + _crosswordData = _crosswordData!.copyWith(grid: newGrid); + + notifyListeners(); + } + + void reset() { + _selectedCrosswordImages.clear(); + _selectedCrosswordImagesData.clear(); + _crosswordData = null; + _isInferringCrosswordData = false; + _inferenceError = null; + notifyListeners(); + } +} diff --git a/crossword_companion/lib/state/puzzle_solver_state.dart b/crossword_companion/lib/state/puzzle_solver_state.dart new file mode 100644 index 0000000..6b2a4d1 --- /dev/null +++ b/crossword_companion/lib/state/puzzle_solver_state.dart @@ -0,0 +1,159 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../models/clue.dart'; +import '../models/clue_answer.dart'; +import '../models/grid_cell.dart'; +import '../models/todo_item.dart'; +import '../services/gemini_service.dart'; +import '../services/puzzle_solver.dart'; +import 'puzzle_data_state.dart'; + +class PuzzleSolverState with ChangeNotifier { + PuzzleSolverState({ + required PuzzleDataState puzzleDataState, + required GeminiService geminiService, + }) // + : _puzzleDataState = puzzleDataState, + _geminiService = geminiService; + + final PuzzleDataState _puzzleDataState; + final GeminiService _geminiService; + final _puzzleSolver = PuzzleSolver(); + + List _todos = []; + List get todos => _todos; + + void setTodos(List newTodos) { + _todos = newTodos; + notifyListeners(); + } + + void updateTodoStatus( + String todoId, + TodoStatus newStatus, { + ClueAnswer? answer, + bool isWrong = false, + }) { + final index = _todos.indexWhere((todo) => todo.id == todoId); + if (index != -1) { + _todos[index] = TodoItem( + id: _todos[index].id, + description: _todos[index].description, + status: newStatus, + answer: answer ?? _todos[index].answer, + isWrong: isWrong, + ); + notifyListeners(); + } + } + + bool _isSolving = false; + bool get isSolving => _isSolving; + set isSolving(bool value) { + _isSolving = value; + notifyListeners(); + } + + Future pauseSolving() async { + isSolving = false; + await _geminiService.cancelCurrentSolve(); + } + + Future resumeSolving() => solvePuzzle(isResuming: true); + + Future restartSolving() async { + if (_puzzleDataState.crosswordData == null) return; + + // Stop any existing solving loops and invalidate their results. + await pauseSolving(); + + // Clear AI-generated letters from the grid, preserving user-entered letters + final newCells = _puzzleDataState.crosswordData!.grid.cells + .map( + (cell) => + cell.copyWith(clearAcrossLetter: true, clearDownLetter: true), + ) + .toList(); + final newGrid = _puzzleDataState.crosswordData!.grid.copyWith( + cells: newCells, + ); + _puzzleDataState.updateCrosswordData( + _puzzleDataState.crosswordData!.copyWith(grid: newGrid), + ); + + // Reset todos + initializeTodos(); + + // Start solving + unawaited(solvePuzzle()); + } + + Future solvePuzzle({bool isResuming = false}) => _puzzleSolver.solve( + this, + _puzzleDataState, + _geminiService, + isResuming: isResuming, + ); + + void resetSolution() { + if (_puzzleDataState.crosswordData == null) return; + + // Clear letters from the grid by creating new cells + final newCells = _puzzleDataState.crosswordData!.grid.cells + .map( + (cell) => GridCell( + type: cell.type, + clueNumber: cell.clueNumber, + acrossLetter: null, + downLetter: null, + userLetter: null, + ), + ) + .toList(); + final newGrid = _puzzleDataState.crosswordData!.grid.copyWith( + cells: newCells, + ); + _puzzleDataState.updateCrosswordData( + _puzzleDataState.crosswordData!.copyWith(grid: newGrid), + ); + + // Reset todos + initializeTodos(); + + notifyListeners(); + } + + void initializeTodos() { + if (_puzzleDataState.crosswordData == null) { + _todos = []; + notifyListeners(); + return; + } + + final newTodos = _puzzleDataState.crosswordData!.clues + .map( + (clue) => TodoItem( + id: clue.id, + description: + '${clue.number}' + '${clue.direction == ClueDirection.across ? 'A' : 'D'}. ' + '${clue.text}', + ), + ) + .toList(); + + setTodos(newTodos); + } + + void reset() { + _todos = []; + _isSolving = false; + notifyListeners(); + } +} diff --git a/crossword_companion/lib/styles.dart b/crossword_companion/lib/styles.dart new file mode 100644 index 0000000..255d7dc --- /dev/null +++ b/crossword_companion/lib/styles.dart @@ -0,0 +1,29 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +const Color conflictColor = Colors.red; +const Color matchingColor = Colors.green; +const Color defaultLetterColor = Colors.black87; +const Color userLetterColor = Colors.blue; +const Color inactiveCellColor = Color(0xFFBDBDBD); // A light grey +const Color emptyCellColor = Colors.white; +const Color cellBorderColor = Colors.grey; + +const TextStyle clueNumberStyle = TextStyle(fontSize: 8); +const TextStyle letterStyle = TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, +); + +final ButtonStyle secondaryActionButtonStyle = ElevatedButton.styleFrom( + disabledBackgroundColor: Colors.transparent, + disabledForegroundColor: const Color.fromRGBO(0, 0, 0, 0.7), +); + +final ButtonStyle primaryActionButtonStyle = ElevatedButton.styleFrom( + backgroundColor: Colors.black, + foregroundColor: Colors.white, +); diff --git a/crossword_companion/lib/widgets/clue_list.dart b/crossword_companion/lib/widgets/clue_list.dart new file mode 100644 index 0000000..23990f2 --- /dev/null +++ b/crossword_companion/lib/widgets/clue_list.dart @@ -0,0 +1,124 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../models/clue.dart'; + +class ClueList extends StatelessWidget { + const ClueList({required this.clues, required this.onClueUpdated, super.key}); + final List clues; + final Function(Clue) onClueUpdated; + + @override + Widget build(BuildContext context) { + final acrossClues = clues + .where((c) => c.direction == ClueDirection.across) + .toList(); + final downClues = clues + .where((c) => c.direction == ClueDirection.down) + .toList(); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Across', style: Theme.of(context).textTheme.headlineSmall), + ...acrossClues.map((c) => _buildClueItem(context, c)), + ], + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Down', style: Theme.of(context).textTheme.headlineSmall), + ...downClues.map((c) => _buildClueItem(context, c)), + ], + ), + ), + ], + ); + } + + Widget _buildClueItem(BuildContext context, Clue clue) => InkWell( + onTap: () => _editClue(context, clue), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text('${clue.number}. ${clue.text}'), + ), + ); + + void _editClue(BuildContext context, Clue clue) { + final textController = TextEditingController(text: clue.text); + final numberController = TextEditingController( + text: clue.number.toString(), + ); + final focusNode = FocusNode(); + + unawaited( + showDialog( + context: context, + builder: (context) => KeyboardListener( + focusNode: focusNode, + onKeyEvent: (event) { + if (event is KeyDownEvent) { + if (event.logicalKey == LogicalKeyboardKey.escape) { + Navigator.of(context).pop(); + } else if (event.logicalKey == LogicalKeyboardKey.enter) { + final newClue = clue.copyWith( + text: textController.text, + number: int.tryParse(numberController.text) ?? clue.number, + ); + onClueUpdated(newClue); + Navigator.of(context).pop(); + } + } + }, + child: AlertDialog( + title: Text('Edit Clue ${clue.number} ${clue.direction.name}'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: numberController, + decoration: const InputDecoration(labelText: 'Clue Number'), + keyboardType: TextInputType.number, + autofocus: true, + ), + TextField( + controller: textController, + decoration: const InputDecoration(labelText: 'Clue Text'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + final newClue = clue.copyWith( + text: textController.text, + number: int.tryParse(numberController.text) ?? clue.number, + ); + onClueUpdated(newClue); + Navigator.of(context).pop(); + }, + child: const Text('Save'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/crossword_companion/lib/widgets/grid_view.dart b/crossword_companion/lib/widgets/grid_view.dart new file mode 100644 index 0000000..ccd6019 --- /dev/null +++ b/crossword_companion/lib/widgets/grid_view.dart @@ -0,0 +1,101 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +import '../models/crossword_grid.dart'; +import '../models/grid_cell.dart'; +import '../styles.dart'; + +class CrosswordGridView extends StatefulWidget { + const CrosswordGridView({ + required this.grid, + required this.onCellTapped, + super.key, + }); + final CrosswordGrid grid; + final Function(int) onCellTapped; + + @override + State createState() => _CrosswordGridViewState(); +} + +class _CrosswordGridViewState extends State { + int _hoveredIndex = -1; + + @override + Widget build(BuildContext context) => GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: widget.grid.width, + ), + itemCount: widget.grid.cells.length, + itemBuilder: (context, index) { + final cell = widget.grid.cells[index]; + final letter = cell.userLetter ?? cell.acrossLetter ?? cell.downLetter; + final hasConflict = + cell.acrossLetter != null && + cell.downLetter != null && + cell.acrossLetter != cell.downLetter; + final hasMatch = + cell.acrossLetter != null && + cell.downLetter != null && + cell.acrossLetter == cell.downLetter; + + final letterColor = cell.userLetter != null + ? userLetterColor + : hasConflict + ? conflictColor + : hasMatch + ? matchingColor + : defaultLetterColor; + + var cellColor = cell.type == GridCellType.inactive + ? Colors.black + : emptyCellColor; + + if (_hoveredIndex == index) { + cellColor = Color.alphaBlend( + const Color.fromRGBO(0, 0, 0, 0.2), + cellColor, + ); + } + + return MouseRegion( + onEnter: (_) => setState(() => _hoveredIndex = index), + onExit: (_) => setState(() => _hoveredIndex = -1), + child: GestureDetector( + onTap: () => widget.onCellTapped(index), + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: cellBorderColor), + color: cellColor, + ), + child: Stack( + children: [ + if (cell.clueNumber != null) + Positioned( + top: 2, + left: 2, + child: Text( + cell.clueNumber.toString(), + style: clueNumberStyle, + ), + ), + if (letter != null) + Center( + child: Text( + letter, + style: letterStyle.copyWith(color: letterColor), + ), + ), + ], + ), + ), + ), + ); + }, + ); +} diff --git a/crossword_companion/lib/widgets/selected_images_view.dart b/crossword_companion/lib/widgets/selected_images_view.dart new file mode 100644 index 0000000..b855ecd --- /dev/null +++ b/crossword_companion/lib/widgets/selected_images_view.dart @@ -0,0 +1,46 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +class SelectedImagesView extends StatelessWidget { + const SelectedImagesView({ + required this.imagesData, + super.key, + this.onRemoveImage, + }); + final List imagesData; + final Function(int)? onRemoveImage; + + @override + Widget build(BuildContext context) => SizedBox( + height: 500, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: imagesData.length, + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.only(right: 8), + child: Stack( + children: [ + Image.memory(imagesData[index], fit: BoxFit.contain), + if (onRemoveImage != null) + Positioned( + top: 0, + right: 0, + child: IconButton( + icon: const Icon( + Icons.remove_circle_outline, + color: Colors.red, + ), + onPressed: () => onRemoveImage!(index), + ), + ), + ], + ), + ), + ), + ); +} diff --git a/crossword_companion/lib/widgets/step1_select_image.dart b/crossword_companion/lib/widgets/step1_select_image.dart new file mode 100644 index 0000000..e35d62f --- /dev/null +++ b/crossword_companion/lib/widgets/step1_select_image.dart @@ -0,0 +1,98 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../platform/platform.dart'; +import '../services/image_picker_service.dart'; +import '../state/app_step_state.dart'; +import '../state/puzzle_data_state.dart'; +import '../styles.dart'; +import 'selected_images_view.dart'; +import 'step_activation_mixin.dart'; + +class StepOneSelectImage extends StatefulWidget { + const StepOneSelectImage({required this.isActive, super.key}); + + final bool isActive; + + @override + State createState() => _StepOneSelectImageState(); +} + +class _StepOneSelectImageState extends State + with StepActivationMixin { + final _imagePickerService = ImagePickerService(); + + @override + bool get isActive => widget.isActive; + + @override + void onActivated() { + final puzzleDataState = Provider.of( + context, + listen: false, + ); + assert(puzzleDataState.isGridClear); + } + + @override + Widget build(BuildContext context) { + final puzzleDataState = Provider.of(context); + final appStepState = Provider.of(context); + final areImagesSelected = + puzzleDataState.selectedCrosswordImages.isNotEmpty; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (areImagesSelected) + SelectedImagesView( + imagesData: puzzleDataState.selectedCrosswordImagesData, + onRemoveImage: puzzleDataState.removeSelectedCrosswordImage, + ), + const SizedBox(height: 16), + Wrap( + alignment: WrapAlignment.start, + spacing: 8, + runSpacing: 8, + children: [ + ElevatedButton.icon( + icon: const Icon(Icons.photo_library), + label: const Text('Gallery'), + onPressed: () async { + final images = await _imagePickerService + .pickMultipleImagesFromGallery(); + await puzzleDataState.addSelectedCrosswordImages(images); + }, + style: secondaryActionButtonStyle, + ), + ElevatedButton.icon( + icon: const Icon(Icons.camera_alt), + label: const Text('Photo'), + onPressed: isMobile() + ? () async { + final image = await _imagePickerService + .pickImageFromCamera(); + if (image != null) { + await puzzleDataState.addSelectedCrosswordImages([ + image, + ]); + } + } + : null, + style: secondaryActionButtonStyle, + ), + ElevatedButton( + onPressed: areImagesSelected ? appStepState.nextStep : null, + style: primaryActionButtonStyle, + child: const Text('Next'), + ), + ], + ), + ], + ); + } +} diff --git a/crossword_companion/lib/widgets/step2_verify_grid_size.dart b/crossword_companion/lib/widgets/step2_verify_grid_size.dart new file mode 100644 index 0000000..299e7ca --- /dev/null +++ b/crossword_companion/lib/widgets/step2_verify_grid_size.dart @@ -0,0 +1,248 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../state/app_step_state.dart'; +import '../state/puzzle_data_state.dart'; +import '../styles.dart'; +import 'selected_images_view.dart'; +import 'step_activation_mixin.dart'; + +class StepTwoVerifyGridSize extends StatefulWidget { + const StepTwoVerifyGridSize({required this.isActive, super.key}); + + final bool isActive; + + @override + State createState() => _StepTwoVerifyGridSizeState(); +} + +class _StepTwoVerifyGridSizeState extends State + with StepActivationMixin { + int? _newWidth; + int? _newHeight; + + @override + bool get isActive => widget.isActive; + + @override + void onActivated() { + final puzzleDataState = Provider.of( + context, + listen: false, + ); + assert(puzzleDataState.isGridClear); + + if (puzzleDataState.selectedCrosswordImages.isNotEmpty && + puzzleDataState.crosswordData == null && + !puzzleDataState.isInferringCrosswordData && + puzzleDataState.inferenceError == null) { + unawaited(puzzleDataState.inferCrosswordData()); + } + } + + @override + Widget build(BuildContext context) { + final puzzleDataState = Provider.of(context); + final appStepState = Provider.of(context); + + if (puzzleDataState.isInferringCrosswordData) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(height: 16), + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Inferring crossword data...'), + SizedBox(height: 8), + Text('(This could take a couple of minutes)'), + ], + ), + ); + } + + if (puzzleDataState.inferenceError != null) { + return Column( + children: [ + Text( + 'Error: ${puzzleDataState.inferenceError}\n' + 'Please go back and try again.', + style: const TextStyle(color: Colors.red), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ElevatedButton( + onPressed: appStepState.previousStep, + child: const Text('Back'), + ), + ], + ), + ], + ); + } + + if (puzzleDataState.crosswordData == null) { + // This can happen if the inference fails. + return Column( + children: [ + const Text( + 'Could not infer crossword data. Please go back and try again.', + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ElevatedButton( + onPressed: appStepState.previousStep, + child: const Text('Back'), + ), + ], + ), + ], + ); + } + + final crosswordData = puzzleDataState.crosswordData!; + _newWidth ??= crosswordData.width; + _newHeight ??= crosswordData.height; + + assert(puzzleDataState.selectedCrosswordImagesData.isNotEmpty); + + return Column( + children: [ + SelectedImagesView( + imagesData: puzzleDataState.selectedCrosswordImagesData, + ), + const SizedBox(height: 24), + Align( + alignment: Alignment.centerLeft, + child: Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 32, + runSpacing: 32, + children: [ + // Rows Stepper + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Column( + children: [ + _buildCircularChevronButton( + icon: Icons.keyboard_arrow_up, + onPressed: () { + setState(() { + _newHeight = (_newHeight ?? 0) + 1; + }); + }, + ), + const SizedBox(height: 16), + _buildCircularChevronButton( + icon: Icons.keyboard_arrow_down, + onPressed: () { + if ((_newHeight ?? 0) > 1) { + setState(() { + _newHeight = (_newHeight ?? 0) - 1; + }); + } + }, + ), + ], + ), + const SizedBox(width: 8), + Text( + '${_newHeight ?? 0} Rows', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + // Columns Stepper + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildCircularChevronButton( + icon: Icons.keyboard_arrow_left, + onPressed: () { + if ((_newWidth ?? 0) > 1) { + setState(() { + _newWidth = (_newWidth ?? 0) - 1; + }); + } + }, + ), + const SizedBox(width: 8), + Text( + '${_newWidth ?? 0} Columns', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(width: 8), + _buildCircularChevronButton( + icon: Icons.keyboard_arrow_right, + onPressed: () { + setState(() { + _newWidth = (_newWidth ?? 0) + 1; + }); + }, + ), + ], + ), + ], + ), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + ElevatedButton( + onPressed: appStepState.previousStep, + style: secondaryActionButtonStyle, + child: const Text('Back'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: puzzleDataState.crosswordData != null + ? () { + final currentData = puzzleDataState.crosswordData!; + puzzleDataState.updateCrosswordData( + currentData.copyWith( + width: _newWidth ?? currentData.width, + height: _newHeight ?? currentData.height, + ), + ); + + appStepState.nextStep(); + } + : null, + style: primaryActionButtonStyle, + child: const Text('Next'), + ), + ], + ), + ], + ); + } + + Widget _buildCircularChevronButton({ + required IconData icon, + required VoidCallback onPressed, + }) => OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + shape: const CircleBorder(), + padding: const EdgeInsets.all(8), + side: const BorderSide(color: Colors.black), + backgroundColor: Colors.transparent, + ), + child: Icon(icon, color: Colors.black), + ); +} diff --git a/crossword_companion/lib/widgets/step3_verify_grid_contents.dart b/crossword_companion/lib/widgets/step3_verify_grid_contents.dart new file mode 100644 index 0000000..dbd1cec --- /dev/null +++ b/crossword_companion/lib/widgets/step3_verify_grid_contents.dart @@ -0,0 +1,226 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../models/grid_cell.dart'; +import '../state/app_step_state.dart'; +import '../state/puzzle_data_state.dart'; +import '../styles.dart'; +import 'grid_view.dart'; +import 'selected_images_view.dart'; +import 'step_activation_mixin.dart'; + +class StepThreeVerifyGridContents extends StatefulWidget { + const StepThreeVerifyGridContents({required this.isActive, super.key}); + + final bool isActive; + + @override + State createState() => + _StepThreeVerifyGridContentsState(); +} + +class _StepThreeVerifyGridContentsState + extends State + with StepActivationMixin { + @override + bool get isActive => widget.isActive; + + @override + void onActivated() { + final puzzleDataState = Provider.of( + context, + listen: false, + ); + assert(puzzleDataState.isGridClear); + } + + void _showEditCellDialog(BuildContext context, int index) { + final puzzleDataState = Provider.of( + context, + listen: false, + ); + unawaited( + showDialog( + context: context, + builder: (context) => SimpleDialog( + title: const Text('Edit Cell'), + children: [ + SimpleDialogOption( + onPressed: () { + puzzleDataState.setCellType( + index, + GridCellType.empty, + clearClueNumber: true, + ); + Navigator.pop(context); + }, + child: const Text('Empty (white)'), + ), + SimpleDialogOption( + onPressed: () { + puzzleDataState.setCellType( + index, + GridCellType.inactive, + clearClueNumber: true, + ); + Navigator.pop(context); + }, + child: const Text('Inactive (black)'), + ), + SimpleDialogOption( + onPressed: () { + Navigator.pop(context); + _showEnterNumberDialog(context, index); + }, + child: const Text('Numbered'), + ), + ], + ), + ), + ); + } + + void _showEnterNumberDialog(BuildContext context, int index) { + final puzzleDataState = Provider.of( + context, + listen: false, + ); + final controller = TextEditingController(); + final errorNotifier = ValueNotifier(null); + final focusNode = FocusNode(); + + unawaited( + showDialog( + context: context, + builder: (context) => KeyboardListener( + focusNode: focusNode, + onKeyEvent: (event) { + if (event is KeyDownEvent) { + if (event.logicalKey == LogicalKeyboardKey.escape) { + Navigator.pop(context); + } else if (event.logicalKey == LogicalKeyboardKey.enter) { + final number = int.tryParse(controller.text); + if (number != null) { + puzzleDataState.setCellType( + index, + GridCellType.empty, + clueNumber: number, + ); + Navigator.pop(context); + } + } + } + }, + child: ValueListenableBuilder( + valueListenable: errorNotifier, + builder: (context, errorText, child) => AlertDialog( + title: const Text('Enter Number'), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + autofocus: true, + decoration: InputDecoration(errorText: errorText), + onChanged: (value) { + if (int.tryParse(value) == null) { + errorNotifier.value = 'Invalid number'; + } else { + errorNotifier.value = null; + } + }, + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: errorText == null + ? () { + final number = int.tryParse(controller.text); + if (number != null) { + puzzleDataState.setCellType( + index, + GridCellType.empty, + clueNumber: number, + ); + Navigator.pop(context); + } + } + : null, + child: const Text('OK'), + ), + ], + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) => + Consumer2( + builder: (context, puzzleDataState, appStepState, child) { + if (puzzleDataState.crosswordData == null) { + return const SizedBox.shrink(); + } + assert(puzzleDataState.selectedCrosswordImagesData.isNotEmpty); + return Column( + children: [ + SelectedImagesView( + imagesData: puzzleDataState.selectedCrosswordImagesData, + ), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerLeft, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: CrosswordGridView( + key: ValueKey(puzzleDataState.crosswordData!.grid), + grid: puzzleDataState.crosswordData!.grid, + onCellTapped: (index) { + _showEditCellDialog(context, index); + }, + ), + ), + Text( + 'Tap a cell to correct its contents.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + ElevatedButton( + onPressed: appStepState.previousStep, + style: secondaryActionButtonStyle, + child: const Text('Back'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: appStepState.nextStep, + style: primaryActionButtonStyle, + child: const Text('Next'), + ), + ], + ), + ], + ); + }, + ); +} diff --git a/crossword_companion/lib/widgets/step4_verify_clue_text.dart b/crossword_companion/lib/widgets/step4_verify_clue_text.dart new file mode 100644 index 0000000..3e9ed97 --- /dev/null +++ b/crossword_companion/lib/widgets/step4_verify_clue_text.dart @@ -0,0 +1,115 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../state/app_step_state.dart'; +import '../state/puzzle_data_state.dart'; +import '../styles.dart'; +import 'clue_list.dart'; +import 'selected_images_view.dart'; + +class StepFourVerifyClueText extends StatefulWidget { + const StepFourVerifyClueText({required this.isActive, super.key}); + + final bool isActive; + + @override + State createState() => _StepFourVerifyClueTextState(); +} + +class _StepFourVerifyClueTextState extends State { + @override + Widget build(BuildContext context) { + final puzzleDataState = Provider.of(context); + final appStepState = Provider.of(context); + final areCluesSet = + puzzleDataState.crosswordData != null && + puzzleDataState.crosswordData!.clues.isNotEmpty; + + if (puzzleDataState.crosswordData == null) { + return const Center(child: CircularProgressIndicator()); + } + + final crosswordData = puzzleDataState.crosswordData!; + assert(puzzleDataState.selectedCrosswordImagesData.isNotEmpty); + + return SingleChildScrollView( + child: Column( + children: [ + SelectedImagesView( + imagesData: puzzleDataState.selectedCrosswordImagesData, + ), + const SizedBox(height: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ClueList( + clues: crosswordData.clues, + onClueUpdated: puzzleDataState.updateClue, + ), + const SizedBox(height: 8), + Text( + 'Tap a clue to edit its text.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + ElevatedButton( + onPressed: appStepState.previousStep, + style: secondaryActionButtonStyle, + child: const Text('Back'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: areCluesSet + ? () { + final errors = puzzleDataState.validateGridAndClues(); + if (errors.isNotEmpty) { + unawaited( + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Validation Errors'), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: errors + .map((e) => Text('- $e')) + .toList(), + ), + ), + actions: [ + TextButton( + onPressed: () => + Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ), + ); + } else { + appStepState.nextStep(); + } + } + : null, + style: primaryActionButtonStyle, + child: const Text('Solve'), + ), + ], + ), + ], + ), + ); + } +} diff --git a/crossword_companion/lib/widgets/step5_solve_puzzle.dart b/crossword_companion/lib/widgets/step5_solve_puzzle.dart new file mode 100644 index 0000000..9730baf --- /dev/null +++ b/crossword_companion/lib/widgets/step5_solve_puzzle.dart @@ -0,0 +1,222 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../models/todo_item.dart'; +import '../state/app_step_state.dart'; +import '../state/puzzle_data_state.dart'; +import '../state/puzzle_solver_state.dart'; +import '../styles.dart'; +import 'grid_view.dart'; +import 'step_activation_mixin.dart'; +import 'todo_list_widget.dart'; + +class StepFiveSolvePuzzle extends StatefulWidget { + const StepFiveSolvePuzzle({required this.isActive, super.key}); + + final bool isActive; + + @override + State createState() => _StepFiveSolvePuzzleState(); +} + +class _StepFiveSolvePuzzleState extends State + with StepActivationMixin { + @override + bool get isActive => widget.isActive; + + @override + void onActivated() { + final puzzleSolverState = Provider.of( + context, + listen: false, + ); + + // Start solving only if we are not already solving and there are todos. + if (!puzzleSolverState.isSolving && + puzzleSolverState.todos.any((t) => t.status != TodoStatus.done)) { + unawaited(puzzleSolverState.solvePuzzle()); + } + } + + void _showEditLetterDialog(BuildContext context, int index) { + final puzzleDataState = Provider.of( + context, + listen: false, + ); + final controller = TextEditingController(); + final focusNode = FocusNode(); + + unawaited( + showDialog( + context: context, + builder: (context) => KeyboardListener( + focusNode: focusNode, + onKeyEvent: (event) { + if (event is KeyDownEvent) { + if (event.logicalKey == LogicalKeyboardKey.escape) { + Navigator.pop(context); + } else if (event.logicalKey == LogicalKeyboardKey.enter) { + puzzleDataState.updateCellLetter(index, controller.text); + Navigator.pop(context); + } + } + }, + child: AlertDialog( + title: const Text('Letter'), + content: TextField( + controller: controller, + autofocus: true, + maxLength: 1, + ), + actions: [ + TextButton( + onPressed: () { + puzzleDataState.updateCellLetter(index, ''); + Navigator.pop(context); + }, + child: const Text('Delete'), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + puzzleDataState.updateCellLetter(index, controller.text); + Navigator.pop(context); + }, + child: const Text('OK'), + ), + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final puzzleDataState = Provider.of(context); + final puzzleSolverState = Provider.of(context); + final appStepState = Provider.of(context); + + if (puzzleDataState.crosswordData == null) { + return const Center(child: CircularProgressIndicator()); + } + + final crosswordData = puzzleDataState.crosswordData!; + return SingleChildScrollView( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Align( + alignment: Alignment.centerLeft, + child: Wrap( + alignment: WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: [ + if (puzzleSolverState.isSolving) + ElevatedButton( + onPressed: puzzleSolverState.pauseSolving, + child: const Text('Pause'), + ), + if (!puzzleSolverState.isSolving && + puzzleSolverState.todos.any( + (t) => t.status != TodoStatus.done, + )) + ElevatedButton( + onPressed: puzzleSolverState.resumeSolving, + child: const Text('Resume'), + ), + ElevatedButton( + onPressed: puzzleSolverState.restartSolving, + child: const Text('Restart'), + ), + ElevatedButton( + onPressed: () async { + if (puzzleSolverState.isSolving) { + await puzzleSolverState.pauseSolving(); + } + puzzleSolverState.resetSolution(); + appStepState.previousStep(); + }, + style: secondaryActionButtonStyle, + child: const Text('Back'), + ), + ElevatedButton( + onPressed: () { + appStepState.reset(); + puzzleDataState.reset(); + puzzleSolverState.reset(); + }, + style: primaryActionButtonStyle, + child: const Text('New Puzzle'), + ), + ], + ), + ), + ), + LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 600) { + // Narrow screen: stack grid and clues vertically + return Column( + children: [ + CrosswordGridView( + grid: crosswordData.grid, + onCellTapped: (index) => + _showEditLetterDialog(context, index), + ), + const SizedBox(height: 16), + TodoListWidget(todos: puzzleSolverState.todos), + ], + ); + } else { + // Wide screen: display grid and clues side-by-side + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: CrosswordGridView( + grid: crosswordData.grid, + onCellTapped: (index) => + _showEditLetterDialog(context, index), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: TodoListWidget(todos: puzzleSolverState.todos), + ), + ], + ); + } + }, + ), + if (puzzleSolverState.isSolving) + const Padding( + padding: EdgeInsets.all(16), + child: Column( + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Solving puzzle...'), + ], + ), + ), + ], + ), + ); + } +} diff --git a/crossword_companion/lib/widgets/step_activation_mixin.dart b/crossword_companion/lib/widgets/step_activation_mixin.dart new file mode 100644 index 0000000..cb5ef23 --- /dev/null +++ b/crossword_companion/lib/widgets/step_activation_mixin.dart @@ -0,0 +1,28 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +mixin StepActivationMixin on State { + bool get isActive; + + @override + void initState() { + super.initState(); + if (isActive) { + onActivated(); + } + } + + @override + void didUpdateWidget(covariant T oldWidget) { + super.didUpdateWidget(oldWidget); + final oldIsActive = (oldWidget as dynamic).isActive as bool; + if (!oldIsActive && isActive) { + WidgetsBinding.instance.addPostFrameCallback((_) => onActivated()); + } + } + + void onActivated(); +} diff --git a/crossword_companion/lib/widgets/todo_list_widget.dart b/crossword_companion/lib/widgets/todo_list_widget.dart new file mode 100644 index 0000000..3608096 --- /dev/null +++ b/crossword_companion/lib/widgets/todo_list_widget.dart @@ -0,0 +1,63 @@ +// Copyright 2025 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +import '../models/todo_item.dart'; +import '../styles.dart'; + +class TodoListWidget extends StatelessWidget { + const TodoListWidget({required this.todos, super.key}); + final List todos; + + @override + Widget build(BuildContext context) => ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: todos.length, + itemBuilder: (context, index) { + final todo = todos[index]; + final confidence = todo.answer?.confidence ?? 0; + final confidenceString = '(${(confidence * 100).toStringAsFixed(0)}%)'; + + return ListTile( + title: RichText( + text: TextSpan( + style: DefaultTextStyle.of(context).style, + children: [ + TextSpan( + text: todo.description, + style: TextStyle(color: _getColorForStatus(todo.status)), + ), + if (todo.answer != null) + TextSpan( + text: ': ${todo.answer!.answer} $confidenceString', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + if (todo.isWrong) + const TextSpan( + text: ' -- WRONG', + style: TextStyle( + fontWeight: FontWeight.bold, + color: conflictColor, + ), + ), + ], + ), + ), + ); + }, + ); + + Color _getColorForStatus(TodoStatus status) { + switch (status) { + case TodoStatus.done: + return matchingColor; + case TodoStatus.inProgress: + return defaultLetterColor; + case TodoStatus.notDone: + return conflictColor; + } + } +} diff --git a/crossword_companion/macos/.gitignore b/crossword_companion/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/crossword_companion/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/crossword_companion/macos/Flutter/Flutter-Debug.xcconfig b/crossword_companion/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/crossword_companion/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/crossword_companion/macos/Flutter/Flutter-Release.xcconfig b/crossword_companion/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/crossword_companion/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/crossword_companion/macos/Flutter/GeneratedPluginRegistrant.swift b/crossword_companion/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..4fbd8c2 --- /dev/null +++ b/crossword_companion/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,20 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import file_selector_macos +import firebase_app_check +import firebase_auth +import firebase_core +import path_provider_foundation + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) + FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) +} diff --git a/crossword_companion/macos/Podfile b/crossword_companion/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/crossword_companion/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/crossword_companion/macos/Runner.xcodeproj/project.pbxproj b/crossword_companion/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..6181cbe --- /dev/null +++ b/crossword_companion/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,805 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 2C5552BFA515EAE7C23BBD09 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2BB8EB15CF17CFFF62FEF9F3 /* Pods_Runner.framework */; }; + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 7BD8C50F6CBF75375DEDE1C2 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 873B2301191E89B74460B42F /* Pods_RunnerTests.framework */; }; + A4922017ABFC851FCDC34A72 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5F8A2216EFD242B3581546A4 /* GoogleService-Info.plist */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 29834DDA4A2E7DDBCA35D5C9 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 2BB8EB15CF17CFFF62FEF9F3 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* crossword_companion.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = crossword_companion.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 3C8FE71684FD386253F3866B /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 51CF10A9077FEF3BBCCC9127 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 5F8A2216EFD242B3581546A4 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; + 72D139987B07946E5B6ED957 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 873B2301191E89B74460B42F /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 87E6AA3AEEE54C3582E36CB5 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + C3F9775AD62EC69D97094235 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7BD8C50F6CBF75375DEDE1C2 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2C5552BFA515EAE7C23BBD09 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 2E851B3DC04C96B2A4CCCB7C /* Pods */ = { + isa = PBXGroup; + children = ( + 72D139987B07946E5B6ED957 /* Pods-Runner.debug.xcconfig */, + 87E6AA3AEEE54C3582E36CB5 /* Pods-Runner.release.xcconfig */, + 51CF10A9077FEF3BBCCC9127 /* Pods-Runner.profile.xcconfig */, + C3F9775AD62EC69D97094235 /* Pods-RunnerTests.debug.xcconfig */, + 3C8FE71684FD386253F3866B /* Pods-RunnerTests.release.xcconfig */, + 29834DDA4A2E7DDBCA35D5C9 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 2E851B3DC04C96B2A4CCCB7C /* Pods */, + 5F8A2216EFD242B3581546A4 /* GoogleService-Info.plist */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* crossword_companion.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 2BB8EB15CF17CFFF62FEF9F3 /* Pods_Runner.framework */, + 873B2301191E89B74460B42F /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 3F80C34AF8E73196404FC3B9 /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + C873AA7B66D3F8DA39685847 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + B84CE4AB6650FEF00A67B379 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* crossword_companion.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + A4922017ABFC851FCDC34A72 /* GoogleService-Info.plist in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 3F80C34AF8E73196404FC3B9 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + B84CE4AB6650FEF00A67B379 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + C873AA7B66D3F8DA39685847 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = C3F9775AD62EC69D97094235 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.crosswordCompanion.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/crossword_companion.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/crossword_companion"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3C8FE71684FD386253F3866B /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.crosswordCompanion.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/crossword_companion.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/crossword_companion"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 29834DDA4A2E7DDBCA35D5C9 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.crosswordCompanion.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/crossword_companion.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/crossword_companion"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/crossword_companion/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/crossword_companion/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/crossword_companion/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/crossword_companion/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/crossword_companion/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..b7e33a8 --- /dev/null +++ b/crossword_companion/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crossword_companion/macos/Runner.xcworkspace/contents.xcworkspacedata b/crossword_companion/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/crossword_companion/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/crossword_companion/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/crossword_companion/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/crossword_companion/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/crossword_companion/macos/Runner/AppDelegate.swift b/crossword_companion/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/crossword_companion/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/crossword_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/crossword_companion/macos/Runner/Base.lproj/MainMenu.xib b/crossword_companion/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/crossword_companion/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crossword_companion/macos/Runner/Configs/AppInfo.xcconfig b/crossword_companion/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..d132d0f --- /dev/null +++ b/crossword_companion/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = crossword_companion + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.crosswordCompanion + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. diff --git a/crossword_companion/macos/Runner/Configs/Debug.xcconfig b/crossword_companion/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/crossword_companion/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/crossword_companion/macos/Runner/Configs/Release.xcconfig b/crossword_companion/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/crossword_companion/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/crossword_companion/macos/Runner/Configs/Warnings.xcconfig b/crossword_companion/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/crossword_companion/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/crossword_companion/macos/Runner/DebugProfile.entitlements b/crossword_companion/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..8165abf --- /dev/null +++ b/crossword_companion/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,16 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-only + + + diff --git a/crossword_companion/macos/Runner/GoogleService-Info.plist b/crossword_companion/macos/Runner/GoogleService-Info.plist new file mode 100644 index 0000000..337c158 --- /dev/null +++ b/crossword_companion/macos/Runner/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyDxmtUAXptAZ4ioCP0XK8qwal__JIc_cuQ + GCM_SENDER_ID + 775552889844 + PLIST_VERSION + 1 + BUNDLE_ID + com.example.crosswordCompanion + PROJECT_ID + crossword-companion-b7759 + STORAGE_BUCKET + crossword-companion-b7759.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:775552889844:ios:7ff0fbf45b8bfd98f03fa1 + + \ No newline at end of file diff --git a/crossword_companion/macos/Runner/Info.plist b/crossword_companion/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/crossword_companion/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/crossword_companion/macos/Runner/MainFlutterWindow.swift b/crossword_companion/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/crossword_companion/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/crossword_companion/macos/Runner/Release.entitlements b/crossword_companion/macos/Runner/Release.entitlements new file mode 100644 index 0000000..741903e --- /dev/null +++ b/crossword_companion/macos/Runner/Release.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-only + + + diff --git a/crossword_companion/macos/RunnerTests/RunnerTests.swift b/crossword_companion/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/crossword_companion/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/crossword_companion/pubspec.yaml b/crossword_companion/pubspec.yaml new file mode 100644 index 0000000..e50792c --- /dev/null +++ b/crossword_companion/pubspec.yaml @@ -0,0 +1,31 @@ +name: crossword_companion +description: "A new Flutter project." +publish_to: "none" +version: 0.1.0 + +environment: + sdk: ^3.8.1 + +dependencies: + firebase_ai: ^3.0.0 + firebase_core: ^4.0.0 + flutter: + sdk: flutter + flutter_svg: ^2.2.1 + google_fonts: ^6.3.2 + image_picker: ^1.1.2 + path: ^1.9.0 + provider: ^6.1.5 + uuid: ^4.4.0 + vector_graphics: ^1.1.0 + http: ^1.2.1 + +dev_dependencies: + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true + assets: + - assets/cc-title.svg.vec # compiled SVG diff --git a/crossword_companion/readme/screen-recording.mov b/crossword_companion/readme/screen-recording.mov new file mode 100644 index 0000000..1182d6f Binary files /dev/null and b/crossword_companion/readme/screen-recording.mov differ diff --git a/crossword_companion/sample_puzzle_images/test1.png b/crossword_companion/sample_puzzle_images/test1.png new file mode 100644 index 0000000..2a4d732 Binary files /dev/null and b/crossword_companion/sample_puzzle_images/test1.png differ diff --git a/crossword_companion/sample_puzzle_images/test2a.png b/crossword_companion/sample_puzzle_images/test2a.png new file mode 100644 index 0000000..c243a7a Binary files /dev/null and b/crossword_companion/sample_puzzle_images/test2a.png differ diff --git a/crossword_companion/sample_puzzle_images/test2b.png b/crossword_companion/sample_puzzle_images/test2b.png new file mode 100644 index 0000000..bc76ff9 Binary files /dev/null and b/crossword_companion/sample_puzzle_images/test2b.png differ diff --git a/crossword_companion/sample_puzzle_images/test2c.png b/crossword_companion/sample_puzzle_images/test2c.png new file mode 100644 index 0000000..ebf4e3a Binary files /dev/null and b/crossword_companion/sample_puzzle_images/test2c.png differ diff --git a/crossword_companion/sample_puzzle_images/test3.png b/crossword_companion/sample_puzzle_images/test3.png new file mode 100644 index 0000000..b9663f2 Binary files /dev/null and b/crossword_companion/sample_puzzle_images/test3.png differ diff --git a/crossword_companion/specs/design.md b/crossword_companion/specs/design.md new file mode 100644 index 0000000..5139621 --- /dev/null +++ b/crossword_companion/specs/design.md @@ -0,0 +1,106 @@ +# Crossword Companion Design + +## 1. Architecture Overview + +The application follows a standard Flutter project structure, using the `firebase_ai` package for generative AI functionality. It is built to work on Android, iOS, web, and macOS. The application logic is centered around a decoupled state management system that uses three distinct `ChangeNotifier` classes to manage the UI, data, and solving process. + +- **Gemini API (AI Models):** The core AI logic is powered by two Gemini models: + - **`gemini-2.5-pro`:** A multi-modal model used for the initial, complex task of analyzing the user's crossword image(s) to infer the grid structure and all clue text. + - **`gemini-2.5-flash`:** A faster, more focused model used for the puzzle-solving step. This model is configured with a detailed system prompt to act as a crossword-solving "expert" and is called individually for each clue. + +## 2. UI/UX Design + +The application uses a single screen with a vertical `Stepper` to guide the user through the workflow. + +- **Explicit State Passing:** The `CrosswordScreen` is responsible for building the `Stepper`. It determines which step is active based on the `currentStep` from the `AppStepState`. It then passes an `isActive` boolean (`isActive: appStepState.currentStep == stepIndex`) to each step's content widget. +- **Mixin-Based Activation Logic:** To adhere to the DRY principle, the common state management logic for each stepper page is encapsulated in a `StepActivationMixin`. This mixin provides the `initState` and `didUpdateWidget` lifecycle methods, which automatically call an `onActivated` method when the step becomes active. Each step's `State` class uses this mixin, ensuring activation logic runs reliably without duplicating code. +- **Encapsulated Controls:** Each step widget is responsible for rendering its own navigation controls (e.g., "NEXT", "BACK", "SOLVE"). These controls directly call methods on the appropriate state notifiers (e.g., `appStepState.nextStep()`, `puzzleSolverState.solvePuzzle()`) to update the application's state. + +### Stepper Steps: + +1. **Select Crossword Image:** The user selects one or more images of a crossword puzzle from their device's gallery or camera. The selected images are displayed, and a "NEXT" button becomes active, allowing the user to proceed. + +2. **Verify Grid Size:** Upon entering this step, the application automatically infers the grid's dimensions from the image(s), showing a loading indicator while it works. The inferred width and height are then displayed in editable text fields for user verification or correction. The user can press "NEXT" to accept the dimensions or "BACK" to re-select the image. + +3. **Verify Grid Contents:** The app displays the inferred grid of black and white squares. The user can tap any cell to toggle its color, correcting any errors from the inference step. "NEXT" and "BACK" buttons are provided for navigation. + +4. **Verify Clue Text:** The inferred "Across" and "Down" clues are displayed in two columns for user verification. The user can tap on any clue to edit its text or number. "SOLVE" and "BACK" buttons are provided. + +5. **Solve the Puzzle:** + * Solving begins automatically when this step is entered. + * The grid is displayed on the left and fills with answers in real-time, with conflicting answers in red and matching answers in green. + * A "To Do" list on the right shows the status of each clue, including the answer and the model's confidence score. Answers that don't fit the grid are marked as "-- WRONG". + * "Pause" and "Resume" buttons allow the user to control the solving process. + * A "Restart" button appears to the right of the "Pause/Resume" button. It clears any AI-provided data while keeping all user-entered data, and starts the solving session over from the beginning. + * A "START OVER" button resets the entire workflow. + * A "BACK" button stops the solving process, clears the partial solution from the grid, and returns to the previous step. + +## 3. State Management + +The `provider` package is used for state management. The architecture follows the **Separation of Concerns** principle by dividing state into three independent `ChangeNotifier` classes, which are provided to the widget tree using `MultiProvider` in `main.dart`. + +- **`AppStepState`**: Manages the UI navigation state, specifically the `currentStep` of the `Stepper`. It exposes methods like `nextStep()`, `previousStep()`, and `reset()`. +- **`PuzzleDataState`**: Manages the lifecycle of the crossword data itself. Its responsibilities include handling image selection, triggering the AI-powered data inference, and managing user-initiated updates to the grid and clues. +- **`PuzzleSolverState`**: Dedicated entirely to the puzzle-solving process. It manages the "to-do" list of clues, orchestrates the `PuzzleSolver` service, and handles the `isSolving`, `pause`, `resume`, and `restart` states. + +This decoupled approach ensures that each part of the state is managed independently, improving maintainability and testability. + +## 4. Services + +- **`ImagePickerService`:** A wrapper around the `image_picker` package. +- **`GeminiService`:** This service handles all communication with the Gemini models. It is configured with the "expert" system prompt for the solver and has methods for: + - `inferCrosswordData(images)`: Calls `gemini-2.5-pro` to analyze one or more images. + - `solveClue(clue, length, pattern)`: Calls `gemini-2.5-flash` to get an answer and confidence score for a single clue. + - `getWordMetadata(word)`: This is a function declaration provided to the `gemini-2.5-flash` model. When the model invokes this function, the application calls the `getWordMetadataFromApi(word)` method, which queries a public dictionary API (`dictionaryapi.dev`) to retrieve grammatical information for the given word. +- **`PuzzleSolver`:** Contains the business logic for the main solving loop, iterating through clues and coordinating with the `GeminiService`, `PuzzleDataState`, and `PuzzleSolverState` to solve the puzzle. + +## 5. Puzzle Solving Logic + +The puzzle-solving process is managed by an app-driven loop within the `PuzzleSolver` service, not by an LLM agent. + +1. **Prompt Generation:** For each clue, the app calculates the required word length and the current letter pattern (e.g., `_A_`) from the grid. +2. **LLM Call:** It sends a focused prompt to the "expert" `gemini-2.5-flash` model containing only the clue text, length, and pattern. +3. **Answer Validation:** The app validates the returned answer. If the length does not match the available space, the answer is marked as wrong, and the clue is queued to be retried later. +4. **Grid Update:** Valid answers are placed on the grid. The UI uses color-coding to indicate confidence: + - **Black:** A single, uncontested answer. + - **Green:** Two matching answers (from an Across and Down clue) for the same cell. + - **Red:** Two conflicting answers for the same cell. +5. **Looping:** The app loops through all unsolved clues until the puzzle is complete, making multiple passes if necessary to retry clues that were previously answered incorrectly. + +### Handling Function Calls and Structured Output + +To ensure robust interaction with the Gemini model for clue solving, the application uses a sophisticated, multi-step process encapsulated within the `_generateJsonWithFunctionsAndSchema` helper method in `GeminiService`. This process is designed to handle both model-driven function calls and a final, strictly-formatted JSON output. + +- **Two-Model Approach:** The service uses two configurations of the `gemini-2.5-flash` model: + - `_clueSolverModelWithFunctions`: This model is configured with the `getWordMetadata` tool, allowing it to request additional information during its reasoning process. + - `_clueSolverModelWithSchema`: This model is configured with a strict JSON output schema, ensuring the final answer is always in the correct format (`{ "answer": "...", "confidence": ... }`). + +- **Chat-Based Interaction:** The process begins by starting a chat session with `_clueSolverModelWithFunctions`. + 1. The initial prompt (clue, length, pattern) is sent. + 2. The app checks the model's response for any `functionCalls`. + 3. If the model requests a function call (e.g., `getWordMetadata`), the app executes it (by calling the dictionary API) and sends the result back to the model in the same chat session. + 4. This loop continues until the model responds with its reasoning complete, without requesting further function calls. + +- **Forcing JSON Output:** Once the function-calling loop is complete, the app takes the entire chat history and uses it to make a final call to the `_clueSolverModelWithSchema`. This effectively asks the model to summarize its final conclusion from the preceding conversation into the required JSON format. + +This robust, app-driven process ensures that the model can access external tools when needed while still providing a predictable, machine-readable output for the application to consume. + +### Request Cancellation + +The `GeminiService` includes a `cancelCurrentSolve()` method, and the `solveClue` method begins with a call to it. However, in the current implementation, the underlying Gemini API calls for clue solving are made via `sendMessage` on a chat, which returns a `Future` and does not support in-flight cancellation. The `cancelCurrentSolve` method is a remnant of a previous, stream-based implementation and currently has no effect. The `PuzzleSolverState` calls this method from its `pauseSolving` and `restartSolving` methods, but it does not interrupt an ongoing `solveClue` operation. A `solveClue` call will always run to completion. + +## 6. Data Models + +- **`AppStepState`**: Manages the current step of the UI stepper. +- **`PuzzleDataState`**: Manages the crossword puzzle data, including images, grid structure, and clues. +- **`PuzzleSolverState`**: Manages the state of the puzzle-solving process. +- **`CrosswordData`:** The top-level model holding the entire puzzle's state. +- **`CrosswordGrid`:** Holds the grid's dimensions and a list of `GridCell` objects. +- **`GridCell`:** Represents a single cell. Crucially, it contains separate `acrossLetter`, `downLetter`, and `userLetter` fields to track answers from both directions and user edits, and to detect conflicts. +- **`Clue`:** Represents a single clue. +- **`ClueAnswer`:** A model to hold the string `answer` and double `confidence` returned by the LLM. +- **`TodoItem`:** Represents a clue in the UI list on the solver page, holding the clue's description, its solving status, the `ClueAnswer`, and an `isWrong` flag. + +## 7. Project Structure + +The project follows the standard structure outlined in `GEMINI.md`. \ No newline at end of file diff --git a/crossword_companion/specs/requirements.md b/crossword_companion/specs/requirements.md new file mode 100644 index 0000000..5554b6a --- /dev/null +++ b/crossword_companion/specs/requirements.md @@ -0,0 +1,50 @@ +# Crossword Companion Requirements + +## 1. Project Overview + +The application will be an open-source sample hosted on GitHub in the flutter org. It aims to demonstrate the use of Flutter, Firebase AI Logic, and Gemini to produce an agentic workflow that can solve a small crossword puzzle (one with a size under 10x10). + +## 2. Target Platforms + +The application will be built with Flutter and run on Android, iOS, web, and macOS. + +## 3. User Interface and Experience (UI/UX) + +The workflow from start to completed puzzle is presented in a single screen with individual components representing the steps of the workflow and an indicator for overall progress. At each step of the workflow, the UI should offer users the opportunity to advance or (for all steps beyond the first) roll back to a previous step. + +## 4. Agentic Workflow Steps + +The application will guide the user through the following steps: + +### 4.1. Crossword Image Input +- The app allows the user to select one or more images of an empty (unsolved) crossword puzzle from the camera or image picker. This allows for separate images of the grid and clues. +- Once chosen, the app should display the image(s), and allow the user to accept them or choose different image(s). + +*Example Grid Image:* +(The user provided an image of a grid-based crossword puzzle at ![example crossword puzzle screenshot](example-screenshot.png) + +### 4.2. Grid Size Inference & Verification +- The agent should infer the crossword dimensions from the crossword image(s). +- The agent will present the inferred height and width values to the user for verification and/or modification before continuing. + +### 4.3. Grid Contents Inference & Verification +- The agent should infer the contents of the crossword grid (cell colors, presence of numbers for answers) from the crossword image(s). +- The inferred contents will be presented to the user for verification/modification. + +### 4.4. Clue Text Inference & Verification +- The agent should infer the crossword clue text from the crossword image(s). +- The inferred clues will be presented to the user for verification. +- The user should have the option of editing a clue's number, direction, and/or text prior to advancing the workflow. + +### 4.5. Puzzle Solving +- The application should solve the puzzle by filling in answers one at a time. +- The UI should animate this process so the user can observe progress. +- The UI will display the model's confidence in each answer and visually flag answers that are invalid or conflict with other answers. +- The app will automatically backtrack and retry clues that were answered incorrectly, using the updated state of the grid to inform the new attempt. +- The UI should offer the user a mechanism to pause and resume the solving process. +- The UI should offer the user a mechanism to restart the solving process, which will clear AI-provided data, keep user-entered data, and start the solving process over from the beginning. + +### 4.6. Finished State +- Once the puzzle is solved, the application will display a "finished" message. +- The completed grid will be displayed. +- A button will be available to restart the workflow, erasing all current state. diff --git a/crossword_companion/specs/tasks.md b/crossword_companion/specs/tasks.md new file mode 100644 index 0000000..7404616 --- /dev/null +++ b/crossword_companion/specs/tasks.md @@ -0,0 +1,72 @@ +This document outlines the development tasks for building the Crossword Companion app. The tasks are structured as a series of milestones, each delivering a piece of visible functionality to the user. + +## [x] Milestone 1: Basic App Shell and UI Structure + +Create the main application window with a vertical stepper to guide the user through the workflow. All steps will be present, but initially disabled beyond the first step. + +- [x] Create the project structure as defined in `design.md`. +- [x] Implement the main screen with a `Stepper` widget containing all 5 steps from the design. +- [x] Use the `provider` package to create a basic `CrosswordState` notifier to manage the current stepper index. +- [x] Refactor the button layout on all step pages to be right-aligned, with the primary action on the far right, as specified in the design. + +## [x] Milestone 2: Select Crossword Image + +Implement the functionality for the user to select a single crossword image (containing both the grid and clues) from their device. + +- [x] Create an `ImagePickerService` to abstract the `image_picker` plugin. +- [x] Implement the UI for Step 1 ("Select Crossword Image"). +- [x] Update `CrosswordState` to hold the selected crossword image. + +## [x] Milestone 3: Grid Size Inference and Verification + +Implement the AI's ability to infer the grid dimensions from the image and allow the user to correct them. + +- [x] Set up the `firebase_ai` package and create a `GeminiService`. +- [x] Implement the `inferCrosswordData(image)` method in `GeminiService` to call the `gemini-2.5-pro` model. +- [x] Create the UI for Step 2 ("Verify Grid Size"). +- [x] Update `CrosswordState` to hold the grid dimensions. + +## [x] Milestone 4: Grid Contents Inference and Verification + +Implement the AI's ability to infer the grid's structure (cells and numbers) and allow the user to edit it. + +- [x] Create the data models: `CrosswordGrid` and `GridCell`. +- [x] Implement the UI for Step 3 ("Verify Grid Contents") that overlays the inferred grid on the original image. + - [x] Add functionality to allow users to tap on cells to set the cell to inactive (black), empty (white), or numbered with a user-provided number. +## [x] Milestone 5: Clue Text Inference and Verification + +Implement the AI's ability to infer the clue text from the crossword image and allow the user to edit it. + +- [x] Create the `Clue` data model. +- [x] Create a `ClueList` widget to display the "Across" and "Down" clues. +- [x] Implement the UI for Step 4 ("Verify Clue Text"). +- [x] Add functionality for the user to edit the clues. + +## [x] Milestone 6: Pre-Solve Validation + +Implement a validation step to ensure the integrity of the puzzle data before solving. + +- [x] Implement a validation step to ensure clue numbers match the numbers in the grid. +- [x] Display a warning to the user if there are mismatches. + +## [x] Milestone 7: Intelligent Puzzle Solving + +Implement the core puzzle-solving logic with enhanced UI, controls, and a more robust, app-driven solving strategy. + +- [x] Create the `ClueAnswer` and `TodoItem` data models. +- [x] Update `GridCell` to track `acrossLetter` and `downLetter` separately to detect conflicts. +- [x] Configure a `gemini-2.5-flash` model with a detailed system prompt to act as a crossword "expert". +- [x] Implement an app-driven solving loop in a dedicated `PuzzleSolver` service. +- [x] Implement answer validation to check if the returned word fits the grid. +- [x] Update the UI to display the answer, confidence score, and a "-- WRONG" status for invalid answers. +- [x] Update the grid UI to color-code letters based on conflicts (red), matches (green), or single entries (black). +- [x] Implement the "Pause" and "Resume" controls. +- [ ] Implement the "Restart" button. +- [x] Implement logic to auto-pause when navigating back and to reset the solution when starting a new solve. +- [x] Add JSON-based debug output to the console for monitoring the puzzle state and prompts. + +## [x] Milestone 8: Refactoring for clarity + +- [x] is there any repeated logic or structure in the stepper pages that should be refactored into a base class or a mixin? +- [x] are there other things that can be refactored to make the code more clear and readable? + \ No newline at end of file diff --git a/crossword_companion/web/favicon.png b/crossword_companion/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/crossword_companion/web/favicon.png differ diff --git a/crossword_companion/web/icons/Icon-192.png b/crossword_companion/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/crossword_companion/web/icons/Icon-192.png differ diff --git a/crossword_companion/web/icons/Icon-512.png b/crossword_companion/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/crossword_companion/web/icons/Icon-512.png differ diff --git a/crossword_companion/web/icons/Icon-maskable-192.png b/crossword_companion/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/crossword_companion/web/icons/Icon-maskable-192.png differ diff --git a/crossword_companion/web/icons/Icon-maskable-512.png b/crossword_companion/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/crossword_companion/web/icons/Icon-maskable-512.png differ diff --git a/crossword_companion/web/index.html b/crossword_companion/web/index.html new file mode 100644 index 0000000..46230c4 --- /dev/null +++ b/crossword_companion/web/index.html @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + crossword_companion + + + + + + + + \ No newline at end of file diff --git a/crossword_companion/web/manifest.json b/crossword_companion/web/manifest.json new file mode 100644 index 0000000..3e56ca6 --- /dev/null +++ b/crossword_companion/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "crossword_companion", + "short_name": "crossword_companion", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} \ No newline at end of file