diff --git a/.github/workflows/basic.yml b/.github/workflows/basic.yml index b24807d..ca6c55a 100644 --- a/.github/workflows/basic.yml +++ b/.github/workflows/basic.yml @@ -1,4 +1,4 @@ -name: Basic CI +name: CI on: push: @@ -7,20 +7,39 @@ on: branches: [ "master" ] jobs: - build: - + build-and-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'temurin' - cache: gradle - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - name: Build with Gradle - run: ./gradlew build + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build + run: ./gradlew assemble + + - name: Run backend tests + env: + JWT_SECRET: test-secret-for-ci-only + run: ./gradlew :backend:test + + - name: Run shared KMP tests (JVM) + run: ./gradlew :shared:jvmTest + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-reports + path: | + backend/build/reports/tests/ + shared/build/reports/tests/ + retention-days: 7 diff --git a/README.md b/README.md index dc0485e..241b69f 100644 --- a/README.md +++ b/README.md @@ -1,176 +1,250 @@ - - -[![Build](https://github.com/MultiplatformKickstarter/Adoptme/actions/workflows/basic.yml/badge.svg)](https://github.com/multiplatformkickstarter/Adoptme/actions/workflows/basic.yml) -[![Kotlin](https://img.shields.io/badge/Kotlin-2.0.0-blue.svg?style=flat&logo=kotlin)](https://kotlinlang.org) -![badge-android](http://img.shields.io/badge/platform-android-6EDB8D.svg?style=flat) -![badge-ios](http://img.shields.io/badge/platform-ios-6EDB8D.svg?style=flat) -![badge-desktop](http://img.shields.io/badge/platform-desktop-6EDB8D.svg?style=flat) -![badge-ktor](http://img.shields.io/badge/platform-ktor-6EDB8D.svg?style=flat) - - - - -
-* Adoptme * - -The first Open Source Template with all boilerplate code needed to quickly create Kotlin Multiplatform Applications. -
- - - - - -  - - - - +

+ Adoptme Logo +

-

+

Adoptme

-About -| -Download -| -Usage -| -PRO Template -| -Who Made This -| -Contribute -| -Bugs and Feedback -| -Social Media -| -License + A full-featured open-source Kotlin Multiplatform app template — Android, iOS, Desktop & Backend.

-
-### About - - - +

+ Production-ready · Compose Multiplatform · Shared UI & Logic · Ktor Backend +

-Adoptme is an opinionated template based in **Kotlin Multiplatform** (KMP) and **Compose Multiplatform**. While most templates only solves the basic functionality, we are aiming to solve most of the common problems when creating applications from initial status to production. +

+ + Build + + Kotlin + Compose Multiplatform + Android + iOS + Desktop + Ktor + License +

-Comes with this stack built-in: +--- -- **Shared UI and Logic for all platforms** -- **Kotlin** -- **Compose Multiplatform** -- **Kotlin Coroutines** -- **Kotlin Serialization** -- **Kotlin Datetime** -- **Voyager** for Navigation and View Models -- **Koin** as Dependency Injection -- **Detekt** for static code analysis -- **Ktlint** linter -- **Ktor** for Client -- **Multiplatform-settings** for storage -- **Kamel** for Images -- **Kermit** for logging -- **Version Catalogs** -- **Ktor for Server** (PostgresSQL+Tomcat) -- **Swagger Codegen** for **OpenAPI** +## What is Adoptme? -#### Components and Screens +**Adoptme** is an opinionated, production-ready Kotlin Multiplatform app template built on top of **Compose Multiplatform**. It targets Android, iOS, and Desktop from a single shared codebase — with a **Ktor backend** included. -* Login/Sign Up -* Basic listing -* Basic insertion form -* Basic Detail -* Basic Debug Mode -* Basic Profile -* Basic CI -* Basic Composable Components -* Dark mode ready -* Navigation -* Accessibility (and color accessible themes) -* Tracking system -* Internationalization (FIGS) -

+Rather than covering only the basics, Adoptme solves the real problems you encounter when taking an app from zero to production: navigation, dependency injection, image loading, internationalization, dark mode, accessibility, CI, and more — all wired up and working out of the box. +Use it as a launchpad for your own KMP application. -### Download +--- -Click to "Use This Template" button to create a new repository with this template. -

+## Features -### Usage + + -Go to the [Documentation page](https://docs.multiplatformkickstarter.com) for all the information. (**WIP**) -

+### Screens & Flows + +- ✅ **Login / Sign Up** — full authentication flow +- ✅ **Onboarding** — swipeable intro slides with localized content +- ✅ **Home** — pet listings with "Near Me" grid and "Last Search" carousel +- ✅ **Search Listing** — filterable pet list by category +- ✅ **Pet Detail** — full pet profile with favorite toggle +- ✅ **Favorites** — lists all favorited pets with unfavorite action, backed by a real API +- ✅ **Inbox** — conversation list with per-conversation chat screen, message bubbles, and send input +- ✅ **Chat** — real-time-style messaging per conversation with auto-reply in debug/mock mode +- ✅ **Pet Upload** — form to submit a new pet listing +- ✅ **Profile** — logged-in user profile with ratings and options +- ✅ **Profile Detail** — public user profile with their listings +- ✅ **Debug Menu** — developer tools screen (debug builds only) +- ✅ **Account Settings / Settings** — stub screens ready to extend + +### Components + +- ✅ `PetCardSmall` & `PetCardBig` with favorite heart icon overlay +- ✅ `RatingBar` star component +- ✅ `PickerItem` list row with icon +- ✅ `EmptyLayout` with optional action button +- ✅ `PetsSearchBar` +- ✅ `ColoredSnackBar` with success/error/info types +- ✅ Shimmer loading animation +- ✅ Navigation bar (bottom bar + rail variants) + +### Architecture & Tech + +- ✅ **Shared UI and Logic** across all platforms +- ✅ **Kotlin 2.4** + **Compose Multiplatform 1.11** +- ✅ **Voyager** — Navigation, Tabs, ScreenModel (ViewModel) +- ✅ **Koin 4** — Dependency Injection with multiplatform support +- ✅ **Ktor 3** (Client + Server) +- ✅ **Kamel 1** — async image loading with per-platform decoders +- ✅ **Kotlin Coroutines** + **StateFlow** for reactive state +- ✅ **Kotlin Serialization** + **Kotlin Datetime** +- ✅ **Multiplatform Settings** — key-value storage +- ✅ **Kermit** — multiplatform logging +- ✅ **Detekt** — static code analysis +- ✅ **Ktlint** — code style linter +- ✅ **Automated tests** — backend route tests (Ktor `testApplication`) + shared KMP unit tests +- ✅ **Version Catalogs** (`libs.versions.toml`) +- ✅ **Firebase Analytics** (Android) +- ✅ **Ktor Backend** — PostgreSQL + Tomcat + OpenAPI / Swagger + +### App-level Features + +- ✅ Dark mode ready +- ✅ Internationalization — English, Spanish, French, Italian, German (FIGS + EN) +- ✅ Accessibility + color-accessible themes +- ✅ Edge-to-edge display with `enableEdgeToEdge()` +- ✅ Favorites powered by `StateFlow>` — reactive across the whole app +- ✅ CI (GitHub Actions) — build, lint, detekt, backend tests, shared KMP tests, test report upload + +--- + +## Requirements + +| Platform | Minimum | +|---|---| +| Android | API 26 (minSdk) | +| iOS | 16.0+ | +| Desktop | JVM 21 | +| Backend | JVM 21, PostgreSQL | + +--- + +## Project Structure + +``` +Adoptme/ +├── composeApp/ # Android application entry point +├── shared/ # Shared KMP module (UI + logic, all platforms) +│ └── commonMain/ +│ ├── di/ # Koin modules +│ ├── feature/ # Feature modules (favorites, profile, petupload, …) +│ ├── navigation/ # Tab navigation objects +│ ├── ui/ # Screens, components, theme, icons +│ └── localization/# Strings for all supported languages +├── backend/ # Ktor server (REST API, JWT auth, PostgreSQL) +└── gradle/ + └── libs.versions.toml +``` + +--- + +## Getting Started + +### 1. Clone the repository + +```bash +git clone https://github.com/MultiplatformKickstarter/Adoptme.git +cd Adoptme +``` + +### 2. Run on Android + +Open the project in Android Studio and run the `composeApp` configuration. + +### 3. Run on Desktop + +```bash +./gradlew :composeApp:run +``` + +### 4. Run on iOS + +Open `iosApp/iosApp.xcodeproj` in Xcode and run on a simulator or device. + +### 5. Run the Backend + +```bash +JWT_SECRET=your-secret ./gradlew :backend:run +``` + +> Requires a running PostgreSQL instance. Configure the connection in `backend/src/main/resources/application.conf`. +> `JWT_SECRET` must be set as an environment variable. For local tests it defaults to a safe fallback automatically. + +### 6. Run Tests + +```bash +# Backend route tests +./gradlew :backend:test -Need a different template? ------------------ +# Shared KMP unit tests (JVM) +./gradlew :shared:jvmTest +``` -Do you need a different use case? Adoptme Template is a good start but the are more **complete templates** that have all you need to create professional apps without a hassle. -Including more features, screens and components. Like: +--- + +## Backend API -- Advanced CI (deployment) -- RSS Component -- Read and Download PDFs -- Built-in video-game -- 3rd party integrations -- **and more!** +The Ktor backend exposes a REST API secured with **JWT authentication**: -**Save months of work plus a lot of money and focus on building a profitable business.** +| Method | Endpoint | Description | +|---|---|---| +| `POST` | `/v1/users/create` | Register a new user | +| `POST` | `/v1/users/login` | Login and receive JWT | +| `POST` | `/v1/users/logout` | Logout | +| `GET` | `/v1/pets/list` | List pets for current user | +| `GET` | `/v1/pets/pet` | Get a single pet | +| `POST` | `/v1/pets/create` | Create a pet listing | +| `PATCH` | `/v1/pets/pet/update` | Update a pet listing | +| `DELETE` | `/v1/pets/delete` | Delete a pet listing | +| `GET` | `/v1/profile` | Get current user profile | +| `POST` | `/v1/profile/create` | Create user profile | +| `PATCH` | `/v1/profile/update` | Update user profile | +| `GET` | `/v1/favorites` | List favorited pet IDs | +| `POST` | `/v1/favorites/add` | Add a pet to favorites | +| `DELETE` | `/v1/favorites/remove` | Remove a pet from favorites | +| `GET` | `/v1/chat/conversations` | List conversations for current user | +| `POST` | `/v1/chat/conversations/create` | Create a new conversation | +| `DELETE` | `/v1/chat/conversations/delete` | Delete a conversation | +| `GET` | `/v1/chat/messages` | Get messages for a conversation | +| `POST` | `/v1/chat/messages/send` | Send a message | -Checkout [multiplatformkickstarter.com](https://www.multiplatformkickstarter.com) for all details or go directly to our [store](https://www.multiplatformkickstarter.com/pricing) -

+OpenAPI docs available at `/openapi` when the server is running. +--- -Who made this --------------- +## Who made this | Ferran Pons | -|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [Ferran Pons](https://github.com/ferranpons) | +|---| +| [Ferran Pons](https://github.com/ferranpons) | + +--- -

+## Contributing -Contribute ----------- +Contributions of any kind are welcome! -1. Create an issue to discuss about your idea -2. [Fork it] (https://github.com/MultiplatformKickstarter/Adoptme/fork) +1. Open an issue to discuss your idea +2. [Fork the repo](https://github.com/MultiplatformKickstarter/Adoptme/fork) 3. Create your feature branch (`git checkout -b my-new-feature`) 4. Commit your changes (`git commit -am 'Add some feature'`) 5. Push to the branch (`git push origin my-new-feature`) -6. Create a new Pull Request -7. Profit! :white_check_mark: -

+6. Open a Pull Request +--- -Bugs and Feedback ------------------ +## Bugs and Feedback -For bugs, questions and discussions please use the [Github Issues](https://github.com/multiplatformkickstarter/Adoptme/issues). -

+For bugs, questions and discussions please use [GitHub Issues](https://github.com/MultiplatformKickstarter/Adoptme/issues). +--- -Social Media ------------- +## Social Media -Check out our social media to be up-to-date about new products, features and releases. - -- [X platform](https://twitter.com/mpkickstarter) - -**Follow us!** -

+ + + +  + + + +--- -License -------- +## License -Copyright 2023-2025 Multiplatform Kickstarter +Copyright 2023–2026 Multiplatform Kickstarter Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -183,10 +257,9 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -

+--- -Credits -------- +Illustrations by Storyset -Illustrations by Storyset \ No newline at end of file +Built with ❤️ for the Kotlin Multiplatform community. diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 7459c2d..fe90e05 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -19,7 +19,8 @@ dependencies { implementation(libs.ktor.server.core) implementation(libs.ktor.server.auth) implementation(libs.ktor.server.auth.jwt) - implementation(libs.ktor.server.locations) + implementation(libs.ktor.resources) + implementation(libs.ktor.server.resources) implementation(libs.ktor.server.host.common) implementation(libs.ktor.server.status.pages) implementation(libs.ktor.server.catching.headers) @@ -48,6 +49,10 @@ dependencies { implementation(libs.hikariCP) implementation(libs.swagger.codegen) - testImplementation(libs.ktor.server.tests.jvm) + testImplementation(libs.ktor.server.test.host) testImplementation(libs.kotlin.test.junit) } + +tasks.withType { + environment("JWT_SECRET", System.getenv("JWT_SECRET") ?: "test-secret-for-local-dev") +} diff --git a/backend/docs/.swagger-codegen/VERSION b/backend/docs/.swagger-codegen/VERSION index 6f495ea..dea21e1 100644 --- a/backend/docs/.swagger-codegen/VERSION +++ b/backend/docs/.swagger-codegen/VERSION @@ -1 +1 @@ -3.0.69 \ No newline at end of file +3.0.80 \ No newline at end of file diff --git a/backend/docs/index.html b/backend/docs/index.html index ec2aee8..8fd0a54 100644 --- a/backend/docs/index.html +++ b/backend/docs/index.html @@ -1,2780 +1,252 @@ - + - - - Sample API - - - - - - - - - - - - - -
-
-
- -
-
-
-
-

Sample API

-
-
-
- -
-
-

Default

-
-
-
-

usersGet

-

Returns a list of users.

-
-
-
-

-

Optional extended description in CommonMark or HTML.

-

-
-
/users
-

-

Usage and SDK Samples

-

- - -
-
-
curl -X GET\
- -H "Authorization: Basic [[basicHash]]"\
--H "Accept: application/json"\
-"http://api.example.com/v1/users"
-
-
-
import io.swagger.client.*;
-import io.swagger.client.auth.*;
-import io.swagger.client.model.*;
-import io.swagger.client.api.DefaultApi;
-
-import java.io.File;
-import java.util.*;
-
-public class DefaultApiExample {
-
-    public static void main(String[] args) {
-        ApiClient defaultClient = Configuration.getDefaultApiClient();
-        // Configure HTTP basic authorization: BasicAuth
-        HttpBasicAuth BasicAuth = (HttpBasicAuth) defaultClient.getAuthentication("BasicAuth");
-        BasicAuth.setUsername("YOUR USERNAME");
-        BasicAuth.setPassword("YOUR PASSWORD");
-
-        DefaultApi apiInstance = new DefaultApi();
-        try {
-            array['String'] result = apiInstance.usersGet();
-            System.out.println(result);
-        } catch (ApiException e) {
-            System.err.println("Exception when calling DefaultApi#usersGet");
-            e.printStackTrace();
-        }
-    }
-}
-
- -
-
import io.swagger.client.api.DefaultApi;
-
-public class DefaultApiExample {
-
-    public static void main(String[] args) {
-        DefaultApi apiInstance = new DefaultApi();
-        try {
-            array['String'] result = apiInstance.usersGet();
-            System.out.println(result);
-        } catch (ApiException e) {
-            System.err.println("Exception when calling DefaultApi#usersGet");
-            e.printStackTrace();
-        }
-    }
-}
-
- -
-
Configuration *apiConfig = [Configuration sharedConfig];
-// Configure HTTP basic authorization (authentication scheme: BasicAuth)
-[apiConfig setUsername:@"YOUR_USERNAME"];
-[apiConfig setPassword:@"YOUR_PASSWORD"];
-
-DefaultApi *apiInstance = [[DefaultApi alloc] init];
-
-// Returns a list of users.
-[apiInstance usersGetWithCompletionHandler: 
-              ^(array['String'] output, NSError* error) {
-                            if (output) {
-                                NSLog(@"%@", output);
-                            }
-                            if (error) {
-                                NSLog(@"Error: %@", error);
-                            }
-                        }];
-
-
- -
-
var SampleApi = require('sample_api');
-var defaultClient = SampleApi.ApiClient.instance;
-// Configure HTTP basic authorization: BasicAuth
-var BasicAuth = defaultClient.authentications['BasicAuth'];
-BasicAuth.username = 'YOUR USERNAME'
-BasicAuth.password = 'YOUR PASSWORD'
-
-var api = new SampleApi.DefaultApi()
-var callback = function(error, data, response) {
-  if (error) {
-    console.error(error);
-  } else {
-    console.log('API called successfully. Returned data: ' + data);
-  }
-};
-api.usersGet(callback);
-
-
- - -
-
using System;
-using System.Diagnostics;
-using IO.Swagger.Api;
-using IO.Swagger.Client;
-using IO.Swagger.Model;
-
-namespace Example
-{
-    public class usersGetExample
-    {
-        public void main()
-        {
-            // Configure HTTP basic authorization: BasicAuth
-            Configuration.Default.Username = "YOUR_USERNAME";
-            Configuration.Default.Password = "YOUR_PASSWORD";
-
-            var apiInstance = new DefaultApi();
-
-            try
-            {
-                // Returns a list of users.
-                array['String'] result = apiInstance.usersGet();
-                Debug.WriteLine(result);
-            }
-            catch (Exception e)
-            {
-                Debug.Print("Exception when calling DefaultApi.usersGet: " + e.Message );
-            }
-        }
-    }
-}
-
-
- -
-
<?php
-require_once(__DIR__ . '/vendor/autoload.php');
-// Configure HTTP basic authorization: BasicAuth
-Swagger\Client\Configuration::getDefaultConfiguration()->setUsername('YOUR_USERNAME');
-Swagger\Client\Configuration::getDefaultConfiguration()->setPassword('YOUR_PASSWORD');
-
-$api_instance = new Swagger\Client\ApiDefaultApi();
-
-try {
-    $result = $api_instance->usersGet();
-    print_r($result);
-} catch (Exception $e) {
-    echo 'Exception when calling DefaultApi->usersGet: ', $e->getMessage(), PHP_EOL;
-}
-?>
-
- -
-
use Data::Dumper;
-use WWW::SwaggerClient::Configuration;
-use WWW::SwaggerClient::DefaultApi;
-# Configure HTTP basic authorization: BasicAuth
-$WWW::SwaggerClient::Configuration::username = 'YOUR_USERNAME';
-$WWW::SwaggerClient::Configuration::password = 'YOUR_PASSWORD';
-
-my $api_instance = WWW::SwaggerClient::DefaultApi->new();
-
-eval { 
-    my $result = $api_instance->usersGet();
-    print Dumper($result);
-};
-if ($@) {
-    warn "Exception when calling DefaultApi->usersGet: $@\n";
-}
-
- -
-
from __future__ import print_statement
-import time
-import swagger_client
-from swagger_client.rest import ApiException
-from pprint import pprint
-# Configure HTTP basic authorization: BasicAuth
-swagger_client.configuration.username = 'YOUR_USERNAME'
-swagger_client.configuration.password = 'YOUR_PASSWORD'
-
-# create an instance of the API class
-api_instance = swagger_client.DefaultApi()
-
-try: 
-    # Returns a list of users.
-    api_response = api_instance.users_get()
-    pprint(api_response)
-except ApiException as e:
-    print("Exception when calling DefaultApi->usersGet: %s\n" % e)
-
-
- -

Parameters

- - - - - - -

Responses

-

Status: 200 - A JSON array of user names

- - - -
-
-
- -
- -
-
- -
-
-
-
-
- -
-
-
- - - - - - - - + + .method-path { + font-size: 1.5em; + background-color: #0f6ab4; + } + + .up { + float:right; + } + + .parameter { + width: 500px; + } + + .param { + width: 500px; + padding: 10px 0 0 20px; + font-weight: bold; + } + + .param-desc { + width: 700px; + padding: 0 0 0 20px; + color: #777; + } + + .param-type { + font-style: italic; + } + + .param-enum-header { + width: 700px; + padding: 0 0 0 60px; + color: #777; + font-weight: bold; + } + + .param-enum { + width: 700px; + padding: 0 0 0 80px; + color: #777; + font-style: italic; + } + + .field-label { + padding: 0; + margin: 0; + clear: both; + } + + .field-items { + padding: 0 0 15px 0; + margin-bottom: 15px; + } + + .return-type { + clear: both; + padding-bottom: 10px; + } + + .param-header { + font-weight: bold; + } + + .method-tags { + text-align: right; + } + + .method-tag { + background: none repeat scroll 0% 0% #24A600; + border-radius: 3px; + padding: 2px 10px; + margin: 2px; + color: #FFF; + display: inline-block; + text-decoration: none; + } + + + +

Sample API

+
Optional multiline or single-line description in CommonMark or HTML.
+
More information: https://helloreverb.com
+
Contact Info: hello@helloreverb.com
+
Version: 0.1.9
+
BasePath:/v1
+
All rights reserved
+
http://apache.org/licenses/LICENSE-2.0.html
+

Access

+
    +
  1. HTTP Basic Authentication
  2. +
+ +

Methods

+ [ Jump to Models ] + +

Table of Contents

+
+

Default

+ + +

Default

+
+
+ Up +
get /users
+
Returns a list of users. (usersGet)
+
Optional extended description in CommonMark or HTML.
+ + + + + + + +

Return type

+
+ + array[String] +
+ + + +

Example data

+
Content-Type: application/json
+
[ "", "" ]
+ +

Produces

+ This API call produces the following media types according to the Accept request header; + the media type will be conveyed by the Content-Type response header. +
    +
  • application/json
  • +
+ +

Responses

+

200

+ A JSON array of user names + +
+
+ +

Models

+ [ Jump to Methods ] + +

Table of Contents

+
    +
+ + diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/AuthenticationConfig.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/AuthenticationConfig.kt index b1045ec..85ffb55 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/AuthenticationConfig.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/AuthenticationConfig.kt @@ -1,14 +1,17 @@ -@file:OptIn(KtorExperimentalLocationsAPI::class) - package com.multiplatformkickstarter import com.multiplatformkickstarter.auth.JWT_CONFIGURATION import com.multiplatformkickstarter.auth.JwtService import com.multiplatformkickstarter.auth.hash import com.multiplatformkickstarter.repository.DatabaseFactory +import com.multiplatformkickstarter.repository.chat.ConversationsRepositoryImpl +import com.multiplatformkickstarter.repository.chat.MessagesRepositoryImpl +import com.multiplatformkickstarter.repository.favorites.FavoritesRepositoryImpl import com.multiplatformkickstarter.repository.pets.PetsRepositoryImp import com.multiplatformkickstarter.repository.profile.ProfileRepositoryImpl import com.multiplatformkickstarter.repository.user.UserRepositoryImp +import com.multiplatformkickstarter.routes.chat +import com.multiplatformkickstarter.routes.favorites import com.multiplatformkickstarter.routes.pets import com.multiplatformkickstarter.routes.profiles import com.multiplatformkickstarter.routes.users @@ -16,7 +19,6 @@ import io.ktor.server.application.Application import io.ktor.server.application.install import io.ktor.server.auth.Authentication import io.ktor.server.auth.jwt.jwt -import io.ktor.server.locations.KtorExperimentalLocationsAPI import io.ktor.server.routing.routing fun Application.configureAuthentication() { @@ -24,6 +26,9 @@ fun Application.configureAuthentication() { val userRepository = UserRepositoryImp() val petRepository = PetsRepositoryImp() val profileRepository = ProfileRepositoryImpl() + val favoritesRepository = FavoritesRepositoryImpl() + val conversationsRepository = ConversationsRepositoryImpl() + val messagesRepository = MessagesRepositoryImpl() val jwtService = JwtService() val hashFunction = { s: String -> hash(s) } @@ -45,5 +50,7 @@ fun Application.configureAuthentication() { users(userRepository, jwtService, hashFunction) pets(petRepository, userRepository) profiles(profileRepository, userRepository) + favorites(favoritesRepository, userRepository) + chat(conversationsRepository, messagesRepository, userRepository) } } diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/GeneralRoutingConfig.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/GeneralRoutingConfig.kt index da408d3..08a8bbe 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/GeneralRoutingConfig.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/GeneralRoutingConfig.kt @@ -1,5 +1,3 @@ -@file:OptIn(KtorExperimentalLocationsAPI::class) - package com.multiplatformkickstarter import io.ktor.http.HttpStatusCode @@ -7,10 +5,9 @@ import io.ktor.server.application.Application import io.ktor.server.application.call import io.ktor.server.application.install import io.ktor.server.http.content.staticResources -import io.ktor.server.locations.KtorExperimentalLocationsAPI -import io.ktor.server.locations.Locations import io.ktor.server.plugins.openapi.openAPI import io.ktor.server.plugins.statuspages.StatusPages +import io.ktor.server.resources.Resources import io.ktor.server.response.respond import io.ktor.server.response.respondText import io.ktor.server.routing.get @@ -18,8 +15,7 @@ import io.ktor.server.routing.routing import io.swagger.codegen.v3.generators.html.StaticHtmlCodegen fun Application.configureGeneralRouting() { - install(Locations) { - } + install(Resources) routing { get("/") { diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/auth/UserSession.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/auth/UserSession.kt index 40b4e27..e0747d0 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/auth/UserSession.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/auth/UserSession.kt @@ -1,3 +1,6 @@ package com.multiplatformkickstarter.auth +import kotlinx.serialization.Serializable + +@Serializable data class UserSession(val userId: Int) diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/models/ConversationResponse.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/models/ConversationResponse.kt new file mode 100644 index 0000000..d037ef1 --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/models/ConversationResponse.kt @@ -0,0 +1,13 @@ +package com.multiplatformkickstarter.models + +import kotlinx.serialization.Serializable + +@Serializable +data class ConversationResponse( + val id: Int, + val petId: Int, + val petName: String, + val buyerUserId: Int, + val sellerUserId: Int, + val createdAt: Long, +) diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/models/MessageResponse.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/models/MessageResponse.kt new file mode 100644 index 0000000..1788d52 --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/models/MessageResponse.kt @@ -0,0 +1,12 @@ +package com.multiplatformkickstarter.models + +import kotlinx.serialization.Serializable + +@Serializable +data class MessageResponse( + val id: Int, + val conversationId: Int, + val senderUserId: Int, + val content: String, + val sentAt: Long, +) diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/plugins/Monitoring.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/plugins/Monitoring.kt index ef35358..2b3f710 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/plugins/Monitoring.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/plugins/Monitoring.kt @@ -2,7 +2,7 @@ package com.multiplatformkickstarter.plugins import io.ktor.server.application.Application import io.ktor.server.application.install -import io.ktor.server.plugins.callloging.CallLogging +import io.ktor.server.plugins.calllogging.CallLogging import io.ktor.server.request.path import org.slf4j.event.Level diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/DatabaseFactory.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/DatabaseFactory.kt index e37b21b..3c3ef9d 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/DatabaseFactory.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/DatabaseFactory.kt @@ -1,5 +1,8 @@ package com.multiplatformkickstarter.repository +import com.multiplatformkickstarter.repository.chat.Conversations +import com.multiplatformkickstarter.repository.chat.Messages +import com.multiplatformkickstarter.repository.favorites.Favorites import com.multiplatformkickstarter.repository.pets.Pets import com.multiplatformkickstarter.repository.profile.Profiles import com.multiplatformkickstarter.repository.user.Users @@ -7,9 +10,9 @@ import com.zaxxer.hikari.HikariConfig import com.zaxxer.hikari.HikariDataSource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import org.jetbrains.exposed.sql.Database -import org.jetbrains.exposed.sql.SchemaUtils -import org.jetbrains.exposed.sql.transactions.transaction +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.jetbrains.exposed.v1.jdbc.transactions.transaction private const val MAX_POOL_SIZE = 3 @@ -21,6 +24,9 @@ object DatabaseFactory { SchemaUtils.create(Users) SchemaUtils.create(Pets) SchemaUtils.create(Profiles) + SchemaUtils.create(Favorites) + SchemaUtils.create(Conversations) + SchemaUtils.create(Messages) } } diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/Conversations.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/Conversations.kt new file mode 100644 index 0000000..ff06bea --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/Conversations.kt @@ -0,0 +1,18 @@ +package com.multiplatformkickstarter.repository.chat + +import com.multiplatformkickstarter.repository.user.Users +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.Table + +private const val PET_NAME_MAX_LENGTH = 128 + +object Conversations : Table() { + val id: Column = integer("id").autoIncrement().uniqueIndex() + val petId: Column = integer("petId") + val petName: Column = varchar("petName", PET_NAME_MAX_LENGTH) + val buyerUserId: Column = integer("buyerUserId").references(Users.userId) + val sellerUserId: Column = integer("sellerUserId").references(Users.userId) + val createdAt: Column = long("createdAt") + + override val primaryKey = PrimaryKey(id) +} diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/ConversationsRepository.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/ConversationsRepository.kt new file mode 100644 index 0000000..e21caa0 --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/ConversationsRepository.kt @@ -0,0 +1,10 @@ +package com.multiplatformkickstarter.repository.chat + +import com.multiplatformkickstarter.models.ConversationResponse + +interface ConversationsRepository { + suspend fun createConversation(petId: Int, petName: String, buyerUserId: Int, sellerUserId: Int): ConversationResponse? + suspend fun getConversationsForUser(userId: Int): List + suspend fun getConversation(conversationId: Int): ConversationResponse? + suspend fun deleteConversation(conversationId: Int, userId: Int): Boolean +} diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/ConversationsRepositoryImpl.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/ConversationsRepositoryImpl.kt new file mode 100644 index 0000000..4d81d2b --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/ConversationsRepositoryImpl.kt @@ -0,0 +1,87 @@ +package com.multiplatformkickstarter.repository.chat + +import com.multiplatformkickstarter.models.ConversationResponse +import com.multiplatformkickstarter.repository.DatabaseFactory.dbQuery +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.or +import org.jetbrains.exposed.v1.core.statements.InsertStatement +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.select + +class ConversationsRepositoryImpl : ConversationsRepository { + + override suspend fun createConversation( + petId: Int, + petName: String, + buyerUserId: Int, + sellerUserId: Int, + ): ConversationResponse? { + val existing = dbQuery { + Conversations.select(Conversations.id) + .where { (Conversations.petId eq petId) and (Conversations.buyerUserId eq buyerUserId) } + .map { rowToConversation(it) } + .singleOrNull() + } + if (existing != null) return existing + + var statement: InsertStatement? = null + dbQuery { + statement = Conversations.insert { + it[Conversations.petId] = petId + it[Conversations.petName] = petName + it[Conversations.buyerUserId] = buyerUserId + it[Conversations.sellerUserId] = sellerUserId + it[Conversations.createdAt] = System.currentTimeMillis() + } + } + return rowToConversation(statement?.resultedValues?.get(0)) + } + + override suspend fun getConversationsForUser(userId: Int): List { + return dbQuery { + Conversations.select( + Conversations.id, + Conversations.petId, + Conversations.petName, + Conversations.buyerUserId, + Conversations.sellerUserId, + Conversations.createdAt, + ).where { (Conversations.buyerUserId eq userId) or (Conversations.sellerUserId eq userId) } + .map { rowToConversation(it)!! } + } + } + + override suspend fun getConversation(conversationId: Int): ConversationResponse? { + return dbQuery { + Conversations.select(Conversations.id) + .where { Conversations.id eq conversationId } + .map { rowToConversation(it) } + .singleOrNull() + } + } + + override suspend fun deleteConversation(conversationId: Int, userId: Int): Boolean { + val deleted = dbQuery { + Conversations.deleteWhere { + (Conversations.id eq conversationId) and + ((Conversations.buyerUserId eq userId) or (Conversations.sellerUserId eq userId)) + } + } + return deleted > 0 + } + + private fun rowToConversation(row: ResultRow?): ConversationResponse? { + if (row == null) return null + return ConversationResponse( + id = row[Conversations.id], + petId = row[Conversations.petId], + petName = row[Conversations.petName], + buyerUserId = row[Conversations.buyerUserId], + sellerUserId = row[Conversations.sellerUserId], + createdAt = row[Conversations.createdAt], + ) + } +} diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/Messages.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/Messages.kt new file mode 100644 index 0000000..441786f --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/Messages.kt @@ -0,0 +1,15 @@ +package com.multiplatformkickstarter.repository.chat + +import com.multiplatformkickstarter.repository.user.Users +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.Table + +object Messages : Table() { + val id: Column = integer("id").autoIncrement().uniqueIndex() + val conversationId: Column = integer("conversationId").references(Conversations.id) + val senderUserId: Column = integer("senderUserId").references(Users.userId) + val content: Column = text("content") + val sentAt: Column = long("sentAt") + + override val primaryKey = PrimaryKey(id) +} diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/MessagesRepository.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/MessagesRepository.kt new file mode 100644 index 0000000..c60b331 --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/MessagesRepository.kt @@ -0,0 +1,8 @@ +package com.multiplatformkickstarter.repository.chat + +import com.multiplatformkickstarter.models.MessageResponse + +interface MessagesRepository { + suspend fun sendMessage(conversationId: Int, senderUserId: Int, content: String): MessageResponse? + suspend fun getMessages(conversationId: Int): List +} diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/MessagesRepositoryImpl.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/MessagesRepositoryImpl.kt new file mode 100644 index 0000000..48e5972 --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/MessagesRepositoryImpl.kt @@ -0,0 +1,49 @@ +package com.multiplatformkickstarter.repository.chat + +import com.multiplatformkickstarter.models.MessageResponse +import com.multiplatformkickstarter.repository.DatabaseFactory.dbQuery +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.statements.InsertStatement +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.select + +class MessagesRepositoryImpl : MessagesRepository { + + override suspend fun sendMessage(conversationId: Int, senderUserId: Int, content: String): MessageResponse? { + var statement: InsertStatement? = null + dbQuery { + statement = Messages.insert { + it[Messages.conversationId] = conversationId + it[Messages.senderUserId] = senderUserId + it[Messages.content] = content + it[Messages.sentAt] = System.currentTimeMillis() + } + } + return rowToMessage(statement?.resultedValues?.get(0)) + } + + override suspend fun getMessages(conversationId: Int): List { + return dbQuery { + Messages.select( + Messages.id, + Messages.conversationId, + Messages.senderUserId, + Messages.content, + Messages.sentAt, + ).where { Messages.conversationId eq conversationId } + .map { rowToMessage(it)!! } + } + } + + private fun rowToMessage(row: ResultRow?): MessageResponse? { + if (row == null) return null + return MessageResponse( + id = row[Messages.id], + conversationId = row[Messages.conversationId], + senderUserId = row[Messages.senderUserId], + content = row[Messages.content], + sentAt = row[Messages.sentAt], + ) + } +} diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/favorites/Favorites.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/favorites/Favorites.kt new file mode 100644 index 0000000..c916701 --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/favorites/Favorites.kt @@ -0,0 +1,11 @@ +package com.multiplatformkickstarter.repository.favorites + +import com.multiplatformkickstarter.repository.user.Users +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.Table + +object Favorites : Table() { + val id: Column = integer("id").autoIncrement().uniqueIndex() + val userId: Column = integer("userId").references(Users.userId) + val petId: Column = integer("petId") +} diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/favorites/FavoritesRepository.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/favorites/FavoritesRepository.kt new file mode 100644 index 0000000..da47935 --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/favorites/FavoritesRepository.kt @@ -0,0 +1,8 @@ +package com.multiplatformkickstarter.repository.favorites + +interface FavoritesRepository { + suspend fun addFavorite(userId: Int, petId: Int): Boolean + suspend fun removeFavorite(userId: Int, petId: Int): Boolean + suspend fun getFavorites(userId: Int): List + suspend fun isFavorite(userId: Int, petId: Int): Boolean +} diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/favorites/FavoritesRepositoryImpl.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/favorites/FavoritesRepositoryImpl.kt new file mode 100644 index 0000000..a478dd7 --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/favorites/FavoritesRepositoryImpl.kt @@ -0,0 +1,47 @@ +package com.multiplatformkickstarter.repository.favorites + +import com.multiplatformkickstarter.repository.DatabaseFactory.dbQuery +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.select + +class FavoritesRepositoryImpl : FavoritesRepository { + + override suspend fun addFavorite(userId: Int, petId: Int): Boolean { + if (isFavorite(userId, petId)) return false + dbQuery { + Favorites.insert { + it[Favorites.userId] = userId + it[Favorites.petId] = petId + } + } + return true + } + + override suspend fun removeFavorite(userId: Int, petId: Int): Boolean { + val deleted = dbQuery { + Favorites.deleteWhere { + (Favorites.userId eq userId) and (Favorites.petId eq petId) + } + } + return deleted > 0 + } + + override suspend fun getFavorites(userId: Int): List { + return dbQuery { + Favorites.select(Favorites.petId) + .where { Favorites.userId eq userId } + .map { it[Favorites.petId] } + } + } + + override suspend fun isFavorite(userId: Int, petId: Int): Boolean { + return dbQuery { + Favorites.select(Favorites.id) + .where { (Favorites.userId eq userId) and (Favorites.petId eq petId) } + .count() > 0 + } + } +} diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/pets/Pets.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/pets/Pets.kt index e5d5522..5215eed 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/pets/Pets.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/pets/Pets.kt @@ -1,8 +1,8 @@ package com.multiplatformkickstarter.repository.pets import com.multiplatformkickstarter.repository.user.Users -import org.jetbrains.exposed.sql.Column -import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.Table @Suppress("MagicNumber") object Pets : Table() { diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/pets/PetsRepositoryImp.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/pets/PetsRepositoryImp.kt index 32c258f..acd7056 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/pets/PetsRepositoryImp.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/pets/PetsRepositoryImp.kt @@ -8,12 +8,13 @@ import com.multiplatformkickstarter.app.common.model.PetModel import com.multiplatformkickstarter.app.common.model.PetSize import com.multiplatformkickstarter.app.common.model.PetStatus import com.multiplatformkickstarter.repository.DatabaseFactory.dbQuery -import org.jetbrains.exposed.sql.ResultRow -import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq -import org.jetbrains.exposed.sql.deleteWhere -import org.jetbrains.exposed.sql.insert -import org.jetbrains.exposed.sql.statements.InsertStatement -import org.jetbrains.exposed.sql.update +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.statements.InsertStatement +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.select +import org.jetbrains.exposed.v1.jdbc.update class PetsRepositoryImp : PetsRepository { override suspend fun addPet( diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/profile/ProfileRepositoryImpl.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/profile/ProfileRepositoryImpl.kt index ca3e6b3..6b452a1 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/profile/ProfileRepositoryImpl.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/profile/ProfileRepositoryImpl.kt @@ -3,11 +3,12 @@ package com.multiplatformkickstarter.repository.profile import com.multiplatformkickstarter.app.common.model.GeoLocation import com.multiplatformkickstarter.app.common.model.Profile import com.multiplatformkickstarter.repository.DatabaseFactory.dbQuery -import org.jetbrains.exposed.sql.ResultRow -import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq -import org.jetbrains.exposed.sql.insert -import org.jetbrains.exposed.sql.statements.InsertStatement -import org.jetbrains.exposed.sql.update +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.statements.InsertStatement +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.select +import org.jetbrains.exposed.v1.jdbc.update class ProfileRepositoryImpl : ProfileRepository { override suspend fun addProfile( diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/profile/Profiles.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/profile/Profiles.kt index 6cb7867..0ea0ec4 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/profile/Profiles.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/profile/Profiles.kt @@ -1,8 +1,8 @@ package com.multiplatformkickstarter.repository.profile import com.multiplatformkickstarter.repository.user.Users -import org.jetbrains.exposed.sql.Column -import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.Table @Suppress("MagicNumber") object Profiles : Table() { diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/user/UserRepositoryImp.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/user/UserRepositoryImp.kt index 171e75f..4e3fb2b 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/user/UserRepositoryImp.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/user/UserRepositoryImp.kt @@ -2,9 +2,11 @@ package com.multiplatformkickstarter.repository.user import com.multiplatformkickstarter.models.DatabaseUser import com.multiplatformkickstarter.repository.DatabaseFactory.dbQuery -import org.jetbrains.exposed.sql.ResultRow -import org.jetbrains.exposed.sql.insert -import org.jetbrains.exposed.sql.statements.InsertStatement +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.statements.InsertStatement +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.select class UserRepositoryImp : UserRepository { override suspend fun addUser( diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/user/Users.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/user/Users.kt index 3b0258d..023d4fb 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/user/Users.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/user/Users.kt @@ -1,7 +1,7 @@ package com.multiplatformkickstarter.repository.user -import org.jetbrains.exposed.sql.Column -import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.Table @Suppress("MagicNumber") object Users : Table() { diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/ChatRoutes.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/ChatRoutes.kt new file mode 100644 index 0000000..a7fa61e --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/ChatRoutes.kt @@ -0,0 +1,120 @@ +package com.multiplatformkickstarter.routes + +import com.multiplatformkickstarter.API_VERSION +import com.multiplatformkickstarter.auth.JWT_CONFIGURATION +import com.multiplatformkickstarter.auth.UserSession +import com.multiplatformkickstarter.repository.chat.ConversationsRepository +import com.multiplatformkickstarter.repository.chat.MessagesRepository +import com.multiplatformkickstarter.repository.user.UserRepository +import com.multiplatformkickstarter.models.DatabaseUser +import io.ktor.http.HttpStatusCode +import io.ktor.http.Parameters +import io.ktor.resources.Resource +import io.ktor.server.application.call +import io.ktor.server.auth.authenticate +import io.ktor.server.auth.principal +import io.ktor.server.request.receive +import io.ktor.server.resources.delete +import io.ktor.server.resources.get +import io.ktor.server.resources.post +import io.ktor.server.response.respond +import io.ktor.server.routing.Route + +const val CHAT = "$API_VERSION/chat" +const val CHAT_CONVERSATIONS = "$CHAT/conversations" +const val CHAT_CONVERSATION_CREATE = "$CHAT_CONVERSATIONS/create" +const val CHAT_CONVERSATION_DELETE = "$CHAT_CONVERSATIONS/delete" +const val CHAT_MESSAGES = "$CHAT/messages" +const val CHAT_MESSAGES_SEND = "$CHAT_MESSAGES/send" + +@Resource(CHAT_CONVERSATIONS) +class ChatConversationsRoute + +@Resource(CHAT_CONVERSATION_CREATE) +class ChatConversationCreateRoute + +@Resource(CHAT_CONVERSATION_DELETE) +class ChatConversationDeleteRoute + +@Resource(CHAT_MESSAGES) +class ChatMessagesRoute + +@Resource(CHAT_MESSAGES_SEND) +class ChatMessagesSendRoute + +@Suppress("UnusedParameter") +fun Route.chat( + conversationsRepository: ConversationsRepository, + messagesRepository: MessagesRepository, + userRepository: UserRepository, +) { + authenticate(JWT_CONFIGURATION) { + chatConversationRoutes(conversationsRepository) + chatMessageRoutes(messagesRepository) + } +} + +private fun Route.chatConversationRoutes( + conversationsRepository: ConversationsRepository, +) { + get { + val user = call.principal() + ?: return@get call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") + call.respond(HttpStatusCode.OK, conversationsRepository.getConversationsForUser(user.userId)) + } + + post { + val user = call.principal() + ?: return@post call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") + val params = call.receive() + val petId = params["petId"]?.toIntOrNull() + ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing petId") + val petName = params["petName"] + ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing petName") + val sellerUserId = params["sellerUserId"]?.toIntOrNull() + ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing sellerUserId") + val conversation = conversationsRepository.createConversation(petId, petName, user.userId, sellerUserId) + if (conversation != null) { + call.respond(HttpStatusCode.Created, conversation) + } else { + call.respond(HttpStatusCode.InternalServerError, "Failed to create conversation") + } + } + + delete { + val user = call.principal() + ?: return@delete call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") + val conversationId = call.request.queryParameters["conversationId"]?.toIntOrNull() + ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing conversationId") + val deleted = conversationsRepository.deleteConversation(conversationId, user.userId) + call.respond(if (deleted) HttpStatusCode.OK else HttpStatusCode.NotFound) + } +} + +private fun Route.chatMessageRoutes( + messagesRepository: MessagesRepository, +) { + get { + call.principal() + ?: return@get call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") + val conversationId = call.request.queryParameters["conversationId"]?.toIntOrNull() + ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing conversationId") + call.respond(HttpStatusCode.OK, messagesRepository.getMessages(conversationId)) + } + + post { + val user = call.principal() + ?: return@post call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") + val params = call.receive() + val conversationId = params["conversationId"]?.toIntOrNull() + ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing conversationId") + val content = params["content"] + ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing content") + val message = messagesRepository.sendMessage(conversationId, user.userId, content) + if (message != null) { + call.respond(HttpStatusCode.Created, message) + } else { + call.respond(HttpStatusCode.InternalServerError, "Failed to send message") + } + } +} diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/FavoritesRoutes.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/FavoritesRoutes.kt new file mode 100644 index 0000000..781e92e --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/FavoritesRoutes.kt @@ -0,0 +1,66 @@ +package com.multiplatformkickstarter.routes + +import com.multiplatformkickstarter.API_VERSION +import com.multiplatformkickstarter.auth.JWT_CONFIGURATION +import com.multiplatformkickstarter.models.DatabaseUser +import com.multiplatformkickstarter.repository.favorites.FavoritesRepository +import com.multiplatformkickstarter.repository.user.UserRepository +import io.ktor.http.HttpStatusCode +import io.ktor.http.Parameters +import io.ktor.resources.Resource +import io.ktor.server.application.call +import io.ktor.server.auth.authenticate +import io.ktor.server.auth.principal +import io.ktor.server.request.receive +import io.ktor.server.resources.delete +import io.ktor.server.resources.get +import io.ktor.server.resources.post +import io.ktor.server.response.respond +import io.ktor.server.routing.Route + +const val FAVORITES = "$API_VERSION/favorites" +const val FAVORITES_ADD = "$FAVORITES/add" +const val FAVORITES_REMOVE = "$FAVORITES/remove" + +@Resource(FAVORITES) +class FavoritesListRoute + +@Resource(FAVORITES_ADD) +class FavoritesAddRoute + +@Resource(FAVORITES_REMOVE) +class FavoritesRemoveRoute + +@Suppress("UnusedParameter") +fun Route.favorites( + favoritesRepository: FavoritesRepository, + userRepository: UserRepository, +) { + authenticate(JWT_CONFIGURATION) { + get { + val user = call.principal() + ?: return@get call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") + val petIds = favoritesRepository.getFavorites(user.userId) + call.respond(HttpStatusCode.OK, petIds) + } + + post { + val user = call.principal() + ?: return@post call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") + val params = call.receive() + val petId = params["petId"]?.toIntOrNull() + ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing petId") + val added = favoritesRepository.addFavorite(user.userId, petId) + call.respond(if (added) HttpStatusCode.Created else HttpStatusCode.Conflict) + } + + delete { + val user = call.principal() + ?: return@delete call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") + val petId = call.request.queryParameters["petId"]?.toIntOrNull() + ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing petId") + val removed = favoritesRepository.removeFavorite(user.userId, petId) + call.respond(if (removed) HttpStatusCode.OK else HttpStatusCode.NotFound) + } + } +} diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/PetsRoutes.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/PetsRoutes.kt index 336cd9f..107d40b 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/PetsRoutes.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/PetsRoutes.kt @@ -11,12 +11,11 @@ import io.ktor.http.Parameters import io.ktor.server.application.call import io.ktor.server.application.log import io.ktor.server.auth.authenticate -import io.ktor.server.locations.KtorExperimentalLocationsAPI -import io.ktor.server.locations.Location -import io.ktor.server.locations.delete -import io.ktor.server.locations.get -import io.ktor.server.locations.patch -import io.ktor.server.locations.post +import io.ktor.resources.Resource +import io.ktor.server.resources.delete +import io.ktor.server.resources.get +import io.ktor.server.resources.patch +import io.ktor.server.resources.post import io.ktor.server.request.receive import io.ktor.server.response.respond import io.ktor.server.routing.Route @@ -34,28 +33,22 @@ const val PETS_GET_ONE = "$PETS/pet" const val PETS_UPDATE_ONE = "$PETS/pet/update" const val PETS_DELETE = "$PETS/delete" -@KtorExperimentalLocationsAPI -@Location(PETS_CREATE) +@Resource(PETS_CREATE) class PetsCreateRoute -@KtorExperimentalLocationsAPI -@Location(PETS_LIST) +@Resource(PETS_LIST) class PetsListRoute -@KtorExperimentalLocationsAPI -@Location(PETS_GET_ONE) +@Resource(PETS_GET_ONE) class PetsGetOneRoute -@KtorExperimentalLocationsAPI -@Location(PETS_UPDATE_ONE) +@Resource(PETS_UPDATE_ONE) class PetsUpdateOneRoute -@KtorExperimentalLocationsAPI -@Location(PETS_DELETE) +@Resource(PETS_DELETE) class PetsDeleteRoute @Suppress("LongMethod", "TooGenericExceptionCaught", "CyclomaticComplexMethod") -@KtorExperimentalLocationsAPI fun Route.pets( petsRepository: PetsRepository, userRepository: UserRepository, diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/ProfileRoutes.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/ProfileRoutes.kt index d8a1b4e..5b3d66f 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/ProfileRoutes.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/ProfileRoutes.kt @@ -10,11 +10,10 @@ import io.ktor.http.Parameters import io.ktor.server.application.call import io.ktor.server.application.log import io.ktor.server.auth.authenticate -import io.ktor.server.locations.KtorExperimentalLocationsAPI -import io.ktor.server.locations.Location -import io.ktor.server.locations.get -import io.ktor.server.locations.patch -import io.ktor.server.locations.post +import io.ktor.resources.Resource +import io.ktor.server.resources.get +import io.ktor.server.resources.patch +import io.ktor.server.resources.post import io.ktor.server.request.receive import io.ktor.server.response.respond import io.ktor.server.routing.Route @@ -26,20 +25,16 @@ const val PROFILE = "$API_VERSION/profile" const val PROFILE_CREATE = "$PROFILE/create" const val PROFILE_UPDATE = "$PROFILE/update" -@KtorExperimentalLocationsAPI -@Location(PROFILE) +@Resource(PROFILE) class ProfileRoute -@KtorExperimentalLocationsAPI -@Location(PROFILE_CREATE) +@Resource(PROFILE_CREATE) class ProfileCreateRoute -@KtorExperimentalLocationsAPI -@Location(PROFILE_UPDATE) +@Resource(PROFILE_UPDATE) class ProfileUpdateRoute @Suppress("LongMethod", "TooGenericExceptionCaught", "CyclomaticComplexMethod") -@KtorExperimentalLocationsAPI fun Route.profiles( profileRepository: ProfileRepository, userRepository: UserRepository, diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/UserRoute.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/UserRoute.kt index 39705e0..7a4076d 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/UserRoute.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/UserRoute.kt @@ -8,9 +8,8 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.Parameters import io.ktor.server.application.call import io.ktor.server.application.log -import io.ktor.server.locations.KtorExperimentalLocationsAPI -import io.ktor.server.locations.Location -import io.ktor.server.locations.post +import io.ktor.resources.Resource +import io.ktor.server.resources.post import io.ktor.server.request.receive import io.ktor.server.response.header import io.ktor.server.response.respond @@ -26,20 +25,16 @@ const val USER_LOGIN = "$USERS/login" const val USER_CREATE = "$USERS/create" const val USER_LOGOUT = "$USERS/logout" -@KtorExperimentalLocationsAPI -@Location(USER_LOGIN) +@Resource(USER_LOGIN) class UserLoginRoute -@KtorExperimentalLocationsAPI -@Location(USER_CREATE) +@Resource(USER_CREATE) class UserCreateRoute -@KtorExperimentalLocationsAPI -@Location(USER_LOGOUT) +@Resource(USER_LOGOUT) class UserLogoutRoute @Suppress("TooGenericExceptionCaught") -@KtorExperimentalLocationsAPI fun Route.users( userRepository: UserRepository, jwtService: JwtService, @@ -53,13 +48,15 @@ fun Route.users( val hash = hashFunction(password) try { val newUser = userRepository.addUser(email, name, hash) - newUser?.userId?.let { - call.sessions.set(UserSession(it)) - call.respondText( - jwtService.generateToken(newUser), - status = HttpStatusCode.Created, - ) + if (newUser == null) { + call.respond(HttpStatusCode.BadRequest, "Email already in use") + return@post } + call.sessions.set(UserSession(newUser.userId)) + call.respondText( + jwtService.generateToken(newUser), + status = HttpStatusCode.Created, + ) } catch (e: Throwable) { this@users.application.log.error("Failed to register user", e) call.respond(HttpStatusCode.BadRequest, "Problems creating User") diff --git a/backend/src/test/kotlin/com/multiplatformkickstarter/ApplicationTest.kt b/backend/src/test/kotlin/com/multiplatformkickstarter/ApplicationTest.kt index 0b6e948..6f08204 100644 --- a/backend/src/test/kotlin/com/multiplatformkickstarter/ApplicationTest.kt +++ b/backend/src/test/kotlin/com/multiplatformkickstarter/ApplicationTest.kt @@ -9,14 +9,11 @@ import kotlin.test.assertEquals class ApplicationTest { @Test - fun testRoot() = - testApplication { - application { - configureGeneralRouting() - } - client.get("/").apply { - assertEquals(HttpStatusCode.OK, status) - assertEquals("Hello World!", bodyAsText()) - } + fun `GET slash returns Hello World`() = testApplication { + application { configureGeneralRouting() } + client.get("/").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals("Hello World!", bodyAsText()) } + } } diff --git a/backend/src/test/kotlin/com/multiplatformkickstarter/TestHelpers.kt b/backend/src/test/kotlin/com/multiplatformkickstarter/TestHelpers.kt new file mode 100644 index 0000000..a91d532 --- /dev/null +++ b/backend/src/test/kotlin/com/multiplatformkickstarter/TestHelpers.kt @@ -0,0 +1,195 @@ +package com.multiplatformkickstarter + +import com.multiplatformkickstarter.app.common.model.GeoLocation +import com.multiplatformkickstarter.app.common.model.PetAge +import com.multiplatformkickstarter.app.common.model.PetCategory +import com.multiplatformkickstarter.app.common.model.PetGender +import com.multiplatformkickstarter.app.common.model.PetModel +import com.multiplatformkickstarter.app.common.model.PetSize +import com.multiplatformkickstarter.app.common.model.PetStatus +import com.multiplatformkickstarter.auth.JWT_CONFIGURATION +import com.multiplatformkickstarter.auth.JwtService +import com.multiplatformkickstarter.auth.UserSession +import com.multiplatformkickstarter.models.ConversationResponse +import com.multiplatformkickstarter.models.DatabaseUser +import com.multiplatformkickstarter.models.MessageResponse +import com.multiplatformkickstarter.repository.chat.ConversationsRepository +import com.multiplatformkickstarter.repository.chat.MessagesRepository +import com.multiplatformkickstarter.repository.favorites.FavoritesRepository +import com.multiplatformkickstarter.repository.pets.PetsRepository +import com.multiplatformkickstarter.repository.user.UserRepository +import com.multiplatformkickstarter.plugins.configureSerialization +import com.multiplatformkickstarter.routes.chat +import com.multiplatformkickstarter.routes.favorites +import com.multiplatformkickstarter.routes.pets +import com.multiplatformkickstarter.routes.users +import io.ktor.server.application.install +import io.ktor.server.auth.Authentication +import io.ktor.server.auth.jwt.jwt +import io.ktor.server.resources.Resources +import io.ktor.server.routing.routing +import io.ktor.server.sessions.Sessions +import io.ktor.server.sessions.directorySessionStorage +import io.ktor.server.sessions.header +import io.ktor.server.testing.ApplicationTestBuilder +import java.io.File + +// ── Shared test fixtures ────────────────────────────────────────────────────── + +val testUser = DatabaseUser(userId = 1, email = "test@test.com", name = "Test User", passwordHash = "testhash") + +fun makePetModel(id: Int = 1, userId: Int = 1, title: String = "Buddy") = PetModel( + id = id, userId = userId, title = title, description = "A lovely pet", + images = listOf("http://example.com/img.jpg"), category = PetCategory.DOGS, + location = GeoLocation(41.4, 2.1), published = "2024-01-01T10:00", + modified = null, breed = "Labrador", age = PetAge.YOUNG, gender = PetGender.MALE, + size = PetSize.MEDIUM, color = "Brown", status = PetStatus.ADOPTABLE, shelterId = null, +) + +// ── Fake repositories ───────────────────────────────────────────────────────── + +class FakeUserRepository(seed: List = listOf(testUser)) : UserRepository { + private val users = seed.toMutableList() + + override suspend fun addUser(email: String, name: String, passwordHash: String): DatabaseUser? { + if (users.any { it.email == email }) return null + val user = DatabaseUser(userId = users.size + 1, email = email, name = name, passwordHash = passwordHash) + users.add(user) + return user + } + + override suspend fun findUser(userId: Int): DatabaseUser? = users.find { it.userId == userId } + + override suspend fun findUserByEmail(email: String): DatabaseUser? = users.find { it.email == email } +} + +class FakePetsRepository : PetsRepository { + private val pets = mutableListOf() + private var nextId = 1 + + override suspend fun addPet( + userId: Int, title: String, description: String, images: String, + category: Int, location: String, published: String, breed: String, + age: String, gender: String, size: String, color: String, status: String, shelterId: Int?, + ): PetModel { + val pet = makePetModel(id = nextId++, userId = userId, title = title) + pets.add(pet) + return pet + } + + override suspend fun getPet(petId: Int): PetModel = pets.first { it.id == petId } + + override suspend fun getPets(userId: Int): List = pets.filter { it.userId == userId } + + override suspend fun delete(petId: Int) { pets.removeIf { it.id == petId } } + + override suspend fun updatePet( + petId: Int, title: String?, description: String?, images: String?, + location: String?, modified: String?, breed: String?, age: String?, + gender: String?, size: String?, color: String?, status: String?, shelterId: Int?, + ): PetModel? { + val idx = pets.indexOfFirst { it.id == petId }.takeIf { it >= 0 } ?: return null + val old = pets[idx] + val updated = PetModel( + id = old.id, userId = old.userId, title = title ?: old.title, + description = description ?: old.description, images = old.images, + category = old.category, location = old.location, published = old.published, + modified = modified ?: old.modified, breed = breed ?: old.breed, + age = if (age != null) PetAge.valueOf(age) else old.age, + gender = if (gender != null) PetGender.valueOf(gender) else old.gender, + size = if (size != null) PetSize.valueOf(size) else old.size, + color = color ?: old.color, status = if (status != null) PetStatus.valueOf(status) else old.status, + shelterId = shelterId ?: old.shelterId, + ) + pets[idx] = updated + return updated + } +} + +class FakeFavoritesRepository : FavoritesRepository { + private val favorites = mutableSetOf>() + + override suspend fun addFavorite(userId: Int, petId: Int): Boolean = favorites.add(userId to petId) + + override suspend fun removeFavorite(userId: Int, petId: Int): Boolean = favorites.remove(userId to petId) + + override suspend fun getFavorites(userId: Int): List = + favorites.filter { it.first == userId }.map { it.second } + + override suspend fun isFavorite(userId: Int, petId: Int): Boolean = (userId to petId) in favorites +} + +class FakeConversationsRepository : ConversationsRepository { + val conversations = mutableListOf() + private var nextId = 1 + + override suspend fun createConversation( + petId: Int, petName: String, buyerUserId: Int, sellerUserId: Int, + ): ConversationResponse { + val conv = ConversationResponse(nextId++, petId, petName, buyerUserId, sellerUserId, 0L) + conversations.add(conv) + return conv + } + + override suspend fun getConversationsForUser(userId: Int): List = + conversations.filter { it.buyerUserId == userId || it.sellerUserId == userId } + + override suspend fun getConversation(conversationId: Int): ConversationResponse? = + conversations.find { it.id == conversationId } + + override suspend fun deleteConversation(conversationId: Int, userId: Int): Boolean = + conversations.removeIf { it.id == conversationId } +} + +class FakeMessagesRepository : MessagesRepository { + val messages = mutableListOf() + private var nextId = 1 + + override suspend fun sendMessage(conversationId: Int, senderUserId: Int, content: String): MessageResponse { + val msg = MessageResponse(nextId++, conversationId, senderUserId, content, 0L) + messages.add(msg) + return msg + } + + override suspend fun getMessages(conversationId: Int): List = + messages.filter { it.conversationId == conversationId } +} + +// ── Application builder helper ──────────────────────────────────────────────── + +fun ApplicationTestBuilder.setupTestApp( + userRepository: UserRepository = FakeUserRepository(), + petsRepository: PetsRepository = FakePetsRepository(), + favoritesRepository: FavoritesRepository = FakeFavoritesRepository(), + conversationsRepository: ConversationsRepository = FakeConversationsRepository(), + messagesRepository: MessagesRepository = FakeMessagesRepository(), +) { + val jwtService = JwtService() + val hashFunction = { s: String -> s } + + application { + install(Resources) + install(Sessions) { + header("user_session", directorySessionStorage(File("build/.sessions-test"))) + } + install(Authentication) { + jwt(JWT_CONFIGURATION) { + verifier(jwtService.verifier) + realm = "Test" + validate { + val id = it.payload.getClaim("id").asInt() + userRepository.findUser(id) + } + } + } + configureSerialization() + routing { + users(userRepository, jwtService, hashFunction) + pets(petsRepository, userRepository) + favorites(favoritesRepository, userRepository) + chat(conversationsRepository, messagesRepository, userRepository) + } + } +} + +fun JwtService.tokenForUser(user: DatabaseUser = testUser): String = generateToken(user) diff --git a/backend/src/test/kotlin/com/multiplatformkickstarter/routes/ChatRoutesTest.kt b/backend/src/test/kotlin/com/multiplatformkickstarter/routes/ChatRoutesTest.kt new file mode 100644 index 0000000..30693bf --- /dev/null +++ b/backend/src/test/kotlin/com/multiplatformkickstarter/routes/ChatRoutesTest.kt @@ -0,0 +1,137 @@ +package com.multiplatformkickstarter.routes + +import com.multiplatformkickstarter.FakeConversationsRepository +import com.multiplatformkickstarter.FakeMessagesRepository +import com.multiplatformkickstarter.auth.JwtService +import com.multiplatformkickstarter.setupTestApp +import com.multiplatformkickstarter.testUser +import com.multiplatformkickstarter.tokenForUser +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.forms.submitForm +import io.ktor.http.HttpStatusCode +import io.ktor.http.Parameters +import io.ktor.server.testing.testApplication +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ChatRoutesTest { + + private val jwtService = JwtService() + private val token get() = jwtService.tokenForUser() + + @Test + fun `get conversations without auth returns 401`() = testApplication { + setupTestApp() + assertEquals(HttpStatusCode.Unauthorized, client.get(CHAT_CONVERSATIONS).status) + } + + @Test + fun `get conversations returns empty list for new user`() = testApplication { + setupTestApp() + val response = client.get(CHAT_CONVERSATIONS) { + header("Authorization", "Bearer $token") + } + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun `create conversation returns 201`() = testApplication { + setupTestApp() + val response = client.submitForm( + url = CHAT_CONVERSATION_CREATE, + formParameters = Parameters.build { + append("petId", "1") + append("petName", "Buddy") + append("sellerUserId", "2") + }, + ) { header("Authorization", "Bearer $token") } + assertEquals(HttpStatusCode.Created, response.status) + } + + @Test + fun `create conversation with missing petId returns bad request`() = testApplication { + setupTestApp() + val response = client.submitForm( + url = CHAT_CONVERSATION_CREATE, + formParameters = Parameters.build { append("petName", "Buddy") }, + ) { header("Authorization", "Bearer $token") } + assertEquals(HttpStatusCode.BadRequest, response.status) + } + + @Test + fun `created conversation appears in conversations list`() = testApplication { + val convRepo = FakeConversationsRepository() + setupTestApp(conversationsRepository = convRepo) + convRepo.createConversation(1, "Buddy", testUser.userId, 2) + val response = client.get(CHAT_CONVERSATIONS) { + header("Authorization", "Bearer $token") + } + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun `delete conversation returns 200`() = testApplication { + val convRepo = FakeConversationsRepository() + setupTestApp(conversationsRepository = convRepo) + convRepo.createConversation(1, "Buddy", testUser.userId, 2) + val response = client.delete(CHAT_CONVERSATION_DELETE) { + header("Authorization", "Bearer $token") + url { parameters.append("conversationId", "1") } + } + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun `delete non-existing conversation returns 404`() = testApplication { + setupTestApp() + val response = client.delete(CHAT_CONVERSATION_DELETE) { + header("Authorization", "Bearer $token") + url { parameters.append("conversationId", "99") } + } + assertEquals(HttpStatusCode.NotFound, response.status) + } + + @Test + fun `get messages without auth returns 401`() = testApplication { + setupTestApp() + assertEquals(HttpStatusCode.Unauthorized, client.get(CHAT_MESSAGES).status) + } + + @Test + fun `send message returns 201`() = testApplication { + setupTestApp() + val response = client.submitForm( + url = CHAT_MESSAGES_SEND, + formParameters = Parameters.build { + append("conversationId", "1") + append("content", "Hello!") + }, + ) { header("Authorization", "Bearer $token") } + assertEquals(HttpStatusCode.Created, response.status) + } + + @Test + fun `send message with missing content returns bad request`() = testApplication { + setupTestApp() + val response = client.submitForm( + url = CHAT_MESSAGES_SEND, + formParameters = Parameters.build { append("conversationId", "1") }, + ) { header("Authorization", "Bearer $token") } + assertEquals(HttpStatusCode.BadRequest, response.status) + } + + @Test + fun `sent message appears in message list`() = testApplication { + val msgRepo = FakeMessagesRepository() + setupTestApp(messagesRepository = msgRepo) + msgRepo.sendMessage(1, testUser.userId, "Hello!") + val response = client.get(CHAT_MESSAGES) { + header("Authorization", "Bearer $token") + url { parameters.append("conversationId", "1") } + } + assertEquals(HttpStatusCode.OK, response.status) + } +} diff --git a/backend/src/test/kotlin/com/multiplatformkickstarter/routes/FavoritesRoutesTest.kt b/backend/src/test/kotlin/com/multiplatformkickstarter/routes/FavoritesRoutesTest.kt new file mode 100644 index 0000000..2447f73 --- /dev/null +++ b/backend/src/test/kotlin/com/multiplatformkickstarter/routes/FavoritesRoutesTest.kt @@ -0,0 +1,99 @@ +package com.multiplatformkickstarter.routes + +import com.multiplatformkickstarter.FakeFavoritesRepository +import com.multiplatformkickstarter.FakeUserRepository +import com.multiplatformkickstarter.auth.JwtService +import com.multiplatformkickstarter.setupTestApp +import com.multiplatformkickstarter.testUser +import com.multiplatformkickstarter.tokenForUser +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.forms.submitForm +import io.ktor.http.HttpStatusCode +import io.ktor.http.Parameters +import io.ktor.server.testing.testApplication +import kotlin.test.Test +import kotlin.test.assertEquals + +class FavoritesRoutesTest { + + private val jwtService = JwtService() + private val token get() = jwtService.tokenForUser() + + @Test + fun `get favorites without auth returns 401`() = testApplication { + setupTestApp() + val response = client.get(FAVORITES) + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + @Test + fun `get favorites returns empty list for new user`() = testApplication { + setupTestApp() + val response = client.get(FAVORITES) { + header("Authorization", "Bearer $token") + } + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun `add favorite returns 201`() = testApplication { + setupTestApp() + val response = client.submitForm( + url = FAVORITES_ADD, + formParameters = Parameters.build { append("petId", "42") }, + ) { + header("Authorization", "Bearer $token") + } + assertEquals(HttpStatusCode.Created, response.status) + } + + @Test + fun `add same favorite twice returns conflict`() = testApplication { + val favRepo = FakeFavoritesRepository() + setupTestApp(favoritesRepository = favRepo) + val params = Parameters.build { append("petId", "42") } + client.submitForm(url = FAVORITES_ADD, formParameters = params) { + header("Authorization", "Bearer $token") + } + val second = client.submitForm(url = FAVORITES_ADD, formParameters = params) { + header("Authorization", "Bearer $token") + } + assertEquals(HttpStatusCode.Conflict, second.status) + } + + @Test + fun `add favorite with missing petId returns bad request`() = testApplication { + setupTestApp() + val response = client.submitForm( + url = FAVORITES_ADD, + formParameters = Parameters.build {}, + ) { + header("Authorization", "Bearer $token") + } + assertEquals(HttpStatusCode.BadRequest, response.status) + } + + @Test + fun `remove existing favorite returns 200`() = testApplication { + val favRepo = FakeFavoritesRepository() + setupTestApp(favoritesRepository = favRepo) + favRepo.addFavorite(testUser.userId, 42) + val response = client.delete(FAVORITES_REMOVE) { + header("Authorization", "Bearer $token") + url { parameters.append("petId", "42") } + } + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun `remove non-existing favorite returns 404`() = testApplication { + setupTestApp() + val response = client.delete(FAVORITES_REMOVE) { + header("Authorization", "Bearer $token") + url { parameters.append("petId", "99") } + } + assertEquals(HttpStatusCode.NotFound, response.status) + } +} diff --git a/backend/src/test/kotlin/com/multiplatformkickstarter/routes/UserRoutesTest.kt b/backend/src/test/kotlin/com/multiplatformkickstarter/routes/UserRoutesTest.kt new file mode 100644 index 0000000..6ff0920 --- /dev/null +++ b/backend/src/test/kotlin/com/multiplatformkickstarter/routes/UserRoutesTest.kt @@ -0,0 +1,109 @@ +package com.multiplatformkickstarter.routes + +import com.multiplatformkickstarter.FakeUserRepository +import com.multiplatformkickstarter.setupTestApp +import com.multiplatformkickstarter.testUser +import io.ktor.client.request.forms.submitForm +import io.ktor.client.request.post +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.http.Parameters +import io.ktor.server.testing.testApplication +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class UserRoutesTest { + + @Test + fun `create user returns 201 and jwt token`() = testApplication { + setupTestApp(userRepository = FakeUserRepository(emptyList())) + val response = client.submitForm( + url = USER_CREATE, + formParameters = Parameters.build { + append("email", "new@test.com") + append("name", "New User") + append("password", "secret") + }, + ) + assertEquals(HttpStatusCode.Created, response.status) + val token = response.bodyAsText() + assertTrue(token.isNotBlank(), "Expected JWT token in response body") + } + + @Test + fun `create user with duplicate email returns bad request`() = testApplication { + setupTestApp() + val response = client.submitForm( + url = USER_CREATE, + formParameters = Parameters.build { + append("email", testUser.email) + append("name", "Duplicate") + append("password", "secret") + }, + ) + assertEquals(HttpStatusCode.BadRequest, response.status) + } + + @Test + fun `create user with missing fields returns unauthorized`() = testApplication { + setupTestApp() + val response = client.submitForm( + url = USER_CREATE, + formParameters = Parameters.build { + append("email", "missing@test.com") + // name and password omitted + }, + ) + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + @Test + fun `login with correct credentials returns token and user_id header`() = testApplication { + setupTestApp() + val response = client.submitForm( + url = USER_LOGIN, + formParameters = Parameters.build { + append("email", testUser.email) + append("password", testUser.passwordHash) // identity hash in tests + }, + ) + assertEquals(HttpStatusCode.OK, response.status) + assertNotNull(response.headers["user_id"], "Expected user_id header") + assertTrue(response.bodyAsText().isNotBlank()) + } + + @Test + fun `login with wrong password returns bad request`() = testApplication { + setupTestApp() + val response = client.submitForm( + url = USER_LOGIN, + formParameters = Parameters.build { + append("email", testUser.email) + append("password", "wrongpassword") + }, + ) + assertEquals(HttpStatusCode.BadRequest, response.status) + } + + @Test + fun `login with missing fields returns unauthorized`() = testApplication { + setupTestApp() + val response = client.submitForm( + url = USER_LOGIN, + formParameters = Parameters.build { + append("email", testUser.email) + // password omitted + }, + ) + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + @Test + fun `logout returns 200`() = testApplication { + setupTestApp() + val response = client.post(USER_LOGOUT) + assertEquals(HttpStatusCode.OK, response.status) + } +} diff --git a/commonMain/io/kamel/image/config/LocalKamelConfig.kt b/commonMain/io/kamel/image/config/LocalKamelConfig.kt new file mode 100644 index 0000000..27c25ae --- /dev/null +++ b/commonMain/io/kamel/image/config/LocalKamelConfig.kt @@ -0,0 +1,14 @@ +package io.kamel.image.config + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf +import io.kamel.core.config.Core +import io.kamel.core.config.KamelConfig + +/** + * Static CompositionLocal that provides the default configuration of [KamelConfig]. + */ +public val LocalKamelConfig: ProvidableCompositionLocal = + staticCompositionLocalOf { detectedKamelConfig ?: KamelConfig.Core } + +public expect var detectedKamelConfig: KamelConfig? \ No newline at end of file diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 77e03f8..ab6f8cc 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -15,6 +15,9 @@ val versionMajor = properties["multiplatformkickstarter.version.major"].toString val versionMinor = properties["multiplatformkickstarter.version.minor"].toString().toInt() val versionPatch = properties["multiplatformkickstarter.version.patch"].toString().toInt() +val minSdkVersion = 26 +val sdkVersion = 37 + fun versionCode(): Int { versionNum?.let { return (versionMajor * 1000000) + (versionMinor * 1000) + it.toInt() @@ -22,22 +25,6 @@ fun versionCode(): Int { } kotlin { - /*@OptIn(ExperimentalWasmDsl::class) - wasmJs { - moduleName = "composeApp" - browser { - commonWebpackConfig { - outputFileName = "composeApp.js" - devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply { - static = (static ?: mutableListOf()).apply { - // Serve sources to debug inside browser - add(project.projectDir.path) - } - } - } - } - binaries.executable() - }*/ androidTarget { @OptIn(ExperimentalKotlinGradlePluginApi::class) compilerOptions { @@ -46,7 +33,6 @@ kotlin { } jvm("desktop") listOf( - iosX64(), iosArm64(), iosSimulatorArm64(), ).forEach { iosTarget -> @@ -78,7 +64,6 @@ kotlin { } desktopMain.dependencies { implementation(compose.desktop.currentOs) - implementation(libs.skiko) implementation(compose.ui) implementation(compose.foundation) implementation(compose.material) @@ -87,7 +72,6 @@ kotlin { implementation(compose.materialIconsExtended) @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) implementation(compose.components.resources) - implementation(libs.skiko.macos.arm64) implementation(libs.androidx.compose.ui.util) @@ -102,6 +86,7 @@ kotlin { // Kamel for image loading implementation(libs.kamel) + implementation(libs.kamel.decoder.image.bitmap) // Voyager for Navigation implementation(libs.voyager.navigator) @@ -127,7 +112,7 @@ kotlin { android { namespace = "com.multiplatformkickstarter.app.android" - compileSdk = libs.versions.android.compileSdk.get().toInt() + compileSdk = sdkVersion sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") sourceSets["main"].res.srcDirs("src/androidMain/res") @@ -135,8 +120,8 @@ android { defaultConfig { applicationId = "com.multiplatformkickstarter.app.android" - minSdk = libs.versions.android.minSdk.get().toInt() - targetSdk = libs.versions.android.targetSdk.get().toInt() + minSdk = minSdkVersion + targetSdk = sdkVersion versionCode = versionCode() versionName = "$versionMajor.$versionMinor.$versionPatch" } @@ -156,6 +141,7 @@ android { } buildFeatures { compose = true + buildConfig = true } dependencies { implementation(project(":shared")) @@ -173,8 +159,6 @@ android { implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.activity.compose) - implementation(libs.accompanist.systemuicontroller) - implementation(libs.koin.core) implementation(libs.koin.android) @@ -204,7 +188,6 @@ compose.desktop { dependencies { implementation(compose.desktop.currentOs) - implementation(libs.skiko) implementation(compose.ui) implementation(compose.foundation) implementation(compose.material) @@ -213,7 +196,6 @@ compose.desktop { implementation(compose.materialIconsExtended) @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) implementation(compose.components.resources) - implementation(libs.skiko.macos.arm64) implementation(project(":shared")) implementation(libs.androidx.compose.ui.util) @@ -229,6 +211,7 @@ compose.desktop { // Kamel for image loading implementation(libs.kamel) + implementation(libs.kamel.decoder.image.bitmap) // Voyager for Navigation implementation(libs.voyager.navigator) diff --git a/composeApp/src/androidMain/kotlin/com/multiplatformkickstarter/app/android/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/multiplatformkickstarter/app/android/MainActivity.kt index 0f6e08c..660a016 100644 --- a/composeApp/src/androidMain/kotlin/com/multiplatformkickstarter/app/android/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/multiplatformkickstarter/app/android/MainActivity.kt @@ -2,13 +2,14 @@ package com.multiplatformkickstarter.app.android import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.multiplatformkickstarter.app.MainApp import com.multiplatformkickstarter.app.common.model.theme.DarkThemeConfig import com.multiplatformkickstarter.app.ui.theme.MultiplatformKickstarterTheme @@ -20,12 +21,27 @@ class MainActivity : ComponentActivity() { val uiState: MainActivityUiState by mutableStateOf(MainActivityUiState.Loading) setContent { - val systemUiController = rememberSystemUiController() val darkTheme = shouldUseDarkTheme(uiState) - // Update the dark content of the system bars to match the theme - DisposableEffect(systemUiController, darkTheme) { - systemUiController.systemBarsDarkContentEnabled = !darkTheme + DisposableEffect(darkTheme) { + enableEdgeToEdge( + statusBarStyle = if (!darkTheme) { + SystemBarStyle.light( + android.graphics.Color.TRANSPARENT, + android.graphics.Color.TRANSPARENT, + ) + } else { + SystemBarStyle.dark(android.graphics.Color.TRANSPARENT) + }, + navigationBarStyle = if (!darkTheme) { + SystemBarStyle.light( + android.graphics.Color.TRANSPARENT, + android.graphics.Color.TRANSPARENT, + ) + } else { + SystemBarStyle.dark(android.graphics.Color.TRANSPARENT) + }, + ) onDispose {} } diff --git a/composeApp/src/androidMain/kotlin/com/multiplatformkickstarter/app/android/core/di/DependencyContainer.kt b/composeApp/src/androidMain/kotlin/com/multiplatformkickstarter/app/android/core/di/DependencyContainer.kt index 2993d9e..5002413 100644 --- a/composeApp/src/androidMain/kotlin/com/multiplatformkickstarter/app/android/core/di/DependencyContainer.kt +++ b/composeApp/src/androidMain/kotlin/com/multiplatformkickstarter/app/android/core/di/DependencyContainer.kt @@ -2,7 +2,7 @@ package com.multiplatformkickstarter.app.android.core.di import android.content.Context import androidx.annotation.VisibleForTesting -import com.google.firebase.ktx.BuildConfig +import com.multiplatformkickstarter.app.android.BuildConfig import com.multiplatformkickstarter.app.di.appModule import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger diff --git a/composeApp/src/commonMain/kotlin/App.kt b/composeApp/src/commonMain/kotlin/App.kt index f7e5acd..1c9ccb1 100644 --- a/composeApp/src/commonMain/kotlin/App.kt +++ b/composeApp/src/commonMain/kotlin/App.kt @@ -1,10 +1,8 @@ import androidx.compose.runtime.Composable import com.multiplatformkickstarter.app.MainApp -import org.jetbrains.compose.ui.tooling.preview.Preview @Suppress("FunctionName") @Composable -@Preview fun App() { MainApp() } diff --git a/composeApp/src/desktopMain/kotlin/Main.kt b/composeApp/src/desktopMain/kotlin/Main.kt index 73004a3..38cac61 100644 --- a/composeApp/src/desktopMain/kotlin/Main.kt +++ b/composeApp/src/desktopMain/kotlin/Main.kt @@ -1,4 +1,6 @@ import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.application import com.multiplatformkickstarter.app.MainApp import com.multiplatformkickstarter.app.di.commonModule @@ -10,7 +12,11 @@ fun main() = startKoin { modules(commonModule) } - Window(onCloseRequest = ::exitApplication) { + Window( + onCloseRequest = ::exitApplication, + state = WindowState(placement = WindowPlacement.Maximized), + title = "Adoptme", + ) { MultiplatformKickstarterTheme { MainApp() } diff --git a/gradle.properties b/gradle.properties index c10e7fd..ddf24cf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -35,4 +35,14 @@ kotlin.native.cachekind=none # Multiplatform Kickstarter config multiplatformkickstarter.version.major = 2 multiplatformkickstarter.version.minor = 2 -multiplatformkickstarter.version.patch = 0 \ No newline at end of file +multiplatformkickstarter.version.patch = 0 +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.builtInKotlin=false +android.newDsl=false \ No newline at end of file diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..baa28d1 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5cf11ec..c2ba230 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,29 +1,24 @@ [versions] -android-compileSdk = "35" -android-minSdk = "26" -android-targetSdk = "35" -kotlin = "2.1.21" -ktor-client = "2.3.12" -ktor-server = "2.3.12" +kotlin = "2.4.0" +ktor-client = "3.5.0" +ktor-server = "3.5.0" multiplatform-settings = "1.3.0" voyager = "1.1.0-beta03" -koin = "4.1.0" -koin-compose = "4.1.0" +koin = "4.2.1" +koin-compose = "4.2.1" junit = "4.13.2" -androidGradlePlugin = "8.11.0" -composeMultiplatform = "1.8.2" -exposed = "0.61.0" -skiko = "0.8.18" -ktlint = "12.3.0" +androidGradlePlugin = "9.2.1" +composeMultiplatform = "1.11.1" +exposed = "1.3.0" +ktlint = "14.2.0" detekt = "1.23.8" -kotlinx-coroutines = "1.10.2" -androidx-junit = "1.2.1" -espressoCore = "3.6.1" -accompanistSystemuicontroller = "0.36.0" -activityCompose = "1.10.1" -firebase = "33.16.0" +kotlinx-coroutines = "1.11.0" +androidx-junit = "1.3.0" +espressoCore = "3.7.0" +activityCompose = "1.13.0" +firebase = "34.14.0" touchlab = "2.1.0" -navigationCommonKtx = "2.9.1" +navigationCommonKtx = "2.9.8" [libraries] # Common @@ -35,14 +30,13 @@ multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", vers multiplatform-settings-no-arg = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "multiplatform-settings" } multiplatform-settings-serialization = { module = "com.russhwolf:multiplatform-settings-serialization", version.ref = "multiplatform-settings" } multiplatform-settings-test = { module = "com.russhwolf:multiplatform-settings-test", version.ref = "multiplatform-settings" } -skiko = { module = "org.jetbrains.skiko:skiko", version.ref = "skiko" } -skiko-macos-arm64 = { module = "org.jetbrains.skiko:skiko-awt-runtime-macos-arm64", version.ref = "skiko" } ktor-client = { module = "io.ktor:ktor-client-core", version.ref = "ktor-client" } -kamel = "media.kamel:kamel-image:0.7.1" -kermit = "co.touchlab:kermit:2.0.6" +kamel = "media.kamel:kamel-image:1.0.9" +kamel-decoder-image-bitmap = "media.kamel:kamel-decoder-image-bitmap:1.0.9" +kermit = "co.touchlab:kermit:2.1.0" -kotlinx-serialization-json = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1" -kotlinx-datetime = "org.jetbrains.kotlinx:kotlinx-datetime:0.7.0" +kotlinx-serialization-json = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0" +kotlinx-datetime = "org.jetbrains.kotlinx:kotlinx-datetime:0.8.0" kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } @@ -56,13 +50,12 @@ koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" } # Android -compose-bom = "androidx.compose:compose-bom:2025.06.01" +compose-bom = "androidx.compose:compose-bom:2026.05.01" koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } -androidx-core-ktx = "androidx.core:core-ktx:1.16.0" +androidx-core-ktx = "androidx.core:core-ktx:1.19.0" androidx-core-splashscreen = "androidx.core:core-splashscreen:1.0.1" androidx-tracing-ktx = "androidx.tracing:tracing-ktx:1.3.0" -accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanistSystemuicontroller" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase" } @@ -76,22 +69,23 @@ androidx-material3-window-size = { module = "androidx.compose.material3:material androidx-ui = { module = "androidx.compose.ui:ui" } androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } -firebase-analytics-ktx = { module = "com.google.firebase:firebase-analytics-ktx" } +firebase-analytics-ktx = { module = "com.google.firebase:firebase-analytics" } # iOS stately-common = { module = "co.touchlab:stately-common", version.ref = "touchlab" } # Desktop koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin-compose" } -kotlinx-coroutines-swingui = "org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.10.2" +kotlinx-coroutines-swingui = "org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.11.0" ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor-client" } -androidx-compose-ui-util = "org.jetbrains.compose.ui:ui-util:1.8.2" +androidx-compose-ui-util = "org.jetbrains.compose.ui:ui-util:1.11.1" # Backend ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor-server" } ktor-server-auth = { module = "io.ktor:ktor-server-auth", version.ref = "ktor-server" } ktor-server-auth-jwt = { module = "io.ktor:ktor-server-auth-jwt", version.ref = "ktor-server" } -ktor-server-locations = { module = "io.ktor:ktor-server-locations", version.ref = "ktor-server" } +ktor-server-resources = { module = "io.ktor:ktor-server-resources", version.ref = "ktor-server" } +ktor-resources = { module = "io.ktor:ktor-resources", version.ref = "ktor-server" } ktor-server-host-common = { module = "io.ktor:ktor-server-host-common", version.ref = "ktor-server" } ktor-server-status-pages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktor-server" } ktor-server-catching-headers = { module = "io.ktor:ktor-server-caching-headers", version.ref = "ktor-server" } @@ -107,17 +101,17 @@ ktor-server-serialization-gson = { module = "io.ktor:ktor-serialization-gson", v ktor-server-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor-server" } ktor-server-tomcat = { module = "io.ktor:ktor-server-tomcat", version.ref = "ktor-server" } ktor-server-openapi = { module = "io.ktor:ktor-server-openapi", version.ref = "ktor-server" } -ktor-server-tests-jvm = { module = "io.ktor:ktor-server-tests-jvm", version.ref = "ktor-server" } +ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor-server" } exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" } exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed" } exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" } kotlinx-html-jvm = "org.jetbrains.kotlinx:kotlinx-html-jvm:0.12.0" -logback-classic = "ch.qos.logback:logback-classic:1.5.18" -postgresql = "org.postgresql:postgresql:42.7.7" -hikariCP = "com.zaxxer:HikariCP:6.3.0" -swagger-codegen = "io.swagger.codegen.v3:swagger-codegen-generators:1.0.57" +logback-classic = "ch.qos.logback:logback-classic:1.5.34" +postgresql = "org.postgresql:postgresql:42.7.11" +hikariCP = "com.zaxxer:HikariCP:7.0.2" +swagger-codegen = "io.swagger.codegen.v3:swagger-codegen-generators:1.0.62" kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } androidx-navigation-common-ktx = { group = "androidx.navigation", name = "navigation-common-ktx", version.ref = "navigationCommonKtx" } @@ -133,8 +127,7 @@ kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } #Backend -kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version = "kotlin" } -shadow = { id = "com.github.johnrengelman.shadow", version = "8.1.1" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +shadow = { id = "com.gradleup.shadow", version = "9.4.2" } [bundles] - diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 033e24c..d997cfc 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37f853b..5dd3c01 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index fcb6fca..739907d 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -83,7 +85,8 @@ done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -111,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -144,7 +146,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -152,7 +154,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -169,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -201,16 +202,15 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 93e3f59..c4bdd3a 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @@ -43,11 +45,11 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -57,22 +59,21 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 0bcad6e..7ce67e6 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -177,7 +177,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "cd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode\n"; + shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode"; }; /* End PBXShellScriptBuildPhase section */ diff --git a/settings.gradle.kts b/settings.gradle.kts index a8528f9..15a4381 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,6 +5,9 @@ pluginManagement { mavenCentral() } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} dependencyResolutionManagement { repositories { diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index bdeeac1..f6656c4 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -35,7 +35,6 @@ kotlin { jvm() listOf( - iosX64(), iosArm64(), iosSimulatorArm64(), ).forEach { @@ -54,7 +53,6 @@ kotlin { } } commonMain.dependencies { - implementation(libs.skiko) implementation(compose.ui) implementation(compose.foundation) implementation(compose.material) @@ -72,6 +70,7 @@ kotlin { // Kamel for image loading implementation(libs.kamel) + implementation(libs.kamel.decoder.image.bitmap) // Voyager for Navigation implementation(libs.voyager.navigator) @@ -108,6 +107,7 @@ kotlin { } commonTest.dependencies { + implementation(kotlin("test")) implementation(libs.koin.test) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.test) @@ -118,9 +118,9 @@ kotlin { android { namespace = "com.multiplatformkickstarter.app" - compileSdk = libs.versions.android.compileSdk.get().toInt() + compileSdk = 36 defaultConfig { - minSdk = libs.versions.android.minSdk.get().toInt() + minSdk = 26 } compileOptions { sourceCompatibility = JavaVersion.VERSION_21 diff --git a/shared/src/androidMain/kotlin/com/multiplatformkickstarter/app/feature/debugmenu/Debug.android.kt b/shared/src/androidMain/kotlin/com/multiplatformkickstarter/app/feature/debugmenu/Debug.android.kt index a879930..9dd11d1 100644 --- a/shared/src/androidMain/kotlin/com/multiplatformkickstarter/app/feature/debugmenu/Debug.android.kt +++ b/shared/src/androidMain/kotlin/com/multiplatformkickstarter/app/feature/debugmenu/Debug.android.kt @@ -1,7 +1,7 @@ package com.multiplatformkickstarter.app.feature.debugmenu class AndroidDebug : Debug { - override val isDebug: Boolean = false + override val isDebug: Boolean = true } actual fun getDebug(): Debug = AndroidDebug() diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/MainApp.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/MainApp.kt index 179d1c9..ab0c0fc 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/MainApp.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/MainApp.kt @@ -1,14 +1,29 @@ package com.multiplatformkickstarter.app import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.transitions.ScaleTransition import com.multiplatformkickstarter.app.ui.screens.MainScreen +import io.kamel.core.config.Core +import io.kamel.core.config.KamelConfig +import io.kamel.core.config.takeFrom +import io.kamel.image.config.LocalKamelConfig +import io.kamel.image.config.imageBitmapDecoder @Suppress("FunctionName") @Composable fun MainApp() { - Navigator(MainScreen()) { - ScaleTransition(it) + val kamelConfig = remember { + KamelConfig { + takeFrom(KamelConfig.Core) + imageBitmapDecoder() + } + } + CompositionLocalProvider(LocalKamelConfig provides kamelConfig) { + Navigator(MainScreen()) { + ScaleTransition(it) + } } } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/common/model/ChatConversation.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/common/model/ChatConversation.kt new file mode 100644 index 0000000..58037a7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/common/model/ChatConversation.kt @@ -0,0 +1,13 @@ +package com.multiplatformkickstarter.app.common.model + +import kotlinx.serialization.Serializable + +@Serializable +data class ChatConversation( + val id: Int, + val petId: Int, + val petName: String, + val buyerUserId: Int, + val sellerUserId: Int, + val createdAt: Long, +) diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/common/model/ChatMessage.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/common/model/ChatMessage.kt new file mode 100644 index 0000000..e554b70 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/common/model/ChatMessage.kt @@ -0,0 +1,12 @@ +package com.multiplatformkickstarter.app.common.model + +import kotlinx.serialization.Serializable + +@Serializable +data class ChatMessage( + val id: Int, + val conversationId: Int, + val senderUserId: Int, + val content: String, + val sentAt: Long, +) diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/di/CommonModule.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/di/CommonModule.kt index 70f674f..fb085f9 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/di/CommonModule.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/di/CommonModule.kt @@ -3,6 +3,7 @@ package com.multiplatformkickstarter.app.di import androidx.compose.material3.SnackbarHostState import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.tab.TabNavigator +import com.multiplatformkickstarter.app.common.model.ChatConversation import com.multiplatformkickstarter.app.common.model.PetCategory import com.multiplatformkickstarter.app.data.repositories.LastSearchAdsMockRepository import com.multiplatformkickstarter.app.data.repositories.NearMeAdsMockRepository @@ -13,6 +14,12 @@ import com.multiplatformkickstarter.app.data.usecases.GetNearMeAdsUseCase import com.multiplatformkickstarter.app.data.usecases.GetSearchUseCase import com.multiplatformkickstarter.app.feature.debugmenu.repositories.GlobalAppSettingsRepository import com.multiplatformkickstarter.app.feature.debugmenu.viewmodel.DebugMenuViewModel +import com.multiplatformkickstarter.app.feature.favorites.repositories.FavoritesRepository +import com.multiplatformkickstarter.app.feature.favorites.viewmodels.FavoritesViewModel +import com.multiplatformkickstarter.app.feature.inbox.ChatViewModel +import com.multiplatformkickstarter.app.feature.inbox.InboxViewModel +import com.multiplatformkickstarter.app.feature.inbox.repositories.ChatRepository +import com.multiplatformkickstarter.app.ui.screens.viewmodel.PetDetailViewModel import com.multiplatformkickstarter.app.feature.petupload.repositories.PetUploadPublishRepository import com.multiplatformkickstarter.app.feature.petupload.usecases.PetUploadUseCase import com.multiplatformkickstarter.app.feature.petupload.viewmodel.PetUploadViewModel @@ -34,7 +41,7 @@ import org.koin.dsl.module val commonModule = module { factory { (navigator: Navigator) -> - HomeScreenViewModel(navigator, get(), get(), get(), get(), get(), get(), get(), get(), get()) + HomeScreenViewModel(navigator, get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } factory { PetUploadViewModel(get(), get(), get(), get()) @@ -48,7 +55,23 @@ val commonModule = module { } factory { (searchId: Int?, petCategory: PetCategory?, navigator: Navigator) -> - SearchListingViewModel(searchId, petCategory, get(), navigator) + SearchListingViewModel(searchId, petCategory, get(), navigator, get()) + } + + factory { (navigator: Navigator) -> + FavoritesViewModel(navigator, get()) + } + + factory { (petId: Int, navigator: Navigator) -> + PetDetailViewModel(petId, navigator, get(), get(), get()) + } + + factory { + InboxViewModel(get(), get()) + } + + factory { (conversation: ChatConversation, navigator: Navigator) -> + ChatViewModel(conversation, navigator, get(), get()) } factory { @@ -77,6 +100,8 @@ val commonModule = module { singleOf(::PetsFromSearchRepository) factoryOf(::GetLastSearchUseCase) + singleOf(::FavoritesRepository) + single { ChatRepository(get(), get(), get()) } singleOf(::SessionRepository) singleOf(::ProfileRepository) singleOf(::Settings) diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/FavoritesScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/FavoritesScreen.kt new file mode 100644 index 0000000..613b46a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/FavoritesScreen.kt @@ -0,0 +1,194 @@ +package com.multiplatformkickstarter.app.feature.favorites + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.koin.koinScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.multiplatformkickstarter.app.common.model.PetModel +import com.multiplatformkickstarter.app.feature.favorites.viewmodels.FavoritesViewModel +import com.multiplatformkickstarter.app.localization.getCurrentLocalization +import com.multiplatformkickstarter.app.ui.components.EmptyLayout +import com.multiplatformkickstarter.app.ui.icon.MultiplatformKickstarterIcons +import com.multiplatformkickstarter.app.ui.theme.MultiplatformKickstarterTheme +import com.multiplatformkickstarter.app.ui.theme.Typography +import io.kamel.image.KamelImage +import io.kamel.image.asyncPainterResource +import org.koin.core.parameter.ParametersHolder + +@OptIn(ExperimentalMaterial3Api::class) +class FavoritesScreen : Screen { + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val localization = getCurrentLocalization() + val viewModel = koinScreenModel( + parameters = { ParametersHolder(listOf(navigator).toMutableList(), false) } + ) + + DisposableEffect(key) { + viewModel.onStarted(navigator) + onDispose {} + } + + val state by viewModel.state.collectAsState() + + MultiplatformKickstarterTheme { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = localization.favorites, + style = Typography.get().headlineSmall, + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + ), + ) + }, + ) { paddingValues -> + Box(modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.TopCenter) { + if (state.favorites.isEmpty()) { + Box(modifier = Modifier.widthIn(max = 600.dp).fillMaxSize()) { + EmptyLayout( + title = localization.favoritesEmptyTitle, + description = localization.favoritesEmptyDescription, + localization = localization, + ) {} + } + } else { + LazyColumn( + modifier = Modifier + .widthIn(max = 600.dp) + .fillMaxSize(), + ) { + items(state.favorites, key = { it.id }) { pet -> + FavoriteItem( + pet = pet, + onPetClicked = { viewModel.onPetDetailClicked(pet.id) }, + onUnfavoriteClicked = { viewModel.onUnfavoriteClicked(pet.id) }, + ) + } + } + } + } + } + } + } +} + +@Composable +private fun FavoriteItem( + pet: PetModel, + onPetClicked: () -> Unit, + onUnfavoriteClicked: () -> Unit, +) { + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable { onPetClicked() }, + headlineContent = { + Text( + text = pet.title, + style = Typography.get().titleMedium, + ) + }, + supportingContent = { + Column { + Text( + text = pet.description, + style = Typography.get().bodySmall, + maxLines = 2, + ) + Text( + text = pet.breed, + style = Typography.get().labelSmall, + color = MaterialTheme.colorScheme.outline, + ) + } + }, + leadingContent = { + KamelImage( + resource = { asyncPainterResource(data = pet.images[0]) }, + contentDescription = pet.title, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(8.dp)), + onLoading = { + Box( + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primaryContainer), + ) + }, + onFailure = { + Box( + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primaryContainer), + ) { + Icon( + imageVector = MultiplatformKickstarterIcons.Pets, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(12.dp), + ) + } + }, + ) + }, + trailingContent = { + IconButton(onClick = onUnfavoriteClicked) { + Icon( + imageVector = MultiplatformKickstarterIcons.Favorite, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.background, + ), + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) +} diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/repositories/FavoritesRepository.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/repositories/FavoritesRepository.kt new file mode 100644 index 0000000..b12aa09 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/repositories/FavoritesRepository.kt @@ -0,0 +1,30 @@ +package com.multiplatformkickstarter.app.feature.favorites.repositories + +import com.multiplatformkickstarter.app.common.model.PetModel +import com.multiplatformkickstarter.app.data.repositories.LastSearchAdsMockRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FavoritesRepository( + private val lastSearchAdsMockRepository: LastSearchAdsMockRepository, +) { + private val _favoriteIds = MutableStateFlow>(emptySet()) + val favoriteIds: StateFlow> = _favoriteIds.asStateFlow() + + fun isFavorite(petId: Int): Boolean = _favoriteIds.value.contains(petId) + + fun toggleFavorite(petId: Int) { + val current = _favoriteIds.value + _favoriteIds.value = if (current.contains(petId)) { + current - petId + } else { + current + petId + } + } + + fun getFavoritePets(): List { + val allPets = lastSearchAdsMockRepository.getAds().getOrNull() ?: emptyList() + return allPets.filter { _favoriteIds.value.contains(it.id) } + } +} diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/viewmodels/FavoritesViewModel.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/viewmodels/FavoritesViewModel.kt new file mode 100644 index 0000000..35d5c36 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/viewmodels/FavoritesViewModel.kt @@ -0,0 +1,45 @@ +package com.multiplatformkickstarter.app.feature.favorites.viewmodels + +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import cafe.adriel.voyager.navigator.Navigator +import com.multiplatformkickstarter.app.common.model.PetModel +import com.multiplatformkickstarter.app.feature.favorites.repositories.FavoritesRepository +import com.multiplatformkickstarter.app.ui.screens.PetDetailScreen +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class FavoritesViewModel( + private var navigator: Navigator, + private val favoritesRepository: FavoritesRepository, +) : ScreenModel { + + private val _state = MutableStateFlow(FavoritesState(favorites = emptyList())) + val state: StateFlow = _state.asStateFlow() + + init { + favoritesRepository.favoriteIds + .onEach { _state.value = _state.value.copy(favorites = favoritesRepository.getFavoritePets()) } + .launchIn(screenModelScope) + } + + fun onStarted(navigator: Navigator) { + this.navigator = navigator + _state.value = _state.value.copy(favorites = favoritesRepository.getFavoritePets()) + } + + fun onPetDetailClicked(petId: Int) { + navigator.push(PetDetailScreen(petId)) + } + + fun onUnfavoriteClicked(petId: Int) { + favoritesRepository.toggleFavorite(petId) + } +} + +data class FavoritesState( + val favorites: List, +) diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatScreen.kt new file mode 100644 index 0000000..41dfbb9 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatScreen.kt @@ -0,0 +1,239 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.multiplatformkickstarter.app.feature.inbox + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.ui.Alignment +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.koin.koinScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.multiplatformkickstarter.app.common.model.ChatConversation +import com.multiplatformkickstarter.app.common.model.ChatMessage +import com.multiplatformkickstarter.app.localization.getCurrentLocalization +import com.multiplatformkickstarter.app.ui.icon.MultiplatformKickstarterIcons +import com.multiplatformkickstarter.app.ui.theme.MultiplatformKickstarterTheme +import com.multiplatformkickstarter.app.ui.theme.Typography +import org.koin.core.parameter.ParametersHolder + +class ChatScreen(private val conversation: ChatConversation) : Screen { + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val localization = getCurrentLocalization() + val viewModel = koinScreenModel( + parameters = { ParametersHolder(listOf(conversation, navigator).toMutableList(), false) } + ) + val state by viewModel.state.collectAsState() + + var showDeleteDialog by remember { mutableStateOf(false) } + + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text(localization.chatDeleteConversation) }, + confirmButton = { + TextButton(onClick = { + showDeleteDialog = false + viewModel.onDeleteConversation() + }) { + Text(localization.chatDeleteConversation) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text(localization.backLabel) + } + }, + ) + } + + MultiplatformKickstarterTheme { + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = { navigator.pop() }) { + Icon( + imageVector = MultiplatformKickstarterIcons.ArrowBack, + contentDescription = localization.backButton, + ) + } + }, + title = { + Text( + text = "${localization.inboxConversationWith} ${conversation.petName}", + style = Typography.get().titleMedium, + ) + }, + actions = { + IconButton(onClick = { showDeleteDialog = true }) { + Icon( + imageVector = MultiplatformKickstarterIcons.Delete, + contentDescription = localization.chatDeleteConversation, + tint = MaterialTheme.colorScheme.error, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + ), + ) + }, + ) { paddingValues -> + Box(modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.TopCenter) { + Column( + modifier = Modifier + .widthIn(max = 600.dp) + .fillMaxSize() + .imePadding(), + ) { + val listState = rememberLazyListState() + LaunchedEffect(state.messages.size) { + if (state.messages.isNotEmpty()) { + listState.animateScrollToItem(state.messages.size - 1) + } + } + + LazyColumn( + state = listState, + modifier = Modifier.weight(1f).padding(horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(state.messages, key = { it.id }) { message -> + MessageBubble( + message = message, + isCurrentUser = message.senderUserId == state.currentUserId, + ) + } + } + + MessageInputBar( + value = state.messageInput, + onValueChange = { viewModel.onMessageTextChanged(it) }, + onSend = { viewModel.onSendMessage() }, + hint = localization.chatMessageHint, + sendLabel = localization.chatSendMessage, + ) + } + } + } + } + } +} + +@Composable +private fun MessageBubble(message: ChatMessage, isCurrentUser: Boolean) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start, + ) { + Box( + modifier = Modifier + .widthIn(max = 280.dp) + .background( + color = if (isCurrentUser) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + shape = RoundedCornerShape( + topStart = 16.dp, + topEnd = 16.dp, + bottomStart = if (isCurrentUser) 16.dp else 4.dp, + bottomEnd = if (isCurrentUser) 4.dp else 16.dp, + ), + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Text( + text = message.content, + style = Typography.get().bodyMedium, + color = if (isCurrentUser) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } +} + +@Composable +private fun MessageInputBar( + value: String, + onValueChange: (String) -> Unit, + onSend: () -> Unit, + hint: String, + sendLabel: String, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.weight(1f), + placeholder = { Text(hint) }, + shape = RoundedCornerShape(24.dp), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { onSend() }), + maxLines = 4, + ) + IconButton( + onClick = onSend, + enabled = value.isNotBlank(), + ) { + Icon( + imageVector = MultiplatformKickstarterIcons.Create, + contentDescription = sendLabel, + tint = if (value.isNotBlank()) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatViewModel.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatViewModel.kt new file mode 100644 index 0000000..17244fe --- /dev/null +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatViewModel.kt @@ -0,0 +1,73 @@ +package com.multiplatformkickstarter.app.feature.inbox + +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import cafe.adriel.voyager.navigator.Navigator +import com.multiplatformkickstarter.app.common.model.ChatConversation +import com.multiplatformkickstarter.app.common.model.ChatMessage +import com.multiplatformkickstarter.app.data.repositories.SessionRepository +import com.multiplatformkickstarter.app.feature.inbox.repositories.ChatRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class ChatViewModel( + private val conversation: ChatConversation, + private val navigator: Navigator, + private val chatRepository: ChatRepository, + private val sessionRepository: SessionRepository, +) : ScreenModel { + + private val _state = MutableStateFlow( + ChatState( + conversation = conversation, + currentUserId = sessionRepository.getUserId(), + ) + ) + val state: StateFlow = _state.asStateFlow() + + init { + loadMessages() + } + + fun loadMessages() { + screenModelScope.launch { + try { + val messages = chatRepository.getMessages(conversation.id) + _state.value = _state.value.copy(messages = messages, error = null) + } catch (e: Exception) { + _state.value = _state.value.copy(error = e.message) + } + } + } + + fun onMessageTextChanged(text: String) { + _state.value = _state.value.copy(messageInput = text) + } + + fun onSendMessage() { + val content = _state.value.messageInput.trim() + if (content.isEmpty()) return + screenModelScope.launch { + _state.value = _state.value.copy(messageInput = "") + chatRepository.sendMessage(conversation.id, content) + loadMessages() + } + } + + fun onDeleteConversation() { + screenModelScope.launch { + chatRepository.deleteConversation(conversation.id) + navigator.pop() + } + } +} + +data class ChatState( + val conversation: ChatConversation, + val messages: List = emptyList(), + val messageInput: String = "", + val currentUserId: Int = -1, + val error: String? = null, +) diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxScreen.kt new file mode 100644 index 0000000..9c0d4a8 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxScreen.kt @@ -0,0 +1,148 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.multiplatformkickstarter.app.feature.inbox + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.ui.Alignment +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.koin.koinScreenModel +import com.multiplatformkickstarter.app.common.model.ChatConversation +import com.multiplatformkickstarter.app.localization.getCurrentLocalization +import com.multiplatformkickstarter.app.ui.components.EmptyLayout +import com.multiplatformkickstarter.app.ui.icon.MultiplatformKickstarterIcons +import com.multiplatformkickstarter.app.ui.theme.MultiplatformKickstarterTheme +import com.multiplatformkickstarter.app.ui.theme.Typography + +class InboxScreen : Screen { + + @Composable + override fun Content() { + val localization = getCurrentLocalization() + val viewModel = koinScreenModel() + val state by viewModel.state.collectAsState() + + MultiplatformKickstarterTheme { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = localization.inbox, + style = Typography.get().headlineSmall, + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + ), + ) + }, + ) { paddingValues -> + Box(modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.TopCenter) { + if (state.error != null) { + Box(modifier = Modifier.widthIn(max = 600.dp).fillMaxSize()) { + EmptyLayout( + title = localization.inboxEmptyTitle, + description = state.error ?: localization.inboxEmptyDescription, + localization = localization, + ) { viewModel.loadConversations() } + } + } else if (state.conversations.isEmpty()) { + Box(modifier = Modifier.widthIn(max = 600.dp).fillMaxSize()) { + EmptyLayout( + title = localization.inboxEmptyTitle, + description = localization.inboxEmptyDescription, + localization = localization, + ) {} + } + } else { + LazyColumn( + modifier = Modifier + .widthIn(max = 600.dp) + .fillMaxSize(), + ) { + items(state.conversations, key = { it.id }) { conversation -> + ConversationItem( + conversation = conversation, + onClick = { viewModel.onConversationClicked(conversation) }, + onDelete = { viewModel.onDeleteConversation(conversation.id) }, + ) + } + } + } + } + } + } + } +} + +@Composable +private fun ConversationItem( + conversation: ChatConversation, + onClick: () -> Unit, + onDelete: () -> Unit, +) { + val localization = getCurrentLocalization() + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() }, + headlineContent = { + Text( + text = "${localization.inboxConversationWith} ${conversation.petName}", + style = Typography.get().titleMedium, + ) + }, + supportingContent = { + Text( + text = "${localization.chatStartedFor} ${conversation.petName}", + style = Typography.get().bodySmall, + ) + }, + leadingContent = { + Icon( + imageVector = MultiplatformKickstarterIcons.Inbox, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + IconButton(onClick = onDelete) { + Icon( + imageVector = MultiplatformKickstarterIcons.Delete, + contentDescription = localization.chatDeleteConversation, + tint = MaterialTheme.colorScheme.error, + ) + } + }, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.background, + ), + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) +} diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxViewModel.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxViewModel.kt new file mode 100644 index 0000000..46cd7b1 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxViewModel.kt @@ -0,0 +1,53 @@ +package com.multiplatformkickstarter.app.feature.inbox + +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import com.multiplatformkickstarter.app.common.model.ChatConversation +import com.multiplatformkickstarter.app.feature.inbox.repositories.ChatRepository +import com.multiplatformkickstarter.app.platform.RootNavigatorRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class InboxViewModel( + private val chatRepository: ChatRepository, + private val rootNavigatorRepository: RootNavigatorRepository, +) : ScreenModel { + + private val _state = MutableStateFlow(InboxState()) + val state: StateFlow = _state.asStateFlow() + + init { + loadConversations() + } + + fun loadConversations() { + screenModelScope.launch { + _state.value = _state.value.copy(isLoading = true, error = null) + try { + val conversations = chatRepository.getConversations() + _state.value = _state.value.copy(conversations = conversations, isLoading = false) + } catch (e: Exception) { + _state.value = _state.value.copy(isLoading = false, error = e.message) + } + } + } + + fun onConversationClicked(conversation: ChatConversation) { + rootNavigatorRepository.navigator.push(ChatScreen(conversation)) + } + + fun onDeleteConversation(conversationId: Int) { + screenModelScope.launch { + chatRepository.deleteConversation(conversationId) + loadConversations() + } + } +} + +data class InboxState( + val conversations: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, +) diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/repositories/ChatRepository.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/repositories/ChatRepository.kt new file mode 100644 index 0000000..ed0ad82 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/repositories/ChatRepository.kt @@ -0,0 +1,161 @@ +package com.multiplatformkickstarter.app.feature.inbox.repositories + +import com.multiplatformkickstarter.app.common.model.ChatConversation +import com.multiplatformkickstarter.app.common.model.ChatMessage +import com.multiplatformkickstarter.app.data.repositories.SessionRepository +import com.multiplatformkickstarter.app.feature.debugmenu.repositories.GlobalAppSettingsRepository +import com.multiplatformkickstarter.app.platform.ServerEnvironment +import com.multiplatformkickstarter.app.platform.ServiceClient +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.forms.submitForm +import io.ktor.http.HttpStatusCode +import io.ktor.http.Parameters + +private const val CONVERSATIONS_PATH = "v1/chat/conversations" +private const val CONVERSATION_CREATE_PATH = "v1/chat/conversations/create" +private const val CONVERSATION_DELETE_PATH = "v1/chat/conversations/delete" +private const val MESSAGES_PATH = "v1/chat/messages" +private const val MESSAGES_SEND_PATH = "v1/chat/messages/send" + +private val mockConversations = listOf( + ChatConversation(id = 1, petId = 1, petName = "Buddy", buyerUserId = -1, sellerUserId = 2, createdAt = 1717776000000L), + ChatConversation(id = 2, petId = 2, petName = "Luna", buyerUserId = 3, sellerUserId = -1, createdAt = 1717862400000L), +) + +private val mockAutoReplies = listOf( + "That sounds great!", + "Sure, when would you like to meet?", + "He/She is very friendly and loves cuddles.", + "The adoption fee is already included.", + "Yes, all vaccinations are up to date!", + "Feel free to come by this weekend.", + "I'll send you more photos shortly.", + "We'd love for them to find a good home!", + "Let me know if you have any other questions.", + "They get along well with other pets too.", +) + +private val mockMessageStore: MutableMap> = mutableMapOf( + 1 to mutableListOf( + ChatMessage(id = 1, conversationId = 1, senderUserId = 2, content = "Hi! Is Buddy still available?", sentAt = 1717776000000L), + ChatMessage(id = 2, conversationId = 1, senderUserId = -1, content = "Yes, come meet him anytime!", sentAt = 1717779600000L), + ), + 2 to mutableListOf( + ChatMessage(id = 3, conversationId = 2, senderUserId = -1, content = "I'm interested in Luna.", sentAt = 1717862400000L), + ChatMessage(id = 4, conversationId = 2, senderUserId = 3, content = "She's wonderful, very calm.", sentAt = 1717866000000L), + ), +) +private var mockNextId = 100 + +class ChatRepository( + private val service: ServiceClient, + private val sessionRepository: SessionRepository, + private val globalAppSettingsRepository: GlobalAppSettingsRepository, +) { + + private val baseUrl get() = ServerEnvironment.PRODUCTION.url + private val authToken get() = sessionRepository.getToken() + + suspend fun getConversations(): List { + if (globalAppSettingsRepository.isMockedContentEnabled()) return mockConversations + return try { + val response = service.httpClient.get("$baseUrl/$CONVERSATIONS_PATH") { + header("Authorization", "Bearer $authToken") + } + when { + response.status.value in 200..299 -> response.body() + else -> emptyList() + } + } catch (e: Exception) { + emptyList() + } + } + + suspend fun createConversation(petId: Int, petName: String, sellerUserId: Int): ChatConversation? { + if (globalAppSettingsRepository.isMockedContentEnabled()) { + return mockConversations.firstOrNull { it.petId == petId } + ?: ChatConversation(id = petId, petId = petId, petName = petName, buyerUserId = -1, sellerUserId = sellerUserId, createdAt = 0L) + } + return try { + val response = service.httpClient.submitForm( + url = "$baseUrl/$CONVERSATION_CREATE_PATH", + formParameters = Parameters.build { + append("petId", petId.toString()) + append("petName", petName) + append("sellerUserId", sellerUserId.toString()) + } + ) { + header("Authorization", "Bearer $authToken") + } + when { + response.status.value in 200..299 -> response.body() + response.status == HttpStatusCode.Conflict -> getConversations().firstOrNull { it.petId == petId } + else -> null + } + } catch (e: Exception) { + null + } + } + + suspend fun deleteConversation(conversationId: Int): Boolean { + if (globalAppSettingsRepository.isMockedContentEnabled()) return true + return try { + val response = service.httpClient.delete("$baseUrl/$CONVERSATION_DELETE_PATH") { + header("Authorization", "Bearer $authToken") + url { parameters.append("conversationId", conversationId.toString()) } + } + response.status == HttpStatusCode.OK + } catch (e: Exception) { + false + } + } + + suspend fun getMessages(conversationId: Int): List { + if (globalAppSettingsRepository.isMockedContentEnabled()) { + return mockMessageStore.getOrPut(conversationId) { mutableListOf() }.toList() + } + return try { + val response = service.httpClient.get("$baseUrl/$MESSAGES_PATH") { + header("Authorization", "Bearer $authToken") + url { parameters.append("conversationId", conversationId.toString()) } + } + when { + response.status.value in 200..299 -> response.body() + else -> emptyList() + } + } catch (e: Exception) { + emptyList() + } + } + + suspend fun sendMessage(conversationId: Int, content: String): ChatMessage? { + if (globalAppSettingsRepository.isMockedContentEnabled()) { + val store = mockMessageStore.getOrPut(conversationId) { mutableListOf() } + val sent = ChatMessage(id = mockNextId++, conversationId = conversationId, senderUserId = -1, content = content, sentAt = 0L) + store.add(sent) + val reply = ChatMessage(id = mockNextId++, conversationId = conversationId, senderUserId = 2, content = mockAutoReplies[(store.size) % mockAutoReplies.size], sentAt = 0L) + store.add(reply) + return sent + } + return try { + val response = service.httpClient.submitForm( + url = "$baseUrl/$MESSAGES_SEND_PATH", + formParameters = Parameters.build { + append("conversationId", conversationId.toString()) + append("content", content) + } + ) { + header("Authorization", "Bearer $authToken") + } + when { + response.status.value in 200..299 -> response.body() + else -> null + } + } catch (e: Exception) { + null + } + } +} diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/petupload/PetUploadScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/petupload/PetUploadScreen.kt index ae1e6bb..4f8727b 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/petupload/PetUploadScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/petupload/PetUploadScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -77,7 +78,7 @@ import kotlin.enums.EnumEntries class PetUploadScreen : Screen { private lateinit var showingModal: MutableState - private val rootSnackbarHostStateRepository = getRootSnackbarHostState() + private val rootSnackbarHostStateRepository by lazy { getRootSnackbarHostState() } @Composable override fun Content() { @@ -196,8 +197,11 @@ class PetUploadScreen : Screen { ) { val scrollState = rememberScrollState() + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { Column( modifier = Modifier + .widthIn(max = 600.dp) + .fillMaxSize() .padding(it).padding(bottom = 46.dp) .background(MaterialTheme.colorScheme.background) .verticalScroll(scrollState) @@ -220,6 +224,7 @@ class PetUploadScreen : Screen { Box( modifier = Modifier + .widthIn(max = 600.dp) .fillMaxSize() ) { Box( @@ -260,6 +265,7 @@ class PetUploadScreen : Screen { } } } + } } if (showingModal.value) { Picker( diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/profile/ProfileDetailScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/profile/ProfileDetailScreen.kt index 79ada48..0a30a06 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/profile/ProfileDetailScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/profile/ProfileDetailScreen.kt @@ -137,7 +137,7 @@ class ProfileDetailScreen(val userId: Int) : Screen { ) } else { KamelImage( - resource = asyncPainterResource(data = state.image!!), + resource = { asyncPainterResource(data = state.image!!) }, contentDescription = "profile image", contentScale = ContentScale.Crop, modifier = Modifier diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/Localization.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/Localization.kt index 4de3e75..7c3d42c 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/Localization.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/Localization.kt @@ -60,8 +60,20 @@ interface Localization { val start: String val insertAdBannerTrait: String - val proFeatureScreenTitle: String - val proFeatureScreenDescription: String + val favoritesEmptyTitle: String + val favoritesEmptyDescription: String + + val inboxEmptyTitle: String + val inboxEmptyDescription: String + + val inboxConversationWith: String + val chatSendMessage: String + val chatDeleteConversation: String + val chatMessageHint: String + val chatStartedFor: String + + val detailFavorite: String + val detailUnfavorite: String val homeFeaturedViewAll: String val homePetsNearYou: String val homeMyLastSearch: String diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/DeutschLocalization.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/DeutschLocalization.kt index 7d99951..e5162d1 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/DeutschLocalization.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/DeutschLocalization.kt @@ -55,8 +55,20 @@ internal object DeutschLocalization : Localization { override val start = "LASST UNS BEGINNEN!" override val insertAdBannerTrait = "Lassen Sie uns Ihnen helfen, eine Familie für Ihr Haustier zu finden. Wenn Ihre Katze gerade kleine Welpen bekommen hat, können Sie unsere Plattform nutzen, um die richtige Person für die Adoption zu finden. Es ist wirklich einfach!" - override val proFeatureScreenTitle = "Bildschirm im Aufbau" - override val proFeatureScreenDescription = "Dieser Bildschirm ist noch im Aufbau. Diese Vorlage hilft Ihnen dabei, Multiplattform-Apps im Handumdrehen zu erstellen." + override val favoritesEmptyTitle = "Noch keine Favoriten" + override val favoritesEmptyDescription = "Tiere, die Sie als Favoriten markieren, werden hier angezeigt. Tippen Sie auf das Herz bei einem Tier, um es zu speichern." + + override val inboxEmptyTitle = "Noch keine Nachrichten" + override val inboxEmptyDescription = "Wenn Sie Kontakt mit einem Tierbesitzer aufnehmen, erscheinen Ihre Gespräche hier." + + override val inboxConversationWith = "Chat über" + override val chatSendMessage = "Senden" + override val chatDeleteConversation = "Gespräch löschen" + override val chatMessageHint = "Nachricht eingeben..." + override val chatStartedFor = "Gespräch gestartet für" + + override val detailFavorite = "Zu Favoriten hinzufügen" + override val detailUnfavorite = "Aus Favoriten entfernen" override val homeFeaturedViewAll = "Alle anzeigen >" override val homePetsNearYou = "Haustiere in Ihrer Nähe" override val homeMyLastSearch = "Meine letzte Suche" @@ -145,11 +157,11 @@ internal object DeutschLocalization : Localization { override val debugMenuTitle = "Debug-Menü" - override val onboardingPromoTitle1 = "Multiplatform Kickstarter" - override val onboardingPromoTitle2 = "Merkmale" - override val onboardingPromoTitle3 = "PRO Template" + override val onboardingPromoTitle1 = "Willkommen bei Adoptme" + override val onboardingPromoTitle2 = "Finde dein Tier" + override val onboardingPromoTitle3 = "Gib ein Zuhause" - override val onboardingPromoLine1 = "Willkommen zu Multiplatform Kickstarter! Dies ist die Onboarding-Komponente, die alle Funktionen dieser Anwendung vorstellt." - override val onboardingPromoLine2 = "Diese Vorlage enthält alles, was Sie benötigen, um Ihre eigenen Anwendungen im Handumdrehen zu starten. Von der Navigation über die Abhängigkeitsinjektion bis zur Server-API." - override val onboardingPromoLine3 = "Wenn Sie mehr benötigen, wie gespeicherte Suchen, Geolokalisierung, Follower, Bewertungen oder einen Marktplatz, müssen Sie nur unsere Pro-Vorlage herunterladen, die Sie unter multiplatformkickstarter.com finden." + override val onboardingPromoLine1 = "! Die App, die dir hilft, das perfekte Tier zur Adoption zu finden oder dein Tier mit einer liebevollen Familie zu verbinden." + override val onboardingPromoLine2 = "Durchsuche Hunderte von Tieren in deiner Nähe, filtere nach Kategorie, Alter, Größe und mehr. Speichere deine Favoriten und kontaktiere Tierheime und Besitzer direkt." + override val onboardingPromoLine3 = "Jedes Tier verdient ein liebevolles Zuhause. Starte heute deine Adoptionsreise und mache einen Unterschied im Leben eines Tieres." } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/EnglishLocalization.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/EnglishLocalization.kt index eba0804..7933f1d 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/EnglishLocalization.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/EnglishLocalization.kt @@ -55,8 +55,20 @@ internal object EnglishLocalization : Localization { override val start = "LET\'S START!" override val insertAdBannerTrait = "Let us help you find a family for your pet. If your cat have just got little puppies you can use our platform to find the right person to adopt them. It\'s really easy!" - override val proFeatureScreenTitle = "PRO Template Feature" - override val proFeatureScreenDescription = "This screen is available on the **Pro Template**. Find more information about the features at " + override val favoritesEmptyTitle = "No favorites yet" + override val favoritesEmptyDescription = "Pets you favorite will appear here. Tap the heart on any pet to save it." + + override val inboxEmptyTitle = "No messages yet" + override val inboxEmptyDescription = "When you contact a pet owner, your conversations will appear here." + + override val inboxConversationWith = "Chat about" + override val chatSendMessage = "Send" + override val chatDeleteConversation = "Delete conversation" + override val chatMessageHint = "Type a message..." + override val chatStartedFor = "Conversation started for" + + override val detailFavorite = "Add to favorites" + override val detailUnfavorite = "Remove from favorites" override val homeFeaturedViewAll = "See all >" override val homePetsNearYou = "Pets near you" override val homeMyLastSearch = "My last search" @@ -145,11 +157,11 @@ internal object EnglishLocalization : Localization { override val debugMenuTitle = "Debug Menu" - override val onboardingPromoTitle1 = "Multiplatform Kickstarter" - override val onboardingPromoTitle2 = "Features" - override val onboardingPromoTitle3 = "PRO Template" + override val onboardingPromoTitle1 = "Welcome to Adoptme" + override val onboardingPromoTitle2 = "Find your pet" + override val onboardingPromoTitle3 = "Give a home" - override val onboardingPromoLine1 = "! This is the onboarding component that showcases all the features you'll find in this application." - override val onboardingPromoLine2 = "This template has all the needed to kickstart your own applications for Android, iOS and Desktop in no time. From Navigation, Compose components to Dependency Injection and even a Server with Login/SignUp and CRUD API built in." - override val onboardingPromoLine3 = "If you need more features like Saved Searches, Geolocation, Followers, Rating or Marketplace functionality you only need to download our **Pro Template** you'll find at " + override val onboardingPromoLine1 = "! The app that helps you find the perfect pet to adopt or connect your pet with a loving family." + override val onboardingPromoLine2 = "Browse hundreds of pets near you, filter by category, age, size and more. Save your favorites and get in touch with shelters and owners directly." + override val onboardingPromoLine3 = "Every pet deserves a loving home. Start your adoption journey today and make a difference in a pet's life." } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/FrenchLocalization.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/FrenchLocalization.kt index 4002139..c092d5c 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/FrenchLocalization.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/FrenchLocalization.kt @@ -55,8 +55,20 @@ internal object FrenchLocalization : Localization { override val start = "COMMENÇONS !" override val insertAdBannerTrait = "Laissez-nous vous aider à trouver une famille pour votre animal de compagnie. Si votre chat vient d'avoir des petits, vous pouvez utiliser notre plateforme pour trouver la bonne personne pour les adopter. C'est vraiment facile !" - override val proFeatureScreenTitle = "Écran en construction" - override val proFeatureScreenDescription = "Cet écran est encore en construction. Ce modèle vous aidera à créer des applications multiplateformes en un rien de temps." + override val favoritesEmptyTitle = "Pas encore de favoris" + override val favoritesEmptyDescription = "Les animaux que vous marquez en favori apparaîtront ici. Appuyez sur le cœur pour enregistrer un animal." + + override val inboxEmptyTitle = "Pas encore de messages" + override val inboxEmptyDescription = "Lorsque vous contactez un propriétaire d'animal, vos conversations apparaîtront ici." + + override val inboxConversationWith = "Chat sur" + override val chatSendMessage = "Envoyer" + override val chatDeleteConversation = "Supprimer la conversation" + override val chatMessageHint = "Écrire un message..." + override val chatStartedFor = "Conversation démarrée pour" + + override val detailFavorite = "Ajouter aux favoris" + override val detailUnfavorite = "Retirer des favoris" override val homeFeaturedViewAll = "Voir tout >" override val homePetsNearYou = "Animaux près de chez vous" override val homeMyLastSearch = "Ma dernière recherche" @@ -145,11 +157,11 @@ internal object FrenchLocalization : Localization { override val debugMenuTitle = "Menu de Débogage" - override val onboardingPromoTitle1 = "Multiplatform Kickstarter" - override val onboardingPromoTitle2 = "Caractéristiques" - override val onboardingPromoTitle3 = "Pro Template" + override val onboardingPromoTitle1 = "Bienvenue sur Adoptme" + override val onboardingPromoTitle2 = "Trouvez votre animal" + override val onboardingPromoTitle3 = "Offrez un foyer" - override val onboardingPromoLine1 = "Bienvenue à Multiplatform Kickstarter! Il s'agit du composant d'intégration qui présentera toutes les fonctionnalités que vous trouverez dans cette application." - override val onboardingPromoLine2 = "Ce modèle contient tout le nécessaire pour démarrer vos propres applications en un rien de temps. De la navigation à l’injection de dépendances vers l’API serveur." - override val onboardingPromoLine3 = "Si vous avez besoin de plus, comme des recherches enregistrées, la géolocalisation, les abonnés, l'évaluation ou une place de marché, il vous suffit de télécharger notre modèle Pro que vous trouverez sur multiplatformkickstarter.com" + override val onboardingPromoLine1 = "! L’application qui vous aide à trouver l’animal de compagnie idéal à adopter ou à connecter votre animal à une famille aimante." + override val onboardingPromoLine2 = "Parcourez des centaines d’animaux près de chez vous, filtrez par catégorie, âge, taille et plus encore. Enregistrez vos favoris et contactez directement les refuges et propriétaires." + override val onboardingPromoLine3 = "Chaque animal mérite un foyer aimant. Commencez votre parcours d’adoption aujourd’hui et faites la différence dans la vie d’un animal." } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/ItalianLocalization.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/ItalianLocalization.kt index c152eee..982fdec 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/ItalianLocalization.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/ItalianLocalization.kt @@ -55,8 +55,20 @@ internal object ItalianLocalization : Localization { override val start = "INIZIAMO!" override val insertAdBannerTrait = "Facciamo in modo di aiutarti a trovare una famiglia per il tuo animale domestico. Se il tuo gatto ha appena avuto cuccioli, puoi utilizzare la nostra piattaforma per trovare la persona giusta per adottarli. È davvero facile!" - override val proFeatureScreenTitle = "Schermo in costruzione" - override val proFeatureScreenDescription = "Questo schermo è ancora in costruzione. Questo modello ti aiuterà a creare app multi-piattaforma in poco tempo." + override val favoritesEmptyTitle = "Nessun preferito ancora" + override val favoritesEmptyDescription = "Gli animali che aggiungi ai preferiti appariranno qui. Tocca il cuore su qualsiasi animale per salvarlo." + + override val inboxEmptyTitle = "Nessun messaggio ancora" + override val inboxEmptyDescription = "Quando contatti il proprietario di un animale, le conversazioni appariranno qui." + + override val inboxConversationWith = "Chat su" + override val chatSendMessage = "Invia" + override val chatDeleteConversation = "Elimina conversazione" + override val chatMessageHint = "Scrivi un messaggio..." + override val chatStartedFor = "Conversazione avviata per" + + override val detailFavorite = "Aggiungi ai preferiti" + override val detailUnfavorite = "Rimuovi dai preferiti" override val homeFeaturedViewAll = "Vedi tutto >" override val homePetsNearYou = "Animali domestici vicino a te" override val homeMyLastSearch = "La mia ultima ricerca" @@ -145,11 +157,11 @@ internal object ItalianLocalization : Localization { override val debugMenuTitle = "Menu di debug" - override val onboardingPromoTitle1 = "Multiplatform Kickstarter" - override val onboardingPromoTitle2 = "Caratteristiche" - override val onboardingPromoTitle3 = "Pro Template" + override val onboardingPromoTitle1 = "Benvenuto su Adoptme" + override val onboardingPromoTitle2 = "Trova il tuo animale" + override val onboardingPromoTitle3 = "Dai una casa" - override val onboardingPromoLine1 = "Benvenuto a Multiplatform Kickstarter! Questo è il componente di onboarding che mostrerà tutte le funzionalità che troverai in questa applicazione." - override val onboardingPromoLine2 = "Questo modello ha tutto il necessario per avviare le tue applicazioni in pochissimo tempo. Dalla navigazione all'inserimento delle dipendenze all'API del server." - override val onboardingPromoLine3 = "Se hai bisogno di altro, come ricerche salvate, geolocalizzazione, follower, valutazione o un marketplace, devi solo scaricare il nostro modello Pro che troverai su multiplatformkickstarter.com" + override val onboardingPromoLine1 = "! L'app che ti aiuta a trovare l'animale domestico perfetto da adottare o a connettere il tuo animale con una famiglia amorevole." + override val onboardingPromoLine2 = "Sfoglia centinaia di animali vicino a te, filtra per categoria, età, taglia e altro ancora. Salva i tuoi preferiti e contatta direttamente rifugi e proprietari." + override val onboardingPromoLine3 = "Ogni animale merita una casa piena d'amore. Inizia il tuo percorso di adozione oggi e fai la differenza nella vita di un animale." } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/SpanishLocalization.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/SpanishLocalization.kt index ce9ca82..d719c45 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/SpanishLocalization.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/SpanishLocalization.kt @@ -55,8 +55,20 @@ internal object SpanishLocalization : Localization { override val start = "¡EMPECEMOS!" override val insertAdBannerTrait = "Permítenos ayudarte a encontrar una familia para tu mascota. Si tu gato acaba de tener cachorros, puedes usar nuestra plataforma para encontrar a la persona adecuada para adoptarlos. ¡Es muy fácil!" - override val proFeatureScreenTitle = "Pantalla en construcción" - override val proFeatureScreenDescription = "Esta pantalla aún está en construcción. Esta plantilla te ayudará a crear aplicaciones multiplataforma en poco tiempo." + override val favoritesEmptyTitle = "Aún no tienes favoritos" + override val favoritesEmptyDescription = "Las mascotas que marques como favoritas aparecerán aquí. Toca el corazón en cualquier mascota para guardarla." + + override val inboxEmptyTitle = "Aún no tienes mensajes" + override val inboxEmptyDescription = "Cuando contactes con el dueño de una mascota, tus conversaciones aparecerán aquí." + + override val inboxConversationWith = "Chat sobre" + override val chatSendMessage = "Enviar" + override val chatDeleteConversation = "Eliminar conversación" + override val chatMessageHint = "Escribe un mensaje..." + override val chatStartedFor = "Conversación iniciada para" + + override val detailFavorite = "Añadir a favoritos" + override val detailUnfavorite = "Eliminar de favoritos" override val homeFeaturedViewAll = "Ver todo >" override val homePetsNearYou = "Mascotas cerca de ti" override val homeMyLastSearch = "Mi última búsqueda" @@ -145,11 +157,11 @@ internal object SpanishLocalization : Localization { override val debugMenuTitle = "Menú de Depuración" - override val onboardingPromoTitle1 = "Multiplatform Kickstarter" - override val onboardingPromoTitle2 = "Funcionalidades" - override val onboardingPromoTitle3 = "Pro Template" + override val onboardingPromoTitle1 = "Bienvenido a Adoptme" + override val onboardingPromoTitle2 = "Encuentra tu mascota" + override val onboardingPromoTitle3 = "Dale un hogar" - override val onboardingPromoLine1 = "Bienvenido a Multiplatform Kickstarter! Este es el componente de Onboarding que mostrará todas las funciones que encontrará en esta aplicación." - override val onboardingPromoLine2 = "Esta plantilla tiene todo lo necesario para iniciar sus propias aplicaciones en poco tiempo. De la navegación a la inyección de dependencias y a la API del servidor." - override val onboardingPromoLine3 = "Si necesita más, como búsquedas guardadas, geolocalización, seguidores, calificación o un mercado, solo necesita descargar nuestra plantilla Pro que encontrará en multiplatformkickstarter.com" + override val onboardingPromoLine1 = "! La app que te ayuda a encontrar la mascota perfecta para adoptar o a conectar tu mascota con una familia amorosa." + override val onboardingPromoLine2 = "Explora cientos de mascotas cerca de ti, filtra por categoría, edad, tamaño y mucho más. Guarda tus favoritos y contacta directamente con refugios y propietarios." + override val onboardingPromoLine3 = "Cada mascota merece un hogar lleno de amor. Empieza tu proceso de adopción hoy y marca la diferencia en la vida de una mascota." } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/navigation/FavoritesTab.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/navigation/FavoritesTab.kt index 8aac048..e27a4f1 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/navigation/FavoritesTab.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/navigation/FavoritesTab.kt @@ -3,11 +3,13 @@ package com.multiplatformkickstarter.app.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.vector.rememberVectorPainter +import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.tab.Tab import cafe.adriel.voyager.navigator.tab.TabOptions +import cafe.adriel.voyager.transitions.ScaleTransition +import com.multiplatformkickstarter.app.feature.favorites.FavoritesScreen import com.multiplatformkickstarter.app.localization.getCurrentLocalization import com.multiplatformkickstarter.app.ui.icon.MultiplatformKickstarterIcons -import com.multiplatformkickstarter.app.ui.screens.ProTemplateFeature internal object FavoritesTab : Tab { override val options: TabOptions @@ -26,6 +28,8 @@ internal object FavoritesTab : Tab { @Composable override fun Content() { - ProTemplateFeature() + Navigator(FavoritesScreen()) { + ScaleTransition(it) + } } } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/navigation/InboxTab.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/navigation/InboxTab.kt index e8fc77e..b1f25f2 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/navigation/InboxTab.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/navigation/InboxTab.kt @@ -3,11 +3,13 @@ package com.multiplatformkickstarter.app.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.vector.rememberVectorPainter +import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.tab.Tab import cafe.adriel.voyager.navigator.tab.TabOptions +import cafe.adriel.voyager.transitions.ScaleTransition +import com.multiplatformkickstarter.app.feature.inbox.InboxScreen import com.multiplatformkickstarter.app.localization.getCurrentLocalization import com.multiplatformkickstarter.app.ui.icon.MultiplatformKickstarterIcons -import com.multiplatformkickstarter.app.ui.screens.ProTemplateFeature internal object InboxTab : Tab { override val options: TabOptions @@ -19,13 +21,15 @@ internal object InboxTab : Tab { TabOptions( index = 3u, title = getCurrentLocalization().inbox, - icon = icon + icon = icon, ) } } @Composable override fun Content() { - ProTemplateFeature() + Navigator(InboxScreen()) { + ScaleTransition(it) + } } } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardBig.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardBig.kt index fc580c4..c13831c 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardBig.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardBig.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -36,6 +37,8 @@ import io.kamel.image.asyncPainterResource @Composable fun PetCardBig( item: PetModel, + isFavorite: Boolean = false, + onFavoriteToggled: (() -> Unit)? = null, onClick: () -> Unit, ) { val imageHeight = 120.dp @@ -54,42 +57,61 @@ fun PetCardBig( interactionSource = remember { MutableInteractionSource() }, ) { Column { - KamelImage( - resource = asyncPainterResource(data = item.images[0]), - contentDescription = "image", - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(roundedCornerSize)) - .height(imageHeight), - onLoading = { - Box( - modifier = Modifier - .clip(shape = RoundedCornerShape(roundedCornerSize)) - .background(color = MaterialTheme.colorScheme.primaryContainer) - .height(imageHeight) - .fillMaxWidth() - .shimmerLoadingAnimation(isLoadingCompleted = false), - ) - }, - onFailure = { - Box( - modifier = Modifier - .clip(shape = RoundedCornerShape(roundedCornerSize)) - .background(color = MaterialTheme.colorScheme.primaryContainer) - .height(imageHeight) - .fillMaxWidth(), - contentAlignment = Alignment.Center, + Box { + KamelImage( + resource = { asyncPainterResource(data = item.images[0]) }, + contentDescription = "image", + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(roundedCornerSize)) + .height(imageHeight), + onLoading = { + Box( + modifier = Modifier + .clip(shape = RoundedCornerShape(roundedCornerSize)) + .background(color = MaterialTheme.colorScheme.primaryContainer) + .height(imageHeight) + .fillMaxWidth() + .shimmerLoadingAnimation(isLoadingCompleted = false), + ) + }, + onFailure = { + Box( + modifier = Modifier + .clip(shape = RoundedCornerShape(roundedCornerSize)) + .background(color = MaterialTheme.colorScheme.primaryContainer) + .height(imageHeight) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(48.dp), + imageVector = MultiplatformKickstarterIcons.BrokenImage, + contentDescription = "image", + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + ) + if (onFavoriteToggled != null) { + IconButton( + onClick = onFavoriteToggled, + modifier = Modifier.align(Alignment.TopEnd).size(40.dp), ) { Icon( - modifier = Modifier.size(48.dp), - imageVector = MultiplatformKickstarterIcons.BrokenImage, - contentDescription = "image", - tint = MaterialTheme.colorScheme.onPrimaryContainer, + imageVector = if (isFavorite) { + MultiplatformKickstarterIcons.Favorite + } else { + MultiplatformKickstarterIcons.FavoriteOutlined + }, + contentDescription = null, + tint = if (isFavorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(22.dp), ) } } - ) + } Text( text = item.title, style = Typography.get().headlineMedium, diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardSmall.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardSmall.kt index 3d2ae1f..868eccc 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardSmall.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardSmall.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -39,6 +40,8 @@ fun PetCardSmall( item: PetModel, width: Dp, height: Dp, + isFavorite: Boolean = false, + onFavoriteToggled: (() -> Unit)? = null, onClick: () -> Unit, ) { val imageHeight = 70.dp @@ -55,42 +58,61 @@ fun PetCardSmall( interactionSource = remember { MutableInteractionSource() }, ) { Column { - KamelImage( - resource = asyncPainterResource(data = item.images[0]), - contentDescription = "image", - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(roundedCornerSize)) - .height(imageHeight), - onLoading = { - Box( - modifier = Modifier - .clip(shape = RoundedCornerShape(roundedCornerSize)) - .background(color = MaterialTheme.colorScheme.primaryContainer) - .height(imageHeight) - .fillMaxWidth() - .shimmerLoadingAnimation(isLoadingCompleted = false), - ) - }, - onFailure = { - Box( - modifier = Modifier - .clip(shape = RoundedCornerShape(roundedCornerSize)) - .background(color = MaterialTheme.colorScheme.primaryContainer) - .height(imageHeight) - .fillMaxWidth(), - contentAlignment = Alignment.Center, + Box { + KamelImage( + resource = { asyncPainterResource(data = item.images[0]) }, + contentDescription = "image", + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(roundedCornerSize)) + .height(imageHeight), + onLoading = { + Box( + modifier = Modifier + .clip(shape = RoundedCornerShape(roundedCornerSize)) + .background(color = MaterialTheme.colorScheme.primaryContainer) + .height(imageHeight) + .fillMaxWidth() + .shimmerLoadingAnimation(isLoadingCompleted = false), + ) + }, + onFailure = { + Box( + modifier = Modifier + .clip(shape = RoundedCornerShape(roundedCornerSize)) + .background(color = MaterialTheme.colorScheme.primaryContainer) + .height(imageHeight) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(36.dp), + imageVector = MultiplatformKickstarterIcons.BrokenImage, + contentDescription = "image", + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + ) + if (onFavoriteToggled != null) { + IconButton( + onClick = onFavoriteToggled, + modifier = Modifier.align(Alignment.TopEnd).size(32.dp), ) { Icon( - modifier = Modifier.size(36.dp), - imageVector = MultiplatformKickstarterIcons.BrokenImage, - contentDescription = "image", - tint = MaterialTheme.colorScheme.onPrimaryContainer, + imageVector = if (isFavorite) { + MultiplatformKickstarterIcons.Favorite + } else { + MultiplatformKickstarterIcons.FavoriteOutlined + }, + contentDescription = null, + tint = if (isFavorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(18.dp), ) } } - ) + } Text( modifier = Modifier.padding(top = 6.dp), text = item.title, diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/icon/MultiplatformKickstarterIcons.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/icon/MultiplatformKickstarterIcons.kt index 8f21c1d..ec2a4e0 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/icon/MultiplatformKickstarterIcons.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/icon/MultiplatformKickstarterIcons.kt @@ -8,6 +8,7 @@ import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.material.icons.automirrored.rounded.TextSnippet import androidx.compose.material.icons.rounded.ArrowDropDown import androidx.compose.material.icons.rounded.BrokenImage +import androidx.compose.material.icons.rounded.FavoriteBorder import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Create @@ -40,6 +41,7 @@ object MultiplatformKickstarterIcons { val Create = Icons.Rounded.Create val Inbox = Icons.Rounded.Email val Favorite = Icons.Rounded.Favorite + val FavoriteOutlined = Icons.Rounded.FavoriteBorder val Exit = Icons.AutoMirrored.Rounded.ExitToApp val ArrowRight = Icons.AutoMirrored.Rounded.KeyboardArrowRight val Email = Icons.Rounded.Email diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/HomeTabScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/HomeTabScreen.kt index 8a38b4b..767e21e 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/HomeTabScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/HomeTabScreen.kt @@ -14,9 +14,11 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.rememberScrollState @@ -135,27 +137,29 @@ class HomeTabScreen : Screen { ) } ) { paddingValues -> - BoxWithConstraints(Modifier.fillMaxSize().padding(paddingValues), propagateMinConstraints = true) { - val maxWidth = this.maxWidth + Box(Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.TopCenter) { + BoxWithConstraints(Modifier.widthIn(max = 600.dp).fillMaxHeight(), propagateMinConstraints = true) { + val maxWidth = this.maxWidth - Column( - modifier = Modifier.fillMaxWidth() - ) { - PetsSearchBar( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp), - localization = localization - ) Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(scrollState) + modifier = Modifier.fillMaxWidth() ) { - BannerHeader(viewModel, localization) - CategoriesCarousel(viewModel, localization) - LastSearchCarousel(viewModel, localization, state) - NearMeListView(viewModel, state, localization, maxWidth) + PetsSearchBar( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp), + localization = localization + ) + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + ) { + BannerHeader(viewModel, localization) + CategoriesCarousel(viewModel, localization) + LastSearchCarousel(viewModel, localization, state) + NearMeListView(viewModel, state, localization, maxWidth) + } } } } @@ -274,8 +278,15 @@ class HomeTabScreen : Screen { modifier = Modifier.fillMaxWidth() ) { items(state.lastSearchAds.size) { index -> - PetCardSmall(state.lastSearchAds[index], 160.dp, 180.dp) { - viewModel.onPetDetailClicked(state.lastSearchAds[index].id) + val pet = state.lastSearchAds[index] + PetCardSmall( + item = pet, + width = 160.dp, + height = 180.dp, + isFavorite = state.favoriteIds.contains(pet.id), + onFavoriteToggled = { viewModel.onFavoriteToggled(pet.id) }, + ) { + viewModel.onPetDetailClicked(pet.id) } if (index == state.lastSearchAds.size - 1) { Spacer(modifier = Modifier.size(16.dp)) @@ -304,7 +315,13 @@ class HomeTabScreen : Screen { maxItemsInEachRow = 2 ) { state.nearMeAds.map { item -> - PetCardSmall(item = item, itemSize, itemSize) { + PetCardSmall( + item = item, + width = itemSize, + height = itemSize, + isFavorite = state.favoriteIds.contains(item.id), + onFavoriteToggled = { viewModel.onFavoriteToggled(item.id) }, + ) { viewModel.onPetDetailClicked(item.id) } } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/OnboardingScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/OnboardingScreen.kt index d7d20c0..106261b 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/OnboardingScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/OnboardingScreen.kt @@ -2,10 +2,11 @@ package com.multiplatformkickstarter.app.ui.screens import androidx.compose.runtime.Composable import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle + import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow @@ -30,14 +31,7 @@ class OnboardingScreen : Screen { append(localization.onboardingPromoTitle1) } } - val onboardingPromoLine1 = - buildAnnotatedString { - append("Welcome to ") - withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append("Multiplatform Kickstarter") - } - append(localization.onboardingPromoLine1) - } + val onboardingPromoLine1 = localization.onboardingPromoLine1.toAnnotatedString() val onboardingPromoTitle2 = buildAnnotatedString { @@ -53,13 +47,7 @@ class OnboardingScreen : Screen { } } - val onboardingPromoLine3 = - buildAnnotatedString { - append(localization.onboardingPromoLine3) - withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append("multiplatformkickstarter.com") - } - } + val onboardingPromoLine3 = localization.onboardingPromoLine3.toAnnotatedString() val carouselItems: List = listOf( diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/PetDetailScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/PetDetailScreen.kt index 38d9099..7bcc4a7 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/PetDetailScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/PetDetailScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -27,38 +28,59 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.koin.koinScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow -import com.multiplatformkickstarter.app.common.model.PetModel -import com.multiplatformkickstarter.app.data.repositories.LastSearchAdsMockRepository import com.multiplatformkickstarter.app.localization.getCurrentLocalization import com.multiplatformkickstarter.app.platform.shimmerLoadingAnimation import com.multiplatformkickstarter.app.ui.icon.MultiplatformKickstarterIcons +import com.multiplatformkickstarter.app.ui.screens.viewmodel.PetDetailViewModel import com.multiplatformkickstarter.app.ui.theme.Typography import io.kamel.image.KamelImage import io.kamel.image.asyncPainterResource +import org.koin.core.parameter.ParametersHolder class PetDetailScreen(private val petId: Int) : Screen { @Composable override fun Content() { val currentNavigator = LocalNavigator.currentOrThrow - val petModel = LastSearchAdsMockRepository().getAds().getOrNull()?.first { petId == it.id } - petModel?.let { - PetDetailView(it) { - currentNavigator.pop() - } + val viewModel = koinScreenModel( + parameters = { ParametersHolder(listOf(petId, currentNavigator).toMutableList(), false) } + ) + val state by viewModel.state.collectAsState() + + state.pet?.let { pet -> + PetDetailView( + petTitle = pet.title, + petDescription = pet.description, + petImageUrl = pet.images[0], + isFavorite = state.isFavorite, + onFavoriteToggled = { viewModel.onFavoriteToggled() }, + onAdoptClicked = { viewModel.onAdoptClicked() }, + onClose = { currentNavigator.pop() }, + ) } } } @Composable -fun PetDetailView(petModel: PetModel, onClose: () -> Unit) { +fun PetDetailView( + petTitle: String, + petDescription: String, + petImageUrl: String, + isFavorite: Boolean, + onFavoriteToggled: () -> Unit, + onAdoptClicked: () -> Unit, + onClose: () -> Unit, +) { val scrollState = rememberScrollState() val localization = getCurrentLocalization() @@ -74,22 +96,41 @@ fun PetDetailView(petModel: PetModel, onClose: () -> Unit) { } }, title = {}, + actions = { + IconButton(onClick = onFavoriteToggled) { + Icon( + imageVector = if (isFavorite) { + MultiplatformKickstarterIcons.Favorite + } else { + MultiplatformKickstarterIcons.FavoriteOutlined + }, + contentDescription = null, + tint = if (isFavorite) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + }, colors = TopAppBarDefaults.mediumTopAppBarColors( containerColor = MaterialTheme.colorScheme.background ) ) } ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { Column( modifier = Modifier + .widthIn(max = 600.dp) .fillMaxSize() .padding(bottom = 16.dp) .verticalScroll(scrollState) .background(MaterialTheme.colorScheme.background) ) { KamelImage( - resource = asyncPainterResource(data = petModel.images[0]), - contentDescription = petModel.title, + resource = { asyncPainterResource(data = petImageUrl) }, + contentDescription = petTitle, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxWidth().height(250.dp), onLoading = { @@ -111,7 +152,7 @@ fun PetDetailView(petModel: PetModel, onClose: () -> Unit) { ) { Icon( modifier = Modifier.size(64.dp), - imageVector = MultiplatformKickstarterIcons.Person, + imageVector = MultiplatformKickstarterIcons.BrokenImage, contentDescription = "image", tint = MaterialTheme.colorScheme.onPrimaryContainer, ) @@ -122,19 +163,18 @@ fun PetDetailView(petModel: PetModel, onClose: () -> Unit) { modifier = Modifier.padding(16.dp) ) { Text( - text = petModel.title, + text = petTitle, style = Typography.get().headlineLarge, color = Color.DarkGray ) Text( - text = petModel.description, + text = petDescription, style = Typography.get().bodyMedium, color = Color.DarkGray ) } Button( - onClick = { - }, + onClick = onAdoptClicked, colors = ButtonDefaults.buttonColors(), modifier = Modifier .fillMaxWidth() @@ -150,18 +190,6 @@ fun PetDetailView(petModel: PetModel, onClose: () -> Unit) { ) } } + } } } - -/* -@Preview(showBackground = true) -@Composable -fun PlanDetailPreview() { - val featuredAdsRepository = NearMeAdsMockRepository() - val planModel = featuredAdsRepository.getFeaturedAd("0") - - MyProjectNameTheme { - AdDetailView(planModel!!) {} - } -} - */ diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/ProTemplateFeatureScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/ProTemplateFeatureScreen.kt deleted file mode 100644 index 2b59eff..0000000 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/ProTemplateFeatureScreen.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.multiplatformkickstarter.app.ui.screens - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import com.multiplatformkickstarter.app.localization.getCurrentLocalization -import com.multiplatformkickstarter.app.resources.Res -import com.multiplatformkickstarter.app.resources.mklogo -import org.jetbrains.compose.resources.vectorResource - -@Suppress("FunctionName") -@Composable -fun ProTemplateFeature( - modifier: Modifier = Modifier, -) { - val localization = getCurrentLocalization() - Column( - modifier = modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image( - imageVector = vectorResource(Res.drawable.mklogo), - "", - modifier = Modifier - .fillMaxHeight(0.4f) - .fillMaxWidth() - .padding(start = 24.dp, end = 24.dp), - contentScale = ContentScale.FillWidth, - ) - Text( - modifier = Modifier.padding(16.dp), - text = localization.proFeatureScreenTitle, - style = MaterialTheme.typography.headlineMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.primary, - ) - val proFeaturesDescription = - buildAnnotatedString { - append(localization.proFeatureScreenDescription) - withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append("multiplatformkickstarter.com") - } - } - Text( - modifier = Modifier.padding(horizontal = 24.dp), - text = proFeaturesDescription, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.outline, - ) - } -} diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/ProfileTabScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/ProfileTabScreen.kt index cf6933d..d9b459e 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/ProfileTabScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/ProfileTabScreen.kt @@ -7,9 +7,12 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.ui.Alignment import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState @@ -21,10 +24,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.statusBarsPadding import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.koin.koinScreenModel import cafe.adriel.voyager.navigator.LocalNavigator @@ -85,9 +88,11 @@ class ProfileTabScreen : Screen { ) { val scrollState = rememberScrollState() MultiplatformKickstarterTheme { - Column(modifier = Modifier.padding(bottom = 80.dp).verticalScroll(scrollState)) { - UserProfileData(state, viewModel) - OptionsList(localization, viewModel) + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + Column(modifier = Modifier.widthIn(max = 600.dp).statusBarsPadding().padding(bottom = 80.dp).verticalScroll(scrollState)) { + UserProfileData(state, viewModel) + OptionsList(localization, viewModel) + } } } } @@ -116,7 +121,7 @@ class ProfileTabScreen : Screen { ) } else { KamelImage( - resource = asyncPainterResource(data = state.image), + resource = { asyncPainterResource(data = state.image) }, contentDescription = "profile image", contentScale = ContentScale.Crop, modifier = Modifier diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/SearchListingScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/SearchListingScreen.kt index 28b8326..8f89e54 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/SearchListingScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/SearchListingScreen.kt @@ -3,9 +3,12 @@ package com.multiplatformkickstarter.app.ui.screens import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.ui.Alignment import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -147,18 +150,23 @@ class SearchListingScreen( ) } ) { + Box(modifier = Modifier.fillMaxSize().padding(it), contentAlignment = Alignment.TopCenter) { Column( modifier = Modifier + .widthIn(max = 600.dp) .fillMaxSize() - .padding(it) .padding(bottom = 16.dp) .verticalScroll(scrollState) .background(MaterialTheme.colorScheme.background), ) { if (state.pets.isNotEmpty()) { state.pets.map { petModel -> - PetCardBig(item = petModel) { + PetCardBig( + item = petModel, + isFavorite = state.favoriteIds.contains(petModel.id), + onFavoriteToggled = { viewModel.onFavoriteToggled(petModel.id) }, + ) { viewModel.onPetClicked(petModel.id) } } @@ -166,6 +174,7 @@ class SearchListingScreen( EmptyLayout(localization = localization) {} } } + } } } } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/HomeScreenViewModel.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/HomeScreenViewModel.kt index fd3a04f..28df433 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/HomeScreenViewModel.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/HomeScreenViewModel.kt @@ -12,6 +12,7 @@ import com.multiplatformkickstarter.app.data.repositories.ProfileRepository import com.multiplatformkickstarter.app.data.repositories.SessionRepository import com.multiplatformkickstarter.app.data.usecases.GetLastSearchUseCase import com.multiplatformkickstarter.app.data.usecases.GetNearMeAdsUseCase +import com.multiplatformkickstarter.app.feature.favorites.repositories.FavoritesRepository import com.multiplatformkickstarter.app.feature.debugmenu.DebugMenuScreen import com.multiplatformkickstarter.app.feature.debugmenu.getDebug import com.multiplatformkickstarter.app.feature.debugmenu.repositories.GlobalAppSettingsRepository @@ -30,6 +31,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.datetime.TimeZone @@ -50,6 +53,7 @@ class HomeScreenViewModel( private val lastSearchesRepository: LastSearchesRepository, private val globalAppSettingsRepository: GlobalAppSettingsRepository, private val settings: Settings, + private val favoritesRepository: FavoritesRepository, ) : ScreenModel { private val _sideEffects = Channel() val sideEffects: Flow = _sideEffects.receiveAsFlow() @@ -62,11 +66,18 @@ class HomeScreenViewModel( nearMeAds = emptyList(), lastSearchAds = emptyList(), currentLanguage = AvailableLanguages.EN, + favoriteIds = emptySet(), ) ) val state: StateFlow = _state.asStateFlow() private val debugComponent = getDebug() + init { + favoritesRepository.favoriteIds + .onEach { _state.value = _state.value.copy(favoriteIds = it) } + .launchIn(screenModelScope) + } + init { if (!debugComponent.isDebug) { if (!sessionRepository.isLoggedIn()) { @@ -151,6 +162,10 @@ class HomeScreenViewModel( fun onUploadPetClicked() { rootNavigatorRepository.navigator.push(PetUploadScreen()) } + + fun onFavoriteToggled(petId: Int) { + favoritesRepository.toggleFavorite(petId) + } } data class HomeScreenState( @@ -160,6 +175,7 @@ data class HomeScreenState( val nearMeAds: List, val lastSearchAds: List, val currentLanguage: AvailableLanguages, + val favoriteIds: Set, ) sealed class HomeScreenSideEffects diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/PetDetailViewModel.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/PetDetailViewModel.kt new file mode 100644 index 0000000..63c0101 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/PetDetailViewModel.kt @@ -0,0 +1,62 @@ +package com.multiplatformkickstarter.app.ui.screens.viewmodel + +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import cafe.adriel.voyager.navigator.Navigator +import com.multiplatformkickstarter.app.common.model.PetModel +import com.multiplatformkickstarter.app.data.repositories.LastSearchAdsMockRepository +import com.multiplatformkickstarter.app.feature.favorites.repositories.FavoritesRepository +import com.multiplatformkickstarter.app.feature.inbox.ChatScreen +import com.multiplatformkickstarter.app.feature.inbox.repositories.ChatRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +class PetDetailViewModel( + private val petId: Int, + private val navigator: Navigator, + private val lastSearchAdsMockRepository: LastSearchAdsMockRepository, + private val favoritesRepository: FavoritesRepository, + private val chatRepository: ChatRepository, +) : ScreenModel { + + private val _state = MutableStateFlow( + PetDetailState( + pet = lastSearchAdsMockRepository.getFeaturedAd(petId), + isFavorite = favoritesRepository.isFavorite(petId), + ) + ) + val state: StateFlow = _state.asStateFlow() + + init { + favoritesRepository.favoriteIds + .onEach { _state.value = _state.value.copy(isFavorite = favoritesRepository.isFavorite(petId)) } + .launchIn(screenModelScope) + } + + fun onFavoriteToggled() { + favoritesRepository.toggleFavorite(petId) + } + + fun onAdoptClicked() { + val pet = _state.value.pet ?: return + screenModelScope.launch { + val conversation = chatRepository.createConversation( + petId = pet.id, + petName = pet.title, + sellerUserId = pet.userId, + ) + if (conversation != null) { + navigator.push(ChatScreen(conversation)) + } + } + } +} + +data class PetDetailState( + val pet: PetModel?, + val isFavorite: Boolean, +) diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/SearchListingViewModel.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/SearchListingViewModel.kt index 526313c..ff8da55 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/SearchListingViewModel.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/SearchListingViewModel.kt @@ -7,11 +7,14 @@ import co.touchlab.kermit.Logger import com.multiplatformkickstarter.app.common.model.PetCategory import com.multiplatformkickstarter.app.common.model.PetModel import com.multiplatformkickstarter.app.data.usecases.GetSearchUseCase +import com.multiplatformkickstarter.app.feature.favorites.repositories.FavoritesRepository import com.multiplatformkickstarter.app.ui.screens.PetDetailScreen import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch @@ -19,13 +22,20 @@ class SearchListingViewModel( private val searchId: Int?, private val petCategory: PetCategory?, private val searchListingUserCase: GetSearchUseCase, - private val navigator: Navigator + private val navigator: Navigator, + private val favoritesRepository: FavoritesRepository, ) : ScreenModel { - private val _state = MutableStateFlow(SearchListingState(null, null, emptyList())) + private val _state = MutableStateFlow(SearchListingState(null, null, emptyList(), emptySet())) val state = _state.asStateFlow() private val _sideEffects = Channel() val sideEffects: Flow = _sideEffects.receiveAsFlow() + init { + favoritesRepository.favoriteIds + .onEach { _state.value = _state.value.copy(favoriteIds = it) } + .launchIn(screenModelScope) + } + fun onStarted() { screenModelScope.launch { if (searchId != null) { @@ -57,12 +67,17 @@ class SearchListingViewModel( fun onPetClicked(id: Int) { navigator.push(PetDetailScreen(id)) } + + fun onFavoriteToggled(petId: Int) { + favoritesRepository.toggleFavorite(petId) + } } data class SearchListingState( val searchId: Int? = null, val petCategory: PetCategory? = null, val pets: List, + val favoriteIds: Set, ) sealed class SearchListingSideEffects { diff --git a/shared/src/commonTest/kotlin/com/multiplatformkickstarter/app/data/repositories/SessionRepositoryTest.kt b/shared/src/commonTest/kotlin/com/multiplatformkickstarter/app/data/repositories/SessionRepositoryTest.kt new file mode 100644 index 0000000..587533e --- /dev/null +++ b/shared/src/commonTest/kotlin/com/multiplatformkickstarter/app/data/repositories/SessionRepositoryTest.kt @@ -0,0 +1,61 @@ +package com.multiplatformkickstarter.app.data.repositories + +import com.russhwolf.settings.MapSettings +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SessionRepositoryTest { + + private fun repo() = SessionRepository(MapSettings()) + + @Test + fun `new repo is not logged in`() { + assertFalse(repo().isLoggedIn()) + } + + @Test + fun `initSession marks user as logged in`() { + val repo = repo() + repo.initSession(id = 42, email = "a@b.com", session = "sess", token = "tok") + assertTrue(repo.isLoggedIn()) + } + + @Test + fun `initSession stores userId`() { + val repo = repo() + repo.initSession(id = 7, email = "a@b.com", session = "sess", token = "tok") + assertEquals(7, repo.getUserId()) + } + + @Test + fun `initSession stores email`() { + val repo = repo() + repo.initSession(id = 1, email = "user@example.com", session = "s", token = "t") + assertEquals("user@example.com", repo.getEmail()) + } + + @Test + fun `initSession stores token`() { + val repo = repo() + repo.initSession(id = 1, email = "e", session = "s", token = "my-jwt-token") + assertEquals("my-jwt-token", repo.getToken()) + } + + @Test + fun `clear resets login state`() { + val repo = repo() + repo.initSession(id = 1, email = "e", session = "s", token = "t") + repo.clear() + assertFalse(repo.isLoggedIn()) + } + + @Test + fun `clear resets userId to default`() { + val repo = repo() + repo.initSession(id = 5, email = "e", session = "s", token = "t") + repo.clear() + assertEquals(-1, repo.getUserId()) + } +} diff --git a/shared/src/commonTest/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatRepositoryMockTest.kt b/shared/src/commonTest/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatRepositoryMockTest.kt new file mode 100644 index 0000000..3a6dd9d --- /dev/null +++ b/shared/src/commonTest/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatRepositoryMockTest.kt @@ -0,0 +1,82 @@ +package com.multiplatformkickstarter.app.feature.inbox + +import com.multiplatformkickstarter.app.common.model.ChatConversation +import com.multiplatformkickstarter.app.common.model.ChatMessage +import com.multiplatformkickstarter.app.feature.inbox.repositories.FakeMockedChatRepository +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ChatRepositoryMockTest { + + private fun makeRepo() = FakeMockedChatRepository() + + @Test + fun `getConversations returns non-empty list`() = runTest { + assertTrue(makeRepo().getConversations().isNotEmpty()) + } + + @Test + fun `getConversations returns two default conversations`() = runTest { + assertEquals(2, makeRepo().getConversations().size) + } + + @Test + fun `getMessages returns seeded messages for conversation 1`() = runTest { + assertTrue(makeRepo().getMessages(conversationId = 1).isNotEmpty()) + } + + @Test + fun `getMessages returns empty list for unknown conversation`() = runTest { + assertTrue(makeRepo().getMessages(conversationId = 999).isEmpty()) + } + + @Test + fun `sendMessage adds sent message and auto-reply`() = runTest { + val repo = makeRepo() + val before = repo.getMessages(1).size + repo.sendMessage(conversationId = 1, content = "Hi!") + assertEquals(before + 2, repo.getMessages(1).size) + } + + @Test + fun `sendMessage returns message with correct content`() = runTest { + val sent = makeRepo().sendMessage(conversationId = 1, content = "Test message") + assertNotNull(sent) + assertEquals("Test message", sent.content) + } + + @Test + fun `auto-reply comes from a different sender than sent message`() = runTest { + val repo = makeRepo() + val beforeSize = repo.getMessages(1).size + repo.sendMessage(conversationId = 1, content = "Hello") + val messages = repo.getMessages(1) + val sent = messages[beforeSize] + val reply = messages[beforeSize + 1] + assertTrue(sent.senderUserId != reply.senderUserId) + } + + @Test + fun `deleteConversation returns true`() = runTest { + assertTrue(makeRepo().deleteConversation(1)) + } + + @Test + fun `createConversation returns conversation with correct petId`() = runTest { + val conv = makeRepo().createConversation(petId = 1, petName = "Buddy", sellerUserId = 2) + assertNotNull(conv) + assertEquals(1, conv.petId) + } + + @Test + fun `multiple sends accumulate messages`() = runTest { + val repo = makeRepo() + val before = repo.getMessages(1).size + repo.sendMessage(1, "first") + repo.sendMessage(1, "second") + assertEquals(before + 4, repo.getMessages(1).size) + } +} diff --git a/shared/src/commonTest/kotlin/com/multiplatformkickstarter/app/feature/inbox/repositories/FakeMockedChatRepository.kt b/shared/src/commonTest/kotlin/com/multiplatformkickstarter/app/feature/inbox/repositories/FakeMockedChatRepository.kt new file mode 100644 index 0000000..8b83779 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/multiplatformkickstarter/app/feature/inbox/repositories/FakeMockedChatRepository.kt @@ -0,0 +1,57 @@ +package com.multiplatformkickstarter.app.feature.inbox.repositories + +import com.multiplatformkickstarter.app.common.model.ChatConversation +import com.multiplatformkickstarter.app.common.model.ChatMessage + +private val autoReplies = listOf( + "That sounds great!", + "Sure, when would you like to meet?", + "He/She is very friendly and loves cuddles.", + "The adoption fee is already included.", + "Yes, all vaccinations are up to date!", + "Feel free to come by this weekend.", + "I'll send you more photos shortly.", + "We'd love for them to find a good home!", + "Let me know if you have any other questions.", + "They get along well with other pets too.", +) + +class FakeMockedChatRepository { + private val conversations = mutableListOf( + ChatConversation(id = 1, petId = 1, petName = "Buddy", buyerUserId = -1, sellerUserId = 2, createdAt = 0L), + ChatConversation(id = 2, petId = 2, petName = "Luna", buyerUserId = 3, sellerUserId = -1, createdAt = 0L), + ) + private val messageStore: MutableMap> = mutableMapOf( + 1 to mutableListOf( + ChatMessage(id = 1, conversationId = 1, senderUserId = 2, content = "Is Buddy available?", sentAt = 0L), + ChatMessage(id = 2, conversationId = 1, senderUserId = -1, content = "Yes, come meet him!", sentAt = 0L), + ), + 2 to mutableListOf( + ChatMessage(id = 3, conversationId = 2, senderUserId = -1, content = "Interested in Luna.", sentAt = 0L), + ChatMessage(id = 4, conversationId = 2, senderUserId = 3, content = "She is very calm.", sentAt = 0L), + ), + ) + private var nextId = 100 + + suspend fun getConversations(): List = conversations.toList() + + suspend fun createConversation(petId: Int, petName: String, sellerUserId: Int): ChatConversation? { + return conversations.firstOrNull { it.petId == petId } + ?: ChatConversation(id = nextId++, petId = petId, petName = petName, buyerUserId = -1, sellerUserId = sellerUserId, createdAt = 0L) + .also { conversations.add(it) } + } + + suspend fun deleteConversation(conversationId: Int): Boolean = true + + suspend fun getMessages(conversationId: Int): List = + messageStore.getOrElse(conversationId) { mutableListOf() }.toList() + + suspend fun sendMessage(conversationId: Int, content: String): ChatMessage { + val store = messageStore.getOrPut(conversationId) { mutableListOf() } + val sent = ChatMessage(id = nextId++, conversationId = conversationId, senderUserId = -1, content = content, sentAt = 0L) + store.add(sent) + val reply = ChatMessage(id = nextId++, conversationId = conversationId, senderUserId = 2, content = autoReplies[store.size % autoReplies.size], sentAt = 0L) + store.add(reply) + return sent + } +}