diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..62c1d86 --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,26 @@ +name: Deploy to Server + +on: + push: + branches: [ dev ] + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SERVER_IP }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASS }} + port: ${{ secrets.SERVER_PORT }} + script: | + cd ~/GoWithMe + docker compose down + git pull + docker compose up -d --build \ No newline at end of file diff --git a/.github/workflows/flutter.yaml b/.github/workflows/flutter.yaml new file mode 100644 index 0000000..80c0a0b --- /dev/null +++ b/.github/workflows/flutter.yaml @@ -0,0 +1,68 @@ +name: Build & Deploy Web + +permissions: + contents: write + +on: + push: + branches: [ dev, main, devops ] + pull_request: + branches: [ dev, main, devops ] + +jobs: + build-and-deploy-web: + runs-on: ubuntu-22.04 + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Install dependencies + run: | + cd frontend + flutter pub get + + - name: Check formatting + run: | + cd frontend + dart format --set-exit-if-changed . + + - name: Run tests with coverage + run: | + cd frontend + flutter test --coverage + + - name: Install lcov + run: sudo apt-get update && sudo apt-get install -y lcov + + - name: Generate HTML report + run: | + cd frontend + genhtml coverage/lcov.info --output-directory coverage/html + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: flutter-coverage-report + path: coverage/html + + - name: Static analysis + run: flutter analyze --no-fatal-warnings --no-fatal-infos + + - name: Build web + run: | + cd frontend + flutter build web --release --base-href='/GoWithMe/' + + - name: Deploy to GitHub Pages + uses: bluefireteam/flutter-gh-pages@v9 + with: + baseHref: '/GoWithMe/' + workingDir: frontend + customArgs: --target="lib/main.dart" diff --git a/.github/workflows/go-backend.yaml b/.github/workflows/go-backend.yaml new file mode 100644 index 0000000..2b5dd43 --- /dev/null +++ b/.github/workflows/go-backend.yaml @@ -0,0 +1,34 @@ +name: Go Backend CI + +on: + push: + branches: [ main, dev ] + pull_request: + branches: [ main, dev ] + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./backend + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Install dependencies + run: go mod download + + - name: Lint + run: go fmt ./... && go vet ./... + + - name: Run tests + run: go test -v ./... + + - name: Build + run: go build -v ./cmd/main.go \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dff8271 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.DS_Store +.idea +api/.DS_STORE +api/protobuf/.DS_STORE +backend/.DS_STORE +backend/.idea +backend/auth/.DS_STORE +backend/auth/.idea +backend/gateway/.DS_STORE +backend/gateway/.idea +backend/interests/.DS_STORE +backend/interests/.idea +backend/profile/.DS_STORE +backend/profile/.idea \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3d84e8d --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +export PATH := $(HOME)/go/bin:$(PATH) + +.PHONY: all +all: backend_dependencies backend_docs + +.PHONY: backend_dependencies +backend_dependencies: + cd backend && go install github.com/swaggo/swag/cmd/swag@latest + cd backend && go mod tidy + +backend_docs: + cd backend && swag init --generalInfo cmd/main.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..c759673 --- /dev/null +++ b/README.md @@ -0,0 +1,307 @@ +# GoWithMe – Find Your Activity Buddy + +**GoWithMe** is a full-stack cross-platform app that helps people find partners for sports and healthy lifestyle activities in their local area. The project was built during the **Flutter Summer Course** at Innopolis University + +--- + +## Team + +| Name | Role(s) | Responsibilities | +|-------------------|-------------------------------|---------------------------------------------------| +| Lavrova Marina | Frontend, Task Planning | Flutter, UI, integration with backend, task board | +| Alexander Blinov | Backend, DB | Architecture, database, backend, API, design slides | +| Leonid Merkulov | DevOps, Tests, Docs, Slides | CI/CD, Docker, tests, documentation, presentation | + +--- + +## Implementation checklist + + +### Technical requirements (20 points) +#### Backend development (8 points) +- [3] Go-based backend (3 points) +- [2] RESTful API with Swagger documentation (2 point) [- api](https://github.com/MAL-da-udal/GoWithMe/blob/dev/backend/internal/api/handlers.go) and [docs](https://github.com/MAL-da-udal/GoWithMe/tree/dev/backend/docs) +- [1] PostgreSQL database with proper schema design (1 point) [- db](https://github.com/MAL-da-udal/GoWithMe/tree/dev/backend/internal/db) +- [1] JWT-based authentication and authorization (1 point) [- jwt](https://github.com/MAL-da-udal/GoWithMe/blob/dev/backend/internal/api/handlers.go) +- [1] Comprehensive unit and integration tests (1 point) [- tests][https://github.com/MAL-da-udal/GoWithMe/blob/dev/backend/internal/api/api_test.go] + +#### Frontend development (8 points) +- [3] Flutter-based cross-platform application (mobile + web) (3 points) +all app +- [1] Responsive UI design with custom widgets (1 point) [- custom widgets](https://github.com/MAL-da-udal/GoWithMe/commit/bd9105f3eb86d3b883eb2aca849dcc2a5669ba45) +- [1] State management implementation (1 point) [- for search](https://github.com/MAL-da-udal/GoWithMe/commit/d6aabbab14efb84f276356a08fad985a69c3d67b) and [for theme](https://github.com/MAL-da-udal/GoWithMe/commit/bd9105f3eb86d3b883eb2aca849dcc2a5669ba45) +- [1] Offline data persistence (1 point) [- sharedPrefs](https://github.com/MAL-da-udal/GoWithMe/commit/1b49dfaa21b93b809e655cc596a6d0c9d690a846) and [getStorage](https://github.com/MAL-da-udal/GoWithMe/commit/c017b6bc00eae3f0e5c6b9612a5ad7d8465defa9) +- [1] Unit and widget tests (1 point) [- here](https://github.com/MAL-da-udal/GoWithMe/commits/dev/frontend/test) +- [1] Support light and dark mode (1 point) [- here](https://github.com/MAL-da-udal/GoWithMe/commit/bd9105f3eb86d3b883eb2aca849dcc2a5669ba45) + +#### DevOps & deployment (4 points) +- [1] Docker compose for all services (1 point) [- here](https://github.com/MAL-da-udal/GoWithMe/blob/dev/compose.yaml) +- [1] CI/CD pipeline implementation (1 point) [- here](https://github.com/MAL-da-udal/GoWithMe/tree/dev/.github/workflows) +- [0] Environment configuration management using config files (1 point) +- [1] GitHub pages for the project (1 point) [- gh-pages](https://mal-da-udal.github.io/GoWithMe/) + +### Non-Technical Requirements (10 points) +#### Project management (4 points) +- [1] GitHub organization with well-maintained repository (1 point) +- [1] Regular commits and meaningful pull requests from all team members (1 point) +- [1] Project board (GitHub Projects) with task tracking (1 point) [- board](https://github.com/orgs/MAL-da-udal/projects/5) +- [1] Team member roles and responsibilities documentation (1 point) + +#### Documentation (4 points) +- [1] Project overview and setup instructions (1 point) +- [1] Screenshots and GIFs of key features (1 point) +- [1] API documentation (1 point) +- [1] Architecture diagrams and explanations (1 point) + +#### Code quality (2 points) +- [1] Consistent code style and formatting during CI/CD pipeline (1 point) +- [1] Code review participation and resolution (1 point) + +### Bonus Features (up to 10 points) +- [2] Localization for Russian (RU) and English (ENG) languages (2 points) [- here](https://github.com/MAL-da-udal/GoWithMe/commit/f6368d41d444b8317f04883e8c9038b5c97de1c9) +- [3] Good UI/UX design (up to 3 points) [- PR with design](https://github.com/MAL-da-udal/GoWithMe/pull/25) +- [0] Integration with external APIs (fitness trackers, health devices) (up to 5 points) +- [2] Comprehensive error handling and user feedback (up to 2 points) [- here](https://github.com/MAL-da-udal/GoWithMe/commit/c9de86cc1e6900de5b2f29abd3961aef8c840026) +- [3] Advanced animations and transitions (up to 3 points) [- custom transitions via go_router](https://github.com/MAL-da-udal/GoWithMe/pull/25/commits/bd9105f3eb86d3b883eb2aca849dcc2a5669ba45) +- [2] Widget implementation for native mobile elements (up to 2 points) [- settings page](https://github.com/MAL-da-udal/GoWithMe/blob/dev/frontend/lib/ui/pages/settings_page.dart) + +Total points implemented: 29/30 (excluding bonus points) + +--- + +## Project Overview + +GoWithMe is designed to help users find activity partners for sports and healthy lifestyle events. The app supports user registration, profile management, activity search, and real-time results. + +--- + +## Setup Instructions + +### Prerequisites +- **Docker** & **Docker Compose** (recommended for all platforms) +- (For development only) Flutter (3.22+) and Go (1.22+) + +--- + +### Quick Start (recommended) + +The easiest way to run the project (backend, frontend, and database) is using Docker Compose: + +```sh +# Clone the repository +git clone https://github.com/MAL-da-udal/GoWithMe.git +cd GoWithMe + +# Build and start all services +docker compose up --build +``` + +- **Backend:** http://localhost:8080 +- **Frontend (Web):** http://localhost:80 +- **Swagger API docs:** http://localhost:8080/swagger/index.html + +--- + +### Development Mode (for development) + +If you want to run and update services separately: + +#### Frontend + +```sh +cd frontend +flutter pub get +flutter run +``` +Then choose the device on which you want to run + +> Important note: now the frontend connected to the API of the deployed server , so if you want to use your local mashine as a server: - change the baseUrl ( uncomment the line 7 and comment the line 6 in the file `frontend/lib/data/api/api_client.dart`) + + +#### Backend + +```sh +cd backend +go mod tidy +go run cmd/main.go +``` + +#### Database + +- You can use the database from Docker Compose or your own local PostgreSQL instance. + +--- + +### Running Tests + +#### Frontend + +```sh +cd frontend +flutter test --coverage +``` + +#### Backend + +```sh +cd backend +go test ./... +``` + + +--- + +## API Documentation + +All documentation can be found in swagger. You can access it on `http://mhdserver.ru:8080/swagger/index.html` + + +--- + +## Architecture + +### Backend +![Backend Arch](./images/backend-arch.png) + +#### Stack Overview + +* **HTTP Server framework:** gin +* **Database ORM:** gorm +* **Database:** PostgreSQL + +### Key features +- JWT Authentication and authorization +- gORM for convenient communication with Database +- Gin framework to organize RESTful API +- Swagger for documentation + +### Frontend Architecture + +#### Stack Overview + +* **State Management:** Flutter_riverpod +* **HTTP Client:** Dio handle errors and refresh auth token automatically +* **Storage:** GetStorage for auth/refresh tokens and shared_prefs for other data +* **Routing**: GoRouter with custom tranistions +* **Cross-platform**: supports all platform with specific settings customization +* **Localization**: easyLocalization + +### Key features + +- Authorization - login/register, handling tokens + - Automatically update access token after it expired using refresh token and interceptors in Dio + - Handle errors + - Store tokens in getStorage +- Profile - create and update profile, offline storing + - Store in cache with shared_prefs + - Upadte via dio +- Search - apply filters, store state in riverpod + - Load interests list from api + - Load users matched the filters + - Allow to open user profile with info and redirect to telegram +- Settings - theme and language switch + logout + - Theme switching via Theme and riverpod + - Translates all texts to chosen language + - Allows to logout + + + +--- + +### Architecture Diagram +### Frontend schema +``` +[User] + ↓ +[UI Layer: Screens & Widgets] + ├── auth_page.dart + ├── home_page.dart + ├── profile_tab.dart + ├── search_tab.dart + ├── settings_page.dart + └── user_profile_page.dart + ↓ +[State Management (Riverpod Providers)] + ├── searchProvider + └── themeProvider + ↓ +[Repositories] + ├── AuthRepository + ├── ProfileRepository + └── SearchRepository + ↓ +[API Client Layer] + └── ApiClient (Dio + interceptors + token refresh logic) + ├── Base URL: http://mhdserver.ru:8080 + ├── Request Interceptor (adds Authorization header) + ├── Error Interceptor (handles 401 + refreshes token) + └── Token Storage: GetStorage + ↓ +[HTTP REST API (Go Backend + PostgreSQL)] +``` +### Layers, which is also included +``` +[Models] + ├── user.dart + └── (interests, etc.) + +[Utilities & Helpers] + ├── validations.dart + ├── show_api_error.dart + ├── open_url.dart + └── text_to_string.dart + +[Local Storage Services] + └── shared_preferences_service.dart + +[Theming & Localization] + ├── app_theme.dart + ├── app_colors.dart + ├── app_text_styles.dart + └── assets/translations/ (en.json, ru.json) + ``` + +Full app structure +``` +[User] + | + v +[Flutter App] + | + v +[ApiClient] --> [AuthRepository, ProfileRepository, SearchRepository] + | + v +[REST API on Go] (Swagger) + | + v +[Business Logic Layer (Go)] + | + v +[PostgreSQL Database] +``` + +## Screenshots & GIFs + +Full demo you could see [here](https://drive.google.com/drive/folders/16_uYnuXlzk4iJTm0Z0DVk7sfJwKf0gUK?usp=sharing) + +### Login screen +![login](./images/login.png) + +### Profile screen +![profile](./images/profile.png) + +### Search +![settings](./images/search.png) +### Settings +![settings](./images/settings.png) + +--- + +## Links + +- **GitHub Organization:** [MAL-da-udal](https://github.com/MAL-da-udal) +- **Repository:** [GoWithMe](https://github.com/MAL-da-udal/GoWithMe) +- **GitHub Pages (Web Demo):** https://mal-da-udal.github.io/GoWithMe/ +- **Presentation video:** https://drive.google.com/drive/folders/16_uYnuXlzk4iJTm0Z0DVk7sfJwKf0gUK?usp=sharing +- **Project board:** https://github.com/orgs/MAL-da-udal/projects/5 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..d8ad59f --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,4 @@ +.DS_STORE +.idea +internal/.DS_STORE +internal/.idea \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index b6dbda7..e943aff 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,2 +1,10 @@ -# TODO +FROM golang:tip-bullseye +WORKDIR /app + +COPY . . +RUN go mod download + +RUN go build -o main ./cmd +RUN chmod +x main +CMD ["./main"] \ No newline at end of file diff --git a/backend/cmd/main.go b/backend/cmd/main.go new file mode 100644 index 0000000..9ebe50c --- /dev/null +++ b/backend/cmd/main.go @@ -0,0 +1,25 @@ +package main + +import ( + _ "backend/docs" + "backend/internal/config" + "backend/internal/db" + "backend/internal/routes" +) + +// @title GoWithMe API Documentation +// @version 1.0 +// @license.name Apache 2.0 +// @license.url http://www.apache.org/licenses/LICENSE-2.0.html +// @BasePath / +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization + +func main() { + config.Load() + db.Connect() + + router := routes.Setup() + router.Run(":8081") +} diff --git a/backend/docs/docs.go b/backend/docs/docs.go new file mode 100644 index 0000000..e52cd4d --- /dev/null +++ b/backend/docs/docs.go @@ -0,0 +1,685 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/auth/": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Validates token, and if it valid returns it's claims", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Authorization" + ], + "summary": "Get token claims", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/auth/login": { + "post": { + "description": "Checks credentials and gives JWT if alles ist gut", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Authorization" + ], + "summary": "Login user", + "parameters": [ + { + "type": "string", + "description": "Username", + "name": "username", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Password", + "name": "password", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/auth/refresh": { + "post": { + "description": "Refreshes access token using refresh token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Authorization" + ], + "summary": "Refresh access token", + "parameters": [ + { + "description": "Refresh Token", + "name": "refresh_token", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/auth/register": { + "post": { + "description": "Registers user with provided login and password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Authorization" + ], + "summary": "Register user", + "parameters": [ + { + "type": "string", + "description": "Username", + "name": "username", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Password", + "name": "password", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/avatar": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Retrieves the avatar image for the specified user or the authenticated user if no user_id is provided.", + "produces": [ + "image/jpeg", + "image/png" + ], + "tags": [ + "Avatar" + ], + "summary": "Get user avatar", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "user_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Image", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Invalid input", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Invalid or unauthorized token", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "Avatar not found", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Server error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates the avatar image for the authenticated user. Supports JPEG and PNG files up to 2MB.", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Avatar" + ], + "summary": "Update user avatar", + "parameters": [ + { + "type": "file", + "description": "Avatar image file (JPEG or PNG)", + "name": "avatar", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "Avatar uploaded successfully", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Invalid input (e.g., file too large, wrong format)", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Invalid or unauthorized token", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Server error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/interests/": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Interests" + ], + "summary": "Get user's interests", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "user_id", + "in": "query" + } + ], + "responses": {} + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Interests" + ], + "summary": "Update user's interests", + "parameters": [ + { + "description": "Interests to set", + "name": "interests", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.UpdateInterestsRequest" + } + } + ], + "responses": {} + } + }, + "/interests/all": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Retrieves paginated users who have specified interests, along with their profiles and all interests", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Interests" + ], + "summary": "Get users by interests", + "parameters": [ + { + "type": "integer", + "description": "Page number (starting from 1)", + "name": "page_num", + "in": "query", + "required": true + }, + { + "type": "integer", + "default": 10, + "description": "Number of users per page", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "Semicolon-separated list of interest IDs (e.g., bicycle;swimming)", + "name": "interests", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "Paginated users with profiles and interests", + "schema": { + "$ref": "#/definitions/models.PaginatedUsersResponse" + } + }, + "400": { + "description": "Invalid input (e.g., missing token, invalid page number, or empty interests)", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Invalid or unauthorized token", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/interests/cats": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Interests" + ], + "summary": "Get existing interests", + "responses": {} + } + }, + "/profile/": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns profile info of specified user id. If not specified, returns profile info of user encoded in jwt", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Profile" + ], + "summary": "Get profile", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "user_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Profile" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates profile", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Profile" + ], + "summary": "Create profile", + "parameters": [ + { + "description": "Profile Info", + "name": "profile_info", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Profile" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Partially updates profile information", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Profile" + ], + "summary": "Update profile", + "parameters": [ + { + "description": "Profile Info to update", + "name": "profile_info", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.UpdateProfileRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Profile" + } + } + } + } + } + }, + "definitions": { + "models.ErrorResponse": { + "type": "object", + "properties": { + "details": { + "type": "string" + } + } + }, + "models.PaginatedUsersResponse": { + "type": "object", + "properties": { + "page_num": { + "type": "integer" + }, + "users": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/models.UserProfileWithInterests" + } + } + } + }, + "models.Profile": { + "type": "object", + "properties": { + "age": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "gender": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "surname": { + "type": "string" + }, + "telegram": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "models.RefreshRequest": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "models.UpdateInterestsRequest": { + "type": "object", + "properties": { + "interests": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "models.UpdateProfileRequest": { + "type": "object", + "properties": { + "age": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "gender": { + "type": "string" + }, + "name": { + "type": "string" + }, + "surname": { + "type": "string" + }, + "telegram": { + "type": "string" + } + } + }, + "models.UserProfileWithInterests": { + "type": "object", + "properties": { + "interests": { + "type": "array", + "items": { + "type": "string" + } + }, + "profile": { + "$ref": "#/definitions/models.Profile" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "", + BasePath: "/", + Schemes: []string{}, + Title: "GoWithMe API Documentation", + Description: "", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json new file mode 100644 index 0000000..4258bd9 --- /dev/null +++ b/backend/docs/swagger.json @@ -0,0 +1,659 @@ +{ + "swagger": "2.0", + "info": { + "title": "GoWithMe API Documentation", + "contact": {}, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0" + }, + "basePath": "/", + "paths": { + "/auth/": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Validates token, and if it valid returns it's claims", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Authorization" + ], + "summary": "Get token claims", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/auth/login": { + "post": { + "description": "Checks credentials and gives JWT if alles ist gut", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Authorization" + ], + "summary": "Login user", + "parameters": [ + { + "type": "string", + "description": "Username", + "name": "username", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Password", + "name": "password", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/auth/refresh": { + "post": { + "description": "Refreshes access token using refresh token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Authorization" + ], + "summary": "Refresh access token", + "parameters": [ + { + "description": "Refresh Token", + "name": "refresh_token", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/auth/register": { + "post": { + "description": "Registers user with provided login and password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Authorization" + ], + "summary": "Register user", + "parameters": [ + { + "type": "string", + "description": "Username", + "name": "username", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Password", + "name": "password", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/avatar": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Retrieves the avatar image for the specified user or the authenticated user if no user_id is provided.", + "produces": [ + "image/jpeg", + "image/png" + ], + "tags": [ + "Avatar" + ], + "summary": "Get user avatar", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "user_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Image", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Invalid input", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Invalid or unauthorized token", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "Avatar not found", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Server error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates the avatar image for the authenticated user. Supports JPEG and PNG files up to 2MB.", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Avatar" + ], + "summary": "Update user avatar", + "parameters": [ + { + "type": "file", + "description": "Avatar image file (JPEG or PNG)", + "name": "avatar", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "Avatar uploaded successfully", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Invalid input (e.g., file too large, wrong format)", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Invalid or unauthorized token", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Server error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/interests/": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Interests" + ], + "summary": "Get user's interests", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "user_id", + "in": "query" + } + ], + "responses": {} + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Interests" + ], + "summary": "Update user's interests", + "parameters": [ + { + "description": "Interests to set", + "name": "interests", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.UpdateInterestsRequest" + } + } + ], + "responses": {} + } + }, + "/interests/all": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Retrieves paginated users who have specified interests, along with their profiles and all interests", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Interests" + ], + "summary": "Get users by interests", + "parameters": [ + { + "type": "integer", + "description": "Page number (starting from 1)", + "name": "page_num", + "in": "query", + "required": true + }, + { + "type": "integer", + "default": 10, + "description": "Number of users per page", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "Semicolon-separated list of interest IDs (e.g., bicycle;swimming)", + "name": "interests", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "Paginated users with profiles and interests", + "schema": { + "$ref": "#/definitions/models.PaginatedUsersResponse" + } + }, + "400": { + "description": "Invalid input (e.g., missing token, invalid page number, or empty interests)", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Invalid or unauthorized token", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/interests/cats": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Interests" + ], + "summary": "Get existing interests", + "responses": {} + } + }, + "/profile/": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns profile info of specified user id. If not specified, returns profile info of user encoded in jwt", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Profile" + ], + "summary": "Get profile", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "user_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Profile" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates profile", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Profile" + ], + "summary": "Create profile", + "parameters": [ + { + "description": "Profile Info", + "name": "profile_info", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Profile" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Partially updates profile information", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Profile" + ], + "summary": "Update profile", + "parameters": [ + { + "description": "Profile Info to update", + "name": "profile_info", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.UpdateProfileRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Profile" + } + } + } + } + } + }, + "definitions": { + "models.ErrorResponse": { + "type": "object", + "properties": { + "details": { + "type": "string" + } + } + }, + "models.PaginatedUsersResponse": { + "type": "object", + "properties": { + "page_num": { + "type": "integer" + }, + "users": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/models.UserProfileWithInterests" + } + } + } + }, + "models.Profile": { + "type": "object", + "properties": { + "age": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "gender": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "surname": { + "type": "string" + }, + "telegram": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "models.RefreshRequest": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "models.UpdateInterestsRequest": { + "type": "object", + "properties": { + "interests": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "models.UpdateProfileRequest": { + "type": "object", + "properties": { + "age": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "gender": { + "type": "string" + }, + "name": { + "type": "string" + }, + "surname": { + "type": "string" + }, + "telegram": { + "type": "string" + } + } + }, + "models.UserProfileWithInterests": { + "type": "object", + "properties": { + "interests": { + "type": "array", + "items": { + "type": "string" + } + }, + "profile": { + "$ref": "#/definitions/models.Profile" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml new file mode 100644 index 0000000..d985311 --- /dev/null +++ b/backend/docs/swagger.yaml @@ -0,0 +1,426 @@ +basePath: / +definitions: + models.ErrorResponse: + properties: + details: + type: string + type: object + models.PaginatedUsersResponse: + properties: + page_num: + type: integer + users: + additionalProperties: + $ref: '#/definitions/models.UserProfileWithInterests' + type: object + type: object + models.Profile: + properties: + age: + type: integer + description: + type: string + gender: + type: string + id: + type: integer + name: + type: string + surname: + type: string + telegram: + type: string + user_id: + type: integer + type: object + models.RefreshRequest: + properties: + refresh_token: + type: string + required: + - refresh_token + type: object + models.UpdateInterestsRequest: + properties: + interests: + items: + type: string + type: array + type: object + models.UpdateProfileRequest: + properties: + age: + type: integer + description: + type: string + gender: + type: string + name: + type: string + surname: + type: string + telegram: + type: string + type: object + models.UserProfileWithInterests: + properties: + interests: + items: + type: string + type: array + profile: + $ref: '#/definitions/models.Profile' + type: object +info: + contact: {} + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + title: GoWithMe API Documentation + version: "1.0" +paths: + /auth/: + get: + consumes: + - application/json + description: Validates token, and if it valid returns it's claims + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Get token claims + tags: + - Authorization + /auth/login: + post: + consumes: + - application/json + description: Checks credentials and gives JWT if alles ist gut + parameters: + - description: Username + in: query + name: username + required: true + type: string + - description: Password + in: query + name: password + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + summary: Login user + tags: + - Authorization + /auth/refresh: + post: + consumes: + - application/json + description: Refreshes access token using refresh token + parameters: + - description: Refresh Token + in: body + name: refresh_token + required: true + schema: + $ref: '#/definitions/models.RefreshRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + summary: Refresh access token + tags: + - Authorization + /auth/register: + post: + consumes: + - application/json + description: Registers user with provided login and password + parameters: + - description: Username + in: query + name: username + required: true + type: string + - description: Password + in: query + name: password + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + summary: Register user + tags: + - Authorization + /avatar: + get: + description: Retrieves the avatar image for the specified user or the authenticated + user if no user_id is provided. + parameters: + - description: User ID + in: query + name: user_id + type: integer + produces: + - image/jpeg + - image/png + responses: + "200": + description: Image + schema: + type: file + "400": + description: Invalid input + schema: + $ref: '#/definitions/models.ErrorResponse' + "401": + description: Invalid or unauthorized token + schema: + $ref: '#/definitions/models.ErrorResponse' + "404": + description: Avatar not found + schema: + $ref: '#/definitions/models.ErrorResponse' + "500": + description: Server error + schema: + $ref: '#/definitions/models.ErrorResponse' + security: + - BearerAuth: [] + summary: Get user avatar + tags: + - Avatar + put: + consumes: + - multipart/form-data + description: Updates the avatar image for the authenticated user. Supports JPEG + and PNG files up to 2MB. + parameters: + - description: Avatar image file (JPEG or PNG) + in: formData + name: avatar + required: true + type: file + produces: + - application/json + responses: + "200": + description: Avatar uploaded successfully + schema: + additionalProperties: + type: string + type: object + "400": + description: Invalid input (e.g., file too large, wrong format) + schema: + $ref: '#/definitions/models.ErrorResponse' + "401": + description: Invalid or unauthorized token + schema: + $ref: '#/definitions/models.ErrorResponse' + "500": + description: Server error + schema: + $ref: '#/definitions/models.ErrorResponse' + security: + - BearerAuth: [] + summary: Update user avatar + tags: + - Avatar + /interests/: + get: + consumes: + - application/json + parameters: + - description: User ID + in: query + name: user_id + type: integer + produces: + - application/json + responses: {} + security: + - BearerAuth: [] + summary: Get user's interests + tags: + - Interests + put: + consumes: + - application/json + parameters: + - description: Interests to set + in: body + name: interests + required: true + schema: + $ref: '#/definitions/models.UpdateInterestsRequest' + produces: + - application/json + responses: {} + security: + - BearerAuth: [] + summary: Update user's interests + tags: + - Interests + /interests/all: + get: + consumes: + - application/json + description: Retrieves paginated users who have specified interests, along with + their profiles and all interests + parameters: + - description: Page number (starting from 1) + in: query + name: page_num + required: true + type: integer + - default: 10 + description: Number of users per page + in: query + name: page_size + type: integer + - description: Semicolon-separated list of interest IDs (e.g., bicycle;swimming) + in: query + name: interests + required: true + type: string + produces: + - application/json + responses: + "200": + description: Paginated users with profiles and interests + schema: + $ref: '#/definitions/models.PaginatedUsersResponse' + "400": + description: Invalid input (e.g., missing token, invalid page number, or + empty interests) + schema: + $ref: '#/definitions/models.ErrorResponse' + "401": + description: Invalid or unauthorized token + schema: + $ref: '#/definitions/models.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/models.ErrorResponse' + security: + - BearerAuth: [] + summary: Get users by interests + tags: + - Interests + /interests/cats: + get: + consumes: + - application/json + produces: + - application/json + responses: {} + security: + - BearerAuth: [] + summary: Get existing interests + tags: + - Interests + /profile/: + get: + consumes: + - application/json + description: Returns profile info of specified user id. If not specified, returns + profile info of user encoded in jwt + parameters: + - description: User ID + in: query + name: user_id + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Profile' + security: + - BearerAuth: [] + summary: Get profile + tags: + - Profile + patch: + consumes: + - application/json + description: Partially updates profile information + parameters: + - description: Profile Info to update + in: body + name: profile_info + required: true + schema: + $ref: '#/definitions/models.UpdateProfileRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Profile' + security: + - BearerAuth: [] + summary: Update profile + tags: + - Profile + post: + consumes: + - application/json + description: Creates profile + parameters: + - description: Profile Info + in: body + name: profile_info + required: true + schema: + $ref: '#/definitions/models.Profile' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create profile + tags: + - Profile +securityDefinitions: + BearerAuth: + in: header + name: Authorization + type: apiKey +swagger: "2.0" diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..6edea20 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,65 @@ +module backend + +go 1.24.1 + +require ( + github.com/gin-gonic/gin v1.10.1 + github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/stretchr/testify v1.10.0 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.0 + github.com/swaggo/swag v1.16.4 + golang.org/x/crypto v0.40.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.30.0 +) + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/bytedance/sonic v1.13.3 // indirect + github.com/bytedance/sonic/loader v0.2.4 // indirect + github.com/cloudwego/base64x v0.1.5 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gin-contrib/cors v1.7.6 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/spec v0.21.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.26.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + golang.org/x/arch v0.18.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/text v0.27.0 // indirect + golang.org/x/tools v0.35.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..68f8b66 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,198 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0= +github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= +github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= +github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= +github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= +github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= +github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= +github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= +github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= +github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc= +golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go new file mode 100644 index 0000000..bd55ba9 --- /dev/null +++ b/backend/internal/api/api_test.go @@ -0,0 +1,154 @@ +package api + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func getTestContext(method, path string, body []byte, token string) (*gin.Context, *httptest.ResponseRecorder) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(method, path, bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = req + + if token != "" { + ctx.Set("jwt", token) + } else { + ctx.Set("jwt", "") + } + return ctx, rec +} + +func TestCreateProfile_NoJWT(t *testing.T) { + ctx, rec := getTestContext("POST", "/profile", nil, "") + CreateProfile(ctx) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", rec.Code) + } +} + +func TestCreateProfile_InvalidJSON(t *testing.T) { + ctx, rec := getTestContext("POST", "/profile", []byte(`{invalid json}`), "validtoken") + CreateProfile(ctx) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", rec.Code) + } +} + +func TestGetProfile_NoJWT(t *testing.T) { + ctx, rec := getTestContext("GET", "/profile", nil, "") + GetProfile(ctx) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", rec.Code) + } +} + +func TestGetProfile_InvalidUserID(t *testing.T) { + ctx, rec := getTestContext("GET", "/profile?user_id=abc", nil, "validtoken") + GetProfile(ctx) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status 400 for invalid user_id, got %d", rec.Code) + } +} + +func TestUpdateProfile_NoJWT(t *testing.T) { + ctx, rec := getTestContext("PATCH", "/profile", nil, "") + UpdateProfile(ctx) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", rec.Code) + } +} + +func TestUpdateProfile_InvalidJSON(t *testing.T) { + ctx, rec := getTestContext("PATCH", "/profile", []byte(`{name:bad}`), "validtoken") + UpdateProfile(ctx) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", rec.Code) + } +} + +func TestUpdateInterests_NoJWT(t *testing.T) { + ctx, rec := getTestContext("PUT", "/interests", nil, "") + UpdateInterests(ctx) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", rec.Code) + } +} + +func TestUpdateInterests_InvalidJSON(t *testing.T) { + ctx, rec := getTestContext("PUT", "/interests", []byte(`not a json`), "validtoken") + UpdateInterests(ctx) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", rec.Code) + } +} + +func TestGetInterests_NoJWT(t *testing.T) { + ctx, rec := getTestContext("GET", "/interests", nil, "") + GetInterests(ctx) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", rec.Code) + } +} + +func TestGetInterests_InvalidUserID(t *testing.T) { + ctx, rec := getTestContext("GET", "/interests?user_id=xyz", nil, "validtoken") + GetInterests(ctx) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", rec.Code) + } +} + +func TestGetAllInterests_NoJWT(t *testing.T) { + ctx, rec := getTestContext("GET", "/interests/cats", nil, "") + GetAllInterests(ctx) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", rec.Code) + } +} + +func TestGetUsersByInterests_NoJWT(t *testing.T) { + ctx, rec := getTestContext("GET", "/interests/all?page_num=1&interests=book", nil, "") + GetUsersByInterests(ctx) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", rec.Code) + } +} + +func TestGetUsersByInterests_InvalidPageNum(t *testing.T) { + ctx, rec := getTestContext("GET", "/interests/all?page_num=0&interests=book", nil, "validtoken") + GetUsersByInterests(ctx) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status 400 for invalid page_num, got %d", rec.Code) + } +} + +func TestUpdateAvatar_NoJWT(t *testing.T) { + ctx, rec := getTestContext("PUT", "/avatar", nil, "") + + rec.Code = 0 + UpdateAvatar(ctx) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", rec.Code) + } +} + +func TestGetAvatar_NoJWT(t *testing.T) { + ctx, rec := getTestContext("GET", "/avatar", nil, "") + GetAvatar(ctx) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", rec.Code) + } +} diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go new file mode 100644 index 0000000..c69da33 --- /dev/null +++ b/backend/internal/api/handlers.go @@ -0,0 +1,614 @@ +package api + +import ( + "backend/internal/models" + "backend/internal/services" + "errors" + "github.com/gin-gonic/gin" + "strconv" + "strings" +) + +// @Summary Register user +// @Description Registers user with provided login and password +// @Tags Authorization +// @Accept json +// @Produce json +// @Param username query string true "Username" +// @Param password query string true "Password" +// @Success 200 {object} map[string]string +// @Router /auth/register [post] +func Register(ctx *gin.Context) { + username := ctx.Query("username") + password := ctx.Query("password") + + if len(username) == 0 || len(password) == 0 { + ctx.JSON(400, models.ErrorResponse{Details: services.ErrInvalidInput.Error()}) + return + } + + accessToken, refreshToken, err := services.RegisterUser(username, password) + if err != nil { + if errors.Is(err, services.ErrUserExists) { + ctx.JSON(400, models.ErrorResponse{Details: err.Error()}) + } else { + ctx.JSON(500, models.ErrorResponse{Details: "Internal server error"}) + } + return + } + + ctx.JSON(200, models.RegisterResponse{AccessToken: accessToken, RefreshToken: refreshToken}) +} + +// @Summary Login user +// @Description Checks credentials and gives JWT if alles ist gut +// @Tags Authorization +// @Accept json +// @Produce json +// @Param username query string true "Username" +// @Param password query string true "Password" +// @Success 200 {object} map[string]string +// @Router /auth/login [post] +func Login(ctx *gin.Context) { + username := ctx.Query("username") + password := ctx.Query("password") + + if len(username) == 0 || len(password) == 0 { + ctx.JSON(400, models.ErrorResponse{Details: services.ErrInvalidInput.Error()}) + return + } + + accessToken, refreshToken, err := services.LoginUser(username, password) + if err != nil { + if errors.Is(err, services.ErrUnknownUserOrPass) { + ctx.JSON(400, models.ErrorResponse{Details: err.Error()}) + } else { + ctx.JSON(500, models.ErrorResponse{Details: "Internal server error"}) + } + return + } + + ctx.JSON(200, models.LoginResponse{AccessToken: accessToken, RefreshToken: refreshToken}) +} + +// @Summary Refresh access token +// @Description Refreshes access token using refresh token +// @Tags Authorization +// @Accept json +// @Produce json +// @Param refresh_token body models.RefreshRequest true "Refresh Token" +// @Success 200 {object} map[string]string +// @Router /auth/refresh [post] +func RefreshToken(ctx *gin.Context) { + + var refreshRequest models.RefreshRequest + + if err := ctx.ShouldBindJSON(&refreshRequest); err != nil { + ctx.JSON(400, models.ErrorResponse{Details: "Refresh token is required"}) + return + } + + newAccessToken, err := services.RefreshAccessToken(refreshRequest.RefreshToken) + if err != nil { + if errors.Is(err, services.ErrInvalidRefreshToken) { + ctx.JSON(401, models.ErrorResponse{Details: err.Error()}) + } else { + ctx.JSON(500, models.ErrorResponse{Details: "Internal server error"}) + } + return + } + + ctx.JSON(200, map[string]string{"access_token": newAccessToken}) +} + +// @Summary Get token claims +// @Description Validates token, and if it valid returns it's claims +// @Tags Authorization +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} map[string]string +// @Router /auth/ [get] +func GetTokenClaims(ctx *gin.Context) { + tokenParam, _ := ctx.Get("jwt") + token := tokenParam.(string) + + if len(token) == 0 { + ctx.JSON(400, models.ErrorResponse{Details: services.ErrInvalidInput.Error()}) + return + } + + claims, err := services.GetTokenClaims(token) + if err != nil { + if errors.Is(err, services.ErrInvalidToken) { + ctx.JSON(401, models.ErrorResponse{Details: err.Error()}) + return + } else { + ctx.JSON(500, models.ErrorResponse{Details: err.Error()}) + return + } + } + + ctx.JSON(200, claims) +} + +// @Summary Create profile +// @Description Creates profile +// @Tags Profile +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param profile_info body models.Profile true "Profile Info" +// @Success 200 {object} map[string]string +// @Router /profile/ [post] +func CreateProfile(ctx *gin.Context) { + var req models.Profile + tokenParam, _ := ctx.Get("jwt") + token := tokenParam.(string) + + if len(token) == 0 { + ctx.JSON(400, models.ErrorResponse{Details: services.ErrInvalidInput.Error()}) + return + } + + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(400, models.ErrorResponse{Details: services.ErrInvalidInput.Error()}) + return + } + + claims, err := services.GetTokenClaims(token) + if err != nil { + if errors.Is(err, services.ErrInvalidToken) { + ctx.JSON(401, models.ErrorResponse{Details: err.Error()}) + return + } else { + ctx.JSON(500, models.ErrorResponse{Details: err.Error()}) + return + } + } + + toCreate := models.Profile{ + UserId: claims.UserID, + Name: req.Name, + Surname: req.Surname, + Age: req.Age, + Gender: req.Gender, + Telegram: req.Telegram, + Description: req.Description, + } + + err = services.CreateProfile(&toCreate) + + if err != nil { + if errors.Is(err, services.ErrProfileAlreadyExists) { + ctx.JSON(400, models.ErrorResponse{Details: err.Error()}) + return + } else { + ctx.JSON(500, models.ErrorResponse{Details: err.Error()}) + return + } + } + + ctx.JSON(201, map[string]interface{}{ + "details": "Created successfully", + }) +} + +// @Summary Get profile +// @Description Returns profile info of specified user id. If not specified, returns profile info of user encoded in jwt +// @Tags Profile +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param user_id query int false "User ID" +// @Success 200 {object} models.Profile +// @Router /profile/ [get] +func GetProfile(ctx *gin.Context) { + tokenParam, _ := ctx.Get("jwt") + token := tokenParam.(string) + + if len(token) == 0 { + ctx.JSON(400, models.ErrorResponse{Details: services.ErrInvalidInput.Error()}) + return + } + + var neededUserId int + + if len(ctx.Query("user_id")) == 0 { + neededUserId = 0 + } else { + var err error + neededUserId, err = strconv.Atoi(ctx.Query("user_id")) + + if err != nil { + ctx.JSON(400, models.ErrorResponse{Details: err.Error()}) + return + } + } + + claims, err := services.GetTokenClaims(token) + if err != nil { + if errors.Is(err, services.ErrInvalidToken) { + ctx.JSON(401, models.ErrorResponse{Details: err.Error()}) + return + } else { + ctx.JSON(500, models.ErrorResponse{Details: err.Error()}) + return + } + } + + info, err := services.GetProfile(&claims, neededUserId) + + if err != nil { + if errors.Is(err, services.ErrProfileDoesNotExists) { + ctx.JSON(400, models.ErrorResponse{Details: err.Error()}) + return + } else { + ctx.JSON(500, models.ErrorResponse{Details: err.Error()}) + return + } + } + + ctx.JSON(200, info) +} + +// @Summary Update profile +// @Description Partially updates profile information +// @Tags Profile +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param profile_info body models.UpdateProfileRequest true "Profile Info to update" +// @Success 200 {object} models.Profile +// @Router /profile/ [patch] +func UpdateProfile(ctx *gin.Context) { + tokenParam, _ := ctx.Get("jwt") + token := tokenParam.(string) + + if len(token) == 0 { + ctx.JSON(400, models.ErrorResponse{Details: services.ErrInvalidInput.Error()}) + return + } + + var req models.UpdateProfileRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(400, models.ErrorResponse{Details: services.ErrInvalidInput.Error()}) + return + } + + claims, err := services.GetTokenClaims(token) + if err != nil { + if errors.Is(err, services.ErrInvalidToken) { + ctx.JSON(401, models.ErrorResponse{Details: err.Error()}) + return + } else { + ctx.JSON(500, models.ErrorResponse{Details: err.Error()}) + return + } + } + + updatedProfile, err := services.UpdateProfile(claims.UserID, &req) + if err != nil { + if errors.Is(err, services.ErrProfileDoesNotExists) { + ctx.JSON(404, models.ErrorResponse{Details: err.Error()}) + return + } else { + ctx.JSON(500, models.ErrorResponse{Details: err.Error()}) + return + } + } + + ctx.JSON(200, updatedProfile) +} + +// @Summary Update user's interests +// @Tags Interests +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param interests body models.UpdateInterestsRequest true "Interests to set" +// @Router /interests/ [put] +func UpdateInterests(ctx *gin.Context) { + tokenParam, _ := ctx.Get("jwt") + token := tokenParam.(string) + + if len(token) == 0 { + ctx.JSON(400, models.ErrorResponse{Details: services.ErrInvalidInput.Error()}) + return + } + + var req models.UpdateInterestsRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(400, models.ErrorResponse{Details: services.ErrInvalidInput.Error()}) + return + } + + claims, err := services.GetTokenClaims(token) + if err != nil { + if errors.Is(err, services.ErrInvalidToken) { + ctx.JSON(401, models.ErrorResponse{Details: err.Error()}) + return + } else { + ctx.JSON(500, models.ErrorResponse{Details: err.Error()}) + return + } + } + + err = services.SetInterests(&claims, req.Interests) + if err != nil { + if errors.Is(err, services.ErrUnknownInterest) { + ctx.JSON(400, models.ErrorResponse{Details: err.Error()}) + return + } else { + ctx.JSON(500, models.ErrorResponse{Details: err.Error()}) + return + } + } + + ctx.JSON(200, map[string]string{"details": "Interests updated successfully"}) +} + +// @Summary Get user's interests +// @Tags Interests +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param user_id query int false "User ID" +// @Router /interests/ [get] +func GetInterests(ctx *gin.Context) { + tokenParam, _ := ctx.Get("jwt") + token := tokenParam.(string) + + if len(token) == 0 { + ctx.JSON(400, models.ErrorResponse{Details: services.ErrInvalidInput.Error()}) + return + } + + var neededUserId int + + if len(ctx.Query("user_id")) == 0 { + neededUserId = 0 + } else { + var err error + neededUserId, err = strconv.Atoi(ctx.Query("user_id")) + + if err != nil { + ctx.JSON(400, models.ErrorResponse{Details: err.Error()}) + return + } + } + + claims, err := services.GetTokenClaims(token) + if err != nil { + if errors.Is(err, services.ErrInvalidToken) { + ctx.JSON(401, models.ErrorResponse{Details: err.Error()}) + return + } else { + ctx.JSON(500, models.ErrorResponse{Details: err.Error()}) + return + } + } + + interests, err := services.GetInterests(&claims, neededUserId) + + if err != nil { + ctx.JSON(500, models.ErrorResponse{Details: err.Error()}) + return + } + + ctx.JSON(200, map[string]interface{}{ + "details": interests, + }) +} + +// @Summary Get existing interests +// @Tags Interests +// @Accept json +// @Produce json +// @Security BearerAuth +// @Router /interests/cats [get] +func GetAllInterests(ctx *gin.Context) { + tokenParam, _ := ctx.Get("jwt") + token := tokenParam.(string) + + if len(token) == 0 { + ctx.JSON(400, models.ErrorResponse{Details: services.ErrInvalidInput.Error()}) + return + } + + _, err := services.GetTokenClaims(token) + if err != nil { + if errors.Is(err, services.ErrInvalidToken) { + ctx.JSON(401, models.ErrorResponse{Details: err.Error()}) + return + } else { + ctx.JSON(500, models.ErrorResponse{Details: err.Error()}) + return + } + } + + interests := services.GetAllInterests() + + ctx.JSON(200, map[string]interface{}{ + "details": interests, + }) +} + +// @Summary Get users by interests +// @Description Retrieves paginated users who have specified interests, along with their profiles and all interests +// @Tags Interests +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param page_num query int true "Page number (starting from 1)" +// @Param page_size query int false "Number of users per page" default(10) +// @Param interests query string true "Semicolon-separated list of interest IDs (e.g., bicycle;swimming)" +// @Success 200 {object} models.PaginatedUsersResponse "Paginated users with profiles and interests" +// @Failure 400 {object} models.ErrorResponse "Invalid input (e.g., missing token, invalid page number, or empty interests)" +// @Failure 401 {object} models.ErrorResponse "Invalid or unauthorized token" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /interests/all [get] +func GetUsersByInterests(ctx *gin.Context) { + // Extract and validate token + tokenParam, _ := ctx.Get("jwt") + token := tokenParam.(string) + + if len(token) == 0 { + ctx.JSON(400, models.ErrorResponse{Details: "Token is required"}) + return + } + + // Extract and validate page number + pageNumStr := ctx.Query("page_num") + pageNum, err := strconv.Atoi(pageNumStr) + if err != nil || pageNum < 1 { + ctx.JSON(400, models.ErrorResponse{Details: "Invalid page number"}) + return + } + + // Extract and validate page size (default to 10) + pageSizeStr := ctx.DefaultQuery("page_size", "10") + pageSize, err := strconv.Atoi(pageSizeStr) + if err != nil || pageSize < 1 { + ctx.JSON(400, models.ErrorResponse{Details: "Invalid page size"}) + return + } + + // Extract and split interests + interestsStr := ctx.Query("interests") + if len(interestsStr) == 0 { + ctx.JSON(400, models.ErrorResponse{Details: "Interests are required"}) + return + } + interests := strings.Split(interestsStr, ";") + + // Validate token + _, err = services.GetTokenClaims(token) + if err != nil { + if errors.Is(err, services.ErrInvalidToken) { + ctx.JSON(401, models.ErrorResponse{Details: "Invalid token"}) + } else { + ctx.JSON(500, models.ErrorResponse{Details: "Internal server error"}) + } + return + } + + // Call service to get users + response, err := services.GetUsersByInterests(pageNum, interests, pageSize) + if err != nil { + ctx.JSON(500, models.ErrorResponse{Details: err.Error()}) + return + } + + ctx.JSON(200, response) +} + +// @Summary Update user avatar +// @Description Updates the avatar image for the authenticated user. Supports JPEG and PNG files up to 2MB. +// @Tags Avatar +// @Accept multipart/form-data +// @Produce json +// @Security BearerAuth +// @Param avatar formData file true "Avatar image file (JPEG or PNG)" +// @Success 200 {object} map[string]string "Avatar uploaded successfully" +// @Failure 400 {object} models.ErrorResponse "Invalid input (e.g., file too large, wrong format)" +// @Failure 401 {object} models.ErrorResponse "Invalid or unauthorized token" +// @Failure 500 {object} models.ErrorResponse "Server error" +// @Router /avatar [put] +func UpdateAvatar(ctx *gin.Context) { + + tokenParam, _ := ctx.Get("jwt") + token := tokenParam.(string) + + if len(token) == 0 { + ctx.JSON(400, models.ErrorResponse{Details: "Token is required"}) + return + } + + claims, err := services.GetTokenClaims(token) + if err != nil { + if errors.Is(err, services.ErrInvalidToken) { + ctx.JSON(401, models.ErrorResponse{Details: "Invalid token"}) + } else { + ctx.JSON(500, models.ErrorResponse{Details: "Internal server error"}) + } + return + } + + file, header, err := ctx.Request.FormFile("avatar") + if err != nil { + ctx.JSON(400, models.ErrorResponse{Details: "Failed to get file"}) + return + } + defer file.Close() + + _, err = services.UploadAvatar(uint(claims.UserID), file, header) + if err != nil { + if errors.Is(err, services.ErrFileTooLarge) || errors.Is(err, services.ErrInvalidFileType) || errors.Is(err, services.ErrFileReadFailed) { + ctx.JSON(400, models.ErrorResponse{Details: err.Error()}) + } else { + ctx.JSON(500, models.ErrorResponse{Details: "Internal server error"}) + } + return + } + + ctx.JSON(200, map[string]string{"details": "Avatar uploaded successfully"}) +} + +// @Summary Get user avatar +// @Description Retrieves the avatar image for the specified user or the authenticated user if no user_id is provided. +// @Tags Avatar +// @Produce image/jpeg,image/png +// @Security BearerAuth +// @Param user_id query int false "User ID" +// @Success 200 {file} file "Image" +// @Failure 400 {object} models.ErrorResponse "Invalid input" +// @Failure 401 {object} models.ErrorResponse "Invalid or unauthorized token" +// @Failure 404 {object} models.ErrorResponse "Avatar not found" +// @Failure 500 {object} models.ErrorResponse "Server error" +// @Router /avatar [get] +func GetAvatar(ctx *gin.Context) { + + tokenParam, _ := ctx.Get("jwt") + token := tokenParam.(string) + + if len(token) == 0 { + ctx.JSON(400, models.ErrorResponse{Details: "Token is required"}) + return + } + + claims, err := services.GetTokenClaims(token) + if err != nil { + if errors.Is(err, services.ErrInvalidToken) { + ctx.JSON(401, models.ErrorResponse{Details: "Invalid token"}) + } else { + ctx.JSON(500, models.ErrorResponse{Details: "Internal server error"}) + } + return + } + + var targetUserId int + userIdStr := ctx.Query("user_id") + if userIdStr == "" { + targetUserId = claims.UserID + } else { + parsedId, err := strconv.Atoi(userIdStr) + if err != nil || parsedId < 1 { + ctx.JSON(400, models.ErrorResponse{Details: "Invalid user_id"}) + return + } + targetUserId = parsedId + } + + path, err := services.GetAvatarPath(targetUserId) + if err != nil { + if errors.Is(err, services.ErrAvatarNotFound) { + ctx.JSON(404, models.ErrorResponse{Details: "Avatar not found"}) + } else { + ctx.JSON(500, models.ErrorResponse{Details: "Internal server error"}) + } + return + } + + ctx.File(path) +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..cb63907 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,93 @@ +package config + +import "os" + +type Config struct { + Routes struct { + CORSAddresses []string + } + + Database struct { + Host string + User string + Password string + Name string + } + + JWT struct { + SecretKey string + } + + Interest struct { + Interests map[string]struct{} + } + + Avatars struct { + UploadDir string + MaxFileSize int64 + } +} + +var AppConfig Config + +func CreateInterests() { + interests := []string{ + "swimming", + "cycling", + "football", + "basketball", + "tennis", + "running", + "yoga", + "golf", + "skiing", + "surfing", + "hiking", + "climbing", + "kayaking", + "fishing", + "horse riding", + "nature walks", + "beach walks", + "dog walking", + "photography walks", + "bird watching", + "fitness", + "weightlifting", + "crossfit", + "pilates", + "dancing", + "skateboarding", + "snowboarding", + "paragliding", + "diving", + "martial arts", + "healthy food", + } + + AppConfig.Interest.Interests = make(map[string]struct{}, 2) + + for _, interest := range interests { + AppConfig.Interest.Interests[interest] = struct{}{} + } +} + +func Load() { + AppConfig.Database.Host = os.Getenv("POSTGRES_HOST") + AppConfig.Database.User = os.Getenv("POSTGRES_USER") + AppConfig.Database.Password = os.Getenv("POSTGRES_PASSWORD") + AppConfig.Database.Name = os.Getenv("POSTGRES_DB") + + AppConfig.JWT.SecretKey = os.Getenv("SECRET_KEY") + + CreateInterests() + + AppConfig.Avatars.UploadDir = "./uploads/avatars" + AppConfig.Avatars.MaxFileSize = 2 * 1024 * 1024 + + if err := os.MkdirAll(AppConfig.Avatars.UploadDir, os.ModePerm); err != nil { + panic("Failed to create avatar upload directory") + } + + AppConfig.Routes.CORSAddresses = []string{"http://mhdserver.ru", "http://localhost:8080", "https://mal-da-udal.github.io/GoWithMe/"} +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go new file mode 100644 index 0000000..f5b95ec --- /dev/null +++ b/backend/internal/db/db.go @@ -0,0 +1,28 @@ +package db + +import ( + "backend/internal/config" + "backend/internal/models" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "log" +) + +var DB *gorm.DB + +func MakeMigrations() { + DB.AutoMigrate(&models.User{}, &models.Profile{}, &models.Interest{}, &models.RefreshToken{}, &models.Avatar{}) +} + +func Connect() { + dsn := "host=" + config.AppConfig.Database.Host + " user=" + config.AppConfig.Database.User + " password=" + config.AppConfig.Database.Password + " dbname=" + config.AppConfig.Database.Name + " sslmode=disable TimeZone=Europe/Moscow" + + var err error + DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) + + if err != nil { + log.Fatalf("Error while connecting to database: %s\n", err.Error()) + } + + MakeMigrations() +} diff --git a/backend/internal/models/auth.go b/backend/internal/models/auth.go new file mode 100644 index 0000000..4d2e9b0 --- /dev/null +++ b/backend/internal/models/auth.go @@ -0,0 +1,43 @@ +package models + +import "time" + +type RegisterRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type RefreshRequest struct { + RefreshToken string `json:"refresh_token" binding:"required"` +} + +type LoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type RegisterResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +type TokenClaimsResponse struct { + UserID int `json:"user_id"` + Username string `json:"username"` +} + +type LoginResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +type ErrorResponse struct { + Details string `json:"details"` +} + +type RefreshToken struct { + ID int `gorm:"primarykey"` + UserID int `gorm:"not null"` + Token string `gorm:"not null"` + ExpiresAt time.Time `gorm:"not null"` +} diff --git a/backend/internal/models/avatar.go b/backend/internal/models/avatar.go new file mode 100644 index 0000000..4c3c56a --- /dev/null +++ b/backend/internal/models/avatar.go @@ -0,0 +1,7 @@ +package models + +type Avatar struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null" json:"user_id"` + Path string `gorm:"not null" json:"path"` +} diff --git a/backend/internal/models/interest.go b/backend/internal/models/interest.go new file mode 100644 index 0000000..d9d698d --- /dev/null +++ b/backend/internal/models/interest.go @@ -0,0 +1,29 @@ +package models + +type Interest struct { + Id int `json:"id" gorm:"primarykey"` + InterestId string `json:"interest_id"` + UserId int `json:"user_id"` +} + +type UpdateInterestsRequest struct { + Interests []string `json:"interests"` +} + +type AllInterestsResponse struct { + Interests []string `json:"interests"` +} + +type UserProfileWithInterests struct { + Profile Profile `json:"profile"` + Interests []string `json:"interests"` +} + +type PaginatedUsersResponse struct { + PageNum int `json:"page_num"` + Users map[int]UserProfileWithInterests `json:"users"` +} + +type InterestsRequest struct { + Interests []string `json:"interests"` +} diff --git a/backend/internal/models/profile.go b/backend/internal/models/profile.go new file mode 100644 index 0000000..5ed8aa4 --- /dev/null +++ b/backend/internal/models/profile.go @@ -0,0 +1,21 @@ +package models + +type Profile struct { + Id int `json:"id" gorm:"primarykey"` + UserId int `json:"user_id"` + Name string `json:"name"` + Surname string `json:"surname"` + Age int `json:"age"` + Gender string `json:"gender"` + Telegram string `json:"telegram"` + Description string `json:"description"` +} + +type UpdateProfileRequest struct { + Name *string `json:"name"` + Surname *string `json:"surname"` + Age *int `json:"age"` + Gender *string `json:"gender"` + Telegram *string `json:"telegram"` + Description *string `json:"description"` +} diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go new file mode 100644 index 0000000..2ec0fda --- /dev/null +++ b/backend/internal/models/user.go @@ -0,0 +1,7 @@ +package models + +type User struct { + Id int `json:"id" gorm:"primarykey"` + Username string `json:"username"` + Password string `json:"password"` +} diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go new file mode 100644 index 0000000..e514e54 --- /dev/null +++ b/backend/internal/routes/routes.go @@ -0,0 +1,85 @@ +package routes + +import ( + api "backend/internal/api" + "backend/internal/config" + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" + "net/http" + "strings" +) + +func jwtMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"}) + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"}) + return + } + + token := parts[1] + + c.Set("jwt", token) + + c.Next() + } +} + +func Setup() *gin.Engine { + router := gin.Default() + + corsConfig := cors.DefaultConfig() + corsConfig.AllowOrigins = config.AppConfig.Routes.CORSAddresses + corsConfig.AllowMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"} + corsConfig.AllowHeaders = []string{"Origin", "Content-Type", "Authorization"} + corsConfig.AllowCredentials = true + router.Use(cors.New(corsConfig)) + + router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + + { + auth := router.Group("/auth") + auth.POST("/register", api.Register) + auth.POST("/login", api.Login) + auth.POST("/refresh", api.RefreshToken) + + authProtected := auth.Group("/") + authProtected.Use(jwtMiddleware()) + authProtected.GET("/", api.GetTokenClaims) + } + + { + profile := router.Group("/profile") + profile.Use(jwtMiddleware()) + profile.POST("/", api.CreateProfile) + profile.GET("/", api.GetProfile) + profile.PATCH("/", api.UpdateProfile) + } + + { + interests := router.Group("/interests") + interests.Use(jwtMiddleware()) + interests.PUT("/", api.UpdateInterests) + interests.GET("/", api.GetInterests) + interests.GET("/all", api.GetUsersByInterests) + interests.GET("/cats", api.GetAllInterests) + } + + { + avatar := router.Group("/avatar") + avatar.Use(jwtMiddleware()) + avatar.PUT("/", api.UpdateAvatar) + avatar.GET("/", api.GetAvatar) + } + + return router +} diff --git a/backend/internal/services/auth_service.go b/backend/internal/services/auth_service.go new file mode 100644 index 0000000..53e4240 --- /dev/null +++ b/backend/internal/services/auth_service.go @@ -0,0 +1,188 @@ +package services + +import ( + "backend/internal/config" + "backend/internal/db" + "backend/internal/models" + "backend/pkg/utils" + "errors" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + "time" +) + +var ( + ErrUnknownUserOrPass error = errors.New("unknown user or password") + ErrUserExists error = errors.New("user already exists") + ErrInvalidInput error = errors.New("invalid input") + ErrInvalidToken error = errors.New("invalid token") + ErrCanNotGetClaims error = errors.New("can not get claims") + ErrInvalidRefreshToken error = errors.New("invalid refresh token") +) + +func generateAccessToken(userID int, username string) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "user_id": userID, + "username": username, + "exp": time.Now().Add(20 * time.Minute).Unix(), + }) + return token.SignedString([]byte(config.AppConfig.JWT.SecretKey)) +} + +func generateRefreshToken(userID int) (string, error) { + expTime := time.Now().Add(14 * 24 * time.Hour) + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "user_id": userID, + "exp": expTime.Unix(), + }) + + tokenString, err := token.SignedString([]byte(config.AppConfig.JWT.SecretKey)) + + if err != nil { + return "", err + } + + refreshToken := models.RefreshToken{ + UserID: userID, + Token: tokenString, + ExpiresAt: expTime, + } + + if err := db.DB.Create(&refreshToken).Error; err != nil { + return "", err + } + + return tokenString, nil +} + +func RegisterUser(username, password string) (string, string, error) { + // Hashing password + hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", "", err + } + + // Checking for existent user + var user models.User + if err := db.DB.First(&user, "username = ?", username).Error; err == nil { + return "", "", ErrUserExists + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return "", "", err + } + + // Creating new user + user = models.User{ + Username: username, + Password: string(hashed), + } + if err := db.DB.Create(&user).Error; err != nil { + return "", "", err + } + + // Generating tokens + accessToken, err := generateAccessToken(user.Id, user.Username) + if err != nil { + return "", "", err + } + + refreshToken, err := generateRefreshToken(user.Id) + if err != nil { + return "", "", err + } + + return accessToken, refreshToken, nil +} + +func LoginUser(username, password string) (string, string, error) { + // Searching user + var user models.User + if err := db.DB.First(&user, "username = ?", username).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", "", ErrUnknownUserOrPass + } + return "", "", err + } + + // Checking passwords + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { + return "", "", ErrUnknownUserOrPass + } + + // Generating tokens + accessToken, err := generateAccessToken(user.Id, user.Username) + if err != nil { + return "", "", err + } + refreshToken, err := generateRefreshToken(user.Id) + if err != nil { + return "", "", err + } + + return accessToken, refreshToken, nil +} + +func GetTokenClaims(token string) (models.TokenClaimsResponse, error) { + tokena, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { + return []byte(config.AppConfig.JWT.SecretKey), nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})) + + if err != nil { + return models.TokenClaimsResponse{}, ErrInvalidToken + } + + if claims, ok := tokena.Claims.(jwt.MapClaims); ok { + var userId int + var username string + userId, err = utils.GetIntFromClaims(claims, "user_id") + if err != nil { + return models.TokenClaimsResponse{}, err + } + + username, err = utils.GetStringFromClaims(claims, "username") + if err != nil { + return models.TokenClaimsResponse{}, err + } + + return models.TokenClaimsResponse{ + UserID: userId, + Username: username, + }, nil + } else { + return models.TokenClaimsResponse{}, ErrCanNotGetClaims + } +} + +func RefreshAccessToken(refreshToken string) (string, error) { + var token models.RefreshToken + // Check if the refresh token exists and is not expired + if err := db.DB.Where("token = ?", refreshToken).First(&token).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", ErrInvalidRefreshToken + } + return "", err + } + + // Check if the token is expired + if token.ExpiresAt.Before(time.Now()) { + if err := db.DB.Delete(&models.RefreshToken{}, "token = ?", refreshToken).Error; err != nil { + return "", err + } + return "", ErrInvalidRefreshToken + } + + // Fetch user + var user models.User + if err := db.DB.First(&user, "id = ?", token.UserID).Error; err != nil { + return "", err + } + + // Generate new access token + newAccessToken, err := generateAccessToken(user.Id, user.Username) + if err != nil { + return "", err + } + + return newAccessToken, nil +} diff --git a/backend/internal/services/avatar_service.go b/backend/internal/services/avatar_service.go new file mode 100644 index 0000000..03cddeb --- /dev/null +++ b/backend/internal/services/avatar_service.go @@ -0,0 +1,105 @@ +package services + +import ( + "backend/internal/config" + "backend/internal/db" + "backend/internal/models" + "errors" + "fmt" + "gorm.io/gorm" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" +) + +var ( + ErrInvalidFileType = errors.New("only JPEG and PNG files are allowed") + ErrFileTooLarge = errors.New("file size exceeds 2MB limit") + ErrFileReadFailed = errors.New("failed to read file") + ErrAvatarNotFound = errors.New("avatar not found") +) + +func UploadAvatar(userID uint, file multipart.File, header *multipart.FileHeader) (string, error) { + + if header.Size > config.AppConfig.Avatars.MaxFileSize { + return "", ErrFileTooLarge + } + + ext := filepath.Ext(header.Filename) + if ext != ".jpg" && ext != ".jpeg" && ext != ".png" { + return "", ErrInvalidFileType + } + + buffer := make([]byte, 512) + _, err := file.Read(buffer) + if err != nil && err != io.EOF { + return "", ErrFileReadFailed + } + mimeType := http.DetectContentType(buffer) + if mimeType != "image/jpeg" && mimeType != "image/png" { + return "", ErrInvalidFileType + } + _, err = file.Seek(0, io.SeekStart) + if err != nil { + return "", errors.New("failed to reset file pointer") + } + + filename := fmt.Sprintf("avatar_%d%s", userID, ext) + filePath := filepath.Join(config.AppConfig.Avatars.UploadDir, filename) + + out, err := os.Create(filePath) + if err != nil { + return "", errors.New("failed to save file") + } + defer out.Close() + + _, err = io.Copy(out, file) + + if err != nil { + return "", errors.New("failed to write file") + } + + var avatar models.Avatar + err = db.DB.Where("user_id = ?", userID).First(&avatar).Error + if err == nil { + + oldPath := avatar.Path + avatar.Path = filePath + err = db.DB.Save(&avatar).Error + if err != nil { + return "", errors.New("failed to update avatar in database") + } + + if oldPath != filePath { + os.Remove(oldPath) + } + } else if errors.Is(err, gorm.ErrRecordNotFound) { + + avatar = models.Avatar{UserID: userID, Path: filePath} + err = db.DB.Create(&avatar).Error + if err != nil { + return "", errors.New("failed to create avatar in database") + } + } else { + return "", errors.New("database error") + } + + return filePath, nil +} + +func GetAvatarPath(userID int) (string, error) { + var avatar models.Avatar + + err := db.DB.Where("user_id = ?", userID).First(&avatar).Error + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", ErrAvatarNotFound + } + return "", err + } + + return avatar.Path, nil +} diff --git a/backend/internal/services/interest_service.go b/backend/internal/services/interest_service.go new file mode 100644 index 0000000..653d548 --- /dev/null +++ b/backend/internal/services/interest_service.go @@ -0,0 +1,140 @@ +package services + +import ( + "backend/internal/config" + "backend/internal/db" + "backend/internal/models" + "errors" +) + +var ( + ErrUnknownInterest error = errors.New("unknown interest") +) + +func SetInterests(claims *models.TokenClaimsResponse, interests []string) error { + + interestSet := make(map[string]struct{}) + for _, interest := range interests { + if _, exists := config.AppConfig.Interest.Interests[interest]; exists { + interestSet[interest] = struct{}{} + } else { + return ErrUnknownInterest + } + } + + err := db.DB.Where("user_id = ?", claims.UserID).Delete(&models.Interest{}).Error + if err != nil { + return err + } + + for interest := range interestSet { + err := db.DB.Create(&models.Interest{ + UserId: claims.UserID, + InterestId: interest, + }).Error + if err != nil { + return err + } + } + + return nil +} + +func GetInterests(claims *models.TokenClaimsResponse, userId int) ([]string, error) { + var neededId int + + if userId == 0 { + neededId = claims.UserID + } else { + neededId = userId + } + + var interests []models.Interest + err := db.DB.Where("user_id = ?", neededId).Find(&interests).Error + + if err != nil { + return nil, err + } + + ret := make([]string, 0, len(interests)) + + for _, interest := range interests { + ret = append(ret, interest.InterestId) + } + + return ret, nil +} + +func GetUsersByInterests(pageNum int, interests []string, pageSize int) (*models.PaginatedUsersResponse, error) { + if pageNum < 1 { + return nil, errors.New("invalid page number") + } + + if len(interests) == 0 { + return nil, errors.New("interests list cannot be empty") + } + + var paginatedUserIDs []int + err := db.DB.Table("interests"). + Select("DISTINCT user_id"). + Where("interest_id IN ?", interests). + Order("user_id ASC"). + Offset((pageNum-1)*pageSize). + Limit(pageSize). + Pluck("user_id", &paginatedUserIDs).Error + if err != nil { + return nil, err + } + + if len(paginatedUserIDs) == 0 { + return &models.PaginatedUsersResponse{PageNum: pageNum, Users: make(map[int]models.UserProfileWithInterests)}, nil + } + + var profiles []models.Profile + err = db.DB.Where("user_id IN ?", paginatedUserIDs).Find(&profiles).Error + if err != nil { + return nil, err + } + + userProfiles := make(map[int]models.Profile) + for _, profile := range profiles { + userProfiles[profile.UserId] = profile + } + + var allInterests []models.Interest + err = db.DB.Where("user_id IN ?", paginatedUserIDs).Find(&allInterests).Error + if err != nil { + return nil, err + } + + userInterests := make(map[int][]string) + for _, interest := range allInterests { + userInterests[interest.UserId] = append(userInterests[interest.UserId], interest.InterestId) + } + + response := &models.PaginatedUsersResponse{ + PageNum: pageNum, + Users: make(map[int]models.UserProfileWithInterests), + } + + for _, userID := range paginatedUserIDs { + if profile, exists := userProfiles[userID]; exists { + response.Users[userID] = models.UserProfileWithInterests{ + Profile: profile, + Interests: userInterests[userID], + } + } + } + + return response, nil +} + +func GetAllInterests() []string { + ret := make([]string, 0, len(config.AppConfig.Interest.Interests)) + + for interest := range config.AppConfig.Interest.Interests { + ret = append(ret, interest) + } + + return ret +} diff --git a/backend/internal/services/profile_service.go b/backend/internal/services/profile_service.go new file mode 100644 index 0000000..d540abf --- /dev/null +++ b/backend/internal/services/profile_service.go @@ -0,0 +1,99 @@ +package services + +import ( + "backend/internal/db" + "backend/internal/models" + "errors" + "gorm.io/gorm" +) + +var ( + ErrProfileDoesNotExists error = errors.New("profile does not exists") + ErrProfileAlreadyExists error = errors.New("profile already exists") +) + +func CreateProfile(info *models.Profile) error { + var profile models.Profile + + if err := db.DB.First(&profile, "user_id=?", info.UserId).Error; err == nil { + return ErrProfileAlreadyExists + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + profile = models.Profile{ + UserId: info.UserId, + Name: info.Name, + Surname: info.Surname, + Age: info.Age, + Gender: info.Gender, + Telegram: info.Telegram, + Description: info.Description, + } + + err := db.DB.Create(&profile).Error + + if err != nil { + return err + } + + return nil +} + +func GetProfile(claims *models.TokenClaimsResponse, userId int) (*models.Profile, error) { + var profile models.Profile + var neededId int + + if userId == 0 { + neededId = claims.UserID + } else { + neededId = userId + } + + if err := db.DB.First(&profile, "user_id=?", neededId).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrProfileDoesNotExists + } else { + return nil, err + } + } + + return &profile, nil +} + +func UpdateProfile(userId int, updateReq *models.UpdateProfileRequest) (*models.Profile, error) { + var profile models.Profile + + if err := db.DB.First(&profile, "user_id=?", userId).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrProfileDoesNotExists + } else { + return nil, err + } + } + + if updateReq.Name != nil { + profile.Name = *updateReq.Name + } + if updateReq.Surname != nil { + profile.Surname = *updateReq.Surname + } + if updateReq.Age != nil { + profile.Age = *updateReq.Age + } + if updateReq.Gender != nil { + profile.Gender = *updateReq.Gender + } + if updateReq.Description != nil { + profile.Description = *updateReq.Description + } + if updateReq.Telegram != nil { + profile.Telegram = *updateReq.Telegram + } + + if err := db.DB.Save(&profile).Error; err != nil { + return nil, err + } + + return &profile, nil +} diff --git a/backend/pkg/utils/shortcuts.go b/backend/pkg/utils/shortcuts.go new file mode 100644 index 0000000..03b49bb --- /dev/null +++ b/backend/pkg/utils/shortcuts.go @@ -0,0 +1,36 @@ +package utils + +import ( + "errors" + "github.com/golang-jwt/jwt/v5" +) + +var ( + ErrClaimDoesNotExists error = errors.New("claim does not exists") + ErrClaimIsNotNumeric error = errors.New("claim is not numeric value") + ErrClaimIsNotString error = errors.New("claim is not string value") +) + +func GetIntFromClaims(claims jwt.MapClaims, key string) (int, error) { + if num, exists := claims[key]; exists { + if val, ok := num.(float64); ok { + return int(val), nil + } else { + return 0, ErrClaimIsNotNumeric + } + } else { + return 0, ErrClaimDoesNotExists + } +} + +func GetStringFromClaims(claims jwt.MapClaims, key string) (string, error) { + if str, exists := claims[key]; exists { + if val, ok := str.(string); ok { + return val, nil + } else { + return "", ErrClaimIsNotString + } + } else { + return "", ErrClaimDoesNotExists + } +} diff --git a/compose.yaml b/compose.yaml index bf81b4a..bee8837 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,6 +1,33 @@ services: + postgres: + image: postgres:latest + environment: + - POSTGRES_USER=gowithme + - POSTGRES_PASSWORD=gwm-pwd-123 + - POSTGRES_DB=gowithme + volumes: + - pgdata:/var/lib/postgresql/data + restart: always + backend: build: ./backend/ - + environment: + - POSTGRES_HOST=postgres + - POSTGRES_USER=gowithme + - POSTGRES_PASSWORD=gwm-pwd-123 + - POSTGRES_DB=gowithme + - SECRET_KEY=guten_tag + ports: + - "8081:8081" + depends_on: + - postgres + frontend: build: ./frontend/ + ports: + - "8080:80" + depends_on: + - backend + +volumes: + pgdata: diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..79c113f --- /dev/null +++ b/frontend/.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 +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# 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/frontend/.metadata b/frontend/.metadata new file mode 100644 index 0000000..603c641 --- /dev/null +++ b/frontend/.metadata @@ -0,0 +1,45 @@ +# 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: "6fba2447e95c451518584c35e25f5433f14d888c" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 6fba2447e95c451518584c35e25f5433f14d888c + base_revision: 6fba2447e95c451518584c35e25f5433f14d888c + - platform: android + create_revision: 6fba2447e95c451518584c35e25f5433f14d888c + base_revision: 6fba2447e95c451518584c35e25f5433f14d888c + - platform: ios + create_revision: 6fba2447e95c451518584c35e25f5433f14d888c + base_revision: 6fba2447e95c451518584c35e25f5433f14d888c + - platform: linux + create_revision: 6fba2447e95c451518584c35e25f5433f14d888c + base_revision: 6fba2447e95c451518584c35e25f5433f14d888c + - platform: macos + create_revision: 6fba2447e95c451518584c35e25f5433f14d888c + base_revision: 6fba2447e95c451518584c35e25f5433f14d888c + - platform: web + create_revision: 6fba2447e95c451518584c35e25f5433f14d888c + base_revision: 6fba2447e95c451518584c35e25f5433f14d888c + - platform: windows + create_revision: 6fba2447e95c451518584c35e25f5433f14d888c + base_revision: 6fba2447e95c451518584c35e25f5433f14d888c + + # 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/frontend/Dockerfile b/frontend/Dockerfile index b6dbda7..c57b05e 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,2 +1,26 @@ -# TODO +FROM ubuntu:latest +RUN apt-get update && apt-get install -y \ + curl git unzip zip nginx ca-certificates && \ + apt-get clean + +RUN git ls-remote https://github.com/flutter/flutter.git || exit 1 + +RUN git clone --single-branch --branch stable --depth 1 https://github.com/flutter/flutter.git /flutter + +ENV PATH="$PATH:/flutter/bin:/flutter/bin/cache/dart-sdk/bin" + +RUN flutter channel stable && \ + flutter upgrade && \ + flutter config --enable-web + +COPY . /app +WORKDIR /app + +RUN flutter pub get && flutter build web --release + +RUN mkdir -p /usr/share/nginx/html && cp -r build/web/* /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..9207519 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1 @@ +## Application \ No newline at end of file diff --git a/frontend/analysis_options.yaml b/frontend/analysis_options.yaml new file mode 100644 index 0000000..f16eead --- /dev/null +++ b/frontend/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: \ No newline at end of file diff --git a/frontend/android/.gitignore b/frontend/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/frontend/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/frontend/android/app/build.gradle.kts b/frontend/android/app/build.gradle.kts new file mode 100644 index 0000000..8e7198b --- /dev/null +++ b/frontend/android/app/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + id("com.android.application") + 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.frontend" + compileSdk = flutter.compileSdkVersion + ndkVersion = "27.0.12077973" + 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.frontend" + // 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/frontend/android/app/src/debug/AndroidManifest.xml b/frontend/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/frontend/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/frontend/android/app/src/main/AndroidManifest.xml b/frontend/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0fd500e --- /dev/null +++ b/frontend/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/android/app/src/main/kotlin/com/example/frontend/MainActivity.kt b/frontend/android/app/src/main/kotlin/com/example/frontend/MainActivity.kt new file mode 100644 index 0000000..1bd2dee --- /dev/null +++ b/frontend/android/app/src/main/kotlin/com/example/frontend/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.frontend + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/frontend/android/app/src/main/res/drawable-v21/launch_background.xml b/frontend/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/frontend/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/frontend/android/app/src/main/res/drawable/launch_background.xml b/frontend/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/frontend/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/values-night/styles.xml b/frontend/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/frontend/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/frontend/android/app/src/main/res/values/styles.xml b/frontend/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/frontend/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/frontend/android/app/src/profile/AndroidManifest.xml b/frontend/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/frontend/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/frontend/android/build.gradle.kts b/frontend/android/build.gradle.kts new file mode 100644 index 0000000..89176ef --- /dev/null +++ b/frontend/android/build.gradle.kts @@ -0,0 +1,21 @@ +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/frontend/android/gradle.properties b/frontend/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /dev/null +++ b/frontend/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/frontend/android/gradle/wrapper/gradle-wrapper.properties b/frontend/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ac3b479 --- /dev/null +++ b/frontend/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/frontend/android/settings.gradle.kts b/frontend/android/settings.gradle.kts new file mode 100644 index 0000000..ab39a10 --- /dev/null +++ b/frontend/android/settings.gradle.kts @@ -0,0 +1,25 @@ +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.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/frontend/assets/fonts/Montserrat-Bold.ttf b/frontend/assets/fonts/Montserrat-Bold.ttf new file mode 100644 index 0000000..02ff6ff Binary files /dev/null and b/frontend/assets/fonts/Montserrat-Bold.ttf differ diff --git a/frontend/assets/fonts/Montserrat-Medium.ttf b/frontend/assets/fonts/Montserrat-Medium.ttf new file mode 100644 index 0000000..dfbcfe4 Binary files /dev/null and b/frontend/assets/fonts/Montserrat-Medium.ttf differ diff --git a/frontend/assets/fonts/Montserrat-Regular.ttf b/frontend/assets/fonts/Montserrat-Regular.ttf new file mode 100644 index 0000000..48ba65e Binary files /dev/null and b/frontend/assets/fonts/Montserrat-Regular.ttf differ diff --git a/frontend/assets/fonts/Montserrat-SemiBold.ttf b/frontend/assets/fonts/Montserrat-SemiBold.ttf new file mode 100644 index 0000000..8dbcdb3 Binary files /dev/null and b/frontend/assets/fonts/Montserrat-SemiBold.ttf differ diff --git a/frontend/assets/images/logo.png b/frontend/assets/images/logo.png new file mode 100644 index 0000000..5af7077 Binary files /dev/null and b/frontend/assets/images/logo.png differ diff --git a/frontend/assets/translations/en.json b/frontend/assets/translations/en.json new file mode 100644 index 0000000..117238c --- /dev/null +++ b/frontend/assets/translations/en.json @@ -0,0 +1,99 @@ +{ + "profile": { + "title": "Profile", + "firstName": "First Name", + "lastName": "Last Name", + "age": "Age", + "telegram": "Telegram", + "description": "Description", + "save": "Save", + "genderFemale": "F", + "genderMale": "M", + "updateSuccess": "Profile updated", + "chooseAvatar": "Upload photo", + "activities": "Activities" + }, + "settings": { + "settings": "Settings", + "language": "Language", + "russian": "Russian", + "english": "English", + "theme": "Theme", + "system": "System", + "dark": "Dark", + "light": "Light" + }, + "search": { + "find": "Find", + "notFound": "No users found" + }, + "home": { + "search": "Search", + "profile": "Profile", + "searchTitle": "Find a Companion" + }, + "auth": { + "login": "Login", + "register": "Register", + "password": "Password", + "confirmPassword": "Retry password", + "error": "An error occurred" + }, + "userProfile": { + "title": "Profile", + "yearsOld": "years old", + "interests": "Interests", + "messageOnTelegram": "Message on Telegram" + }, + "errors": { + "400": "Invalid data. Please check your input.", + "401": "Authorization failed. Please log in again.", + "403": "You do not have access to this resource.", + "404": "Data not found.", + "409": "This user already exists.", + "500": "Internal server error. Please try again later.", + "502": "Server issues. Please try again later.", + "503": "Server temporarily unavailable.", + "default": "An error occurred. Please try again." + }, + "interests": { + "swimming": "Swimming", + "cycling": "Cycling", + "football": "Football", + "basketball": "Basketball", + "tennis": "Tennis", + "running": "Running", + "yoga": "Yoga", + "golf": "Golf", + "skiing": "Skiing", + "surfing": "Surfing", + "hiking": "Hiking", + "climbing": "Climbing", + "kayaking": "Kayaking", + "fishing": "Fishing", + "horse riding": "Horse Riding", + "nature walks": "Nature Walks", + "beach walks": "Beach Walks", + "dog walking": "Dog Walking", + "photography walks": "Photography Walks", + "bird watching": "Bird Watching", + "fitness": "Fitness", + "weightlifting": "Weightlifting", + "crossfit": "CrossFit", + "pilates": "Pilates", + "dancing": "Dancing", + "skateboarding": "Skateboarding", + "snowboarding": "Snowboarding", + "paragliding": "Paragliding", + "diving": "Diving", + "martial arts": "Martial Arts", + "healthy food": "Healthy food" + }, + "logout": { + "button": "Log out", + "title": "Log out", + "confirm": "Are you sure you want to log out?", + "yes": "Yes", + "no": "No" + } +} \ No newline at end of file diff --git a/frontend/assets/translations/ru.json b/frontend/assets/translations/ru.json new file mode 100644 index 0000000..5049441 --- /dev/null +++ b/frontend/assets/translations/ru.json @@ -0,0 +1,99 @@ +{ + "settings": { + "settings": "Настройки", + "language": "Язык", + "russian": "Русский", + "english": "Английский", + "theme": "Тема", + "system": "Системная", + "dark": "Тёмная", + "light": "Светлая" + }, + "profile": { + "title": "Профиль", + "firstName": "Имя", + "lastName": "Фамилия", + "age": "Возраст", + "telegram": "Telegram", + "description": "Описание", + "save": "Сохранить", + "genderFemale": "Ж", + "genderMale": "М", + "updateSuccess": "Профиль обновлён", + "chooseAvatar": "Загрузить фото", + "activities": "Интересы" + }, + "search": { + "find": "Найти", + "notFound": "Пользователи не найдены" + }, + "home": { + "search": "Поиск", + "profile": "Профиль", + "searchTitle": "Найти компаньона" + }, + "auth": { + "login": "Войти", + "register": "Регистрация", + "password": "Пароль", + "confirmPassword": "Повторите пароль", + "error": "Произошла ошибка" + }, + "userProfile": { + "title": "Профиль", + "yearsOld": "лет", + "interests": "Интересы", + "messageOnTelegram": "Написать в Telegram" + }, + "errors": { + "400": "Некорректные данные. Проверьте введённую информацию.", + "401": "Авторизация не удалась. Попробуйте войти снова.", + "403": "У вас нет доступа к этому ресурсу.", + "404": "Данные не найдены.", + "409": "Этот пользователь уже существует.", + "500": "Произошла внутренняя ошибка сервера. Попробуйте позже.", + "502": "Проблемы с сервером. Повторите позже.", + "503": "Сервер временно недоступен.", + "default": "Произошла ошибка. Повторите попытку." + }, + "interests": { + "swimming": "Плавание", + "cycling": "Велоспорт", + "football": "Футбол", + "basketball": "Баскетбол", + "tennis": "Теннис", + "running": "Бег", + "yoga": "Йога", + "golf": "Гольф", + "skiing": "Лыжи", + "surfing": "Сёрфинг", + "hiking": "Походы", + "climbing": "Скалолазание", + "kayaking": "Каякинг", + "fishing": "Рыбалка", + "horse riding": "Верховая езда", + "nature walks": "Прогулки на природе", + "beach walks": "Прогулки по пляжу", + "dog walking": "Выгул собак", + "photography walks": "Фотопрогулки", + "bird watching": "Наблюдение за птицами", + "fitness": "Фитнес", + "weightlifting": "Тяжёлая атлетика", + "crossfit": "Кроссфит", + "pilates": "Пилатес", + "dancing": "Танцы", + "skateboarding": "Скейтбординг", + "snowboarding": "Сноуборд", + "paragliding": "Парапланеризм", + "diving": "Дайвинг", + "martial arts": "Боевые искусства", + "healthy food": "Здоровое питание" + }, + "logout": { + "button": "Выйти", + "title": "Выход", + "confirm": "Вы уверены, что хотите выйти?", + "yes": "Да", + "no": "Нет" + } +} \ No newline at end of file diff --git a/frontend/devtools_options.yaml b/frontend/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/frontend/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/frontend/ios/.gitignore b/frontend/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/frontend/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/frontend/ios/Flutter/AppFrameworkInfo.plist b/frontend/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..7c56964 --- /dev/null +++ b/frontend/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 + 12.0 + + diff --git a/frontend/ios/Flutter/Debug.xcconfig b/frontend/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/frontend/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/frontend/ios/Flutter/Release.xcconfig b/frontend/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/frontend/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/frontend/ios/Podfile b/frontend/ios/Podfile new file mode 100644 index 0000000..e549ee2 --- /dev/null +++ b/frontend/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '12.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/frontend/ios/Podfile.lock b/frontend/ios/Podfile.lock new file mode 100644 index 0000000..2398aa5 --- /dev/null +++ b/frontend/ios/Podfile.lock @@ -0,0 +1,42 @@ +PODS: + - Flutter (1.0.0) + - image_picker_ios (0.0.1): + - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - url_launcher_ios (0.0.1): + - Flutter + +DEPENDENCIES: + - Flutter (from `Flutter`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + +SPEC CHECKSUMS: + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + +PODFILE CHECKSUM: 4305caec6b40dde0ae97be1573c53de1882a07e5 + +COCOAPODS: 1.16.2 diff --git a/frontend/ios/Runner.xcodeproj/project.pbxproj b/frontend/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..4946cac --- /dev/null +++ b/frontend/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,730 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 2086AB28BF8EB8A3C17393B6 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BFC40FC68F08E3AB823C9CB9 /* Pods_RunnerTests.framework */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 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 */; }; + CEDDA4F55408030FE7E4AB64 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 433212155117BA6740700A57 /* Pods_Runner.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 */ + 106CCA9724C28540621C08F8 /* 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 = ""; }; + 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 = ""; }; + 433212155117BA6740700A57 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 48330C29DB2F5923A554A6AB /* 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 = ""; }; + 72A03884C56EACB9C66F5928 /* 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 = ""; }; + 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 = ""; }; + 7EC9D2E0CF45E19ADCEE43F5 /* 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 = ""; }; + 7FC3B518A963F4CCBA13E086 /* 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 = ""; }; + 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 = ""; }; + 98E6B23C4D892897248AC6BC /* 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 = ""; }; + BFC40FC68F08E3AB823C9CB9 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 0651ACE149C37E6991AFB142 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2086AB28BF8EB8A3C17393B6 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CEDDA4F55408030FE7E4AB64 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 4EA73D261B856C7E551366CE /* Frameworks */ = { + isa = PBXGroup; + children = ( + 433212155117BA6740700A57 /* Pods_Runner.framework */, + BFC40FC68F08E3AB823C9CB9 /* 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 */, + AE153A222F1425F0D51F2A14 /* Pods */, + 4EA73D261B856C7E551366CE /* 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 = ""; + }; + AE153A222F1425F0D51F2A14 /* Pods */ = { + isa = PBXGroup; + children = ( + 7EC9D2E0CF45E19ADCEE43F5 /* Pods-Runner.debug.xcconfig */, + 48330C29DB2F5923A554A6AB /* Pods-Runner.release.xcconfig */, + 106CCA9724C28540621C08F8 /* Pods-Runner.profile.xcconfig */, + 7FC3B518A963F4CCBA13E086 /* Pods-RunnerTests.debug.xcconfig */, + 98E6B23C4D892897248AC6BC /* Pods-RunnerTests.release.xcconfig */, + 72A03884C56EACB9C66F5928 /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 30378B0D995F263478402709 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 0651ACE149C37E6991AFB142 /* 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 = ( + D9E739C5051E13DF485E9744 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 89C1BFC95742AC679A7F380B /* [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 */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 30378B0D995F263478402709 /* [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; + }; + 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"; + }; + 89C1BFC95742AC679A7F380B /* [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; + }; + 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"; + }; + D9E739C5051E13DF485E9744 /* [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 */ + 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 = 12.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)"; + DEVELOPMENT_TEAM = X4QZG2T5PZ; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.frontend.gowithme; + 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 = 7FC3B518A963F4CCBA13E086 /* 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.frontend.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 = 98E6B23C4D892897248AC6BC /* 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.frontend.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 = 72A03884C56EACB9C66F5928 /* 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.frontend.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 = 12.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 = 12.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)"; + DEVELOPMENT_TEAM = X4QZG2T5PZ; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.frontend.gowithme; + 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)"; + DEVELOPMENT_TEAM = X4QZG2T5PZ; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.frontend.gowithme; + 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/frontend/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/frontend/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/frontend/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/frontend/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/frontend/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/frontend/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/frontend/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/frontend/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/frontend/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/ios/Runner.xcworkspace/contents.xcworkspacedata b/frontend/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/frontend/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/frontend/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/frontend/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/frontend/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/frontend/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/frontend/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/frontend/ios/Runner/AppDelegate.swift b/frontend/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/frontend/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/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/frontend/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/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/frontend/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/frontend/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/frontend/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/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/frontend/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/frontend/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/frontend/ios/Runner/Base.lproj/LaunchScreen.storyboard b/frontend/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/frontend/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/ios/Runner/Base.lproj/Main.storyboard b/frontend/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/frontend/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/ios/Runner/Info.plist b/frontend/ios/Runner/Info.plist new file mode 100644 index 0000000..d33b63b --- /dev/null +++ b/frontend/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Frontend + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + frontend + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/frontend/ios/Runner/Runner-Bridging-Header.h b/frontend/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/frontend/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/frontend/ios/RunnerTests/RunnerTests.swift b/frontend/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/frontend/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/frontend/lib/data/api/api_client.dart b/frontend/lib/data/api/api_client.dart new file mode 100644 index 0000000..c853193 --- /dev/null +++ b/frontend/lib/data/api/api_client.dart @@ -0,0 +1,82 @@ +import 'package:dio/dio.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:frontend/data/enums/get_storage_key.dart'; +import 'package:frontend/data/functions/show_api_error.dart'; + +final baseUrl = "http://mhdserver.ru:8081"; +// final baseUrl = "http://127.0.0.1:8081/" + +class ApiClient { + final GetStorage storage = GetStorage(); + final Dio dio = Dio( + BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 60), + receiveTimeout: const Duration(seconds: 60), + contentType: 'application/json', + ), + ); + + ApiClient() { + dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) async { + options.headers['Accept'] = "application/json"; + options.followRedirects = true; + String? token = storage.read(GetStorageKey.accessToken.value); + options.headers['Authorization'] = "Bearer $token"; + return handler.next(options); + }, + + onError: (e, handler) async { + final suppressNotification = + e.requestOptions.extra['suppressErrorNotification'] == true; + + if (e.response?.statusCode == 401) { + final newAccessToken = await refreshToken(); + if (newAccessToken != null) { + await storage.write( + GetStorageKey.accessToken.value, + newAccessToken, + ); + dio.options.headers['Authorization'] = "Bearer $newAccessToken"; + return handler.resolve(await dio.fetch(e.requestOptions)); + } else { + storage.erase(); + } + } + if (!suppressNotification) { + showApiError(e); + } + return handler.next(e); + }, + ), + ); + } + + Future refreshToken() async { + try { + final refreshToken = await storage.read(GetStorageKey.refreshToken.value); + final response = await dio.post( + '/auth/refresh', + data: {'refresh_token': refreshToken}, + ); + + final newAccessToken = response.data['access_token']; + await storage.write(GetStorageKey.accessToken.value, newAccessToken); + return newAccessToken; + } catch (e) { + await clearTokens(); + return null; + } + } + + Future saveTokens(String accessToken, String refreshToken) async { + await storage.write(GetStorageKey.accessToken.value, accessToken); + await storage.write(GetStorageKey.refreshToken.value, refreshToken); + } + + Future clearTokens() async { + await storage.erase(); + } +} diff --git a/frontend/lib/data/enums/get_storage_key.dart b/frontend/lib/data/enums/get_storage_key.dart new file mode 100644 index 0000000..0d86821 --- /dev/null +++ b/frontend/lib/data/enums/get_storage_key.dart @@ -0,0 +1,8 @@ +enum GetStorageKey { + accessToken('accessToken'), + refreshToken('refreshToken'); + + final String value; + + const GetStorageKey(this.value); +} diff --git a/frontend/lib/data/functions/open_url.dart b/frontend/lib/data/functions/open_url.dart new file mode 100644 index 0000000..30c16bd --- /dev/null +++ b/frontend/lib/data/functions/open_url.dart @@ -0,0 +1,8 @@ +import 'package:url_launcher/url_launcher.dart'; + +Future openUrl(String urlString) async { + final Uri url = Uri.parse(urlString); + if (!await launchUrl(url)) { + throw Exception('Could not launch $url'); + } +} diff --git a/frontend/lib/data/functions/show_api_error.dart b/frontend/lib/data/functions/show_api_error.dart new file mode 100644 index 0000000..385fc97 --- /dev/null +++ b/frontend/lib/data/functions/show_api_error.dart @@ -0,0 +1,20 @@ +import 'package:dio/dio.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:frontend/main.dart'; + +void showApiError(DioException error) { + final statusCode = error.response?.statusCode; + + final message = 'errors.${statusCode ?? 'default'}'.tr(); + + final messenger = rootScaffoldMessengerKey.currentState; + messenger?.clearSnackBars(); + messenger?.showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Colors.red, + duration: Duration(seconds: 4), + ), + ); +} diff --git a/frontend/lib/data/functions/text_to_string.dart b/frontend/lib/data/functions/text_to_string.dart new file mode 100644 index 0000000..cae5404 --- /dev/null +++ b/frontend/lib/data/functions/text_to_string.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; + +String themeToString(ThemeMode mode) { + return switch (mode) { + ThemeMode.light => 'light', + ThemeMode.dark => 'dark', + ThemeMode.system => 'system', + }; +} diff --git a/frontend/lib/data/functions/validations.dart b/frontend/lib/data/functions/validations.dart new file mode 100644 index 0000000..18ce168 --- /dev/null +++ b/frontend/lib/data/functions/validations.dart @@ -0,0 +1,29 @@ +String? validateLogin(String login) { + if (login.isEmpty) { + return 'Please enter a login'; + } + return null; +} + +String? validatePassword(String password) { + if (password.isEmpty) { + return 'Please enter a password'; + } + if (password.length < 6) { + return 'Password must be at least 6 characters'; + } + return null; +} + +String? validatePasswordConfirmation( + String password, + String passwordConfirmation, +) { + if (password != passwordConfirmation) { + return 'Passwords do not match'; + } + if (password.isEmpty) { + return 'Please enter a password'; + } + return null; +} diff --git a/frontend/lib/data/models/user.dart b/frontend/lib/data/models/user.dart new file mode 100644 index 0000000..0c2f235 --- /dev/null +++ b/frontend/lib/data/models/user.dart @@ -0,0 +1,40 @@ +import 'dart:typed_data'; + +class User { + final String name; + final String surname; + final int age; + final List interests; + final int id; + final String? gender; + final String? description; + final String? telegram; + Uint8List? avatarBytes; + + User({ + required this.name, + required this.surname, + required this.age, + required this.interests, + required this.id, + required this.gender, + required this.description, + required this.telegram, + this.avatarBytes, + }); + + factory User.fromJson(Map json) { + final profile = json['profile']; + final interests = List.from(json['interests'] ?? []); + return User( + name: profile['name'], + surname: profile['surname'], + age: profile['age'], + interests: interests, + id: profile['user_id'], + gender: profile['gender'], + description: profile['description'], + telegram: profile['telegram'], + ); + } +} diff --git a/frontend/lib/data/repositories/auth_repository.dart b/frontend/lib/data/repositories/auth_repository.dart new file mode 100644 index 0000000..57309ca --- /dev/null +++ b/frontend/lib/data/repositories/auth_repository.dart @@ -0,0 +1,36 @@ +import 'package:frontend/domain/services/app_services.dart'; + +import '../api/api_client.dart'; + +class AuthRepository { + final ApiClient apiClient; + + AuthRepository(this.apiClient); + + Future login(String username, String password) async { + final response = await apiClient.dio.post( + '/auth/login', + queryParameters: {'username': username, 'password': password}, + ); + + final data = response.data; + final accessToken = data['access_token']; + final refreshToken = data['refresh_token']; + + await apiClient.saveTokens(accessToken, refreshToken); + } + + Future register(String username, String password) async { + final response = await apiClient.dio.post( + '/auth/register', + queryParameters: {'username': username, 'password': password}, + ); + + final data = response.data; + final accessToken = data['access_token']; + final refreshToken = data['refresh_token']; + + await apiClient.saveTokens(accessToken, refreshToken); + await profileRepository.createProfile(); + } +} diff --git a/frontend/lib/data/repositories/profile_repository.dart b/frontend/lib/data/repositories/profile_repository.dart new file mode 100644 index 0000000..49d798f --- /dev/null +++ b/frontend/lib/data/repositories/profile_repository.dart @@ -0,0 +1,133 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:frontend/data/api/api_client.dart'; +import 'package:frontend/domain/services/shared_preferences_service.dart'; + +class ProfileRepository { + final SharedPreferencesService prefs; + final ApiClient apiClient; + + ProfileRepository(this.apiClient, this.prefs); + + Future> loadCachedProfile() async { + return await prefs.loadProfile(); + } + + Future> fetchAndCacheProfile() async { + final response = await apiClient.dio.get('/profile'); + final interests = await getUserInterests(); + final data = response.data; + + await prefs.saveProfile( + name: data['name'], + surname: data['surname'], + age: data['age'].toString(), + alias: data['telegram'], + gender: data['gender'], + description: data['description'], + activities: interests, + ); + + return data; + } + + Future> getProfile() async { + final response = await apiClient.dio.get('/profile/'); + return response.data; + } + + Future updateProfile( + String name, + String surname, + String age, + String alias, + String gender, + String description, + List activities, + ) async { + final data = { + 'name': name, + 'surname': surname, + 'age': int.tryParse(age) ?? 0, + 'telegram': alias, + 'gender': gender, + 'description': description, + 'id': 0, + 'user_id': 0, + }; + await apiClient.dio.patch('/profile/', data: data); + await updateUserInterests(activities); + + await prefs.saveProfile( + name: name, + surname: surname, + age: age, + alias: alias, + gender: gender, + description: description, + activities: activities, + ); + } + + Future createProfile() async { + final data = { + "id": 0, + "user_id": 0, + "name": "", + "surname": "", + "gender": "", + "telegram": "", + "age": 0, + "description": "", + }; + await apiClient.dio.post('/profile/', data: data); + } + + Future> getUserInterests() async { + final response = await apiClient.dio.get('/interests/'); + final data = response.data; + return List.from(data['details'] ?? []); + } + + Future updateUserInterests(List interests) async { + final body = {'interests': interests}; + await apiClient.dio.put('/interests/', data: body); + } + + Future uploadAvatar(Uint8List bytes) async { + if (bytes.length > 2 * 1024 * 1024) { + throw Exception('Avatar file is too large'); + } + final formData = FormData.fromMap({ + 'avatar': MultipartFile.fromBytes(bytes, filename: 'image.png'), + }); + + await apiClient.dio.put('/avatar/', data: formData); + + await SharedPreferencesService().saveAvatar(bytes); + } + + Future fetchAvatar(int? userId) async { + try { + final response = await apiClient.dio.get>( + '/avatar', + queryParameters: {'user_id': userId}, + options: Options( + responseType: ResponseType.bytes, + extra: {'suppressErrorNotification': true}, + ), + ); + + final bytes = Uint8List.fromList(response.data!); + await SharedPreferencesService().saveAvatar(bytes); + return bytes; + } on DioException catch (e) { + if (e.response?.statusCode != 404) { + rethrow; + } else { + return null; + } + } + } +} diff --git a/frontend/lib/data/repositories/search_repository.dart b/frontend/lib/data/repositories/search_repository.dart new file mode 100644 index 0000000..22768cf --- /dev/null +++ b/frontend/lib/data/repositories/search_repository.dart @@ -0,0 +1,47 @@ +import 'package:frontend/data/api/api_client.dart'; +import 'package:frontend/data/models/user.dart'; +import 'package:frontend/domain/services/shared_preferences_service.dart'; + +class SearchRepository { + final ApiClient apiClient; + final SharedPreferencesService prefs; + + SearchRepository(this.apiClient, this.prefs); + + Future> getInterestCategories() async { + final response = await apiClient.dio.get('/interests/cats'); + final data = response.data['details']; + return List.from(data); + } + + Future> searchUsersByInterests(List interests) async { + if (interests.isEmpty) { + return []; + } + final interestsString = interests.join(';'); + final response = await apiClient.dio.get( + '/interests/all', + queryParameters: { + 'page_num': 1, + 'page_size': 100, // TODO: change + 'interests': interestsString, + }, + ); + + final usersMap = response.data['users'] as Map; + final users = usersMap.values + .map((userJson) => User.fromJson(userJson)) + .toList(); + int? id = await getId(); + users.removeWhere((user) => user.id == id); + + return users; + } + + Future getId() async { + final response = await apiClient.dio.get('/auth/'); + final data = response.data; + prefs.saveId(data['user_id']); + return data['user_id']; + } +} diff --git a/frontend/lib/domain/providers/search_provider.dart b/frontend/lib/domain/providers/search_provider.dart new file mode 100644 index 0000000..bcb0a7b --- /dev/null +++ b/frontend/lib/domain/providers/search_provider.dart @@ -0,0 +1,92 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frontend/data/models/user.dart'; +import 'package:frontend/domain/services/app_services.dart'; + +final searchProvider = ChangeNotifierProvider( + (ref) => SearchProvider(), +); + +class SearchProvider extends ChangeNotifier { + List interests = []; + List selected = []; + List users = []; + bool isSearched = false; + User? currentUser; + bool isLoading = false; + bool isLoadingInterests = false; + void startLoadingInterests() { + isLoadingInterests = true; + } + + void stopLoadingInterests() { + isLoadingInterests = false; + } + + void search() { + isSearched = true; + } + + void reset() { + isSearched = false; + } + + void setInterests(List newInterests) { + interests = newInterests; + stopLoadingInterests(); + notifyListeners(); + } + + void addSelected(String newSelected) { + if (!selected.contains(newSelected)) { + selected.add(newSelected); + } + notifyListeners(); + } + + void removeSelected(String newSelected) { + if (selected.contains(newSelected)) { + selected.remove(newSelected); + } + notifyListeners(); + } + + Future setUsers(List newUsers) async { + users = newUsers; + await updateUserAvatars(); + notifyListeners(); + } + + void resetAll() { + interests = []; + selected = []; + users = []; + isSearched = false; + } + + void setCurrentUser(User newUser) { + currentUser = newUser; + notifyListeners(); + } + + Future updateUserAvatars() async { + for (final user in users) { + try { + final bytes = await profileRepository.fetchAvatar(user.id); + user.avatarBytes = bytes; + } catch (_) {} + } + notifyListeners(); + } + + void startLoading() { + isLoading = true; + users.clear(); + notifyListeners(); + } + + void stopLoading() { + isLoading = false; + notifyListeners(); + } +} diff --git a/frontend/lib/domain/providers/theme_provider.dart b/frontend/lib/domain/providers/theme_provider.dart new file mode 100644 index 0000000..75fc36f --- /dev/null +++ b/frontend/lib/domain/providers/theme_provider.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frontend/data/functions/text_to_string.dart'; +import 'package:frontend/domain/services/shared_preferences_service.dart'; + +final themeProvider = StateNotifierProvider( + (ref) => ThemeNotifier(), +); + +class ThemeNotifier extends StateNotifier { + ThemeNotifier() : super(ThemeMode.system) { + _loadTheme(); + } + + Future _loadTheme() async { + final themeString = await SharedPreferencesService().loadTheme(); + + state = switch (themeString) { + 'light' => ThemeMode.light, + 'dark' => ThemeMode.dark, + _ => ThemeMode.system, + }; + } + + Future setTheme(ThemeMode mode) async { + state = mode; + await SharedPreferencesService().saveTheme(themeToString(mode)); + } + + Future switchTheme() async { + if (state == ThemeMode.light) { + setTheme(ThemeMode.dark); + } else if (state == ThemeMode.dark) { + setTheme(ThemeMode.light); + } else if (state == ThemeMode.system) {} + } +} diff --git a/frontend/lib/domain/services/app_services.dart b/frontend/lib/domain/services/app_services.dart new file mode 100644 index 0000000..171cddf --- /dev/null +++ b/frontend/lib/domain/services/app_services.dart @@ -0,0 +1,14 @@ +import 'package:frontend/data/repositories/profile_repository.dart'; +import 'package:frontend/data/repositories/search_repository.dart'; +import 'package:frontend/domain/services/shared_preferences_service.dart'; +import '../../data/api/api_client.dart'; +import '../../data/repositories/auth_repository.dart'; + +final apiClient = ApiClient(); + +final authRepository = AuthRepository(apiClient); + +final profileRepository = ProfileRepository(apiClient, sharedPreferences); +final searchRepository = SearchRepository(apiClient, sharedPreferences); + +final sharedPreferences = SharedPreferencesService(); diff --git a/frontend/lib/domain/services/shared_preferences_service.dart b/frontend/lib/domain/services/shared_preferences_service.dart new file mode 100644 index 0000000..7bd3e4f --- /dev/null +++ b/frontend/lib/domain/services/shared_preferences_service.dart @@ -0,0 +1,101 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:shared_preferences/shared_preferences.dart'; + +class SharedPreferencesService { + static const _avatarKey = 'user_avatar'; + static const _nameKey = 'name'; + static const _surnameKey = 'surname'; + static const _ageKey = 'age'; + static const _aliasKey = 'telegram'; + static const _genderKey = 'gender'; + static const _descriptionKey = 'description'; + static const _activitiesKey = 'activities'; + static const _idKey = 'id'; + + Future saveAvatar(Uint8List bytes) async { + final prefs = await SharedPreferences.getInstance(); + prefs.setString(_avatarKey, base64Encode(bytes)); + } + + Future loadAvatar() async { + final prefs = await SharedPreferences.getInstance(); + final base64Image = prefs.getString(_avatarKey); + if (base64Image != null) { + return base64Decode(base64Image); + } + return null; + } + + Future saveProfile({ + required String name, + required String surname, + required String age, + required String alias, + required String gender, + required String description, + required List activities, + }) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_nameKey, name); + await prefs.setString(_surnameKey, surname); + await prefs.setString(_ageKey, age); + await prefs.setString(_aliasKey, alias); + await prefs.setString(_genderKey, gender); + await prefs.setString(_descriptionKey, description); + saveActivities(activities); + } + + Future saveActivities(List activities) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList(_activitiesKey, activities); + } + + Future> loadProfile() async { + final prefs = await SharedPreferences.getInstance(); + return { + 'name': prefs.getString(_nameKey) ?? '', + 'surname': prefs.getString(_surnameKey) ?? '', + 'age': prefs.getString(_ageKey) ?? '', + 'telegram': prefs.getString(_aliasKey) ?? '', + 'gender': prefs.getString(_genderKey) ?? 'Ж', + 'description': prefs.getString(_descriptionKey) ?? '', + 'activities': prefs.getStringList(_activitiesKey) ?? [], + }; + } + + Future clearProfile() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_nameKey); + await prefs.remove(_surnameKey); + await prefs.remove(_ageKey); + await prefs.remove(_aliasKey); + await prefs.remove(_genderKey); + await prefs.remove(_descriptionKey); + await prefs.remove(_activitiesKey); + await prefs.remove(_avatarKey); + await prefs.remove(_idKey); + prefs.clear(); + } + + Future loadTheme() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString('themeMode'); + } + + Future saveTheme(String theme) async { + final prefs = await SharedPreferences.getInstance(); + prefs.setString('themeMode', theme); + } + + Future saveId(int id) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_idKey, id); + } + + Future loadId() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt(_idKey); + } +} diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart new file mode 100644 index 0000000..1b0c89b --- /dev/null +++ b/frontend/lib/main.dart @@ -0,0 +1,48 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:frontend/domain/providers/theme_provider.dart'; +import 'package:frontend/router.dart'; +import 'package:frontend/ui/theme/app_theme.dart'; + +final GlobalKey rootScaffoldMessengerKey = + GlobalKey(); + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await EasyLocalization.ensureInitialized(); + + await GetStorage.init(); + + runApp( + EasyLocalization( + supportedLocales: const [Locale('en'), Locale('ru')], + path: 'assets/translations', + fallbackLocale: const Locale('en'), + child: const ProviderScope(child: MyApp()), + ), + ); +} + +class MyApp extends ConsumerWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final themeMode = ref.watch(themeProvider); + + return MaterialApp.router( + scaffoldMessengerKey: rootScaffoldMessengerKey, + debugShowCheckedModeBanner: false, + + routerConfig: router, + theme: AppTheme.light, + darkTheme: AppTheme.dark, + themeMode: themeMode, + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + locale: context.locale, + ); + } +} diff --git a/frontend/lib/router.dart b/frontend/lib/router.dart new file mode 100644 index 0000000..62299f9 --- /dev/null +++ b/frontend/lib/router.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:frontend/ui/pages/auth_page.dart'; +import 'package:frontend/ui/pages/home_page.dart'; +import 'package:frontend/ui/pages/settings_page.dart'; +import 'package:frontend/ui/pages/splash_page.dart'; +import 'package:frontend/ui/pages/user_profile_page.dart'; + +final router = GoRouter( + initialLocation: '/', + + routes: [ + GoRoute(path: '/', builder: (context, state) => const SplashPage()), + GoRoute(path: '/auth', builder: (context, state) => const AuthPage()), + GoRoute( + path: '/home', + pageBuilder: (context, state) { + return CustomTransitionPage( + transitionDuration: const Duration(milliseconds: 400), + key: state.pageKey, + child: HomePage(), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: CurveTween( + curve: Curves.easeInOutCirc, + ).animate(animation), + child: child, + ); + }, + ); + }, + ), + GoRoute( + path: '/home/:index', + pageBuilder: (context, state) { + return CustomTransitionPage( + transitionDuration: const Duration(milliseconds: 400), + key: state.pageKey, + child: HomePage(index: state.pathParameters['index']), + + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: CurveTween( + curve: Curves.easeInOutCirc, + ).animate(animation), + child: child, + ); + }, + ); + }, + ), + + GoRoute( + path: '/settings', + pageBuilder: (context, state) { + return CustomTransitionPage( + transitionDuration: const Duration(milliseconds: 400), + key: state.pageKey, + child: SettingsPage(), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: CurveTween( + curve: Curves.easeInOutCirc, + ).animate(animation), + child: child, + ); + }, + ); + }, + ), + + GoRoute( + path: '/profile/:token', + + pageBuilder: (context, state) { + return CustomTransitionPage( + transitionDuration: const Duration(milliseconds: 400), + key: state.pageKey, + child: UserProfilePage(token: state.pathParameters['token']!), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: CurveTween( + curve: Curves.easeInOutCirc, + ).animate(animation), + child: child, + ); + }, + ); + }, + ), + ], +); diff --git a/frontend/lib/ui/pages/auth_page.dart b/frontend/lib/ui/pages/auth_page.dart new file mode 100644 index 0000000..e3bb39b --- /dev/null +++ b/frontend/lib/ui/pages/auth_page.dart @@ -0,0 +1,230 @@ +import 'package:flutter/material.dart'; +import 'package:frontend/data/functions/validations.dart'; +import 'package:go_router/go_router.dart'; +import 'package:frontend/domain/services/app_services.dart'; +import 'package:frontend/ui/theme/app_colors.dart'; +import 'package:frontend/ui/widgets/icon_back.dart'; +import 'package:easy_localization/easy_localization.dart'; + +class AuthPage extends StatefulWidget { + const AuthPage({super.key}); + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + final _formKey = GlobalKey(); + bool? isLogin; + final nameComtroller = TextEditingController(); + final passwordController = TextEditingController(); + final confirmPasswordController = TextEditingController(); + bool isLoading = false; + bool isObscured = true; + bool isObscuredConfirm = true; + + @override + void initState() { + super.initState(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + _formKey.currentState!.save(); + + setState(() => isLoading = true); + + try { + if (isLogin == true) { + await authRepository.login( + nameComtroller.text.trim(), + passwordController.text.trim(), + ); + } else { + await authRepository.register( + nameComtroller.text.trim(), + passwordController.text.trim(), + ); + } + if (mounted) { + context.go('/home/1'); + } + } catch (e) { + } finally { + if (mounted) setState(() => isLoading = false); + } + } + + void clearControllers() { + nameComtroller.clear(); + passwordController.clear(); + confirmPasswordController.clear(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), + child: Scaffold( + body: SafeArea( + child: Stack( + children: [ + if (isLogin != null) ...[ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + IconBack( + callback: () { + clearControllers(); + setState(() => isLogin = null); + isObscured = true; + isObscuredConfirm = true; + }, + ), + const Spacer(), + ], + ), + ), + ], + Center( + child: SingleChildScrollView( + child: Column( + children: [ + Image.asset( + 'assets/images/logo.png', + height: MediaQuery.of(context).size.height * 0.2, + ), + + SizedBox( + height: MediaQuery.of(context).size.height * 0.15, + ), + + if (isLogin == null) ...[ + ElevatedButton( + onPressed: () => setState(() => isLogin = true), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of( + context, + ).colorScheme.primary, // + padding: EdgeInsets.symmetric( + horizontal: 40, + vertical: 16, + ), + ), + child: Text('auth.login'.tr()), + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () => setState(() => isLogin = false), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of( + context, + ).colorScheme.primary, + ), + child: Text('auth.register'.tr()), + ), + + const SizedBox(height: 20), + ], + + if (isLogin != null) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Form( + key: _formKey, + child: Column( + children: [ + TextFormField( + controller: nameComtroller, + decoration: InputDecoration( + labelText: 'auth.login'.tr(), + ), + validator: (val) => validateLogin(val ?? ''), + ), + const SizedBox(height: 20), + TextFormField( + controller: passwordController, + obscureText: isObscured, + decoration: InputDecoration( + suffixIcon: IconButton( + icon: Icon( + isObscured + ? Icons.visibility_off + : Icons.visibility, + ), + onPressed: () { + setState(() { + isObscured = !isObscured; + }); + }, + ), + labelText: 'auth.password'.tr(), + ), + validator: (val) => + validatePassword(val ?? ''), + ), + if (isLogin == false) ...[ + const SizedBox(height: 20), + TextFormField( + controller: confirmPasswordController, + obscureText: isObscuredConfirm, + decoration: InputDecoration( + labelText: 'auth.confirmPassword'.tr(), + suffixIcon: IconButton( + icon: Icon( + isObscuredConfirm + ? Icons.visibility_off + : Icons.visibility, + ), + onPressed: () { + setState(() { + isObscuredConfirm = + !isObscuredConfirm; + }); + }, + ), + ), + validator: (val) => + validatePasswordConfirmation( + passwordController.text, + val ?? '', + ), + ), + ], + const SizedBox(height: 20), + isLoading + ? CircularProgressIndicator() + : ElevatedButton( + onPressed: _submit, + style: ElevatedButton.styleFrom( + backgroundColor: isLogin == true + ? AppColors.primary + : null, + padding: EdgeInsets.symmetric( + horizontal: 40, + vertical: 16, + ), + ), + child: Text( + isLogin! + ? 'auth.login'.tr() + : 'auth.register'.tr(), + ), + ), + ], + ), + ), + ), + ], + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/lib/ui/pages/bottom_tabs/profile_tab.dart b/frontend/lib/ui/pages/bottom_tabs/profile_tab.dart new file mode 100644 index 0000000..7fc4de1 --- /dev/null +++ b/frontend/lib/ui/pages/bottom_tabs/profile_tab.dart @@ -0,0 +1,278 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frontend/domain/providers/search_provider.dart'; +import 'package:go_router/go_router.dart'; +import 'package:frontend/domain/services/app_services.dart'; +import 'package:frontend/domain/services/shared_preferences_service.dart'; +import 'package:frontend/ui/widgets/custom_filter_chip.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:easy_localization/easy_localization.dart'; + +class ProfileTab extends ConsumerStatefulWidget { + const ProfileTab({super.key}); + + @override + ConsumerState createState() => _ProfileTabState(); +} + +class _ProfileTabState extends ConsumerState { + String gender = 'Ж'; + List selectedActivities = []; + final nameController = TextEditingController(); + final surnameController = TextEditingController(); + final ageController = TextEditingController(); + final aliasController = TextEditingController(); + final descriptionController = TextEditingController(); + final _prefsService = SharedPreferencesService(); + + Uint8List? _avatarBytes; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadAvatar(); + _loadCachedThenUpdate(); + final search = ref.read(searchProvider); + if (search.interests.isEmpty) { + _loadInterests(); + } + }); + } + + Future _loadInterests() async { + final cats = await searchRepository.getInterestCategories(); + ref.read(searchProvider).setInterests(cats); + } + + Future _loadCachedThenUpdate() async { + final cached = await profileRepository.loadCachedProfile(); + _applyProfileToUI(cached, null); + + try { + final updated = await profileRepository.fetchAndCacheProfile(); + final activities = await profileRepository.getUserInterests(); + _applyProfileToUI(updated, activities); + } catch (e) { + // print("Ошибка загрузки профиля с сервера: $e"); + } + } + + void _applyProfileToUI(Map data, List? activities) { + setState(() { + nameController.text = data['name']; + surnameController.text = data['surname']; + ageController.text = data['age'] == 0 ? '' : data['age'].toString(); + aliasController.text = data['telegram']; + gender = data['gender']; + descriptionController.text = data['description']; + selectedActivities = data['activities'] == null + ? activities ?? [] + : List.from(data['activities']); + }); + } + + Future _pickAvatar() async { + final picker = ImagePicker(); + final image = await picker.pickImage(source: ImageSource.gallery); + if (image != null) { + final bytes = await image.readAsBytes(); + setState(() => _avatarBytes = bytes); + await profileRepository.uploadAvatar(bytes); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('profile.updateSuccess'.tr()))); + } + } + + Future _loadAvatar() async { + final local = await _prefsService.loadAvatar(); + if (local != null) { + if (!mounted) return; + setState(() => _avatarBytes = local); + } + + try { + final fresh = await profileRepository.fetchAvatar(null); + if (!mounted) return; + setState(() => _avatarBytes = fresh); + } on DioException catch (e) { + if (e.response?.statusCode != 404) { + rethrow; + } + } + } + + @override + void dispose() { + nameController.dispose(); + surnameController.dispose(); + ageController.dispose(); + aliasController.dispose(); + descriptionController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final activities = ref.watch(searchProvider).interests; + return GestureDetector( + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), + child: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Stack( + children: [ + Row( + children: [ + const Spacer(), + IconButton( + onPressed: () { + context.go('/settings'); + }, + icon: Icon(Icons.settings), + ), + ], + ), + Column( + children: [ + GestureDetector( + onTap: _pickAvatar, + child: CircleAvatar( + radius: 50, + backgroundImage: _avatarBytes != null + ? MemoryImage(_avatarBytes!) + : null, + child: _avatarBytes == null + ? const Icon(Icons.upload, size: 20) + : null, + ), + ), + const SizedBox(height: 16), + TextField( + controller: nameController, + decoration: InputDecoration( + labelText: 'profile.firstName'.tr(), + ), + ), + const SizedBox(height: 15), + TextField( + controller: surnameController, + decoration: InputDecoration( + labelText: 'profile.lastName'.tr(), + ), + ), + const SizedBox(height: 15), + TextField( + controller: ageController, + decoration: InputDecoration(labelText: 'profile.age'.tr()), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 15), + TextField( + controller: aliasController, + decoration: InputDecoration( + labelText: 'profile.telegram'.tr(), + prefixText: '@', + hintText: 'username', + ), + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ChoiceChip( + label: Text('profile.genderFemale'.tr()), + selected: gender == 'Ж', + onSelected: (_) => setState(() => gender = 'Ж'), + backgroundColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + selectedColor: Theme.of(context).colorScheme.primary, + labelStyle: TextStyle( + color: gender == 'Ж' + ? Colors.white + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + const SizedBox(width: 10), + ChoiceChip( + label: Text('profile.genderMale'.tr()), + selected: gender == 'М', + onSelected: (_) => setState(() => gender = 'М'), + backgroundColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + selectedColor: Theme.of(context).colorScheme.primary, + labelStyle: TextStyle( + color: gender == 'М' + ? Colors.white + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ], + ), + const SizedBox(height: 16), + TextField( + controller: descriptionController, + decoration: InputDecoration( + labelText: 'profile.description'.tr(), + ), + maxLines: 3, + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: activities.map((act) { + final isSelected = selectedActivities.contains(act); + return CustomFilterChip( + label: 'interests.$act'.tr(), + selected: isSelected, + onSelected: (_) { + setState(() { + isSelected + ? selectedActivities.remove(act) + : selectedActivities.add(act); + }); + }, + ); + }).toList(), + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () async { + await profileRepository.updateProfile( + nameController.text, + surnameController.text, + ageController.text, + aliasController.text, + gender, + descriptionController.text, + selectedActivities, + ); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('profile.updateSuccess'.tr())), + ); + }, + child: Text("profile.save".tr()), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/lib/ui/pages/bottom_tabs/search_tab.dart b/frontend/lib/ui/pages/bottom_tabs/search_tab.dart new file mode 100644 index 0000000..3799d91 --- /dev/null +++ b/frontend/lib/ui/pages/bottom_tabs/search_tab.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:frontend/domain/providers/search_provider.dart'; +import 'package:frontend/domain/services/app_services.dart'; +import 'package:frontend/ui/widgets/custom_filter_chip.dart'; +import 'package:frontend/ui/widgets/gender_icon.dart'; +import 'package:easy_localization/easy_localization.dart'; + +class SearchTab extends ConsumerStatefulWidget { + const SearchTab({super.key}); + + @override + ConsumerState createState() => _SearchTabState(); +} + +class _SearchTabState extends ConsumerState { + @override + void initState() { + super.initState(); + WidgetsFlutterBinding.ensureInitialized(); + if (ref.read(searchProvider).interests.isEmpty) { + _loadInterests(); + } + ref.read(searchProvider).reset(); + } + + Future _loadInterests() async { + final search = ref.read(searchProvider); + search.startLoadingInterests(); + final cats = await searchRepository.getInterestCategories(); + ref.read(searchProvider).setInterests(cats); + } + + Future _searchUsers() async { + final search = ref.read(searchProvider); + search.startLoading(); + final selected = ref.watch(searchProvider).selected; + final users = await searchRepository.searchUsersByInterests(selected); + await search.setUsers(users); + search.search(); + search.stopLoading(); + } + + @override + Widget build(BuildContext context) { + final interests = ref.watch(searchProvider).interests; + final selected = ref.watch(searchProvider).selected; + final users = ref.watch(searchProvider).users; + final search = ref.watch(searchProvider); + + return SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + if (interests.isEmpty && search.isLoadingInterests) + const Center(child: CircularProgressIndicator()), + Wrap( + spacing: 8, + runSpacing: 8, + children: interests.map((interest) { + final isSelected = selected.contains(interest); + + return CustomFilterChip( + label: 'interests.$interest'.tr(), + selected: isSelected, + onSelected: (_) { + isSelected + ? search.removeSelected(interest) + : search.addSelected(interest); + }, + ); + }).toList(), + ), + const SizedBox(height: 16), + Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ElevatedButton( + onPressed: _searchUsers, + child: Text('search.find'.tr()), + ), + ], + ), + ), + const SizedBox(height: 16), + if (users.isEmpty && search.isSearched && !search.isLoading) + Text('search.notFound'.tr()), + if (search.isLoading) + const Center(child: CircularProgressIndicator()), + ...users.map((user) { + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundImage: user.avatarBytes != null + ? MemoryImage(user.avatarBytes!) + : null, + child: user.avatarBytes == null + ? const Icon(Icons.person) + : null, + ), + title: Row( + children: [ + Text('${user.name}, ${user.age}'), + const SizedBox(width: 5), + GenderIcon(gender: user.gender), + ], + ), + subtitle: Text( + user.interests.map((e) => 'interests.$e'.tr()).join(', '), + ), + onTap: () { + ref.read(searchProvider).setCurrentUser(user); + context.push('/profile/${user.id}'); + }, + ), + ); + }).toList(), + ], + ), + ); + } +} diff --git a/frontend/lib/ui/pages/home_page.dart b/frontend/lib/ui/pages/home_page.dart new file mode 100644 index 0000000..4ad4bd5 --- /dev/null +++ b/frontend/lib/ui/pages/home_page.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:frontend/ui/pages/bottom_tabs/profile_tab.dart'; +import 'package:frontend/ui/pages/bottom_tabs/search_tab.dart'; +import 'package:frontend/ui/theme/app_colors.dart'; +import 'package:easy_localization/easy_localization.dart'; + +class HomePage extends StatefulWidget { + final String? index; + const HomePage({super.key, this.index}); + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + late int _currentIndex; + late PageController _pageController; + + @override + void initState() { + super.initState(); + _currentIndex = int.tryParse(widget.index ?? '0') ?? 0; + _pageController = PageController(initialPage: _currentIndex); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: PageView( + controller: _pageController, + onPageChanged: (index) { + setState(() { + _currentIndex = index; + }); + }, + children: const [SearchTab(), ProfileTab()], + ), + appBar: _currentIndex == 0 + ? AppBar( + backgroundColor: Colors.transparent, + title: Text( + 'home.searchTitle'.tr(), + style: Theme.of(context).textTheme.titleLarge, + ), + ) + : null, + bottomNavigationBar: BottomNavigationBar( + currentIndex: _currentIndex, + onTap: (index) { + _pageController.animateToPage( + index, + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOutCirc, + ); + }, + selectedItemColor: AppColors.primary, + items: [ + BottomNavigationBarItem( + icon: const Icon(Icons.search), + label: 'home.search'.tr(), + ), + BottomNavigationBarItem( + icon: const Icon(Icons.person), + label: 'home.profile'.tr(), + ), + ], + ), + ); + } +} diff --git a/frontend/lib/ui/pages/settings_page.dart b/frontend/lib/ui/pages/settings_page.dart new file mode 100644 index 0000000..36908a0 --- /dev/null +++ b/frontend/lib/ui/pages/settings_page.dart @@ -0,0 +1,202 @@ +import 'dart:io' show Platform; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:frontend/domain/providers/theme_provider.dart'; +import 'package:frontend/ui/widgets/icon_back.dart'; +import 'package:frontend/ui/widgets/logout_button.dart'; + +class SettingsPage extends ConsumerWidget { + const SettingsPage({super.key}); + + bool get isCupertino => !kIsWeb && (Platform.isIOS || Platform.isMacOS); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final themeMode = ref.watch(themeProvider); + final themeNotifier = ref.read(themeProvider.notifier); + + if (isCupertino) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text( + 'settings.settings'.tr(), + style: Theme.of(context).textTheme.titleLarge, + ), + leading: CupertinoNavigationBarBackButton( + onPressed: () => context.go('/home/1'), + color: Theme.of(context).iconTheme.color, + ), + ), + child: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'settings.theme'.tr(), + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + CupertinoFormSection.insetGrouped( + backgroundColor: Colors.transparent, + children: [ + CupertinoFormRow( + prefix: Text('settings.system'.tr()), + child: CupertinoSwitch( + value: themeMode == ThemeMode.system, + onChanged: (val) { + if (val) themeNotifier.setTheme(ThemeMode.system); + }, + ), + ), + CupertinoFormRow( + prefix: Text('settings.light'.tr()), + child: CupertinoSwitch( + value: themeMode == ThemeMode.light, + onChanged: (val) { + if (val) themeNotifier.setTheme(ThemeMode.light); + }, + ), + ), + CupertinoFormRow( + prefix: Text('settings.dark'.tr()), + child: CupertinoSwitch( + value: themeMode == ThemeMode.dark, + onChanged: (val) { + if (val) themeNotifier.setTheme(ThemeMode.dark); + }, + ), + ), + ], + ), + Padding(padding: EdgeInsets.only(top: 24)), + Text( + 'settings.language'.tr(), + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + CupertinoFormSection.insetGrouped( + backgroundColor: Colors.transparent, + children: [ + CupertinoListTile( + title: Text( + 'Русский 🇷🇺', + style: Theme.of(context).textTheme.bodyLarge, + ), + + trailing: context.locale.languageCode == 'ru' + ? Icon( + CupertinoIcons.check_mark, + color: CupertinoColors.activeBlue, + ) + : null, + onTap: () => context.setLocale(Locale('ru')), + ), + CupertinoListTile( + title: Text( + 'English 🇺🇸', + style: Theme.of(context).textTheme.bodyLarge, + ), + trailing: context.locale.languageCode == 'en' + ? Icon( + CupertinoIcons.check_mark, + color: CupertinoColors.activeBlue, + ) + : null, + onTap: () => context.setLocale(Locale('en')), + ), + ], + ), + Padding(padding: EdgeInsets.only(top: 16)), + LogoutButton(), + ], + ), + ), + ); + } + + return Scaffold( + appBar: AppBar( + title: Text( + 'settings.settings'.tr(), + style: Theme.of(context).textTheme.titleLarge, + ), + leading: IconBack(callback: () => context.go('/home/1')), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'settings.theme'.tr(), + style: Theme.of(context).textTheme.titleLarge, + ), + + RadioListTile( + title: Text('settings.system'.tr()), + value: ThemeMode.system, + groupValue: themeMode, + onChanged: (mode) async { + if (mode != null) { + await themeNotifier.setTheme(mode); + } + }, + ), + RadioListTile( + title: Text('settings.light'.tr()), + value: ThemeMode.light, + groupValue: themeMode, + onChanged: (mode) async { + if (mode != null) { + await themeNotifier.setTheme(mode); + } + }, + ), + RadioListTile( + title: Text('settings.dark'.tr()), + value: ThemeMode.dark, + groupValue: themeMode, + onChanged: (mode) async { + if (mode != null) { + await themeNotifier.setTheme(mode); + } + }, + ), + Padding(padding: EdgeInsets.only(top: 24)), + Text( + 'settings.language'.tr(), + style: Theme.of(context).textTheme.titleLarge, + ), + ListTile( + leading: Text('🇷🇺'), + title: Text('Русский'), + onTap: () => context.setLocale(Locale('ru')), + trailing: context.locale.languageCode == 'ru' + ? Icon( + CupertinoIcons.check_mark, + color: CupertinoColors.activeBlue, + ) + : null, + ), + ListTile( + leading: Text('🇺🇸'), + title: Text('English'), + onTap: () => context.setLocale(Locale('en')), + trailing: context.locale.languageCode == 'en' + ? Icon( + CupertinoIcons.check_mark, + color: CupertinoColors.activeBlue, + ) + : null, + ), + Padding(padding: EdgeInsets.only(top: 16)), + + LogoutButton(), + ], + ), + ); + } +} diff --git a/frontend/lib/ui/pages/splash_page.dart b/frontend/lib/ui/pages/splash_page.dart new file mode 100644 index 0000000..eab95ca --- /dev/null +++ b/frontend/lib/ui/pages/splash_page.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:frontend/data/enums/get_storage_key.dart'; +import 'package:frontend/domain/services/app_services.dart'; + +class SplashPage extends StatefulWidget { + const SplashPage({super.key}); + + @override + State createState() => _SplashPageState(); +} + +class _SplashPageState extends State { + @override + void initState() { + super.initState(); + _checkAuth(); + } + + Future _checkAuth() async { + final refreshToken = await apiClient.storage.read( + GetStorageKey.refreshToken.value, + ); + if (refreshToken != null && refreshToken.isNotEmpty) { + final newAccessToken = await apiClient.refreshToken(); + + if (newAccessToken != null) { + await apiClient.saveTokens(newAccessToken, refreshToken); + + if (mounted) context.go('/home'); + + return; + } + } + + if (mounted) context.go('/auth'); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea(child: Center(child: CircularProgressIndicator())), + ); + } +} diff --git a/frontend/lib/ui/pages/user_profile_page.dart b/frontend/lib/ui/pages/user_profile_page.dart new file mode 100644 index 0000000..369d102 --- /dev/null +++ b/frontend/lib/ui/pages/user_profile_page.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frontend/data/functions/open_url.dart'; +import 'package:frontend/domain/providers/search_provider.dart'; +import 'package:frontend/ui/widgets/custom_filter_chip.dart'; +import 'package:frontend/ui/widgets/gender_icon.dart'; +import 'package:frontend/ui/widgets/icon_back.dart'; +import 'package:easy_localization/easy_localization.dart'; + +class UserProfilePage extends ConsumerStatefulWidget { + final String token; + const UserProfilePage({super.key, required this.token}); + + @override + ConsumerState createState() => _UserProfilePageState(); +} + +class _UserProfilePageState extends ConsumerState { + @override + Widget build(BuildContext context) { + final user = ref.watch(searchProvider).currentUser; + return Scaffold( + appBar: AppBar( + elevation: 0, + backgroundColor: Colors.transparent, + leading: IconBack(), + title: Text( + 'userProfile.title'.tr(), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + centerTitle: true, + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 16), + CircleAvatar( + radius: 55, + backgroundColor: Colors.grey.shade200, + backgroundImage: user?.avatarBytes != null + ? MemoryImage(user!.avatarBytes!) + : null, + child: user?.avatarBytes == null + ? Icon(Icons.person, size: 50, color: Colors.grey) + : null, + ), + const SizedBox(height: 20), + Text( + '${user!.name} ${user.surname}', + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${user.age} ${'userProfile.yearsOld'.tr()}', + style: TextStyle(color: Colors.grey.shade700), + ), + const SizedBox(width: 8), + GenderIcon(gender: user.gender), + ], + ), + const SizedBox(height: 20), + if (user.description != null && user.description!.isNotEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.tertiaryFixed, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + user.description!, + style: const TextStyle(fontSize: 16), + ), + ), + const SizedBox(height: 24), + Align( + alignment: Alignment.centerLeft, + child: Text( + 'userProfile.interests'.tr(), + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + ), + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: user.interests.map((act) { + return CustomFilterChip( + label: 'interests.$act'.tr(), + selected: false, + onSelected: (_) {}, + ); + }).toList(), + ), + const SizedBox(height: 32), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 14, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: () async { + openUrl('https://t.me/${user.telegram}'); + }, + icon: const Icon(Icons.telegram), + label: Text("userProfile.messageOnTelegram".tr()), + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/lib/ui/theme/app_colors.dart b/frontend/lib/ui/theme/app_colors.dart new file mode 100644 index 0000000..978b6cc --- /dev/null +++ b/frontend/lib/ui/theme/app_colors.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class AppColors { + static const primary = Color.fromARGB(255, 43, 47, 188); + static const background = Color.fromARGB(255, 250, 249, 249); + static const text = Color(0xFF212121); + static const secondaryText = Color(0xFF757575); + static const card = Colors.white; + + static const darkBackground = Color(0xFF121212); + static const darkCard = Color(0xFF1E1E1E); + static const darkModeText = Color(0xFFF6F6F6); +} diff --git a/frontend/lib/ui/theme/app_text_styles.dart b/frontend/lib/ui/theme/app_text_styles.dart new file mode 100644 index 0000000..77959f3 --- /dev/null +++ b/frontend/lib/ui/theme/app_text_styles.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'app_colors.dart'; + +class AppTextStyles { + static const headline = TextStyle( + fontFamily: 'Montserrat', + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppColors.text, + ); + static const title = TextStyle( + fontFamily: 'Montserrat', + fontSize: 32, + fontWeight: FontWeight.bold, + color: AppColors.text, + ); + static const body = TextStyle( + fontFamily: 'Montserrat', + fontSize: 16, + color: AppColors.text, + ); + + static const caption = TextStyle( + fontFamily: 'Montserrat', + fontSize: 12, + color: AppColors.secondaryText, + ); +} diff --git a/frontend/lib/ui/theme/app_theme.dart b/frontend/lib/ui/theme/app_theme.dart new file mode 100644 index 0000000..90e0194 --- /dev/null +++ b/frontend/lib/ui/theme/app_theme.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:frontend/ui/theme/app_text_styles.dart'; +import 'app_colors.dart'; + +class AppTheme { + static ThemeData light = ThemeData( + fontFamily: 'Montserrat', + + brightness: Brightness.light, + scaffoldBackgroundColor: Colors.white, + appBarTheme: const AppBarTheme( + color: AppColors.primary, + foregroundColor: Colors.white, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + textStyle: AppTextStyles.body.copyWith( + color: Colors.black, + fontWeight: FontWeight.w600, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 32), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: const Color.fromARGB(255, 241, 239, 239), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + hintStyle: const TextStyle(color: Colors.grey), + labelStyle: AppTextStyles.body, + ), + textTheme: TextTheme( + bodyLarge: AppTextStyles.body, + headlineMedium: AppTextStyles.headline, + titleLarge: AppTextStyles.title, + ), + + colorScheme: ColorScheme.light( + primary: AppColors.primary, + secondary: AppColors.secondaryText, + tertiary: Colors.black.withAlpha(200), + tertiaryFixed: Colors.blue.shade50, + ), + iconTheme: const IconThemeData(color: Colors.black, size: 24), + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom(foregroundColor: Colors.black, iconSize: 24), + ), + listTileTheme: ListTileThemeData( + titleTextStyle: AppTextStyles.body.copyWith(color: Colors.black), + textColor: Colors.black, + ), + ); + + static ThemeData dark = ThemeData( + brightness: Brightness.dark, + scaffoldBackgroundColor: AppColors.darkBackground, + fontFamily: 'Montserrat', + + appBarTheme: const AppBarTheme( + color: AppColors.primary, + foregroundColor: Colors.white, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + textStyle: AppTextStyles.body.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 32), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.grey.shade800, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + hintStyle: const TextStyle(color: Colors.grey), + labelStyle: AppTextStyles.body.copyWith(color: AppColors.darkModeText), + ), + + textTheme: TextTheme( + bodyLarge: AppTextStyles.body.copyWith(color: AppColors.darkModeText), + headlineMedium: AppTextStyles.headline.copyWith( + color: AppColors.darkModeText, + ), + titleLarge: AppTextStyles.title.copyWith(color: AppColors.darkModeText), + ), + colorScheme: const ColorScheme.dark( + primary: AppColors.primary, + secondary: AppColors.secondaryText, + tertiary: Colors.white12, + tertiaryFixed: Colors.white12, + ), + iconTheme: const IconThemeData(color: Colors.white), + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom(foregroundColor: Colors.white), + ), + listTileTheme: ListTileThemeData( + titleTextStyle: AppTextStyles.body.copyWith(color: Colors.white), + textColor: Colors.white, + ), + ); +} diff --git a/frontend/lib/ui/widgets/custom_filter_chip.dart b/frontend/lib/ui/widgets/custom_filter_chip.dart new file mode 100644 index 0000000..84fa6d7 --- /dev/null +++ b/frontend/lib/ui/widgets/custom_filter_chip.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +class CustomFilterChip extends StatelessWidget { + final String label; + final bool selected; + final void Function(bool)? onSelected; + + const CustomFilterChip({ + super.key, + required this.label, + required this.selected, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + return FilterChip( + checkmarkColor: Colors.white, + label: Text(label), + selected: selected, + onSelected: onSelected, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + selectedColor: Theme.of(context).colorScheme.primary, + labelStyle: TextStyle( + color: selected + ? Colors.white + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + width: 0.8, + ), + ), + ); + } +} diff --git a/frontend/lib/ui/widgets/gender_icon.dart b/frontend/lib/ui/widgets/gender_icon.dart new file mode 100644 index 0000000..fdda3c5 --- /dev/null +++ b/frontend/lib/ui/widgets/gender_icon.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +class GenderIcon extends StatelessWidget { + final String? gender; + const GenderIcon({super.key, this.gender = ''}); + + @override + Widget build(BuildContext context) { + if (gender == 'М') { + //TODO: update russian letters + return const Icon(Icons.male, color: Colors.blue); + } else if (gender == 'Ж') { + return const Icon(Icons.female, color: Colors.pink); + } else { + return Container(); + } + } +} diff --git a/frontend/lib/ui/widgets/icon_back.dart b/frontend/lib/ui/widgets/icon_back.dart new file mode 100644 index 0000000..4adfbba --- /dev/null +++ b/frontend/lib/ui/widgets/icon_back.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class IconBack extends StatelessWidget { + final VoidCallback? callback; + const IconBack({super.key, this.callback}); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: () { + if (callback != null) { + callback!(); + } else { + context.go('/home'); + } + }, + icon: Icon(Icons.arrow_back, color: Theme.of(context).iconTheme.color), + ); + } +} diff --git a/frontend/lib/ui/widgets/logout_button.dart b/frontend/lib/ui/widgets/logout_button.dart new file mode 100644 index 0000000..398dae2 --- /dev/null +++ b/frontend/lib/ui/widgets/logout_button.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:frontend/domain/providers/search_provider.dart'; +import 'package:frontend/domain/services/app_services.dart'; +import 'package:easy_localization/easy_localization.dart'; + +class LogoutButton extends ConsumerWidget { + const LogoutButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return TextButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('logout.title'.tr()), + content: Text('logout.confirm'.tr()), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('logout.no'.tr()), + ), + TextButton( + onPressed: () async { + await apiClient.clearTokens(); + ref.read(searchProvider).resetAll(); + await sharedPreferences.clearProfile(); + if (context.mounted) context.go('/auth'); + }, + child: Text('logout.yes'.tr()), + ), + ], + ), + ); + }, + child: Text( + "logout.button".tr(), + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: Colors.red, fontSize: 20), + ), + ); + } +} diff --git a/frontend/linux/.gitignore b/frontend/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/frontend/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/frontend/linux/CMakeLists.txt b/frontend/linux/CMakeLists.txt new file mode 100644 index 0000000..cba8d2d --- /dev/null +++ b/frontend/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "frontend") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.frontend") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/frontend/linux/flutter/CMakeLists.txt b/frontend/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/frontend/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/frontend/linux/flutter/generated_plugin_registrant.cc b/frontend/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..7299b5c --- /dev/null +++ b/frontend/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,19 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/frontend/linux/flutter/generated_plugin_registrant.h b/frontend/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/frontend/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/frontend/linux/flutter/generated_plugins.cmake b/frontend/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..786ff5c --- /dev/null +++ b/frontend/linux/flutter/generated_plugins.cmake @@ -0,0 +1,25 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/frontend/linux/runner/CMakeLists.txt b/frontend/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/frontend/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/frontend/linux/runner/main.cc b/frontend/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/frontend/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/frontend/linux/runner/my_application.cc b/frontend/linux/runner/my_application.cc new file mode 100644 index 0000000..89bc259 --- /dev/null +++ b/frontend/linux/runner/my_application.cc @@ -0,0 +1,130 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "frontend"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "frontend"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/frontend/linux/runner/my_application.h b/frontend/linux/runner/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/frontend/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/frontend/macos/.gitignore b/frontend/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/frontend/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/frontend/macos/Flutter/Flutter-Debug.xcconfig b/frontend/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/frontend/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/frontend/macos/Flutter/Flutter-Release.xcconfig b/frontend/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/frontend/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/frontend/macos/Flutter/GeneratedPluginRegistrant.swift b/frontend/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..d993eb7 --- /dev/null +++ b/frontend/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,18 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import file_selector_macos +import path_provider_foundation +import shared_preferences_foundation +import url_launcher_macos + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) +} diff --git a/frontend/macos/Podfile b/frontend/macos/Podfile new file mode 100644 index 0000000..29c8eb3 --- /dev/null +++ b/frontend/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.14' + +# 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/frontend/macos/Podfile.lock b/frontend/macos/Podfile.lock new file mode 100644 index 0000000..51a5873 --- /dev/null +++ b/frontend/macos/Podfile.lock @@ -0,0 +1,48 @@ +PODS: + - file_selector_macos (0.0.1): + - FlutterMacOS + - flutter_secure_storage_macos (6.1.3): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) + - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + +EXTERNAL SOURCES: + file_selector_macos: + :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos + flutter_secure_storage_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos + FlutterMacOS: + :path: Flutter/ephemeral + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + +SPEC CHECKSUMS: + file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 + flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54 + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 + +PODFILE CHECKSUM: 7eb978b976557c8c1cd717d8185ec483fd090a82 + +COCOAPODS: 1.16.2 diff --git a/frontend/macos/Runner.xcodeproj/project.pbxproj b/frontend/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..6c56742 --- /dev/null +++ b/frontend/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,801 @@ +// !$*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 */ + 2EC805745D065A1EF43636D0 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2973526660FB685F5FB095C2 /* 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 */; }; + 7C5E5422282589B9C4E1AED4 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6BC7A99F507E5EA672B251B5 /* Pods_RunnerTests.framework */; }; +/* 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 */ + 0CCF63B1C73BB90CA23D0458 /* 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 = ""; }; + 2973526660FB685F5FB095C2 /* 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 /* frontend.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = frontend.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 = ""; }; + 51381C21C1A2A1EF4939A491 /* 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 = ""; }; + 6BC7A99F507E5EA672B251B5 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 71E3742E39DEE4895A177A6A /* 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 = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 84717C33C99393FD04ED8E73 /* 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 = ""; }; + D283E5019F42256D7C833DA1 /* 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 = ""; }; + FF2C3147CAEC8060CF401E59 /* 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 = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7C5E5422282589B9C4E1AED4 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2EC805745D065A1EF43636D0 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 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 */, + BB032A867DC88234DFE09309 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* frontend.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 = ""; + }; + BB032A867DC88234DFE09309 /* Pods */ = { + isa = PBXGroup; + children = ( + 0CCF63B1C73BB90CA23D0458 /* Pods-Runner.debug.xcconfig */, + 84717C33C99393FD04ED8E73 /* Pods-Runner.release.xcconfig */, + 71E3742E39DEE4895A177A6A /* Pods-Runner.profile.xcconfig */, + D283E5019F42256D7C833DA1 /* Pods-RunnerTests.debug.xcconfig */, + FF2C3147CAEC8060CF401E59 /* Pods-RunnerTests.release.xcconfig */, + 51381C21C1A2A1EF4939A491 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 2973526660FB685F5FB095C2 /* Pods_Runner.framework */, + 6BC7A99F507E5EA672B251B5 /* 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 = ( + B8014404DCCCCC36281C696D /* [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 = ( + AA1C37C10BAEA5AA33604DE4 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 31470B6233E6095C2F0132E4 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* frontend.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 */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 31470B6233E6095C2F0132E4 /* [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; + }; + 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"; + }; + AA1C37C10BAEA5AA33604DE4 /* [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; + }; + B8014404DCCCCC36281C696D /* [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; + }; +/* 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 = D283E5019F42256D7C833DA1 /* 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.frontend.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/frontend.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/frontend"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = FF2C3147CAEC8060CF401E59 /* 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.frontend.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/frontend.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/frontend"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 51381C21C1A2A1EF4939A491 /* 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.frontend.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/frontend.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/frontend"; + }; + 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.14; + 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.14; + 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.14; + 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/frontend/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/frontend/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/frontend/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..87dd1bf --- /dev/null +++ b/frontend/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/macos/Runner.xcworkspace/contents.xcworkspacedata b/frontend/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/frontend/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/frontend/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/frontend/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/frontend/macos/Runner/AppDelegate.swift b/frontend/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/frontend/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/frontend/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/frontend/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/frontend/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/frontend/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/frontend/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/frontend/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/frontend/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/frontend/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/frontend/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/frontend/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/frontend/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/frontend/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/frontend/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/frontend/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/frontend/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/frontend/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/frontend/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/frontend/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/frontend/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/frontend/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/frontend/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/frontend/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/frontend/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/frontend/macos/Runner/Base.lproj/MainMenu.xib b/frontend/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/frontend/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/macos/Runner/Configs/AppInfo.xcconfig b/frontend/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..af9cf1f --- /dev/null +++ b/frontend/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 = frontend + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.frontend + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. diff --git a/frontend/macos/Runner/Configs/Debug.xcconfig b/frontend/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/frontend/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/frontend/macos/Runner/Configs/Release.xcconfig b/frontend/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/frontend/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/frontend/macos/Runner/Configs/Warnings.xcconfig b/frontend/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/frontend/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/frontend/macos/Runner/DebugProfile.entitlements b/frontend/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/frontend/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/frontend/macos/Runner/Info.plist b/frontend/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/frontend/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/frontend/macos/Runner/MainFlutterWindow.swift b/frontend/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/frontend/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/frontend/macos/Runner/Release.entitlements b/frontend/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/frontend/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/frontend/macos/RunnerTests/RunnerTests.swift b/frontend/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/frontend/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/frontend/pubspec.lock b/frontend/pubspec.lock new file mode 100644 index 0000000..f700304 --- /dev/null +++ b/frontend/pubspec.lock @@ -0,0 +1,687 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dio: + dependency: "direct main" + description: + name: dio + sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9" + url: "https://pub.dev" + source: hosted + version: "5.8.0+1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + easy_localization: + dependency: "direct main" + description: + name: easy_localization + sha256: "0f5239c7b8ab06c66440cfb0e9aa4b4640429c6668d5a42fe389c5de42220b12" + url: "https://pub.dev" + source: hosted + version: "3.0.7+1" + easy_logger: + dependency: transitive + description: + name: easy_logger + sha256: c764a6e024846f33405a2342caf91c62e357c24b02c04dbc712ef232bf30ffb7 + url: "https://pub.dev" + source: hosted + version: "0.0.2" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" + url: "https://pub.dev" + source: hosted + version: "0.9.3+2" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "8c9250b2bd2d8d4268e39c82543bacbaca0fda7d29e0728c3c4bbb7c820fd711" + url: "https://pub.dev" + source: hosted + version: "0.9.4+3" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.dev" + source: hosted + version: "2.6.2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b" + url: "https://pub.dev" + source: hosted + version: "0.9.3+4" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e + url: "https://pub.dev" + source: hosted + version: "2.0.28" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + get: + dependency: transitive + description: + name: get + sha256: c79eeb4339f1f3deffd9ec912f8a923834bec55f7b49c9e882b8fef2c139d425 + url: "https://pub.dev" + source: hosted + version: "4.7.2" + get_storage: + dependency: "direct main" + description: + name: get_storage + sha256: "39db1fffe779d0c22b3a744376e86febe4ade43bf65e06eab5af707dc84185a2" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: c489908a54ce2131f1d1b7cc631af9c1a06fac5ca7c449e959192089f9489431 + url: "https://pub.dev" + source: hosted + version: "16.0.0" + http: + dependency: transitive + description: + name: http + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "317a5d961cec5b34e777b9252393f2afbd23084aa6e60fcf601dcf6341b9ebeb" + url: "https://pub.dev" + source: hosted + version: "0.8.12+23" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100" + url: "https://pub.dev" + source: hosted + version: "0.8.12+2" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "34a65f6740df08bbbeb0a1abd8e6d32107941fd4868f67a507b25601651022c9" + url: "https://pub.dev" + source: hosted + version: "0.2.1+2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "1b90ebbd9dcf98fb6c1d01427e49a55bd96b5d67b8c67cf955d60a5de74207c1" + url: "https://pub.dev" + source: hosted + version: "0.2.1+2" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0" + url: "https://pub.dev" + source: hosted + version: "2.10.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + url: "https://pub.dev" + source: hosted + version: "10.0.9" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + url: "https://pub.dev" + source: hosted + version: "3.0.9" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + url: "https://pub.dev" + source: hosted + version: "2.2.17" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" + url: "https://pub.dev" + source: hosted + version: "2.4.10" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + url: "https://pub.dev" + source: hosted + version: "0.7.4" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" + url: "https://pub.dev" + source: hosted + version: "6.3.16" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + url: "https://pub.dev" + source: hosted + version: "15.0.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" +sdks: + dart: ">=3.8.1 <4.0.0" + flutter: ">=3.27.0" diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml new file mode 100644 index 0000000..29ed389 --- /dev/null +++ b/frontend/pubspec.yaml @@ -0,0 +1,50 @@ +name: frontend +description: "A flutter app" +publish_to: 'none' + + +version: 1.0.0+1 + +environment: + sdk: ^3.8.1 + +dependencies: + flutter: + sdk: flutter + + cupertino_icons: ^1.0.8 + go_router: ^16.0.0 + shared_preferences: ^2.5.3 + image_picker: ^1.1.2 + flutter_riverpod: ^2.6.1 + url_launcher: ^6.3.2 + dio: ^5.8.0+1 + get_storage: ^2.1.1 + easy_localization: ^3.0.7+1 + +dev_dependencies: + flutter_test: + sdk: flutter + + flutter_lints: ^5.0.0 + + +flutter: + + uses-material-design: true + + assets: + - assets/images/ + - assets/fonts/ + - assets/translations/ + + fonts: + - family: Montserrat + fonts: + - asset: assets/fonts/Montserrat-Regular.ttf + - asset: assets/fonts/Montserrat-Bold.ttf + weight: 700 + - asset: assets/fonts/Montserrat-Medium.ttf + weight: 500 + - asset: assets/fonts/Montserrat-SemiBold.ttf + weight: 600 \ No newline at end of file diff --git a/frontend/test/data/functions/open_url_test.dart b/frontend/test/data/functions/open_url_test.dart new file mode 100644 index 0000000..6771d8d --- /dev/null +++ b/frontend/test/data/functions/open_url_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/data/functions/open_url.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; + +class FakeUrlLauncher extends UrlLauncherPlatform { + final bool shouldSucceed; + FakeUrlLauncher({required this.shouldSucceed}); + + @override + Future launchUrl(String url, LaunchOptions options) async { + return shouldSucceed; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + setUp(() { + UrlLauncherPlatform.instance = FakeUrlLauncher(shouldSucceed: true); + }); + + test('openUrl does not throw if launchUrl returns true', () async { + await openUrl('https://example.com'); + }); + + test('openUrl throws exception if launchUrl returns false', () async { + UrlLauncherPlatform.instance = FakeUrlLauncher(shouldSucceed: false); + expect(() => openUrl('https://example.com'), throwsException); + }); +} diff --git a/frontend/test/data/functions/text_to_string_test.dart b/frontend/test/data/functions/text_to_string_test.dart new file mode 100644 index 0000000..521b122 --- /dev/null +++ b/frontend/test/data/functions/text_to_string_test.dart @@ -0,0 +1,17 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:frontend/data/functions/text_to_string.dart'; + +void main() { + group('themeToString', () { + test('returns correct string for ThemeMode.light', () { + expect(themeToString(ThemeMode.light), 'light'); + }); + test('returns correct string for ThemeMode.dark', () { + expect(themeToString(ThemeMode.dark), 'dark'); + }); + test('returns correct string for ThemeMode.system', () { + expect(themeToString(ThemeMode.system), 'system'); + }); + }); +} diff --git a/frontend/test/data/functions/validations_test.dart b/frontend/test/data/functions/validations_test.dart new file mode 100644 index 0000000..23f5d08 --- /dev/null +++ b/frontend/test/data/functions/validations_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/data/functions/validations.dart'; + +String defaultPassword() => '123456'; + +void main() { + group('validateLogin', () { + test('returns error if login is empty', () { + expect(validateLogin(''), 'Please enter a login'); + }); + test('returns null if login is not empty', () { + expect(validateLogin('user'), null); + }); + }); + + group('validatePassword', () { + test('returns error if password is empty', () { + expect(validatePassword(''), 'Please enter a password'); + }); + test('returns error if password is too short', () { + expect(validatePassword('123'), 'Password must be at least 6 characters'); + }); + test('returns null if password is valid', () { + expect(validatePassword(defaultPassword()), null); + }); + }); + + group('validatePasswordConfirmation', () { + test('returns error if passwords do not match', () { + expect( + validatePasswordConfirmation(defaultPassword(), '654321'), + 'Passwords do not match', + ); + }); + test('returns error if password is empty', () { + expect(validatePasswordConfirmation('', ''), 'Please enter a password'); + }); + test('returns null if passwords match and not empty', () { + expect( + validatePasswordConfirmation(defaultPassword(), defaultPassword()), + null, + ); + }); + }); +} diff --git a/frontend/test/domain/services/shared_preferences_service_test.dart b/frontend/test/domain/services/shared_preferences_service_test.dart new file mode 100644 index 0000000..14c544e --- /dev/null +++ b/frontend/test/domain/services/shared_preferences_service_test.dart @@ -0,0 +1,51 @@ +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:frontend/domain/services/shared_preferences_service.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('saveAvatar and loadAvatar', () async { + final service = SharedPreferencesService(); + final bytes = Uint8List.fromList([1, 2, 3, 4]); + await service.saveAvatar(bytes); + final loaded = await service.loadAvatar(); + expect(loaded, bytes); + }); + + test('saveProfile and loadProfile', () async { + final service = SharedPreferencesService(); + await service.saveProfile( + name: 'John', + surname: 'Doe', + age: '30', + alias: 'jdoe', + gender: 'M', + description: 'desc', + activities: ['run', 'swim'], + ); + final profile = await service.loadProfile(); + expect(profile['name'], 'John'); + expect(profile['surname'], 'Doe'); + expect(profile['age'], '30'); + expect(profile['telegram'], 'jdoe'); + expect(profile['gender'], 'M'); + expect(profile['description'], 'desc'); + expect(profile['activities'], contains('run')); + expect(profile['activities'], contains('swim')); + await service.clearProfile(); + final cleared = await service.loadProfile(); + expect(cleared['name'], ''); + expect(cleared['activities'], isEmpty); + }); + + test('saveTheme and loadTheme', () async { + final service = SharedPreferencesService(); + await service.saveTheme('dark'); + final theme = await service.loadTheme(); + expect(theme, 'dark'); + }); +} diff --git a/frontend/test/ui/widgets/custom_filter_chip_test.dart b/frontend/test/ui/widgets/custom_filter_chip_test.dart new file mode 100644 index 0000000..f12627b --- /dev/null +++ b/frontend/test/ui/widgets/custom_filter_chip_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/ui/widgets/custom_filter_chip.dart'; + +void main() { + testWidgets('CustomFilterChip toggles selection', ( + WidgetTester tester, + ) async { + bool selected = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomFilterChip( + label: 'Test', + selected: selected, + onSelected: (val) => selected = val, + ), + ), + ), + ); + expect(find.text('Test'), findsOneWidget); + await tester.tap(find.text('Test')); + // selected должен измениться, но из-за особенностей StatelessWidget это не проверить напрямую + }); +} diff --git a/frontend/test/ui/widgets/icon_back_test.dart b/frontend/test/ui/widgets/icon_back_test.dart new file mode 100644 index 0000000..88ff7a3 --- /dev/null +++ b/frontend/test/ui/widgets/icon_back_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/ui/widgets/icon_back.dart'; +import 'package:go_router/go_router.dart'; + +void main() { + testWidgets('IconBack triggers callback if provided', ( + WidgetTester tester, + ) async { + bool tapped = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: IconBack(callback: () => tapped = true)), + ), + ); + await tester.tap(find.byType(IconButton)); + expect(tapped, true); + }); + + testWidgets('IconBack navigates if callback is not provided', ( + WidgetTester tester, + ) async { + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Scaffold(body: IconBack()), + ), + GoRoute(path: '/home', builder: (context, state) => const SizedBox()), + ], + ); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.tap(find.byType(IconButton)); + // Можно проверить, что не возникло ошибок + }); +} diff --git a/frontend/test/ui/widgets/logout_button_test.dart b/frontend/test/ui/widgets/logout_button_test.dart new file mode 100644 index 0000000..3679a2a --- /dev/null +++ b/frontend/test/ui/widgets/logout_button_test.dart @@ -0,0 +1,63 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/ui/widgets/logout_button.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets('LogoutButton shows dialog and handles yes click', ( + tester, + ) async { + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Scaffold(body: LogoutButton()), + ), + GoRoute(path: '/auth', builder: (context, state) => const SizedBox()), + ], + ); + + await tester.pumpWidget( + ProviderScope( + child: EasyLocalization( + supportedLocales: const [Locale('en'), Locale('ru')], + path: 'assets/translations', + fallbackLocale: const Locale('ru'), + startLocale: const Locale('ru'), + child: Builder( + builder: (context) { + return MaterialApp.router( + routerConfig: router, + locale: context.locale, + supportedLocales: context.supportedLocales, + localizationsDelegates: context.localizationDelegates, + ); + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Выйти'), findsOneWidget); + + await tester.tap(find.text('Выйти')); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + + await tester.tap(find.text('Да')); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsNothing); + }); +} diff --git a/frontend/web/favicon.png b/frontend/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/frontend/web/favicon.png differ diff --git a/frontend/web/icons/Icon-192.png b/frontend/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/frontend/web/icons/Icon-192.png differ diff --git a/frontend/web/icons/Icon-512.png b/frontend/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/frontend/web/icons/Icon-512.png differ diff --git a/frontend/web/icons/Icon-maskable-192.png b/frontend/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/frontend/web/icons/Icon-maskable-192.png differ diff --git a/frontend/web/icons/Icon-maskable-512.png b/frontend/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/frontend/web/icons/Icon-maskable-512.png differ diff --git a/frontend/web/index.html b/frontend/web/index.html new file mode 100644 index 0000000..14e214a --- /dev/null +++ b/frontend/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + frontend + + + + + + diff --git a/frontend/web/manifest.json b/frontend/web/manifest.json new file mode 100644 index 0000000..409f4c9 --- /dev/null +++ b/frontend/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "frontend", + "short_name": "frontend", + "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" + } + ] +} diff --git a/frontend/windows/.gitignore b/frontend/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/frontend/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/frontend/windows/CMakeLists.txt b/frontend/windows/CMakeLists.txt new file mode 100644 index 0000000..c826e18 --- /dev/null +++ b/frontend/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(frontend LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "frontend") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/frontend/windows/flutter/CMakeLists.txt b/frontend/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/frontend/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/frontend/windows/flutter/generated_plugin_registrant.cc b/frontend/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..043a96f --- /dev/null +++ b/frontend/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,17 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/frontend/windows/flutter/generated_plugin_registrant.h b/frontend/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/frontend/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/frontend/windows/flutter/generated_plugins.cmake b/frontend/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..a95e267 --- /dev/null +++ b/frontend/windows/flutter/generated_plugins.cmake @@ -0,0 +1,25 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/frontend/windows/runner/CMakeLists.txt b/frontend/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/frontend/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/frontend/windows/runner/Runner.rc b/frontend/windows/runner/Runner.rc new file mode 100644 index 0000000..a60d323 --- /dev/null +++ b/frontend/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "frontend" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "frontend" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "frontend.exe" "\0" + VALUE "ProductName", "frontend" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/frontend/windows/runner/flutter_window.cpp b/frontend/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/frontend/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/frontend/windows/runner/flutter_window.h b/frontend/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/frontend/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/frontend/windows/runner/main.cpp b/frontend/windows/runner/main.cpp new file mode 100644 index 0000000..a9dd93d --- /dev/null +++ b/frontend/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"frontend", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/frontend/windows/runner/resource.h b/frontend/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/frontend/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/frontend/windows/runner/resources/app_icon.ico b/frontend/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/frontend/windows/runner/resources/app_icon.ico differ diff --git a/frontend/windows/runner/runner.exe.manifest b/frontend/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/frontend/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/frontend/windows/runner/utils.cpp b/frontend/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/frontend/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/frontend/windows/runner/utils.h b/frontend/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/frontend/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/frontend/windows/runner/win32_window.cpp b/frontend/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/frontend/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/frontend/windows/runner/win32_window.h b/frontend/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/frontend/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/images/backend-arch.png b/images/backend-arch.png new file mode 100644 index 0000000..bd5eb8e Binary files /dev/null and b/images/backend-arch.png differ diff --git a/images/login.png b/images/login.png new file mode 100644 index 0000000..da443a8 Binary files /dev/null and b/images/login.png differ diff --git a/images/profile.png b/images/profile.png new file mode 100644 index 0000000..a0b5a48 Binary files /dev/null and b/images/profile.png differ diff --git a/images/search.png b/images/search.png new file mode 100644 index 0000000..a2ed27c Binary files /dev/null and b/images/search.png differ diff --git a/images/settings.png b/images/settings.png new file mode 100644 index 0000000..a0326f7 Binary files /dev/null and b/images/settings.png differ