diff --git a/3party/expat b/3party/expat index 9cbdb916de..74d91febb0 160000 --- a/3party/expat +++ b/3party/expat @@ -1 +1 @@ -Subproject commit 9cbdb916de2a7bd1aa649e55efc38d2426680359 +Subproject commit 74d91febb0995b7c6706dfd4eed2d39fb1694421 diff --git a/3party/icu/icu b/3party/icu/icu index 680f521746..6af11aa609 160000 --- a/3party/icu/icu +++ b/3party/icu/icu @@ -1 +1 @@ -Subproject commit 680f521746a3bd6a86f25f25ee50a62d88b489cf +Subproject commit 6af11aa609f3fdf735cab5fdc051cd840960186b diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 7262ce8c17..0000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1 +0,0 @@ -See [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) \ No newline at end of file diff --git a/CONTRIBUTORS b/CONTRIBUTORS deleted file mode 100644 index e2a8243541..0000000000 --- a/CONTRIBUTORS +++ /dev/null @@ -1,102 +0,0 @@ -This file contains a list of people who have contributed to the -public version of MAPS.ME and Organic Maps. - -Original MAPS.ME (MapsWithMe) design and implementation: - Yury Melnichek - Alexander Borsuk - Viktor Govako - Siarhei Rachytski - --------------------------------------------------------------------------------- -Organic Maps contributions: --------------------------------------------------------------------------------- - Alexander Borsuk - Roman Tsisyk - Viktor Govako - Caspar Nuël - Konstantin Pastbin - Nishant Bhandari - Sebastiao Sousa - Harry Bond - -Organic Maps translations: - Karina Kordon - Konstantin Pastbin - Metehan Özyürek - Joan Montané - Luna Rose - - --------------------------------------------------------------------------------- -MAPS.ME contributions (before Organic Maps was forked in 2020-2021): --------------------------------------------------------------------------------- -Code contributions: - Dmitry Yunitski - Lev Dragunov - Sergey Yershov - Vladimir Byko-Ianko - Yuri Gorshenin - Maxim Pimenov - Roman Kuznetsov - Konstantin Shalnev - Ilja Zverev - Vlad Mihaylenko - Ilya Grechuhin - Alexander Marchuk - Sergey Magidovich - Yury Rakhuba - Kirill Zhdanovich - Dmitry Kunin - Denis Koronchik - Darafei Praliaskouski - Igor Khmurets - Timur Bernikowich - Roman Sorokin - Alexander Gusak - Alexei Vitenko - Artem Polkovnikov - Alex Gontmakher - Dima Korolev - Max Grigorev - -Porting to Tizen platform: - Sergey Pisarchik - -Testing and automation: - Timofey Danshin - -Design and styles: - Igor Tomko - Urbica http://urbica.co - Vasiliy Cherkasov - Maksim Okala-Kulak - -Strings and translations: - Nataliya Yakavenka - Daria Terentieva - Satoshi Iida - Mathias Wittwer - R3gi - Hidde Wieringa - Vasily Korotkevich - Mark N. Kuramochi - Lidia Vasiljeva - -Project management: - Alexander Matveenko - -Marketing & support: - Sergey Ermilov - Anna Mozheiko - Alexander Bobko - Marat Mukhamedov - Alena Miranovich - Polina Kovalchuk - Ekaterina Sazonova - Alesya Serada - -Special thanks to: - Yauheniya Melnichek - Yuri Gurski - Dmitry Matveev - Anna Yakovleva diff --git a/NOTICE b/NOTICE index ebb7cc966b..98f33f17d6 100644 --- a/NOTICE +++ b/NOTICE @@ -1,5 +1,4 @@ -Copyright 2020 My.com B.V. (Mail.Ru Group) -Copyright 2021 Organic Maps Contributors +Copyright 2024 rebus.tj (Rebus Group) Not really Rebus will be updated Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -13,10 +12,10 @@ 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. +The app is based on Organic maps. Most libraries in the following directories made by other people and organizations and licensed in different ways: * `3party` * `tools` Please refer to their LICENCE, COPYING or NOTICE files for terms of use. Some icons files may be copyrighted by (C) 2020 My.com B.V. (Mail.Ru Group) -See also `data/copyright.html` file for a full list of copyright notices. diff --git a/README.md b/README.md index a685f7db65..a611eb769c 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,45 @@ -# Organic Maps +# Tourism Map Tajikistan - +This app is for Tajikistan tourists. It's based on open source app Organic map. -[Organic Maps](https://organicmaps.app) is a free Android & iOS offline maps app for travellers, tourists, drivers, hikers, and cyclists. -It uses crowd-sourced [OpenStreetMap](https://www.openstreetmap.org) data and is developed with love by the creators of **MapsWithMe** (later renamed to **Maps.Me**) and by our community. -No ads, no tracking, no data collection, no crapware. Your [donations](https://organicmaps.app/donate/) and positive reviews motivate and inspire us, thanks ❤️! +## Please Read all of this before continuing -

- - Download on the App Store - - - Get it on Google Play - - - Explore it on AppGallery - - - Get it on F-Droid - -

+Don't forget make changes to this file, when you make significant changes to navigation. +version of Android Studio I used: android-studio-2024.1.1.13-mac_arm.dmg +version of XCode I used: 16.1 -

- - - - -

+## Navigation on Android -## Features +The first activity app starts is SplashActivity.java. There it navigates to MainActivity +where all of the data about Tajikistan places are located, +it has its own navigation system on Jetpack compose. +There we navigateToAuthIfNotAuthed() and then navigateToMapToDownloadIfNotPresent(). +In AuthActivity we move Tajikistan map to app's internal storage and if it is successful, +it won't navigate to map to download. +When map download is finished it will go to MainActivity. +When you sign in or up, it will navigate to MainActivity. + +## Navigation on iOS + +The first screen to be shown is Map screen (see MapsAppDelegate.mm). I (Emin) couldn't change it. +There's such logic in MapsAppDelegate.mm: + +``` +if (Tajikistan is loaded) { + if (token is nil) navigate to Auth (note: token is cleared when user signs out) + else navigate to TourismMain (Home) +} +``` + +In Auth when user signs in or up, it navigates to TourismMain +In TourismMain goes to auth if not authorized + +## Features of their map Organic Maps is the ultimate companion app for travellers, tourists, hikers, and cyclists: -- Detailed offline maps with places that don't exist on other maps, thanks to [OpenStreetMap](https://openstreetmap.org) +- Detailed offline maps with places that don't exist on other maps, thanks + to [OpenStreetMap](https://openstreetmap.org) - Cycling routes, hiking trails, and walking paths - Contour lines, elevation profiles, peaks, and slopes - Turn-by-turn walking, cycling, and car navigation with voice guidance @@ -42,145 +49,9 @@ Organic Maps is the ultimate companion app for travellers, tourists, hikers, and - Countries and regions don't take a lot of space - Free and open-source -## Why Organic? - -Organic Maps is pure and organic, made with love: - -- Respects your privacy -- Saves your battery -- No unexpected mobile data charges - -Organic Maps is free from trackers and other bad stuff: - -- No ads -- No tracking -- No data collection -- No phoning home -- No annoying registration -- No mandatory tutorials -- No noisy email spam -- No push notifications -- No crapware -- ~~No pesticides~~ Purely organic! - -The Android application is verified by the Exodus Privacy Project: - - - - -The iOS application is verified by TrackerControl for iOS: - - - - -
- -Organic Maps doesn't request excessive permissions to spy on you: - -

- - -

- -At Organic Maps, we believe that privacy is a fundamental human right: - -- Organic Maps is an indie community-driven open-source project -- We protect your privacy from Big Tech's prying eyes -- Stay safe no matter where you are - -Reject surveillance - embrace your freedom. - -[**Give Organic Maps a try!**](#install) - -## Who is paying for the development? - -The app is free for everyone, so we rely on donations. Please donate at [organicmaps.app/donate](https://organicmaps.app/donate) to support us! - -Beloved institutional sponsors below have provided targeted grants to cover some infrastructure costs and fund development of new selected features: - - - - - - - - - - - - - - - - - - -
- The NLnet Foundation - - The Search & Fonts improvement project has been funded through NGI0 Entrust Fund. NGI0 Entrust Fund is established by the NLnet Foundation with financial support from the European Commission's Next Generation Internet programme, under the aegis of DG Communications Networks, Content and Technology under grant agreement No 101069594. -
- Google Summer of Code - - Google backed 5 student's projects in the Google Summer of Code program during 2022 and 2023 programs. Noteworthy projects included Android Auto and Wikipedia Dump Extractor. -
- Mythic Beasts - - Mythic Beasts ISP provides us two virtual servers with 400 TB/month of free bandwidth to host and serve maps downloads and updates. -
- FUTO - - FUTO has awarded $1000 micro-grant to Organic Maps in February 2023. -
- -The majority of all expenses have been funded by founders of the project since its inception. The project is far from achieving any sort of financial sustainability. The current level of voluntary donations falls significantly short of covering efforts needed to sustain the app. Any new developments of features are beyond the scope of possibility due to the absence of the necessary financial resources. - -Please consider [donating](https://organicmaps.app/donate) if you want to see this open-source project thriving, not dying. There are [other ways how to support the project](#contributing). No coding skills required. - ## Copyrights Licensed under the Apache License, Version 2.0. See -[LICENSE](https://github.com/organicmaps/organicmaps/blob/master/LICENSE), -[NOTICE](https://github.com/organicmaps/organicmaps/blob/master/NOTICE) -and [data/copyright.html](http://htmlpreview.github.io/?https://github.com/organicmaps/organicmaps/blob/master/data/copyright.html) +[LICENSE](https://github.com/Ohpleaseman/tourism/blob/master/LICENSE), +[NOTICE](https://github.com/Ohpleaseman/tourism/blob/master/NOTICE) for more information. - -## Governance - -See [docs/GOVERNANCE.md](docs/GOVERNANCE.md). - - - -## Contributing - -If you want to build the project, check [docs/INSTALL.md](docs/INSTALL.md). If you want to help the project, -see [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md). You can [help in many ways](https://organicmaps.app/support-us/), the ability to code is not necessary. - -## Beta - -Please join our beta program, suggest your features, and report bugs: - -- [iOS Beta (TestFlight)](https://testflight.apple.com/join/lrKCl08I) -- [Android Beta (Firebase)](https://appdistribution.firebase.dev/i/2f0fee463107b137) - -## Feedback - -- **Rate us on the [App Store](https://apps.apple.com/app/organic-maps/id1567437057) -and [Google Play](https://play.google.com/store/apps/details?id=app.organicmaps)**. -- **Star us on GitHub**. -- Report bugs or issues to [the issue tracker](https://github.com/organicmaps/organicmaps/issues). -- [Discuss](https://github.com/organicmaps/organicmaps/discussions/categories/ideas) ideas or propose feature requests. -- Subscribe to our [Telegram Channel](https://t.me/OrganicMapsApp) or to the [[matrix] space](https://matrix.to/#/#organicmaps:matrix.org) for updates. -- Join our [Telegram Group](https://t.me/OrganicMaps) to discuss with other users. - - Присоединяйтесь к нашей [русскоязычной группе в Telegram](https://t.me/OrganicMapsRu) для обратной связи и помощи. - - Diğer kullanıcılarla tartışmak için [Telegram Grubumuza](https://t.me/OrganicMapsTR) katılın. - - Rejoignez notre groupe [Telegram](https://t.me/OrganicMapsFR) pour obtenir de l'aide. -- Contact us by [email](mailto:hello@organicmaps.app). -- Follow our updates in -[Mastodon](https://fosstodon.org/@organicmaps), -[Facebook](https://facebook.com/OrganicMaps), -[Twitter](https://twitter.com/OrganicMapsApp), -[Instagram](https://instagram.com/organicmaps.app/). - - Güncellemelerimizi [Instagram](https://instagram.com/organicmapstr/) üzerinden takip edin. - -The Organic Maps community abides by the CNCF [code of conduct](https://github.com/organicmaps/organicmaps/blob/master/docs/CODE_OF_CONDUCT.md). diff --git a/android/app/.gitignore b/android/app/.gitignore index 42b0b5086e..8d5c24fc9a 100644 --- a/android/app/.gitignore +++ b/android/app/.gitignore @@ -31,3 +31,6 @@ # ignore autogenerated metadata (see prepareGoogleReleaseListing in build.gradle) /src/google/play/listings + +# ignore google releases +/google/release diff --git a/android/app/build.gradle b/android/app/build.gradle index 3b7dc6cff7..a4856b1310 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -12,14 +12,22 @@ buildscript { def taskName = getGradle().getStartParameter().getTaskRequests().toString().toLowerCase() def isFdroid = taskName.contains('fdroid') def isBeta = taskName.contains('beta') + def isRelease = taskName.contains('release') // Firebase Crashlytics compile-time feature flag: -Pfirebase=true|false def googleFirebaseServicesFlag = findProperty('firebase') // Enable Firebase for all beta flavors except fdroid only if google-services.json exists. def googleFirebaseServicesDefault = isBeta && !isFdroid && file("$projectDir/google-services.json").exists() - ext.googleFirebaseServicesEnabled = googleFirebaseServicesFlag != null ? - googleFirebaseServicesFlag == '' || googleFirebaseServicesFlag.toBoolean() : - googleFirebaseServicesDefault + + /* + We want to use Firebase Crashlytics for Tourism. We can't use it for debug, + because debug version has its own package_name, release doesn't, so I (Emin) changed + the condition for ext.googleFirebaseServicesEnabled + */ +// ext.googleFirebaseServicesEnabled = googleFirebaseServicesFlag != null ? +// googleFirebaseServicesFlag == '' || googleFirebaseServicesFlag.toBoolean() : +// googleFirebaseServicesDefault + ext.googleFirebaseServicesEnabled = isRelease dependencies { classpath 'com.android.tools.build:gradle:8.4.1' @@ -53,6 +61,11 @@ if (googleFirebaseServicesEnabled) { } apply plugin: 'com.github.triplet.play' apply plugin: 'ru.cian.huawei-publish-gradle-plugin' +apply plugin: 'org.jetbrains.kotlin.android' +apply plugin: 'kotlin-parcelize' +apply plugin: 'org.jetbrains.kotlin.plugin.serialization' +apply plugin: 'kotlin-kapt' +apply plugin: 'com.google.dagger.hilt.android' def run(cmd) { def stdout = new ByteArrayOutputStream() @@ -81,8 +94,8 @@ def getCommitMessage() { def osName = System.properties['os.name'].toLowerCase() -project.ext.appId = 'app.organicmaps' -project.ext.appName = 'Organic Maps' +project.ext.appId = 'tj.tourism.rebus' +project.ext.appName = 'Tourism Map Tajikistan' java { toolchain { @@ -96,6 +109,7 @@ android { buildFeatures { dataBinding = true buildConfig = true + compose true } // All properties are read from gradle.properties file compileSdk propCompileSdkVersion.toInteger() @@ -105,10 +119,10 @@ android { defaultConfig { // Default package name is taken from the manifest and should be app.organicmaps def ver = getVersion() - versionCode = ver.V1 - versionName = ver.V2 - println('Version: ' + versionName) - println('VersionCode: ' + versionCode) + versionCode = 4 + versionName = "1.0.1" +// println('Version: ' + versionName) +// println('VersionCode: ' + versionCode) minSdk propMinSdkVersion.toInteger() targetSdk propTargetSdkVersion.toInteger() applicationId project.ext.appId @@ -166,6 +180,9 @@ android { } setProperty('archivesBaseName', appName.replaceAll('\\s','') + '-' + defaultConfig.versionCode) + vectorDrawables { + useSupportLibrary true + } } flavorDimensions += 'default' @@ -344,11 +361,81 @@ android { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } + kotlinOptions { + jvmTarget = '17' + } + composeOptions { + kotlinCompilerExtensionVersion '1.5.14' + } + packaging { + resources { + excludes += '/META-INF/{AL2.0,LGPL2.1}' + } + } } dependencies { + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.7' + implementation 'androidx.activity:activity-compose:1.9.3' + implementation platform('androidx.compose:compose-bom:2024.12.01') + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-graphics' + implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.compose.material3:material3' + androidTestImplementation platform('androidx.compose:compose-bom:2024.12.01') + androidTestImplementation 'androidx.compose.ui:ui-test-junit4' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' + debugImplementation 'androidx.compose.ui:ui-tooling' + debugImplementation 'androidx.compose.ui:ui-test-manifest' + + // hilt + def hilt = '2.51.1' + implementation "com.google.dagger:hilt-android:$hilt" + kapt "com.google.dagger:hilt-compiler:$hilt" + kapt "androidx.hilt:hilt-compiler:1.2.0" + implementation 'androidx.hilt:hilt-navigation-compose:1.2.0' + + // navigation + implementation 'androidx.navigation:navigation-compose:2.8.5' + + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3" + // countries + implementation 'com.hbb20:ccp:2.7.3' + // webview + implementation "androidx.webkit:webkit:1.11.0" + // compress + implementation 'id.zelory:compressor:3.0.1' + // restart app + implementation 'com.jakewharton:process-phoenix:3.0.0' + + //Background processing + def coroutines = '1.8.1' + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines" + // Coroutine Lifecycle Scopes + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7' + + // region Network + // Retrofit + def retrofit = '2.11.0' + implementation "com.squareup.retrofit2:retrofit:$retrofit" + implementation "com.squareup.retrofit2:converter-gson:$retrofit" + def okhttp = '5.0.0-alpha.14' + implementation "com.squareup.okhttp3:okhttp:$okhttp" + implementation "com.squareup.okhttp3:logging-interceptor:$okhttp" + implementation 'com.google.code.gson:gson:2.11.0' + def coil_version = '2.7.0' + implementation("io.coil-kt:coil-compose:$coil_version") + implementation("io.coil-kt:coil-svg:$coil_version") + // endregion + + // Room + def room = '2.6.1' + implementation "androidx.room:room-ktx:$room" + implementation "androidx.room:room-runtime:$room" + kapt "androidx.room:room-compiler:$room" + // Google Play Location Services // // Please add symlinks to google/java/app/organicmaps/location for each new gms-enabled flavor below: @@ -402,6 +489,10 @@ dependencies { testImplementation 'org.mockito:mockito-inline:5.2.0' } +kapt { + correctErrorTypes true +} + tasks.withType(JavaCompile) { options.compilerArgs << '-Xlint:unchecked' << '-Xlint:deprecation' } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 19d264243d..30dba117e1 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -28,3 +28,25 @@ # R8 crypts the source line numbers in all log messages. # https://github.com/organicmaps/organicmaps/issues/6559#issuecomment-1812039926 -dontoptimize + +# For some unknown reason we couldn't find out, requests are not working properly +# when the app is shrinked and/or minified, so we keep all of these things out from R8 effects. +-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation +-keep,allowobfuscation,allowshrinking interface retrofit2.Call +-keep,allowobfuscation,allowshrinking class retrofit2.Response + +-if interface * { @retrofit2.http.* public *** *(...); } +-keep,allowoptimization,allowshrinking,allowobfuscation class <3> + +-keep class app.tourism.data.remote.** { *; } + +-keep public class app.tourism.data.dto.** { + public void set*(***); + public *** get*(); + public protected private *; +} +-keep public class app.tourism.domain.models.** { + public void set*(***); + public *** get*(); + public protected private *; +} diff --git a/android/app/src/debug/ic_launcher-playstore.png b/android/app/src/debug/ic_launcher-playstore.png new file mode 100644 index 0000000000..d45d5fc2e9 Binary files /dev/null and b/android/app/src/debug/ic_launcher-playstore.png differ diff --git a/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..036d09bc5f --- /dev/null +++ b/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..036d09bc5f --- /dev/null +++ b/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/debug/res/mipmap-hdpi/ic_launcher.png b/android/app/src/debug/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 25cea1a699..0000000000 Binary files a/android/app/src/debug/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/debug/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/debug/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000..f4cb39b3c4 Binary files /dev/null and b/android/app/src/debug/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/android/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.png deleted file mode 100644 index 7c95ae3b83..0000000000 Binary files a/android/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.webp b/android/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000000..1a03d09455 Binary files /dev/null and b/android/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/debug/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/debug/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..4cf5b13f87 Binary files /dev/null and b/android/app/src/debug/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/android/app/src/debug/res/mipmap-mdpi/ic_launcher.png b/android/app/src/debug/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index c39a6ea6e1..0000000000 Binary files a/android/app/src/debug/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/debug/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/debug/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000..39283c221f Binary files /dev/null and b/android/app/src/debug/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/android/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.png deleted file mode 100644 index e049c0bc1e..0000000000 Binary files a/android/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.webp b/android/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000000..d97a4f2338 Binary files /dev/null and b/android/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/debug/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/debug/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..ca9216c60c Binary files /dev/null and b/android/app/src/debug/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/android/app/src/debug/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/debug/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index d0aeb2ccc8..0000000000 Binary files a/android/app/src/debug/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/debug/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/debug/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000..e431741f66 Binary files /dev/null and b/android/app/src/debug/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index bead024b02..0000000000 Binary files a/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.webp b/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000000..6746396c4c Binary files /dev/null and b/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..b41ef70ac0 Binary files /dev/null and b/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index bcf93bd22f..0000000000 Binary files a/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..9b67b7c19e Binary files /dev/null and b/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index caff93fb95..0000000000 Binary files a/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000000..99c9732425 Binary files /dev/null and b/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..a29a3b8c24 Binary files /dev/null and b/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 856ae7f43a..0000000000 Binary files a/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..5a65551b36 Binary files /dev/null and b/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index c21da8b401..0000000000 Binary files a/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000000..673134a442 Binary files /dev/null and b/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..905c83fd56 Binary files /dev/null and b/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/debug/res/values/ic_launcher_background.xml b/android/app/src/debug/res/values/ic_launcher_background.xml new file mode 100644 index 0000000000..beab31f753 --- /dev/null +++ b/android/app/src/debug/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #000000 + \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5edfb32bb2..c54cada790 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,30 +1,30 @@ - - + + android:glEsVersion="0x00020000" + android:required="true" /> + android:name="android.hardware.wifi" + android:required="false" /> + android:name="android.hardware.location" + android:required="false" /> + android:name="android.hardware.location.network" + android:required="false" /> - - + android:name="android.hardware.location.gps" + android:required="false" /> + + + - - + + + - - - + // + --> + + + + + // + --> - - - + + - + + android:largeScreens="true" + android:xlargeScreens="true" /> - + android:name=".MwmApplication" + android:allowBackup="true" + android:backupInForeground="true" + android:dataExtractionRules="@xml/backup_content_v31" + android:fullBackupContent="@xml/backup_content" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:localeConfig="@xml/locales_config" + android:networkSecurityConfig="@xml/network_security_config" + android:resizeableActivity="true" + android:supportsRtl="true" + android:theme="@style/MwmTheme" + android:usesCleartextTraffic="true" + tools:targetApi="33"> + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + - - + - - + + - + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + + + + - - - - - - - - - + + + + + + + + + + @@ -222,7 +234,7 @@ - + @@ -241,16 +253,20 @@ - + - - - - - - - + + + + + + + + + @@ -300,7 +316,7 @@ - + @@ -322,134 +338,135 @@ + + - - - + android:targetActivity=".SplashActivity"> - - + + + - + android:name="app.tourism.MainActivity" + android:screenOrientation="portrait" + android:exported="false" + android:theme="@style/MwmTheme" /> - + android:name=".DownloadResourcesLegacyActivity" + android:configChanges="orientation|screenLayout|screenSize" /> - + android:name=".MwmActivity" + android:configChanges="uiMode" + android:launchMode="singleTask" + android:windowSoftInputMode="stateAlwaysHidden|adjustPan" /> - + android:name=".downloader.DownloaderActivity" + android:configChanges="orientation|screenLayout|screenSize" + android:label="@string/download_maps" + android:parentActivityName=".MwmActivity" + android:windowSoftInputMode="adjustResize" /> + android:name=".search.SearchActivity" + android:configChanges="orientation|screenLayout|screenSize" + android:label="@string/search_map" + android:parentActivityName=".MwmActivity" + android:windowSoftInputMode="stateVisible|adjustResize" /> + + + - - + android:name=".bookmarks.BookmarkCategoriesActivity" + android:configChanges="orientation|screenLayout|screenSize" + android:label="@string/bookmarks_and_tracks" + android:parentActivityName=".MwmActivity" + android:windowSoftInputMode="adjustResize" /> - + android:name=".bookmarks.BookmarkListActivity" + android:configChanges="orientation|screenLayout|screenSize" + android:label="@string/bookmarks" + android:parentActivityName=".bookmarks.BookmarkCategoriesActivity" + android:windowSoftInputMode="adjustResize" /> - + android:name=".editor.EditorActivity" + android:configChanges="orientation|screenLayout|screenSize" + android:label="@string/edit_place" + android:parentActivityName=".MwmActivity" + android:windowSoftInputMode="adjustResize" /> - + android:name=".editor.ProfileActivity" + android:parentActivityName=".settings.SettingsActivity" /> - + android:name=".editor.FeatureCategoryActivity" + android:parentActivityName=".MwmActivity" + android:windowSoftInputMode="adjustResize" /> - + android:name=".editor.ReportActivity" + android:parentActivityName=".MwmActivity" /> - + android:name=".editor.OsmLoginActivity" + android:parentActivityName=".MwmActivity" /> + android:windowSoftInputMode="stateVisible" /> + android:name=".widget.placepage.PlaceDescriptionActivity" + android:label="@string/place_description_title" /> - + android:name=".settings.DrivingOptionsActivity" + android:label="@string/driving_options_title" /> + + + + + + + + + + + + - - - - - + android:name=".routing.NavigationService" + android:enabled="true" + android:exported="false" + android:foregroundServiceType="location" + android:stopWithTask="false" /> + /> - - - - - - - - - - - + android:resource="@xml/file_paths" /> + - + android:name="android.webkit.WebView.EnableSafeBrowsing" + android:value="false" /> - + android:name="android.webkit.WebView.MetricsOptOut" + android:value="true" /> + + + + + + + + - + + \ No newline at end of file diff --git a/android/app/src/main/assets/tajikistan.mwm b/android/app/src/main/assets/tajikistan.mwm new file mode 100644 index 0000000000..c8be6efb1a Binary files /dev/null and b/android/app/src/main/assets/tajikistan.mwm differ diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000..0a92a2bd8e Binary files /dev/null and b/android/app/src/main/ic_launcher-playstore.png differ diff --git a/android/app/src/main/java/app/organicmaps/MapPlaceholderActivity.java b/android/app/src/main/java/app/organicmaps/MapPlaceholderActivity.java index 0d0a0db503..22c6d5f2f1 100644 --- a/android/app/src/main/java/app/organicmaps/MapPlaceholderActivity.java +++ b/android/app/src/main/java/app/organicmaps/MapPlaceholderActivity.java @@ -1,5 +1,6 @@ package app.organicmaps; +import android.content.Context; import android.content.Intent; import android.os.Bundle; @@ -10,6 +11,9 @@ import app.organicmaps.base.BaseMwmFragmentActivity; import app.organicmaps.display.DisplayChangedListener; import app.organicmaps.display.DisplayManager; import app.organicmaps.display.DisplayType; +import app.tourism.data.prefs.Language; +import app.tourism.data.prefs.UserPreferences; +import app.tourism.utils.LocaleHelper; public class MapPlaceholderActivity extends BaseMwmFragmentActivity implements DisplayChangedListener { @@ -18,6 +22,17 @@ public class MapPlaceholderActivity extends BaseMwmFragmentActivity implements D private DisplayManager mDisplayManager; private boolean mRemoveDisplayListener = true; + @Override + protected void attachBaseContext(Context newBase) { + Language language = new UserPreferences(newBase).getLanguage(); + if(language != null) { + String languageCode = language.getCode(); + super.attachBaseContext(LocaleHelper.localeUpdateResources(newBase, languageCode)); + } else { + super.attachBaseContext(newBase); + } + } + @Override protected void onSafeCreate(@Nullable Bundle savedInstanceState) { diff --git a/android/app/src/main/java/app/organicmaps/MwmActivity.java b/android/app/src/main/java/app/organicmaps/MwmActivity.java index 150f863b90..4112f3ceee 100644 --- a/android/app/src/main/java/app/organicmaps/MwmActivity.java +++ b/android/app/src/main/java/app/organicmaps/MwmActivity.java @@ -12,8 +12,11 @@ import android.location.Location; import android.net.Uri; import android.os.Build; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; import android.text.TextUtils; import android.text.method.LinkMovementMethod; +import android.util.Log; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.View; @@ -50,6 +53,7 @@ import app.organicmaps.bookmarks.data.MapObject; import app.organicmaps.display.DisplayChangedListener; import app.organicmaps.display.DisplayManager; import app.organicmaps.display.DisplayType; +import app.organicmaps.downloader.CountryItem; import app.organicmaps.downloader.DownloaderActivity; import app.organicmaps.downloader.DownloaderFragment; import app.organicmaps.downloader.MapManager; @@ -81,7 +85,6 @@ import app.organicmaps.routing.NavigationService; import app.organicmaps.routing.RoutePointInfo; import app.organicmaps.routing.RoutingBottomMenuListener; import app.organicmaps.routing.RoutingController; -import app.organicmaps.routing.RoutingErrorDialogFragment; import app.organicmaps.routing.RoutingOptions; import app.organicmaps.routing.RoutingPlanFragment; import app.organicmaps.routing.RoutingPlanInplaceController; @@ -107,6 +110,8 @@ import app.organicmaps.widget.menu.MainMenu; import app.organicmaps.widget.placepage.PlacePageController; import app.organicmaps.widget.placepage.PlacePageData; import app.organicmaps.widget.placepage.PlacePageViewModel; +import app.tourism.data.dto.PlaceLocation; + import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.ArrayList; @@ -119,6 +124,8 @@ import static android.content.pm.PackageManager.PERMISSION_GRANTED; import static app.organicmaps.location.LocationState.FOLLOW; import static app.organicmaps.location.LocationState.FOLLOW_AND_ROTATE; import static app.organicmaps.location.LocationState.LOCATION_TAG; +import static app.tourism.ui.utils.ShowToastKt.showToast; +import static app.tourism.utils.MapUtilsKt.isInsideTajikistan; public class MwmActivity extends BaseMwmFragmentActivity implements PlacePageActivationListener, @@ -544,6 +551,64 @@ public class MwmActivity extends BaseMwmFragmentActivity */ if (Map.isEngineCreated()) onRenderingInitializationFinished(); + + tjkMapDownloadingHandling(); + + PlaceLocation endPoint = getIntent().getParcelableExtra("end_point"); + if(endPoint != null) + routeForSiteFromMainActivityHandling(endPoint); + } + + private void tjkMapDownloadingHandling() { + Handler handler = new Handler(Looper.getMainLooper()); + Runnable delayedAction = () -> { + CountryItem mCurrentCountry = CountryItem.fill("Tajikistan"); + + if(mCurrentCountry.status != CountryItem.STATUS_DONE) { + // navigate to Dushanbe so it automatically downloads Tajikistan map + goToDushanbe(); + } else { + goToTjkIfNotThere(); + } + }; + handler.postDelayed(delayedAction, 1000); + } + + private void routeForSiteFromMainActivityHandling(PlaceLocation endPoint) { + Handler handler = new Handler(Looper.getMainLooper()); + Runnable delayedAction = () -> { + showRouteForSiteFromMainActivity(endPoint); + }; + handler.postDelayed(delayedAction, 1000); + } + + private void showRouteForSiteFromMainActivity(PlaceLocation endPoint) { + startLocationToPoint(endPoint.toMapObject()); + } + + private void goToTjkIfNotThere() { + final double[] center = Framework.nativeGetScreenRectCenter(); + final double lat = center[0]; + final double lon = center[1]; + if(!isInsideTajikistan(lat, lon)) + goToTjk(); + } + + private void goToDushanbe() { + Framework.nativeZoomToPoint(38.5598, 68.7870, 10, false); + } + + public void goToTjk() { + Framework.nativeZoomToPoint(38.5598, 68.7870, 8, false); + } + + public void blockScreen() { + getWindow().setFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, + WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE); + } + + public void removeScreenBlock() { + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE); } private void refreshLightStatusBar() @@ -1623,9 +1688,9 @@ public class MwmActivity extends BaseMwmFragmentActivity @Override public void onCommonBuildError(int lastResultCode, @NonNull String[] lastMissingMaps) { - RoutingErrorDialogFragment fragment = RoutingErrorDialogFragment.create(getSupportFragmentManager().getFragmentFactory(), - getApplicationContext(), lastResultCode, lastMissingMaps); - fragment.show(getSupportFragmentManager(), RoutingErrorDialogFragment.class.getSimpleName()); +// RoutingErrorDialogFragment fragment = RoutingErrorDialogFragment.create(getSupportFragmentManager().getFragmentFactory(), +// getApplicationContext(), lastResultCode, lastMissingMaps); +// fragment.show(getSupportFragmentManager(), RoutingErrorDialogFragment.class.getSimpleName()); } @Override diff --git a/android/app/src/main/java/app/organicmaps/MwmApplication.java b/android/app/src/main/java/app/organicmaps/MwmApplication.java index c745f343c1..c11944eef3 100644 --- a/android/app/src/main/java/app/organicmaps/MwmApplication.java +++ b/android/app/src/main/java/app/organicmaps/MwmApplication.java @@ -45,11 +45,13 @@ import app.organicmaps.util.UiUtils; import app.organicmaps.util.Utils; import app.organicmaps.util.log.Logger; import app.organicmaps.util.log.LogsManager; +import dagger.hilt.android.HiltAndroidApp; import java.io.IOException; import java.lang.ref.WeakReference; import java.util.List; +@HiltAndroidApp public class MwmApplication extends Application implements Application.ActivityLifecycleCallbacks { @NonNull diff --git a/android/app/src/main/java/app/organicmaps/SplashActivity.java b/android/app/src/main/java/app/organicmaps/SplashActivity.java index f46babb906..0a0bc0a4ef 100644 --- a/android/app/src/main/java/app/organicmaps/SplashActivity.java +++ b/android/app/src/main/java/app/organicmaps/SplashActivity.java @@ -16,6 +16,7 @@ import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.appcompat.app.AppCompatActivity; +import app.tourism.MainActivity; import app.organicmaps.display.DisplayManager; import app.organicmaps.location.LocationHelper; import app.organicmaps.util.Config; @@ -23,6 +24,10 @@ import app.organicmaps.util.LocationUtils; import app.organicmaps.util.ThemeUtils; import app.organicmaps.util.concurrency.UiThread; import app.organicmaps.util.log.Logger; +import app.tourism.data.prefs.Language; +import app.tourism.data.prefs.UserPreferences; +import app.tourism.utils.LocaleHelper; + import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.io.IOException; @@ -43,6 +48,17 @@ public class SplashActivity extends AppCompatActivity @NonNull private final Runnable mInitCoreDelayedTask = this::init; + @Override + protected void attachBaseContext(Context newBase) { + Language language = new UserPreferences(newBase).getLanguage(); + if(language != null) { + String languageCode = language.getCode(); + super.attachBaseContext(LocaleHelper.localeUpdateResources(newBase, languageCode)); + } else { + super.attachBaseContext(newBase); + } + } + @Override protected void onCreate(@Nullable Bundle savedInstanceState) { @@ -153,7 +169,7 @@ public class SplashActivity extends AppCompatActivity // Re-use original intent to retain all flags and payload. // https://github.com/organicmaps/organicmaps/issues/6944 final Intent intent = Objects.requireNonNull(getIntent()); - intent.setComponent(new ComponentName(this, DownloadResourcesLegacyActivity.class)); + intent.setComponent(new ComponentName(this, MainActivity.class)); // Flags like FLAG_ACTIVITY_NEW_TASK and FLAG_ACTIVITY_RESET_TASK_IF_NEEDED will break the cold start of the app. // https://github.com/organicmaps/organicmaps/pull/7287 intent.setFlags(intent.getFlags() & (Intent.FLAG_ACTIVITY_FORWARD_RESULT | Intent.FLAG_GRANT_READ_URI_PERMISSION)); diff --git a/android/app/src/main/java/app/organicmaps/base/BaseMwmFragmentActivity.java b/android/app/src/main/java/app/organicmaps/base/BaseMwmFragmentActivity.java index 640b8a5d9e..0615f8adf0 100644 --- a/android/app/src/main/java/app/organicmaps/base/BaseMwmFragmentActivity.java +++ b/android/app/src/main/java/app/organicmaps/base/BaseMwmFragmentActivity.java @@ -25,6 +25,9 @@ import app.organicmaps.util.RtlUtils; import app.organicmaps.util.ThemeUtils; import app.organicmaps.util.concurrency.UiThread; import app.organicmaps.util.log.Logger; +import app.tourism.data.prefs.Language; +import app.tourism.data.prefs.UserPreferences; +import app.tourism.utils.LocaleHelper; import java.util.Objects; @@ -51,6 +54,17 @@ public abstract class BaseMwmFragmentActivity extends AppCompatActivity throw new IllegalArgumentException("Attempt to apply unsupported theme: " + theme); } + @Override + protected void attachBaseContext(Context newBase) { + Language language = new UserPreferences(newBase).getLanguage(); + if(language != null) { + String languageCode = language.getCode(); + super.attachBaseContext(LocaleHelper.localeUpdateResources(newBase, languageCode)); + } else { + super.attachBaseContext(newBase); + } + } + /** * Shows splash screen and initializes the core in case when it was not initialized. * diff --git a/android/app/src/main/java/app/organicmaps/downloader/DownloaderActivity.java b/android/app/src/main/java/app/organicmaps/downloader/DownloaderActivity.java index 0f74beb581..98e30d5e6a 100644 --- a/android/app/src/main/java/app/organicmaps/downloader/DownloaderActivity.java +++ b/android/app/src/main/java/app/organicmaps/downloader/DownloaderActivity.java @@ -1,9 +1,14 @@ package app.organicmaps.downloader; +import android.content.Context; + import androidx.fragment.app.Fragment; import app.organicmaps.base.BaseMwmFragmentActivity; import app.organicmaps.base.OnBackPressListener; +import app.tourism.data.prefs.Language; +import app.tourism.data.prefs.UserPreferences; +import app.tourism.utils.LocaleHelper; public class DownloaderActivity extends BaseMwmFragmentActivity { @@ -15,6 +20,17 @@ public class DownloaderActivity extends BaseMwmFragmentActivity return DownloaderFragment.class; } + @Override + protected void attachBaseContext(Context newBase) { + Language language = new UserPreferences(newBase).getLanguage(); + if(language != null) { + String languageCode = language.getCode(); + super.attachBaseContext(LocaleHelper.localeUpdateResources(newBase, languageCode)); + } else { + super.attachBaseContext(newBase); + } + } + @Override public void onBackPressed() { diff --git a/android/app/src/main/java/app/organicmaps/downloader/OnmapDownloader.java b/android/app/src/main/java/app/organicmaps/downloader/OnmapDownloader.java index 7af329f644..83185a40af 100644 --- a/android/app/src/main/java/app/organicmaps/downloader/OnmapDownloader.java +++ b/android/app/src/main/java/app/organicmaps/downloader/OnmapDownloader.java @@ -1,26 +1,34 @@ package app.organicmaps.downloader; +import static androidx.core.content.ContextCompat.startActivity; + +import android.content.Intent; import android.location.Location; +import android.os.Handler; +import android.os.Looper; import android.text.TextUtils; import android.view.View; import android.view.ViewGroup; import android.widget.Button; import android.widget.TextView; +import android.widget.Toast; import androidx.annotation.Nullable; - import androidx.core.view.ViewCompat; + +import java.util.List; +import java.util.Objects; + import app.organicmaps.MwmActivity; import app.organicmaps.R; import app.organicmaps.location.LocationHelper; import app.organicmaps.routing.RoutingController; -import app.organicmaps.widget.WheelProgressView; import app.organicmaps.util.Config; import app.organicmaps.util.ConnectionState; import app.organicmaps.util.StringUtils; import app.organicmaps.util.UiUtils; - -import java.util.List; +import app.organicmaps.widget.WheelProgressView; +import app.tourism.MainActivity; public class OnmapDownloader implements MwmActivity.LeftAnimationTrackListener { @@ -28,7 +36,6 @@ public class OnmapDownloader implements MwmActivity.LeftAnimationTrackListener private final MwmActivity mActivity; private final View mFrame; - private final TextView mParent; private final TextView mTitle; private final TextView mSize; private final WheelProgressView mProgress; @@ -36,6 +43,8 @@ public class OnmapDownloader implements MwmActivity.LeftAnimationTrackListener private int mStorageSubscriptionSlot; + private boolean alreadyNavigating = false; + @Nullable private CountryItem mCurrentCountry; @@ -63,6 +72,7 @@ public class OnmapDownloader implements MwmActivity.LeftAnimationTrackListener return; } } + } @Override @@ -76,6 +86,22 @@ public class OnmapDownloader implements MwmActivity.LeftAnimationTrackListener } }; + private void navigationToMainActivityHandling() { + Handler handler = new Handler(Looper.getMainLooper()); + Runnable delayedAction = () -> { + if(mCurrentCountry != null) { + if(mCurrentCountry.present && !alreadyNavigating) { + alreadyNavigating = true; + mActivity.removeScreenBlock(); + Intent intent = new Intent(mActivity, MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(mActivity, intent, null); + } + } + }; + handler.postDelayed(delayedAction, 1000); + } + private final MapManager.CurrentCountryChangedListener mCountryChangedListener = new MapManager.CurrentCountryChangedListener() { @Override @@ -83,6 +109,7 @@ public class OnmapDownloader implements MwmActivity.LeftAnimationTrackListener { mCurrentCountry = (TextUtils.isEmpty(countryId) ? null : CountryItem.fill(countryId)); updateState(true); + stopIfNotTjk(); } }; @@ -91,18 +118,9 @@ public class OnmapDownloader implements MwmActivity.LeftAnimationTrackListener updateStateInternal(shouldAutoDownload); } - private static boolean isMapDownloading(@Nullable CountryItem country) - { - if (country == null) return false; - - boolean enqueued = country.status == CountryItem.STATUS_ENQUEUED; - boolean progress = country.status == CountryItem.STATUS_PROGRESS; - boolean applying = country.status == CountryItem.STATUS_APPLYING; - return enqueued || progress || applying; - } - private void updateProgressState(boolean shouldAutoDownload) { + navigationToMainActivityHandling(); updateStateInternal(shouldAutoDownload); } @@ -118,26 +136,22 @@ public class OnmapDownloader implements MwmActivity.LeftAnimationTrackListener mCurrentCountry.status == CountryItem.STATUS_APPLYING); boolean failed = (mCurrentCountry.status == CountryItem.STATUS_FAILED); + + showFrame = (enqueued || progress || failed || mCurrentCountry.status == CountryItem.STATUS_DOWNLOADABLE); if (showFrame) { - boolean hasParent = !CountryItem.isRoot(mCurrentCountry.topmostParentId); - + setUiForTjkDownload(); UiUtils.showIf(progress || enqueued, mProgress); UiUtils.showIf(!progress && !enqueued, mButton); - UiUtils.showIf(hasParent, mParent); - - if (hasParent) - mParent.setText(mCurrentCountry.topmostParentName); - - mTitle.setText(mCurrentCountry.name); String sizeText; if (progress) { + mActivity.blockScreen(); mProgress.setPending(false); mProgress.setProgress(Math.round(mCurrentCountry.progress)); sizeText = StringUtils.formatUsingSystemLocale("%1$s %2$.2f%%", @@ -147,6 +161,7 @@ public class OnmapDownloader implements MwmActivity.LeftAnimationTrackListener { if (enqueued) { + mActivity.blockScreen(); sizeText = mActivity.getString(R.string.downloader_queued); mProgress.setPending(true); } @@ -174,6 +189,7 @@ public class OnmapDownloader implements MwmActivity.LeftAnimationTrackListener mButton.setText(failed ? R.string.downloader_retry : R.string.download); + mActivity.removeScreenBlock(); } } @@ -184,11 +200,22 @@ public class OnmapDownloader implements MwmActivity.LeftAnimationTrackListener UiUtils.showIf(showFrame, mFrame); } + public void stopIfNotTjk() { + if(mCurrentCountry != null && !Objects.equals(mCurrentCountry.id, "Tajikistan")) { + mActivity.goToTjk(); + Toast.makeText(mActivity, R.string.plz_dont_go_out_of_tjk, Toast.LENGTH_LONG).show(); + MapManager.nativeCancel(mCurrentCountry.id); + } + } + + public void setUiForTjkDownload() { + mTitle.setText(mActivity.getString(R.string.wait_tjk_map_downloading)); + } + public OnmapDownloader(MwmActivity activity) { mActivity = activity; mFrame = activity.findViewById(R.id.onmap_downloader); - mParent = mFrame.findViewById(R.id.downloader_parent); mTitle = mFrame.findViewById(R.id.downloader_title); mSize = mFrame.findViewById(R.id.downloader_size); @@ -208,6 +235,7 @@ public class OnmapDownloader implements MwmActivity.LeftAnimationTrackListener if (mCurrentCountry == null) return; + mActivity.blockScreen(); boolean retry = (mCurrentCountry.status == CountryItem.STATUS_FAILED); if (retry) { diff --git a/android/app/src/main/java/app/organicmaps/maplayer/MapButtonsController.java b/android/app/src/main/java/app/organicmaps/maplayer/MapButtonsController.java index 94cbbc6f9c..9ee54603eb 100644 --- a/android/app/src/main/java/app/organicmaps/maplayer/MapButtonsController.java +++ b/android/app/src/main/java/app/organicmaps/maplayer/MapButtonsController.java @@ -43,6 +43,8 @@ public class MapButtonsController extends Fragment @Nullable private View mBottomButtonsFrame; @Nullable + private View mBackButton; + @Nullable private FloatingActionButton mToggleMapLayerButton; @Nullable @@ -81,6 +83,7 @@ public class MapButtonsController extends Fragment mInnerLeftButtonsFrame = mFrame.findViewById(R.id.map_buttons_inner_left); mInnerRightButtonsFrame = mFrame.findViewById(R.id.map_buttons_inner_right); mBottomButtonsFrame = mFrame.findViewById(R.id.map_buttons_bottom); + mBackButton = mFrame.findViewById(R.id.back_btn); final FloatingActionButton helpButton = mFrame.findViewById(R.id.help_button); if (helpButton != null) @@ -99,8 +102,6 @@ public class MapButtonsController extends Fragment .setOnClickListener((v) -> mMapButtonClickListener.onMapButtonClick(MapButtons.zoomIn)); mFrame.findViewById(R.id.nav_zoom_out) .setOnClickListener((v) -> mMapButtonClickListener.onMapButtonClick(MapButtons.zoomOut)); - final View bookmarksButton = mFrame.findViewById(R.id.btn_bookmarks); - bookmarksButton.setOnClickListener((v) -> mMapButtonClickListener.onMapButtonClick(MapButtons.bookmarks)); final View myPosition = mFrame.findViewById(R.id.my_position); mNavMyPosition = new MyPositionButton(myPosition, (v) -> mMapButtonClickListener.onMapButtonClick(MapButtons.myPosition)); @@ -140,7 +141,6 @@ public class MapButtonsController extends Fragment mButtonsMap = new HashMap<>(); mButtonsMap.put(MapButtons.zoom, zoomFrame); mButtonsMap.put(MapButtons.myPosition, myPosition); - mButtonsMap.put(MapButtons.bookmarks, bookmarksButton); mButtonsMap.put(MapButtons.search, searchButton); if (mToggleMapLayerButton != null) @@ -154,6 +154,11 @@ public class MapButtonsController extends Fragment UiUtils.setViewInsetsPadding(view, windowInsets); return windowInsets; }); + + if(mBackButton != null) { + mBackButton.setOnClickListener((v) -> activity.getOnBackPressedDispatcher().onBackPressed()); + } + return mFrame; } diff --git a/android/app/src/main/java/app/organicmaps/widget/placepage/PlacePageController.java b/android/app/src/main/java/app/organicmaps/widget/placepage/PlacePageController.java index 5e0110084b..43e1f864a3 100644 --- a/android/app/src/main/java/app/organicmaps/widget/placepage/PlacePageController.java +++ b/android/app/src/main/java/app/organicmaps/widget/placepage/PlacePageController.java @@ -527,18 +527,10 @@ public class PlacePageController extends Fragment implements // And move the bookmark button at the end if (needToShowRoutingButtons && RoutingController.get().isStopPointAllowed()) buttons.add(PlacePageButtons.ButtonType.ROUTE_ADD); - else - buttons.add(mapObject.isBookmark() - ? PlacePageButtons.ButtonType.BOOKMARK_DELETE - : PlacePageButtons.ButtonType.BOOKMARK_SAVE); if (needToShowRoutingButtons) { buttons.add(PlacePageButtons.ButtonType.ROUTE_TO); - if (RoutingController.get().isStopPointAllowed()) - buttons.add(mapObject.isBookmark() - ? PlacePageButtons.ButtonType.BOOKMARK_DELETE - : PlacePageButtons.ButtonType.BOOKMARK_SAVE); } } mViewModel.setCurrentButtons(buttons); diff --git a/android/app/src/main/java/app/organicmaps/widget/placepage/PlacePageView.java b/android/app/src/main/java/app/organicmaps/widget/placepage/PlacePageView.java index f452f99e79..3a0ac466bf 100644 --- a/android/app/src/main/java/app/organicmaps/widget/placepage/PlacePageView.java +++ b/android/app/src/main/java/app/organicmaps/widget/placepage/PlacePageView.java @@ -423,19 +423,6 @@ public class PlacePageView extends Fragment implements View.OnClickListener, // showTaxiOffer(mapObject); - if (RoutingController.get().isNavigating() || RoutingController.get().isPlanning()) - { - UiUtils.hide(mEditPlace, mAddOrganisation, mAddPlace, mEditTopSpace); - } - else - { - UiUtils.showIf(Editor.nativeShouldShowEditPlace(), mEditPlace); - UiUtils.showIf(Editor.nativeShouldShowAddBusiness(), mAddOrganisation); - UiUtils.showIf(Editor.nativeShouldShowAddPlace(), mAddPlace); - UiUtils.showIf(UiUtils.isVisible(mEditPlace) - || UiUtils.isVisible(mAddOrganisation) - || UiUtils.isVisible(mAddPlace), mEditTopSpace); - } updateLinksView(); updateOpeningHoursView(); updateWikipediaView(); diff --git a/android/app/src/main/java/app/tourism/AuthActivity.kt b/android/app/src/main/java/app/tourism/AuthActivity.kt new file mode 100644 index 0000000000..b482c6f605 --- /dev/null +++ b/android/app/src/main/java/app/tourism/AuthActivity.kt @@ -0,0 +1,64 @@ +package app.tourism + +import android.content.Context +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.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import app.organicmaps.R +import app.tourism.data.prefs.UserPreferences +import app.tourism.data.repositories.PlacesRepository +import app.tourism.ui.screens.auth.AuthNavigation +import app.tourism.ui.theme.OrganicMapsTheme +import app.tourism.utils.LocaleHelper +import app.tourism.utils.MapFileMover +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class AuthActivity : ComponentActivity() { + @Inject + lateinit var placesRepository: PlacesRepository + + @Inject + lateinit var userPreferences: UserPreferences + + override fun attachBaseContext(newBase: Context) { + val languageCode = UserPreferences(newBase).getLanguage()?.code + super.attachBaseContext(LocaleHelper.localeUpdateResources(newBase, languageCode ?: "ru")) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + lifecycleScope.launch { + if (!userPreferences.getIsEverythingSetup()) { + if (!MapFileMover().main(this@AuthActivity)) return@launch + userPreferences.setIsEverythingSetup(true) + } + } + + val blackest = resources.getColor(R.color.button_text) // yes, I know + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(blackest), + navigationBarStyle = SystemBarStyle.dark(blackest) + ) + setContent { + OrganicMapsTheme() { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Column(modifier = Modifier.padding(innerPadding)) { + AuthNavigation() + } + } + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/Constants.kt b/android/app/src/main/java/app/tourism/Constants.kt new file mode 100644 index 0000000000..3728100e42 --- /dev/null +++ b/android/app/src/main/java/app/tourism/Constants.kt @@ -0,0 +1,78 @@ +package app.tourism + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import app.organicmaps.R +import app.tourism.ui.theme.getBorderColor + +const val TAG = "GLOBAL_TAG" +const val BASE_URL = "https://tourismmap.tj" +const val DEBUG_BASE_URL = "https://test.tourismmap.tj/" + +object Constants { + // UI + val SCREEN_PADDING = 16.dp + val USUAL_WINDOW_INSETS = WindowInsets( + left = SCREEN_PADDING, + right = SCREEN_PADDING, + bottom = 0.dp, + top = 0.dp + ) + + // image loading + const val IMAGE_URL_EXAMPLE = + "https://img.freepik.com/free-photo/young-woman-hiker-taking-photo-with-smartphone-on-mountains-peak-in-winter_335224-427.jpg?w=2000" + const val THUMBNAIL_URL_EXAMPLE = + "https://render.fineartamerica.com/images/images-profile-flow/400/images-medium-large-5/awesome-solitude-bess-hamiti.jpg" + const val LOGO_URL_EXAMPLE = "https://brandeps.com/logo-download/O/OSCE-logo-vector-01.svg" + + // data + val categories = mapOf( + "sights" to R.string.sights, + "restaurants" to R.string.restaurants, + "hotels_tourism" to R.string.hotels_tourism, + ) +} + +@Composable +fun Modifier.applyAppBorder() = this + .border( + width = 1.dp, + color = getBorderColor(), + shape = RoundedCornerShape(20.dp) + ) + .clip(RoundedCornerShape(20.dp)) + +@Composable +fun Modifier.drawOverlayForTextBehind() = + this.background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.9f), + ) + ) + ) + +@Composable +fun Modifier.drawDarkContainerBehind(): Modifier { + val alpha = 0.4f + return this + .clip(RoundedCornerShape(20.dp)) + .background( + Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = alpha), + Color.Black.copy(alpha = alpha) + ) + ) + ) +} diff --git a/android/app/src/main/java/app/tourism/ImagesDownloadService.kt b/android/app/src/main/java/app/tourism/ImagesDownloadService.kt new file mode 100644 index 0000000000..add762f39e --- /dev/null +++ b/android/app/src/main/java/app/tourism/ImagesDownloadService.kt @@ -0,0 +1,172 @@ +package app.tourism + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.lifecycle.LifecycleService +import androidx.lifecycle.lifecycleScope +import app.organicmaps.R +import app.tourism.data.prefs.UserPreferences +import app.tourism.data.repositories.PlacesRepository +import app.tourism.domain.models.resource.DownloadProgress +import app.tourism.utils.LocaleHelper +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val STOP_SERVICE_ACTION = "app.tourism.STOP_SERVICE" +private const val CHANNEL_ID = "images_download_channel" +private const val PROGRESS_NOTIFICATION_ID = 1 +private const val SUMMARY_NOTIFICATION_ID = 2 + +@AndroidEntryPoint +class ImagesDownloadService : LifecycleService() { + + + @Inject + lateinit var placesRepository: PlacesRepository + private lateinit var notificationManager: NotificationManagerCompat + + override fun attachBaseContext(newBase: Context) { + val languageCode = UserPreferences(newBase).getLanguage()?.code + super.attachBaseContext(LocaleHelper.localeUpdateResources(newBase, languageCode ?: "ru")) + } + + override fun onCreate() { + super.onCreate() + + notificationManager = NotificationManagerCompat.from(this) + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + + // Stops the service when cancel button is clicked + if (intent?.action == STOP_SERVICE_ACTION) { + stopSelf() + return START_NOT_STICKY + } + + // downloading all images + lifecycleScope.launch(Dispatchers.IO) { + placesRepository.downloadAllImages().collectLatest { progress -> + updateNotification(progress) + } + } + + return START_STICKY + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name = getString(R.string.channel_name) + val descriptionText = getString(R.string.channel_description) + val importance = NotificationManager.IMPORTANCE_HIGH + val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { + description = descriptionText + } + + notificationManager.createNotificationChannel(channel) + } + } + + private fun updateNotification(downloadProgress: DownloadProgress) { + var shouldStopSelf = false + + val stopIntent = Intent(this, ImagesDownloadService::class.java).apply { + action = STOP_SERVICE_ACTION + } + val stopPendingIntent = PendingIntent.getService( + this, 0, stopIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_download) + .setContentTitle(getString(R.string.downloading_images)) + .setSilent(true) + .addAction(R.drawable.ic_cancel, getString(R.string.cancel), stopPendingIntent) + + val groupKey = "images_download_group" + val summaryNotification = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_download) + .setContentTitle(getString(R.string.downloading_images)) + .setStyle(NotificationCompat.InboxStyle()) + .setGroup(groupKey) + .setGroupSummary(true) + + when (downloadProgress) { + is DownloadProgress.Loading -> { + downloadProgress.stats?.let { stats -> + val statsInString = + "${stats.filesDownloaded}/${stats.filesTotalNum} (${stats.percentagesCompleted}%)" + + builder.setContentText("${getString(R.string.images_downloaded)}: $statsInString") + builder.setProgress(100, stats.percentagesCompleted, false) + } + } + + is DownloadProgress.Finished -> { + downloadProgress.stats?.let { stats -> + val statsInString = + "${stats.filesDownloaded}/${stats.filesTotalNum} (${stats.percentagesCompleted}%)" + + if (stats.percentagesCompleted == 100) { + summaryNotification.setContentTitle("${getString(R.string.all_images_were_downloaded)}: $statsInString") + summaryNotification.setContentText(null) + } else if (stats.percentagesCompleted >= 95) { + summaryNotification.setContentTitle("${getString(R.string.most_images_were_downloaded)}: $statsInString") + summaryNotification.setContentText(null) + } else { + summaryNotification.setContentTitle("${getString(R.string.not_all_images_were_downloaded)}: $statsInString") + summaryNotification.setContentText(null) + } + } + notificationManager.cancel(PROGRESS_NOTIFICATION_ID) + shouldStopSelf = true + } + + is DownloadProgress.Error -> { + summaryNotification.setContentTitle( + downloadProgress.message ?: getString(R.string.smth_went_wrong) + ) + summaryNotification.setContentText("") + summaryNotification.setProgress(0, 0, false) + + notificationManager.cancel(PROGRESS_NOTIFICATION_ID) + shouldStopSelf = true + } + + else -> {} + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val notificationPermission = + ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + + if (notificationPermission != PackageManager.PERMISSION_GRANTED) + return + } + + if (shouldStopSelf) { + lifecycleScope.launch { + notificationManager.notify(SUMMARY_NOTIFICATION_ID, summaryNotification.build()) + delay(1000L) // Delay to ensure notification is shown + stopSelf() + } + } else { + notificationManager.notify(PROGRESS_NOTIFICATION_ID, builder.build()) + } + } +} diff --git a/android/app/src/main/java/app/tourism/MainActivity.kt b/android/app/src/main/java/app/tourism/MainActivity.kt new file mode 100644 index 0000000000..2e15c67dc3 --- /dev/null +++ b/android/app/src/main/java/app/tourism/MainActivity.kt @@ -0,0 +1,121 @@ +package app.tourism + +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.wifi.WifiManager +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.runtime.collectAsState +import androidx.core.content.ContextCompat.startActivity +import androidx.lifecycle.lifecycleScope +import app.organicmaps.DownloadResourcesLegacyActivity +import app.organicmaps.R +import app.organicmaps.downloader.CountryItem +import app.tourism.data.prefs.UserPreferences +import app.tourism.data.remote.WifiReceiver +import app.tourism.domain.models.resource.Resource +import app.tourism.ui.screens.main.MainSection +import app.tourism.ui.screens.main.ThemeViewModel +import app.tourism.ui.screens.main.profile.profile.ProfileViewModel +import app.tourism.ui.theme.OrganicMapsTheme +import app.tourism.utils.LocaleHelper +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import javax.inject.Inject + + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + private val wifiReceiver = WifiReceiver() + + @Inject + lateinit var userPreferences: UserPreferences + + private val themeVM: ThemeViewModel by viewModels() + private val profileVM: ProfileViewModel by viewModels() + + override fun attachBaseContext(newBase: Context) { + val languageCode = UserPreferences(newBase).getLanguage()?.code + super.attachBaseContext(LocaleHelper.localeUpdateResources(newBase, languageCode ?: "ru")) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val intentFilter = IntentFilter() + intentFilter.addAction(WifiManager.NETWORK_STATE_CHANGED_ACTION) + registerReceiver(wifiReceiver, intentFilter) + + navigateToAuthIfNotAuthed() + navigateToMapToDownloadIfNotPresent() + + val blackest = resources.getColor(R.color.button_text) // yes, I know + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(blackest), + navigationBarStyle = SystemBarStyle.dark(blackest) + ) + + setContent { + val isDark = themeVM.theme.collectAsState().value?.code == "dark" + + OrganicMapsTheme(darkTheme = isDark) { + MainSection(themeVM) + } + } + } + + private fun navigateToMapToDownloadIfNotPresent() { + if (userPreferences.getIsEverythingSetup()) { + val mCurrentCountry = CountryItem.fill("Tajikistan") + if (!mCurrentCountry.present) { + val intent = Intent(this, DownloadResourcesLegacyActivity::class.java) + startActivity(this, intent, null) + } + } + } + + private fun navigateToAuthIfNotAuthed() { + val token = userPreferences.getToken() + if (token.isNullOrEmpty()) { + navigateToAuth() + return + } + + profileVM.getPersonalData() + lifecycleScope.launch { + profileVM.profileDataResource.collectLatest { + if (it is Resource.Success) { + it.data?.apply { + language?.let { lang -> + userPreferences.setLanguage(lang) + } + theme?.let { theme -> + themeVM.setTheme(theme) + } + userPreferences.setUserId(id.toString()) + } + } + if (it is Resource.Error) { + if (it.message?.contains("unauth", ignoreCase = true) == true) + navigateToAuth() + } + } + } + } + + private fun navigateToAuth() { + val intent = Intent(this, AuthActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(this, intent, null) + } + + override fun onDestroy() { + unregisterReceiver(wifiReceiver) + super.onDestroy() + } +} diff --git a/android/app/src/main/java/app/tourism/data/db/Converters.kt b/android/app/src/main/java/app/tourism/data/db/Converters.kt new file mode 100644 index 0000000000..2c7ed1cc2a --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/db/Converters.kt @@ -0,0 +1,13 @@ +package app.tourism.data.db + +import androidx.room.TypeConverter +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class Converters { + @TypeConverter + fun fromList(value : List) = Json.encodeToString(value) + + @TypeConverter + fun toList(value: String) = Json.decodeFromString>(value) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/data/db/Database.kt b/android/app/src/main/java/app/tourism/data/db/Database.kt new file mode 100644 index 0000000000..1017a54257 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/db/Database.kt @@ -0,0 +1,39 @@ +package app.tourism.data.db + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import app.tourism.data.db.dao.CurrencyRatesDao +import app.tourism.data.db.dao.HashesDao +import app.tourism.data.db.dao.ImagesToDownloadDao +import app.tourism.data.db.dao.PlacesDao +import app.tourism.data.db.dao.ReviewsDao +import app.tourism.data.db.entities.CurrencyRatesEntity +import app.tourism.data.db.entities.FavoriteSyncEntity +import app.tourism.data.db.entities.HashEntity +import app.tourism.data.db.entities.ImageToDownloadEntity +import app.tourism.data.db.entities.PlaceEntity +import app.tourism.data.db.entities.ReviewEntity +import app.tourism.data.db.entities.ReviewPlannedToPostEntity + +@Database( + entities = [ + PlaceEntity::class, + ReviewEntity::class, + ReviewPlannedToPostEntity::class, + FavoriteSyncEntity::class, + HashEntity::class, + CurrencyRatesEntity::class, + ImageToDownloadEntity::class + ], + version = 2, + exportSchema = false +) +@TypeConverters(Converters::class) +abstract class Database : RoomDatabase() { + abstract val currencyRatesDao: CurrencyRatesDao + abstract val placesDao: PlacesDao + abstract val hashesDao: HashesDao + abstract val reviewsDao: ReviewsDao + abstract val imagesToDownloadDao: ImagesToDownloadDao +} diff --git a/android/app/src/main/java/app/tourism/data/db/dao/CurrencyRatesDao.kt b/android/app/src/main/java/app/tourism/data/db/dao/CurrencyRatesDao.kt new file mode 100644 index 0000000000..c56d3e4808 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/db/dao/CurrencyRatesDao.kt @@ -0,0 +1,17 @@ +package app.tourism.data.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import app.tourism.data.db.entities.CurrencyRatesEntity + +@Dao +interface CurrencyRatesDao { + + @Query("SELECT * FROM currency_rates") + suspend fun getCurrencyRates(): CurrencyRatesEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun updateCurrencyRates(entity: CurrencyRatesEntity) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/data/db/dao/HashesDao.kt b/android/app/src/main/java/app/tourism/data/db/dao/HashesDao.kt new file mode 100644 index 0000000000..cf156a79d7 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/db/dao/HashesDao.kt @@ -0,0 +1,25 @@ +package app.tourism.data.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import app.tourism.data.db.entities.HashEntity + +@Dao +interface HashesDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertHash(hash: HashEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertHashes(hashes: List) + + @Query("SELECT * FROM hashes WHERE categoryId = :id") + suspend fun getHash(id: Long): HashEntity? + + @Query("SELECT * FROM hashes") + suspend fun getHashes(): List + + @Query("DELETE FROM hashes") + suspend fun deleteHashes() +} diff --git a/android/app/src/main/java/app/tourism/data/db/dao/ImagesToDownloadDao.kt b/android/app/src/main/java/app/tourism/data/db/dao/ImagesToDownloadDao.kt new file mode 100644 index 0000000000..5827ae077d --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/db/dao/ImagesToDownloadDao.kt @@ -0,0 +1,22 @@ +package app.tourism.data.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import app.tourism.data.db.entities.ImageToDownloadEntity + +@Dao +interface ImagesToDownloadDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertImages(places: List) + + @Query("UPDATE images_to_download SET downloaded = :downloaded WHERE url = :url") + suspend fun markAsDownloaded(url: String, downloaded: Boolean) + + @Query("UPDATE images_to_download SET downloaded = 0") + suspend fun markAllImagesAsNotDownloaded() + + @Query("SELECT * FROM images_to_download") + suspend fun getAllImages(): List +} diff --git a/android/app/src/main/java/app/tourism/data/db/dao/PlacesDao.kt b/android/app/src/main/java/app/tourism/data/db/dao/PlacesDao.kt new file mode 100644 index 0000000000..2db39e11b1 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/db/dao/PlacesDao.kt @@ -0,0 +1,61 @@ +package app.tourism.data.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import app.tourism.data.db.entities.FavoriteSyncEntity +import app.tourism.data.db.entities.PlaceEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface PlacesDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertPlaces(places: List) + + @Query("DELETE FROM places WHERE id IN (:idsList)") + suspend fun deletePlaces(idsList: List) + + @Query("DELETE FROM places") + suspend fun deleteAllPlaces() + + @Query("DELETE FROM places WHERE categoryId = :categoryId AND language =:language") + suspend fun deleteAllPlacesByCategory(categoryId: Long, language: String) + + @Query("SELECT * FROM places WHERE UPPER(name) LIKE UPPER(:q) AND language =:language") + fun search(q: String = "", language: String): Flow> + + @Query("SELECT * FROM places WHERE categoryId = :categoryId AND language =:language ORDER BY rating DESC, name ASC") + fun getSortedPlacesByCategoryIdFlow(categoryId: Long, language: String): Flow> + + @Query("SELECT * FROM places WHERE categoryId = :categoryId AND language =:language") + fun getPlacesByCategoryIdNotFlow(categoryId: Long, language: String): List + + @Query("SELECT * FROM places WHERE categoryId =:categoryId AND language =:language ORDER BY rating DESC LIMIT 15") + fun getTopPlacesByCategoryId(categoryId: Long, language: String): Flow> + + @Query("SELECT * FROM places WHERE id = :placeId AND language =:language") + fun getPlaceById(placeId: Long, language: String): Flow + + @Query("SELECT * FROM places WHERE isFavorite = 1 AND UPPER(name) LIKE UPPER(:q) AND language =:language") + fun getFavoritePlacesFlow(q: String = "", language: String): Flow> + + @Query("SELECT * FROM places WHERE isFavorite = 1 AND UPPER(name) LIKE UPPER(:q) AND language =:language") + fun getFavoritePlaces(q: String = "", language: String): List + + @Query("UPDATE places SET isFavorite = :isFavorite WHERE id = :placeId") + suspend fun setFavorite(placeId: Long, isFavorite: Boolean) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun addFavoriteSync(favoriteSyncEntity: FavoriteSyncEntity) + + @Query("DELETE FROM favorites_to_sync WHERE placeId = :placeId") + suspend fun removeFavoriteSync(placeId: Long) + + @Query("DELETE FROM favorites_to_sync WHERE placeId in (:placeId)") + suspend fun removeFavoriteSync(placeId: List) + + @Query("SELECT * FROM favorites_to_sync") + fun getFavoriteSyncData(): List +} diff --git a/android/app/src/main/java/app/tourism/data/db/dao/ReviewsDao.kt b/android/app/src/main/java/app/tourism/data/db/dao/ReviewsDao.kt new file mode 100644 index 0000000000..5d5ab15eee --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/db/dao/ReviewsDao.kt @@ -0,0 +1,52 @@ +package app.tourism.data.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import app.tourism.data.db.entities.ReviewEntity +import app.tourism.data.db.entities.ReviewPlannedToPostEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ReviewsDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertReview(review: ReviewEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertReviews(reviews: List) + + @Query("DELETE FROM reviews WHERE id = :id") + suspend fun deleteReview(id: Long) + + @Query("DELETE FROM reviews WHERE id in (:idsList)") + suspend fun deleteReviews(idsList: List) + + @Query("DELETE FROM reviews") + suspend fun deleteAllReviews() + + @Query("DELETE FROM reviews WHERE placeId = :placeId") + suspend fun deleteAllPlaceReviews(placeId: Long) + + @Query("SELECT * FROM reviews WHERE placeId = :placeId") + fun getReviewsForPlace(placeId: Long): Flow> + + @Query("UPDATE reviews SET deletionPlanned = :deletionPlanned WHERE id = :id") + fun markReviewForDeletion(id: Long, deletionPlanned: Boolean = true) + + @Query("SELECT * FROM reviews WHERE deletionPlanned = 1") + fun getReviewsPlannedForDeletion(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertReviewPlannedToPost(review: ReviewPlannedToPostEntity) + + @Query("DELETE FROM reviews_planned_to_post WHERE placeId = :placeId") + suspend fun deleteReviewPlannedToPost(placeId: Long) + + @Query("SELECT * FROM reviews_planned_to_post") + fun getReviewsPlannedToPost(): List + + @Query("SELECT * FROM reviews_planned_to_post WHERE placeId = :placeId") + fun getReviewsPlannedToPostFlow(placeId: Long): Flow> +} diff --git a/android/app/src/main/java/app/tourism/data/db/entities/CurrencyRatesEntity.kt b/android/app/src/main/java/app/tourism/data/db/entities/CurrencyRatesEntity.kt new file mode 100644 index 0000000000..34b960c089 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/db/entities/CurrencyRatesEntity.kt @@ -0,0 +1,16 @@ +package app.tourism.data.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import app.tourism.domain.models.profile.CurrencyRates + +@Entity(tableName = "currency_rates") +data class CurrencyRatesEntity( + @PrimaryKey + val id: Long, + val usd: Double, + val eur: Double, + val rub: Double, +) { + fun toCurrencyRates() = CurrencyRates(usd, eur, rub) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/data/db/entities/FavoriteSyncEntity.kt b/android/app/src/main/java/app/tourism/data/db/entities/FavoriteSyncEntity.kt new file mode 100644 index 0000000000..924fe99cbf --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/db/entities/FavoriteSyncEntity.kt @@ -0,0 +1,10 @@ +package app.tourism.data.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "favorites_to_sync") +data class FavoriteSyncEntity( + @PrimaryKey val placeId: Long, + val isFavorite: Boolean, +) \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/data/db/entities/HashEntity.kt b/android/app/src/main/java/app/tourism/data/db/entities/HashEntity.kt new file mode 100644 index 0000000000..d78a4e6107 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/db/entities/HashEntity.kt @@ -0,0 +1,10 @@ +package app.tourism.data.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "hashes") +data class HashEntity( + @PrimaryKey val categoryId: Long, + val value: String, +) diff --git a/android/app/src/main/java/app/tourism/data/db/entities/ImageToDownloadEntity.kt b/android/app/src/main/java/app/tourism/data/db/entities/ImageToDownloadEntity.kt new file mode 100644 index 0000000000..ce0e8fad85 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/db/entities/ImageToDownloadEntity.kt @@ -0,0 +1,11 @@ +package app.tourism.data.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "images_to_download") +data class ImageToDownloadEntity( + @PrimaryKey + val url: String, + val downloaded: Boolean +) \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/data/db/entities/PlaceEntity.kt b/android/app/src/main/java/app/tourism/data/db/entities/PlaceEntity.kt new file mode 100644 index 0000000000..305397cf3f --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/db/entities/PlaceEntity.kt @@ -0,0 +1,51 @@ +package app.tourism.data.db.entities + +import androidx.room.Embedded +import androidx.room.Entity +import app.tourism.data.dto.PlaceLocation +import app.tourism.domain.models.common.PlaceShort +import app.tourism.domain.models.details.PlaceFull + +@Entity(tableName = "places", primaryKeys = ["id", "language"] ) +data class PlaceEntity( + val id: Long, + val categoryId: Long, + val name: String, + val excerpt: String, + val description: String, + val cover: String, + val gallery: List, + @Embedded val coordinates: CoordinatesEntity?, + val rating: Double, + val isFavorite: Boolean, + val language: String, +) { + fun toPlaceFull() = PlaceFull( + id = id, + name = name, + rating = rating, + excerpt = excerpt, + description = description, + placeLocation = coordinates?.toPlaceLocation(name), + cover = cover, + pics = gallery, + isFavorite = isFavorite, + language = language + ) + + fun toPlaceShort() = PlaceShort( + id = id, + name = name, + cover = cover, + rating = rating, + excerpt = excerpt, + isFavorite = isFavorite + ) +} + +data class CoordinatesEntity( + val latitude: Double, + val longitude: Double +) { + fun toPlaceLocation(name: String) = PlaceLocation(name, latitude, longitude) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/data/db/entities/ReviewEntity.kt b/android/app/src/main/java/app/tourism/data/db/entities/ReviewEntity.kt new file mode 100644 index 0000000000..141377a4cb --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/db/entities/ReviewEntity.kt @@ -0,0 +1,29 @@ +package app.tourism.data.db.entities + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.PrimaryKey +import app.tourism.domain.models.details.Review + +@Entity(tableName = "reviews") +data class ReviewEntity( + @PrimaryKey val id: Long, + val placeId: Long, + @Embedded val user: JustUser, + val comment: String, + val date: String, + val rating: Int, + val images: List, + val deletionPlanned: Boolean = false, +) { + fun toReview() = Review( + id = id, + placeId = placeId, + rating = rating, + user = user.toUser(), + date = date, + comment = comment, + picsUrls = images, + deletionPlanned = deletionPlanned, + ) +} diff --git a/android/app/src/main/java/app/tourism/data/db/entities/ReviewToPublishEntity.kt b/android/app/src/main/java/app/tourism/data/db/entities/ReviewToPublishEntity.kt new file mode 100644 index 0000000000..044f85eacf --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/db/entities/ReviewToPublishEntity.kt @@ -0,0 +1,23 @@ +package app.tourism.data.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import app.tourism.domain.models.details.ReviewToPost +import java.io.File + +@Entity(tableName = "reviews_planned_to_post") +data class ReviewPlannedToPostEntity( + @PrimaryKey(autoGenerate = true) val id: Long? = null, + val placeId: Long, + val comment: String, + val rating: Int, + val images: List, +) { + fun toReviewsPlannedToPostDto(): ReviewToPost { + val imageFiles = images.map { File(it) } + + return ReviewToPost( + placeId, comment, rating, imageFiles + ) + } +} diff --git a/android/app/src/main/java/app/tourism/data/db/entities/UserEntity.kt b/android/app/src/main/java/app/tourism/data/db/entities/UserEntity.kt new file mode 100644 index 0000000000..f583f03f42 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/db/entities/UserEntity.kt @@ -0,0 +1,12 @@ +package app.tourism.data.db.entities + +import app.tourism.domain.models.details.User + +data class JustUser( + val userId: Long, + val fullName: String, + val avatar: String?, + val country: String +) { + fun toUser() = User(id = userId, name = fullName, pfpUrl = avatar, countryCodeName = country) +} diff --git a/android/app/src/main/java/app/tourism/data/dto/AllDataDto.kt b/android/app/src/main/java/app/tourism/data/dto/AllDataDto.kt new file mode 100644 index 0000000000..12255937d8 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/dto/AllDataDto.kt @@ -0,0 +1,16 @@ +package app.tourism.data.dto + +import app.tourism.data.dto.place.PlaceDto + +data class AllDataDto( + val attractions_ru: List, + val attractions_en: List, + val restaurants_ru: List, + val restaurants_en: List, + val accommodations_ru: List, + val accommodations_en: List, + val attractions_hash: String, + val restaurants_hash: String, + val accommodations_hash: String, +) + diff --git a/android/app/src/main/java/app/tourism/data/dto/CategoryDto.kt b/android/app/src/main/java/app/tourism/data/dto/CategoryDto.kt new file mode 100644 index 0000000000..164f3cc56e --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/dto/CategoryDto.kt @@ -0,0 +1,9 @@ +package app.tourism.data.dto + +import app.tourism.data.dto.place.PlaceDto + +data class CategoryDto( + val hash: String, + val ru: List, + val en: List, +) diff --git a/android/app/src/main/java/app/tourism/data/dto/FavoritesDto.kt b/android/app/src/main/java/app/tourism/data/dto/FavoritesDto.kt new file mode 100644 index 0000000000..e6148e89f1 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/dto/FavoritesDto.kt @@ -0,0 +1,8 @@ +package app.tourism.data.dto + +import app.tourism.data.dto.place.PlaceDto + +data class FavoritesDto( + val ru: List, + val en: List, +) diff --git a/android/app/src/main/java/app/tourism/data/dto/FavoritesIdsDto.kt b/android/app/src/main/java/app/tourism/data/dto/FavoritesIdsDto.kt new file mode 100644 index 0000000000..b6924bf049 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/dto/FavoritesIdsDto.kt @@ -0,0 +1,5 @@ +package app.tourism.data.dto + +data class FavoritesIdsDto( + val marks: List +) diff --git a/android/app/src/main/java/app/tourism/data/dto/HashDto.kt b/android/app/src/main/java/app/tourism/data/dto/HashDto.kt new file mode 100644 index 0000000000..cb9407a1c9 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/dto/HashDto.kt @@ -0,0 +1,3 @@ +package app.tourism.data.dto + +data class HashDto(val hash: String) \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/data/dto/PlaceLocation.kt b/android/app/src/main/java/app/tourism/data/dto/PlaceLocation.kt new file mode 100644 index 0000000000..d62fdf9d93 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/dto/PlaceLocation.kt @@ -0,0 +1,16 @@ +package app.tourism.data.dto + +import android.os.Parcelable +import app.organicmaps.bookmarks.data.FeatureId +import app.organicmaps.bookmarks.data.MapObject +import app.tourism.data.db.entities.CoordinatesEntity +import kotlinx.parcelize.Parcelize + +@Parcelize +data class PlaceLocation(val name: String, val lat: Double, val lon: Double) : Parcelable { + fun toMapObject() = MapObject.createMapObject( + FeatureId.EMPTY, MapObject.POI, name, "", lat, lon + ); + + fun toCoordinatesEntity() = CoordinatesEntity(lat, lon) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/data/dto/auth/AuthResponseDataDto.kt b/android/app/src/main/java/app/tourism/data/dto/auth/AuthResponseDataDto.kt new file mode 100644 index 0000000000..025045b6a0 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/dto/auth/AuthResponseDataDto.kt @@ -0,0 +1,11 @@ +package app.tourism.data.dto.auth + +import app.tourism.domain.models.auth.AuthResponse + +data class AuthResponseDto( + val token: String, +) { + fun toAuthResponse() = AuthResponse( + token = token + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/data/dto/auth/EmailBodyDto.kt b/android/app/src/main/java/app/tourism/data/dto/auth/EmailBodyDto.kt new file mode 100644 index 0000000000..052140fed6 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/dto/auth/EmailBodyDto.kt @@ -0,0 +1,3 @@ +package app.tourism.data.dto.auth + +data class EmailBodyDto(val email: String) diff --git a/android/app/src/main/java/app/tourism/data/dto/currency/CurrencyRates.kt b/android/app/src/main/java/app/tourism/data/dto/currency/CurrencyRates.kt new file mode 100644 index 0000000000..0f90b8fc20 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/dto/currency/CurrencyRates.kt @@ -0,0 +1,11 @@ +package app.tourism.data.dto.currency + +import app.tourism.data.db.entities.CurrencyRatesEntity +import app.tourism.domain.models.profile.CurrencyRates + +data class CurrencyRatesDataDto(val data: CurrencyRatesDto) { + fun toCurrencyRates() = CurrencyRates(data.usd, data.eur, data.rub) + fun toCurrencyRatesEntity() = CurrencyRatesEntity(1, data.usd, data.eur, data.rub) +} + +data class CurrencyRatesDto(val usd: Double, val eur: Double, val rub: Double) \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/data/dto/place/CoordinatesDto.kt b/android/app/src/main/java/app/tourism/data/dto/place/CoordinatesDto.kt new file mode 100644 index 0000000000..aaf792a747 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/dto/place/CoordinatesDto.kt @@ -0,0 +1,21 @@ +package app.tourism.data.dto.place + +import app.tourism.data.dto.PlaceLocation + +data class CoordinatesDto( + val latitude: String?, + val longitude: String? +) { + fun toPlaceLocation(name: String): PlaceLocation? { + try { + return PlaceLocation( + name, + latitude!!.toDouble(), + longitude!!.toDouble() + ) + } catch (e: Exception) { + e.printStackTrace() + return null + } + } +} diff --git a/android/app/src/main/java/app/tourism/data/dto/place/PlaceDto.kt b/android/app/src/main/java/app/tourism/data/dto/place/PlaceDto.kt new file mode 100644 index 0000000000..7b9708b6b0 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/dto/place/PlaceDto.kt @@ -0,0 +1,29 @@ +package app.tourism.data.dto.place + +import app.tourism.domain.models.details.PlaceFull + +data class PlaceDto( + val id: Long, + val name: String, + val coordinates: CoordinatesDto?, + val cover: String, + val feedbacks: List?, + val gallery: List, + val rating: String, + val short_description: String, + val long_description: String, +) { + fun toPlaceFull(isFavorite: Boolean, language: String) = PlaceFull( + id = id, + name = name, + rating = rating.toDouble(), + excerpt = short_description, + description = long_description, + placeLocation = coordinates?.toPlaceLocation(name), + cover = cover, + pics = gallery, + isFavorite = isFavorite, + reviews = feedbacks?.map { it.toReview() } ?: emptyList(), + language = language, + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/data/dto/place/ReviewDto.kt b/android/app/src/main/java/app/tourism/data/dto/place/ReviewDto.kt new file mode 100644 index 0000000000..98ceab9456 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/dto/place/ReviewDto.kt @@ -0,0 +1,23 @@ +package app.tourism.data.dto.place + +import app.tourism.domain.models.details.Review + +data class ReviewDto( + val id: Long, + val mark_id: Long, + val images: List, + val message: String, + val points: Int, + val created_at: String, + val user: UserDto +) { + fun toReview() = Review( + id = id, + placeId = mark_id, + rating = points, + user = user.toUser(), + date = created_at, + comment = message, + picsUrls = images + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/data/dto/place/ReviewIdsDto.kt b/android/app/src/main/java/app/tourism/data/dto/place/ReviewIdsDto.kt new file mode 100644 index 0000000000..6067b17775 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/dto/place/ReviewIdsDto.kt @@ -0,0 +1,5 @@ +package app.tourism.data.dto.place + +data class ReviewIdsDto( + val feedbacks: List, +) diff --git a/android/app/src/main/java/app/tourism/data/dto/place/ReviewsDto.kt b/android/app/src/main/java/app/tourism/data/dto/place/ReviewsDto.kt new file mode 100644 index 0000000000..704004a45a --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/dto/place/ReviewsDto.kt @@ -0,0 +1,3 @@ +package app.tourism.data.dto.place + +data class ReviewsDto(val data: List) diff --git a/android/app/src/main/java/app/tourism/data/dto/place/UserDto.kt b/android/app/src/main/java/app/tourism/data/dto/place/UserDto.kt new file mode 100644 index 0000000000..4e98c490e3 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/dto/place/UserDto.kt @@ -0,0 +1,18 @@ +package app.tourism.data.dto.place + +import app.tourism.domain.models.details.User + + +data class UserDto( + val id: Long, + val avatar: String, + val country: String, + val full_name: String, +) { + fun toUser() = User( + id = id, + name = full_name, + countryCodeName = country, + pfpUrl = avatar, + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/data/dto/profile/LanguageDto.kt b/android/app/src/main/java/app/tourism/data/dto/profile/LanguageDto.kt new file mode 100644 index 0000000000..15ada740d1 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/dto/profile/LanguageDto.kt @@ -0,0 +1,3 @@ +package app.tourism.data.dto.profile + +data class LanguageDto(val language: String) diff --git a/android/app/src/main/java/app/tourism/data/dto/profile/ThemeDto.kt b/android/app/src/main/java/app/tourism/data/dto/profile/ThemeDto.kt new file mode 100644 index 0000000000..5dc2256e0d --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/dto/profile/ThemeDto.kt @@ -0,0 +1,3 @@ +package app.tourism.data.dto.profile + +data class ThemeDto(val theme: String) diff --git a/android/app/src/main/java/app/tourism/data/dto/profile/User.kt b/android/app/src/main/java/app/tourism/data/dto/profile/User.kt new file mode 100644 index 0000000000..a2dc181630 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/dto/profile/User.kt @@ -0,0 +1,24 @@ +package app.tourism.data.dto.profile + +import app.tourism.domain.models.profile.PersonalData + +data class User( + val id: Long, + val avatar: String?, + val country: String, + val full_name: String, + val email: String, + val language: String?, + val theme: String?, + val username: String +) { + fun toPersonalData() = PersonalData( + id = id, + fullName = full_name, + country = country, + pfpUrl = avatar, + email = email, + language = language, + theme = theme, + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/data/dto/profile/UserData.kt b/android/app/src/main/java/app/tourism/data/dto/profile/UserData.kt new file mode 100644 index 0000000000..169883e5e9 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/dto/profile/UserData.kt @@ -0,0 +1,3 @@ +package app.tourism.data.dto.profile + +data class UserData(val data: User) diff --git a/android/app/src/main/java/app/tourism/data/prefs/UserPreferences.kt b/android/app/src/main/java/app/tourism/data/prefs/UserPreferences.kt new file mode 100644 index 0000000000..b8967e1af1 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/prefs/UserPreferences.kt @@ -0,0 +1,53 @@ +package app.tourism.data.prefs + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import app.organicmaps.R + +class UserPreferences(context: Context) { + private var sharedPref: SharedPreferences = + context.getSharedPreferences("user", Context.MODE_PRIVATE) + + val languages = listOf( + Language(code = "ru", name = "Русский"), + Language(code = "en", name = "English") + ) + + val themes = listOf( + Theme(code = "dark", name = context.getString(R.string.dark_theme)), + Theme(code = "light", name = context.getString(R.string.light_theme)), + ) + + fun getLanguage(): Language? { + val languageCode = sharedPref.getString("language", null) + return languages.firstOrNull() { it.code == languageCode } + } + + fun setLanguage(value: String) = sharedPref.edit { putString("language", value) } + + fun getTheme(): Theme? { + val themeCode = sharedPref.getString("theme", "") + return themes.firstOrNull() { it.code == themeCode } + } + + fun setTheme(value: String?) = sharedPref.edit { putString("theme", value) } + + fun getToken() = sharedPref.getString("token", "") + fun setToken(value: String?) = sharedPref.edit { putString("token", value) } + + fun getUserId() = sharedPref.getString("user_id", "") + fun setUserId(value: String?) = sharedPref.edit { putString("user_id", value) } + + fun getIsEverythingSetup() = sharedPref.getBoolean("is_everything_setup", false) + fun setIsEverythingSetup(value: Boolean) = + sharedPref.edit { putBoolean("is_everything_setup", value) } + + fun getShouldSyncLanguage() = sharedPref.getBoolean("should_sync_language", false) + fun setShouldSyncLanguage(value: Boolean) = + sharedPref.edit { putBoolean("should_sync_language", value) } +} + +data class Language(val code: String, val name: String) + +data class Theme(val code: String, val name: String) \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/data/remote/CurrencyApi.kt b/android/app/src/main/java/app/tourism/data/remote/CurrencyApi.kt new file mode 100644 index 0000000000..9434cd3a2e --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/remote/CurrencyApi.kt @@ -0,0 +1,11 @@ +package app.tourism.data.remote + +import app.tourism.data.dto.currency.CurrencyRatesDataDto +import retrofit2.Response +import retrofit2.http.GET + +interface CurrencyApi { + + @GET("currency") + suspend fun getCurrency(): Response +} diff --git a/android/app/src/main/java/app/tourism/data/remote/NetworkUtils.kt b/android/app/src/main/java/app/tourism/data/remote/NetworkUtils.kt new file mode 100644 index 0000000000..c36a949c20 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/remote/NetworkUtils.kt @@ -0,0 +1,85 @@ +package app.tourism.data.remote + +import android.content.Context +import android.net.ConnectivityManager +import app.organicmaps.R +import app.tourism.domain.models.SimpleResponse +import app.tourism.domain.models.resource.Resource +import com.google.gson.Gson +import kotlinx.coroutines.flow.FlowCollector +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONException +import retrofit2.HttpException +import retrofit2.Response +import java.io.IOException + +suspend inline fun FlowCollector>.handleGenericCall( + call: () -> Response, + mapper: (T) -> Re, + context: Context, + emitLoadingStatusBeforeCall: Boolean = true +) { + if (emitLoadingStatusBeforeCall) emit(Resource.Loading()) + try { + val response = call() + val body = response.body()?.let { mapper(it) } + if (response.isSuccessful) emit(Resource.Success(body)) + else emit(response.parseError()) + } catch (e: HttpException) { + e.printStackTrace() + emit(Resource.Error(context.getString(R.string.smth_went_wrong))) + } catch (e: IOException) { + e.printStackTrace() + emit(Resource.Error(context.getString(R.string.no_network))) + } catch (e: Exception) { + e.printStackTrace() + emit(Resource.Error(context.getString(R.string.smth_went_wrong))) + } +} + +suspend inline fun handleResponse( + call: () -> Response, + context: Context, +): Resource { + try { + val response = call() + if (response.isSuccessful) { + val body = response.body()!! + return Resource.Success(body) + } else return response.parseError() + } catch (e: HttpException) { + e.printStackTrace() + return Resource.Error(context.getString(R.string.smth_went_wrong)) + } catch (e: IOException) { + e.printStackTrace() + return Resource.Error(context.getString(R.string.no_network)) + } catch (e: Exception) { + e.printStackTrace() + return Resource.Error(context.getString(R.string.smth_went_wrong)) + } +} + +inline fun Response.parseError(): Resource { + return try { + val response = Gson() + .fromJson( + errorBody()?.string().toString(), + SimpleResponse::class.java + ) + + Resource.Error(message = response?.message ?: "") + } catch (e: JSONException) { + e.printStackTrace() + Resource.Error(e.toString()) + } +} + +fun String.toFormDataRequestBody() = this.toRequestBody("multipart/form-data".toMediaTypeOrNull()) + +fun isOnline(context: Context): Boolean { + val cm = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager? + val netInfo = cm!!.activeNetworkInfo + return netInfo != null && netInfo.isConnected() +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/data/remote/TourismApi.kt b/android/app/src/main/java/app/tourism/data/remote/TourismApi.kt new file mode 100644 index 0000000000..8cb5a0ca10 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/remote/TourismApi.kt @@ -0,0 +1,120 @@ +package app.tourism.data.remote + +import app.tourism.data.dto.AllDataDto +import app.tourism.data.dto.CategoryDto +import app.tourism.data.dto.FavoritesDto +import app.tourism.data.dto.FavoritesIdsDto +import app.tourism.data.dto.auth.AuthResponseDto +import app.tourism.data.dto.auth.EmailBodyDto +import app.tourism.data.dto.place.ReviewDto +import app.tourism.data.dto.place.ReviewIdsDto +import app.tourism.data.dto.place.ReviewsDto +import app.tourism.data.dto.profile.LanguageDto +import app.tourism.data.dto.profile.ThemeDto +import app.tourism.data.dto.profile.UserData +import app.tourism.domain.models.SimpleResponse +import okhttp3.MultipartBody +import okhttp3.RequestBody +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.GET +import retrofit2.http.HTTP +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query + +interface TourismApi { + // region auth + @FormUrlEncoded + @POST("login") + suspend fun signIn( + @Field("email") email: String, + @Field("password") password: String, + ): Response + + @FormUrlEncoded + @POST("register") + suspend fun signUp( + @Field("full_name") fullName: String, + @Field("email") email: String, + @Field("password") password: String, + @Field("password_confirmation") passwordConfirmation: String, + @Field("country") country: String, + ): Response + + @POST("logout") + suspend fun signOut(): Response + + @POST("forgot-password") + suspend fun sendEmailForPasswordReset(@Body emailBody: EmailBodyDto): Response + // endregion auth + + // region profile + @GET("user") + suspend fun getUser(): Response + + @Multipart + @POST("profile") + suspend fun updateProfile( + @Part("full_name") fullName: RequestBody? = null, + @Part("email") email: RequestBody? = null, + @Part("country") country: RequestBody? = null, + @Part("language") language: RequestBody? = null, + @Part("theme") theme: RequestBody? = null, + @Part("_method") _method: RequestBody? = "PUT".toFormDataRequestBody(), + @Part avatar: MultipartBody.Part? = null + ): Response + + @PUT("profile/lang") + suspend fun updateLanguage(@Body language: LanguageDto): Response + + @PUT("profile/theme") + suspend fun updateTheme(@Body theme: ThemeDto): Response + // endregion profile + + // region places + @GET("marks/{id}") + suspend fun getPlacesByCategory( + @Path("id") id: Long, + @Query("hash") hash: String + ): Response + + @GET("marks/all") + suspend fun getAllPlaces(): Response + // endregion places + + // region favorites + @GET("favourite-marks") + suspend fun getFavorites(): Response + + @POST("favourite-marks") + suspend fun addFavorites(@Body ids: FavoritesIdsDto): Response + + @HTTP(method = "DELETE", path = "favourite-marks", hasBody = true) + suspend fun removeFromFavorites(@Body ids: FavoritesIdsDto): Response + // endregion favorites + + // region reviews + @GET("feedbacks/{id}") + suspend fun getReviewsByPlaceId(@Path("id") id: Long): Response + + @Multipart + @POST("feedbacks") + suspend fun postReview( + @Part("message") comment: RequestBody? = null, + @Part("mark_id") placeId: RequestBody? = null, + @Part("points") points: RequestBody? = null, + @Part images: List? = null + ): Response + + @HTTP(method = "DELETE", path = "feedbacks", hasBody = true) + suspend fun deleteReviews( + @Body feedbacks: ReviewIdsDto, + ): Response + // endregion reviews +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/data/remote/WifiReceiver.kt b/android/app/src/main/java/app/tourism/data/remote/WifiReceiver.kt new file mode 100644 index 0000000000..ebf4af4a74 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/remote/WifiReceiver.kt @@ -0,0 +1,46 @@ +package app.tourism.data.remote + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.net.NetworkInfo +import android.net.wifi.WifiManager +import app.tourism.data.prefs.UserPreferences +import app.tourism.data.repositories.PlacesRepository +import app.tourism.data.repositories.ProfileRepository +import app.tourism.data.repositories.ReviewsRepository +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class WifiReceiver : BroadcastReceiver() { + @Inject + lateinit var reviewsRepository: ReviewsRepository + + @Inject + lateinit var placesRepository: PlacesRepository + + @Inject + lateinit var profileRepository: ProfileRepository + + @Inject + lateinit var userPreferences: UserPreferences + + override fun onReceive(context: Context, intent: Intent) { + val info: NetworkInfo? = intent.getParcelableExtra(WifiManager.EXTRA_NETWORK_INFO) + + if (info != null && info.isConnected) { + CoroutineScope(Dispatchers.IO).launch { + delay(2000L) // to avoid errors + CoroutineScope(Dispatchers.IO).launch { reviewsRepository.syncReviews() } + CoroutineScope(Dispatchers.IO).launch { placesRepository.syncFavorites() } + CoroutineScope(Dispatchers.IO).launch { profileRepository.syncLanguageIfNecessary() } + } + } + } +} + diff --git a/android/app/src/main/java/app/tourism/data/repositories/AuthRepository.kt b/android/app/src/main/java/app/tourism/data/repositories/AuthRepository.kt new file mode 100644 index 0000000000..9f5a968bfc --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/repositories/AuthRepository.kt @@ -0,0 +1,54 @@ +package app.tourism.data.repositories + +import android.content.Context +import app.tourism.data.dto.auth.EmailBodyDto +import app.tourism.data.remote.TourismApi +import app.tourism.data.remote.handleGenericCall +import app.tourism.domain.models.SimpleResponse +import app.tourism.domain.models.auth.AuthResponse +import app.tourism.domain.models.auth.RegistrationData +import app.tourism.domain.models.resource.Resource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +class AuthRepository(private val api: TourismApi, private val context: Context) { + fun signIn(email: String, password: String): Flow> = flow { + handleGenericCall( + call = { api.signIn(email, password) }, + mapper = { it.toAuthResponse() }, + context + ) + } + + fun signUp(registrationData: RegistrationData) = flow { + handleGenericCall( + call = { + api.signUp( + registrationData.fullName, + registrationData.email, + registrationData.password, + registrationData.passwordConfirmation, + registrationData.country + ) + }, + mapper = { it.toAuthResponse() }, + context + ) + } + + fun signOut(): Flow> = flow { + handleGenericCall( + call = { api.signOut() }, + mapper = { it }, + context + ) + } + + fun sendEmailForPasswordReset(email: String) = flow { + handleGenericCall( + call = { api.sendEmailForPasswordReset(EmailBodyDto(email)) }, + mapper = { it }, + context + ) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/data/repositories/CurrencyRepository.kt b/android/app/src/main/java/app/tourism/data/repositories/CurrencyRepository.kt new file mode 100644 index 0000000000..6a9ac64bdf --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/repositories/CurrencyRepository.kt @@ -0,0 +1,33 @@ +package app.tourism.data.repositories + +import android.content.Context +import app.tourism.data.db.Database +import app.tourism.data.remote.CurrencyApi +import app.tourism.data.remote.handleGenericCall +import app.tourism.domain.models.profile.CurrencyRates +import app.tourism.domain.models.resource.Resource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +class CurrencyRepository( + private val api: CurrencyApi, + private val db: Database, + val context: Context +) { + val currenciesDao = db.currencyRatesDao + + suspend fun getCurrency(): Flow> = flow { + currenciesDao.getCurrencyRates()?.let { + emit(Resource.Success(it.toCurrencyRates())) + } + + handleGenericCall( + call = { api.getCurrency() }, + mapper = { + db.currencyRatesDao.updateCurrencyRates(it.toCurrencyRatesEntity()) + it.toCurrencyRates() + }, + context + ) + } +} diff --git a/android/app/src/main/java/app/tourism/data/repositories/PlacesRepository.kt b/android/app/src/main/java/app/tourism/data/repositories/PlacesRepository.kt new file mode 100644 index 0000000000..d6ad3584e6 --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/repositories/PlacesRepository.kt @@ -0,0 +1,376 @@ +package app.tourism.data.repositories + +import android.content.Context +import android.util.Log +import app.organicmaps.R +import app.tourism.data.db.Database +import app.tourism.data.db.entities.FavoriteSyncEntity +import app.tourism.data.db.entities.HashEntity +import app.tourism.data.db.entities.ImageToDownloadEntity +import app.tourism.data.db.entities.PlaceEntity +import app.tourism.data.db.entities.ReviewEntity +import app.tourism.data.dto.FavoritesIdsDto +import app.tourism.data.dto.place.PlaceDto +import app.tourism.data.prefs.UserPreferences +import app.tourism.data.remote.TourismApi +import app.tourism.data.remote.handleGenericCall +import app.tourism.data.remote.handleResponse +import app.tourism.domain.models.SimpleResponse +import app.tourism.domain.models.categories.PlaceCategory +import app.tourism.domain.models.common.PlaceShort +import app.tourism.domain.models.details.PlaceFull +import app.tourism.domain.models.resource.DownloadProgress +import app.tourism.domain.models.resource.DownloadStats +import app.tourism.domain.models.resource.Resource +import coil.imageLoader +import coil.request.ErrorResult +import coil.request.ImageRequest +import coil.request.SuccessResult +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.flow + +class PlacesRepository( + private val api: TourismApi, + db: Database, + val userPreferences: UserPreferences, + @ApplicationContext private val context: Context, +) { + private val placesDao = db.placesDao + private val reviewsDao = db.reviewsDao + private val hashesDao = db.hashesDao + private val imagesToDownloadDao = db.imagesToDownloadDao + + private val language = userPreferences.getLanguage()?.code ?: "ru" + + fun downloadAllData(): Flow> = flow { + // this is for test +// hashesDao.deleteHashes() + val hashes = hashesDao.getHashes() + val favoritesResponse = handleResponse(call = { api.getFavorites() }, context) + + if (hashes.isEmpty()) { + handleGenericCall( + call = { api.getAllPlaces() }, + mapper = { data -> + // get data + val favoritesEn = + if (favoritesResponse is Resource.Success) + favoritesResponse.data?.en?.map { + it.toPlaceFull(true, language = "en") + } else null + + val reviewsEntities = mutableListOf() + + fun PlaceDto.toEntity( + placeCategory: PlaceCategory, + language: String + ): PlaceEntity { + var placeFull = this.toPlaceFull(false, language) + placeFull = + placeFull.copy( + isFavorite = favoritesEn?.any { it.id == placeFull.id } ?: false + ) + + placeFull.reviews?.let { it1 -> + reviewsEntities.addAll(it1.map { it.toReviewEntity() }) + } + return placeFull.toPlaceEntity(placeCategory.id) + } + + val sightsEntitiesEn = data.attractions_en.map { placeDto -> + placeDto.toEntity(PlaceCategory.Sights, "en") + } + val restaurantsEntitiesEn = data.restaurants_en.map { placeDto -> + placeDto.toEntity(PlaceCategory.Restaurants, "en") + } + val hotelsEntitiesEn = data.accommodations_en.map { placeDto -> + placeDto.toEntity(PlaceCategory.Hotels, "en") + } + val sightsEntitiesRu = data.attractions_ru.map { placeDto -> + placeDto.toEntity(PlaceCategory.Sights, "ru") + } + val restaurantsEntitiesRu = data.restaurants_ru.map { placeDto -> + placeDto.toEntity(PlaceCategory.Restaurants, "ru") + } + val hotelsEntitiesRu = data.accommodations_ru.map { placeDto -> + placeDto.toEntity(PlaceCategory.Hotels, "ru") + } + + val allPlacesEntities = + sightsEntitiesEn + restaurantsEntitiesEn + hotelsEntitiesEn + sightsEntitiesRu + restaurantsEntitiesRu + hotelsEntitiesRu + + // add all images urls to download + val imagesToDownload = mutableListOf() + allPlacesEntities.forEach { placeEntity -> + val gallery = placeEntity.gallery.filter { it.isNotBlank() } + .map { ImageToDownloadEntity(it, false) } + imagesToDownload.addAll(gallery) + if (placeEntity.cover.isNotBlank()) { + val cover = ImageToDownloadEntity(placeEntity.cover, false) + imagesToDownload.add(cover) + } + } + reviewsEntities.forEach { reviewEntity -> + val images = reviewEntity.images.filter { it.isNotBlank() } + .map { ImageToDownloadEntity(it, false) } + imagesToDownload.addAll(images) + + reviewEntity.user.avatar?.let { + if (it.isNotBlank()) { + val userPfp = ImageToDownloadEntity(it, false) + imagesToDownload.add(userPfp) + } + } + } + + imagesToDownloadDao.insertImages(imagesToDownload) + + // update places + placesDao.deleteAllPlaces() + placesDao.insertPlaces(allPlacesEntities) + + // update reviews + reviewsDao.deleteAllReviews() + reviewsDao.insertReviews(reviewsEntities) + + // update favorites + favoritesEn?.forEach { + placesDao.setFavorite(it.id, it.isFavorite) + } + + // update hashes + hashesDao.insertHashes( + listOf( + HashEntity(PlaceCategory.Sights.id, data.attractions_hash), + HashEntity(PlaceCategory.Restaurants.id, data.restaurants_hash), + HashEntity(PlaceCategory.Hotels.id, data.accommodations_hash), + ) + ) + + // return response + SimpleResponse(message = context.getString(R.string.download_successful)) + }, + context + ) + } else { + emit(Resource.Success(SimpleResponse(message = context.getString(R.string.download_successful)))) + } + } + + fun downloadAllImages(): Flow = flow { + try { + val imagesToDownload = imagesToDownloadDao.getAllImages() + val notDownloadedImages = imagesToDownload.filter { !it.downloaded } + + val filesTotalNum = imagesToDownload.size + val filesDownloaded = filesTotalNum - notDownloadedImages.size + val downloadStats = DownloadStats( + filesTotalNum, + filesDownloaded, + 0 + ) + + if (downloadStats.percentagesCompleted >= 90) return@flow + + notDownloadedImages.forEach { + try { + val request = ImageRequest.Builder(context) + .data(it.url) + .build() + val result = context.imageLoader.execute(request) + + when (result) { + is SuccessResult -> { + downloadStats.filesDownloaded++ + imagesToDownloadDao.markAsDownloaded(it.url, true) + } + + is ErrorResult -> { + downloadStats.filesFailedToDownload++ + Log.d("", "Url failed to download: ${it.url}") + } + } + + downloadStats.updatePercentage() + Log.d("", "downloadStats: $downloadStats") + + if (downloadStats.isAllFilesProcessed()) { + emit(DownloadProgress.Finished(downloadStats)) + } else { + emit(DownloadProgress.Loading(downloadStats)) + } + } catch (e: Exception) { + downloadStats.filesFailedToDownload++ + e.printStackTrace() + } + } + } catch (e: Exception) { + e.printStackTrace() + emit(DownloadProgress.Error(message = context.getString(R.string.smth_went_wrong))) + } + } + + suspend fun markAllImagesAsNotDownloadedIfCacheWasCleared() { + // if coil cache is less than 10 MB, + // then most likely it was cleared and data needs to be downloaded again + // so we mark all images as not downloaded + context.imageLoader.diskCache?.let { + if (it.size < 10000000) { + imagesToDownloadDao.markAllImagesAsNotDownloaded() + } + } + } + + suspend fun shouldDownloadImages(): Boolean { + val imagesToDownload = imagesToDownloadDao.getAllImages() + val notDownloadedImages = imagesToDownload.filter { !it.downloaded } + + val filesTotalNum = imagesToDownload.size + val filesDownloaded = filesTotalNum - notDownloadedImages.size + val percentage = (filesDownloaded * 100) / filesTotalNum + + return percentage < 90 + } + + fun search(q: String): Flow>> = channelFlow { + placesDao.search("%$q%", language).collectLatest { placeEntities -> + val places = placeEntities.map { it.toPlaceShort() } + send(Resource.Success(places)) + } + } + + fun getPlacesByCategoryFromDbFlow(id: Long): Flow>> = channelFlow { + placesDao.getSortedPlacesByCategoryIdFlow(categoryId = id, language) + .collectLatest { placeEntities -> + send(Resource.Success(placeEntities.map { it.toPlaceShort() })) + } + } + + suspend fun getPlacesByCategoryFromApiIfThereIsChange(id: Long) { + val hash = hashesDao.getHash(id) + + val favorites = placesDao.getFavoritePlaces("%%", language) + val resource = + handleResponse(call = { api.getPlacesByCategory(id, hash?.value ?: "") }, context) + + if (hash != null && resource is Resource.Success) { + resource.data?.let { categoryDto -> + if (categoryDto.hash.isBlank()) return + // update places + val placesEn = categoryDto.en.map { placeDto -> + var placeFull = placeDto.toPlaceFull(false, "en") + placeFull = + placeFull.copy(isFavorite = favorites.any { it.id == placeFull.id }) + placeFull + } + val placesRu = categoryDto.ru.map { placeDto -> + var placeFull = placeDto.toPlaceFull(false, "ru") + placeFull = + placeFull.copy(isFavorite = favorites.any { it.id == placeFull.id }) + placeFull + } + + val oldCacheRu = placesDao.getPlacesByCategoryIdNotFlow(id, "ru") + val oldCacheEn = placesDao.getPlacesByCategoryIdNotFlow(id, "en") + val oldCache = oldCacheEn + oldCacheRu + + val allPlaces = mutableListOf() + allPlaces.addAll(placesEn) + allPlaces.addAll(placesRu) + + val placesRemovedFromApi = + oldCache + .filter { oldCachePlace -> !allPlaces.any { oldCachePlace.id == it.id } } + .map { it.id } + + placesDao.deletePlaces(placesRemovedFromApi) + placesDao.insertPlaces(allPlaces.map { it.toPlaceEntity(id) }) + + // update reviews + val reviewsEntities = mutableListOf() + allPlaces.forEach { place -> + reviewsDao.deleteAllPlaceReviews(place.id) + place.reviews?.map { review -> review.toReviewEntity() } + ?.also { reviewEntity -> reviewsEntities.addAll(reviewEntity) } + } + reviewsDao.insertReviews(reviewsEntities) + + // update hash + hashesDao.insertHash(hash.copy(value = categoryDto.hash)) + } + } + } + + fun getTopPlaces(id: Long): Flow>> = channelFlow { + placesDao.getTopPlacesByCategoryId(categoryId = id, language = language) + .collectLatest { placeEntities -> + send(Resource.Success(placeEntities.map { it.toPlaceShort() })) + } + } + + fun getPlaceById(id: Long): Flow> = channelFlow { + placesDao.getPlaceById(id, language).collectLatest { placeEntity -> + if (placeEntity != null) + send(Resource.Success(placeEntity.toPlaceFull())) + else + send(Resource.Error(message = "Не найдено")) + } + } + + fun getFavorites(q: String): Flow>> = channelFlow { + placesDao.getFavoritePlacesFlow("%$q%", language) + .collectLatest { placeEntities -> + send(Resource.Success(placeEntities.map { it.toPlaceShort() })) + } + } + + suspend fun setFavorite(placeId: Long, isFavorite: Boolean) { + placesDao.setFavorite(placeId, isFavorite) + + val favoritesIdsDto = FavoritesIdsDto(marks = listOf(placeId)) + + val favoriteSyncEntity = FavoriteSyncEntity(placeId, isFavorite) + placesDao.addFavoriteSync(favoriteSyncEntity) + val response: Resource = if (isFavorite) + handleResponse(call = { api.addFavorites(favoritesIdsDto) }, context) + else + handleResponse(call = { api.removeFromFavorites(favoritesIdsDto) }, context) + + if (response is Resource.Success) + placesDao.removeFavoriteSync(favoriteSyncEntity.placeId) + else if (response is Resource.Error) + placesDao.addFavoriteSync(favoriteSyncEntity) + } + + suspend fun syncFavorites() { + val favoritesToSyncEntities = placesDao.getFavoriteSyncData() + + val favoritesToAdd = favoritesToSyncEntities.filter { it.isFavorite }.map { it.placeId } + val favoritesToRemove = favoritesToSyncEntities.filter { !it.isFavorite }.map { it.placeId } + + if (favoritesToAdd.isNotEmpty()) { + val responseToAddFavs = + handleResponse( + call = { api.addFavorites(FavoritesIdsDto(favoritesToAdd)) }, + context + ) + if (responseToAddFavs is Resource.Success) { + placesDao.removeFavoriteSync(favoritesToAdd) + } + } + + if (favoritesToRemove.isNotEmpty()) { + val responseToRemoveFavs = + handleResponse( + call = { api.removeFromFavorites(FavoritesIdsDto(favoritesToRemove)) }, + context + ) + if (responseToRemoveFavs is Resource.Success) { + placesDao.removeFavoriteSync(favoritesToRemove) + } + } + } +} diff --git a/android/app/src/main/java/app/tourism/data/repositories/ProfileRepository.kt b/android/app/src/main/java/app/tourism/data/repositories/ProfileRepository.kt new file mode 100644 index 0000000000..bcbbcf3cbd --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/repositories/ProfileRepository.kt @@ -0,0 +1,101 @@ +package app.tourism.data.repositories + +import android.content.Context +import app.tourism.data.dto.profile.LanguageDto +import app.tourism.data.dto.profile.ThemeDto +import app.tourism.data.prefs.UserPreferences +import app.tourism.data.remote.TourismApi +import app.tourism.data.remote.handleGenericCall +import app.tourism.data.remote.handleResponse +import app.tourism.data.remote.toFormDataRequestBody +import app.tourism.domain.models.profile.PersonalData +import app.tourism.domain.models.resource.Resource +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File + +class ProfileRepository( + private val api: TourismApi, + private val userPreferences: UserPreferences, + @ApplicationContext private val context: Context +) { + fun getPersonalData(): Flow> = flow { + handleGenericCall( + call = { api.getUser() }, + mapper = { + it.data.toPersonalData() + }, + context + ) + } + + fun updateProfile( + fullName: String, + country: String, + email: String?, + pfpFile: File? + ): Flow> = + flow { + var pfpMultipart: MultipartBody.Part? = null + if (pfpFile != null) { + val requestBody = pfpFile.asRequestBody("image/*".toMediaType()) + pfpMultipart = + MultipartBody.Part.createFormData("avatar", pfpFile.name, requestBody) + } + + val language = userPreferences.getLanguage()?.code + val theme = userPreferences.getTheme()?.code + + handleGenericCall( + call = { + api.updateProfile( + fullName = fullName.toFormDataRequestBody(), + email = email?.toFormDataRequestBody(), + country = country.toFormDataRequestBody(), + language = language.toString().toFormDataRequestBody(), + theme = theme.toString().toFormDataRequestBody(), + avatar = pfpMultipart + ) + }, + mapper = { it.data.toPersonalData() }, + context + ) + } + + suspend fun updateLanguage(code: String) { + try { + val resource = handleResponse( + call = { api.updateLanguage(language = LanguageDto(code)) }, + context + ) + if (resource is Resource.Success) { + userPreferences.setShouldSyncLanguage(false) + } else if (resource is Resource.Error) { + userPreferences.setShouldSyncLanguage(true) + } + } catch (e: Exception) { + userPreferences.setShouldSyncLanguage(true) + println(e.message) + } + } + + suspend fun syncLanguageIfNecessary() { + userPreferences.getLanguage()?.code?.let { + if (userPreferences.getShouldSyncLanguage()) { + updateLanguage(it) + } + } + } + + suspend fun updateTheme(code: String) { + try { + api.updateTheme(theme = ThemeDto(code)) + } catch (e: Exception) { + println(e.message) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/data/repositories/ReviewsRepository.kt b/android/app/src/main/java/app/tourism/data/repositories/ReviewsRepository.kt new file mode 100644 index 0000000000..74fc582f8b --- /dev/null +++ b/android/app/src/main/java/app/tourism/data/repositories/ReviewsRepository.kt @@ -0,0 +1,189 @@ +package app.tourism.data.repositories + +import android.content.Context +import app.organicmaps.R +import app.tourism.data.db.Database +import app.tourism.data.dto.place.ReviewIdsDto +import app.tourism.data.remote.TourismApi +import app.tourism.data.remote.handleResponse +import app.tourism.data.remote.isOnline +import app.tourism.data.remote.toFormDataRequestBody +import app.tourism.domain.models.SimpleResponse +import app.tourism.domain.models.details.Review +import app.tourism.domain.models.details.ReviewToPost +import app.tourism.domain.models.resource.Resource +import app.tourism.utils.compress +import app.tourism.utils.saveToInternalStorage +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File + +class ReviewsRepository( + @ApplicationContext val context: Context, + private val api: TourismApi, + private val db: Database, +) { + private val reviewsDao = db.reviewsDao + + fun getReviewsForPlace(id: Long): Flow>> = channelFlow { + reviewsDao.getReviewsForPlace(id).collectLatest { reviewsEntities -> + val reviews = reviewsEntities.map { it.toReview() } + send(Resource.Success(reviews)) + } + } + + fun isThereReviewPlannedToPublish(placeId: Long): Flow = channelFlow { + reviewsDao.getReviewsPlannedToPostFlow(placeId).collectLatest { reviewsEntities -> + send(reviewsEntities.isNotEmpty()) + } + } + + suspend fun getReviewsFromApi(id: Long) { + val getReviewsResponse = handleResponse(call = { api.getReviewsByPlaceId(id) }, context) + if (getReviewsResponse is Resource.Success) { + reviewsDao.deleteAllPlaceReviews(id) + getReviewsResponse.data?.data?.map { it.toReview().toReviewEntity() } + ?.let { reviewsDao.insertReviews(it) } + } + } + + fun postReview(review: ReviewToPost): Flow> = flow { + emit(Resource.Loading()) + val imageFiles = mutableListOf() + review.images.forEach { imageFiles.add(compress(it, context)) } + + if (isOnline(context)) { + val postReviewResponse = handleResponse( + call = { + api.postReview( + placeId = review.placeId.toString().toFormDataRequestBody(), + comment = review.comment.toFormDataRequestBody(), + points = review.rating.toString().toFormDataRequestBody(), + images = getMultipartFromImageFiles(imageFiles) + ) + }, + context + ) + + if (postReviewResponse is Resource.Success) { + updateReviewsForDb(review.placeId) + emit(Resource.Success(SimpleResponse(context.getString(R.string.review_was_published)))) + } else if (postReviewResponse is Resource.Error) { + emit(Resource.Error(postReviewResponse.message ?: "")) + } + } else { + try { + saveToInternalStorage(imageFiles, context) + reviewsDao.insertReviewPlannedToPost(review.toReviewPlannedToPostEntity(imageFiles)) + emit(Resource.Error(context.getString(R.string.review_will_be_published_when_online))) + } catch (e: OutOfMemoryError) { + e.printStackTrace() + emit(Resource.Error(context.getString(R.string.smth_went_wrong))) + } + } + } + + fun deleteReview(id: Long): Flow> = + flow { + if (isOnline(context)) { + val deleteReviewResponse = + handleResponse( + call = { api.deleteReviews(ReviewIdsDto(listOf(id))) }, + context, + ) + + if (deleteReviewResponse is Resource.Success) { + reviewsDao.deleteReview(id) + } + emit(deleteReviewResponse) + } else { + reviewsDao.markReviewForDeletion(id) + } + } + + suspend fun syncReviews() { + deleteReviewsThatWereNotDeletedOnTheServer() + publishReviewsThatWereNotPublished() + } + + private suspend fun deleteReviewsThatWereNotDeletedOnTheServer() { + val reviews = reviewsDao.getReviewsPlannedForDeletion() + if (reviews.isNotEmpty()) { + val reviewsIds = reviews.map { it.id } + val response = + handleResponse(call = { api.deleteReviews(ReviewIdsDto(reviewsIds)) }, context) + if (response is Resource.Success) { + reviewsDao.deleteReviews(reviewsIds) + } + } + } + + private suspend fun publishReviewsThatWereNotPublished() { + val reviewsPlannedToPostEntities = reviewsDao.getReviewsPlannedToPost() + if (reviewsPlannedToPostEntities.isNotEmpty()) { + val reviews = reviewsPlannedToPostEntities.map { it.toReviewsPlannedToPostDto() } + reviews.forEach { + CoroutineScope(Dispatchers.IO).launch { + val response = handleResponse( + call = { + api.postReview( + placeId = it.placeId.toString().toFormDataRequestBody(), + comment = it.comment.toFormDataRequestBody(), + points = it.rating.toString().toFormDataRequestBody(), + images = getMultipartFromImageFiles(it.images) + ) + }, + context, + ) + if (response is Resource.Success) { + try { + updateReviewsForDb(it.placeId) + reviewsDao.deleteReviewPlannedToPost(it.placeId) + } catch (e: Exception) { + e.printStackTrace() + } + } else if (response is Resource.Error) { + try { + reviewsDao.deleteReviewPlannedToPost(it.placeId) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + } + } + + private suspend fun updateReviewsForDb(id: Long) { + val getReviewsResponse = handleResponse( + call = { api.getReviewsByPlaceId(id) }, + context + ) + if (getReviewsResponse is Resource.Success) { + reviewsDao.deleteAllPlaceReviews(id) + val reviews = + getReviewsResponse.data?.data?.map { it.toReview().toReviewEntity() } ?: listOf() + reviewsDao.insertReviews(reviews) + } + } + + private fun getMultipartFromImageFiles(imageFiles: List): MutableList { + val imagesMultipart = mutableListOf() + imageFiles.forEach { + val requestBody = it.asRequestBody("image/*".toMediaType()) + val imageMultipart = + MultipartBody.Part.createFormData("images[]", it.name, requestBody) + imagesMultipart.add(imageMultipart) + } + return imagesMultipart + } +} diff --git a/android/app/src/main/java/app/tourism/di/DatabaseModule.kt b/android/app/src/main/java/app/tourism/di/DatabaseModule.kt new file mode 100644 index 0000000000..c2f951d6bd --- /dev/null +++ b/android/app/src/main/java/app/tourism/di/DatabaseModule.kt @@ -0,0 +1,26 @@ +package app.tourism.di + +import android.app.Application +import androidx.room.Room +import app.tourism.data.db.Database +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + @Provides + @Singleton + fun provideDatabase(app: Application): Database { + return Room.databaseBuilder( + app, Database::class.java, "tourism_database" + ) + .fallbackToDestructiveMigration() + .allowMainThreadQueries() + .build() + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/di/NetworkModule.kt b/android/app/src/main/java/app/tourism/di/NetworkModule.kt new file mode 100644 index 0000000000..1827153753 --- /dev/null +++ b/android/app/src/main/java/app/tourism/di/NetworkModule.kt @@ -0,0 +1,82 @@ +package app.tourism.di + +import android.content.Context +import app.tourism.BASE_URL +import app.tourism.data.prefs.UserPreferences +import app.tourism.data.remote.CurrencyApi +import app.tourism.data.remote.TourismApi +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import javax.inject.Named +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + @Provides + @Singleton + fun provideApi(okHttpClient: OkHttpClient): TourismApi { + return Retrofit.Builder() + .baseUrl("$BASE_URL/api/") + .addConverterFactory(GsonConverterFactory.create()) + .client(okHttpClient) + .build() + .create(TourismApi::class.java) + } + + @Provides + @Singleton + fun provideHttpClient( + @ApplicationContext context: Context, + userPreferences: UserPreferences + ): OkHttpClient { + return OkHttpClient.Builder() + .addInterceptor( + HttpLoggingInterceptor() + .setLevel(HttpLoggingInterceptor.Level.BASIC) + ) + .addInterceptor { chain -> + val original = chain.request() + original.body.toString() + + val requestBuilder = original.newBuilder() + + userPreferences.getToken()?.let { + requestBuilder.addHeader("Authorization", "Bearer $it") + } + + val request = requestBuilder + .addHeader("Accept", "application/json") + .method(original.method, original.body).build() + + chain.proceed(request) + + }.build() + } + + @Provides + @Singleton + @Named(CURRENCY_RETROFIT_LABEL) + fun provideCurrencyRetrofit(client: OkHttpClient): Retrofit { + return Retrofit.Builder() + .baseUrl("$BASE_URL/api/") + .addConverterFactory(GsonConverterFactory.create()) + .client(client) + .build() + } + + @Provides + @Singleton + fun provideCurrencyApi(@Named(CURRENCY_RETROFIT_LABEL) retrofit: Retrofit): CurrencyApi { + return retrofit.create(CurrencyApi::class.java) + } +} + +const val CURRENCY_RETROFIT_LABEL = "currency retrofit" diff --git a/android/app/src/main/java/app/tourism/di/PreferencesModule.kt b/android/app/src/main/java/app/tourism/di/PreferencesModule.kt new file mode 100644 index 0000000000..19c86077b1 --- /dev/null +++ b/android/app/src/main/java/app/tourism/di/PreferencesModule.kt @@ -0,0 +1,23 @@ +package app.tourism.di + +import android.content.Context +import app.tourism.data.prefs.UserPreferences +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object PreferencesModule { + + @Provides + @Singleton + fun provideUserPreferences( + @ApplicationContext context: Context + ): UserPreferences { + return UserPreferences(context) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/di/RepositoriesModule.kt b/android/app/src/main/java/app/tourism/di/RepositoriesModule.kt new file mode 100644 index 0000000000..a940a5b222 --- /dev/null +++ b/android/app/src/main/java/app/tourism/di/RepositoriesModule.kt @@ -0,0 +1,73 @@ +package app.tourism.di + +import android.content.Context +import app.tourism.data.db.Database +import app.tourism.data.prefs.UserPreferences +import app.tourism.data.remote.CurrencyApi +import app.tourism.data.remote.TourismApi +import app.tourism.data.repositories.AuthRepository +import app.tourism.data.repositories.CurrencyRepository +import app.tourism.data.repositories.PlacesRepository +import app.tourism.data.repositories.ProfileRepository +import app.tourism.data.repositories.ReviewsRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object RepositoriesModule { + @Provides + @Singleton + fun provideAuthRepository( + api: TourismApi, + @ApplicationContext context: Context, + ): AuthRepository { + return AuthRepository(api, context) + } + + @Provides + @Singleton + fun providePlacesRepository( + api: TourismApi, + db: Database, + userPreferences: UserPreferences, + @ApplicationContext context: Context, + ): PlacesRepository { + return PlacesRepository(api, db, userPreferences, context) + } + + @Provides + @Singleton + fun provideReviewsRepository( + api: TourismApi, + db: Database, + @ApplicationContext context: Context, + ): ReviewsRepository { + return ReviewsRepository(context, api, db) + } + + + @Provides + @Singleton + fun provideProfileRepository( + api: TourismApi, + userPreferences: UserPreferences, + @ApplicationContext context: Context, + ): ProfileRepository { + return ProfileRepository(api, userPreferences, context) + } + + @Provides + @Singleton + fun provideCurrencyRepository( + api: CurrencyApi, + db: Database, + @ApplicationContext context: Context, + ): CurrencyRepository { + return CurrencyRepository(api, db, context ) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/domain/models/SimpleResponse.kt b/android/app/src/main/java/app/tourism/domain/models/SimpleResponse.kt new file mode 100644 index 0000000000..be85ec1df3 --- /dev/null +++ b/android/app/src/main/java/app/tourism/domain/models/SimpleResponse.kt @@ -0,0 +1,3 @@ +package app.tourism.domain.models + +data class SimpleResponse(val message: String) \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/domain/models/auth/AuthResponse.kt b/android/app/src/main/java/app/tourism/domain/models/auth/AuthResponse.kt new file mode 100644 index 0000000000..7007d6c2db --- /dev/null +++ b/android/app/src/main/java/app/tourism/domain/models/auth/AuthResponse.kt @@ -0,0 +1,3 @@ +package app.tourism.domain.models.auth + +data class AuthResponse(val token: String) diff --git a/android/app/src/main/java/app/tourism/domain/models/auth/RegistrationData.kt b/android/app/src/main/java/app/tourism/domain/models/auth/RegistrationData.kt new file mode 100644 index 0000000000..f734bae550 --- /dev/null +++ b/android/app/src/main/java/app/tourism/domain/models/auth/RegistrationData.kt @@ -0,0 +1,9 @@ +package app.tourism.domain.models.auth + +data class RegistrationData( + val fullName: String, + val email: String, + val password: String, + val passwordConfirmation: String, + val country: String, +) diff --git a/android/app/src/main/java/app/tourism/domain/models/categories/Category.kt b/android/app/src/main/java/app/tourism/domain/models/categories/Category.kt new file mode 100644 index 0000000000..314d050b0e --- /dev/null +++ b/android/app/src/main/java/app/tourism/domain/models/categories/Category.kt @@ -0,0 +1,9 @@ +package app.tourism.domain.models.categories + +data class Category(val value: String?, val label: String) + +enum class PlaceCategory(val id: Long) { + Sights(1), // called attractions in the server + Restaurants(2), + Hotels(3) // called accommodations in the server +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/domain/models/common/PlaceShort.kt b/android/app/src/main/java/app/tourism/domain/models/common/PlaceShort.kt new file mode 100644 index 0000000000..262a8df43b --- /dev/null +++ b/android/app/src/main/java/app/tourism/domain/models/common/PlaceShort.kt @@ -0,0 +1,10 @@ +package app.tourism.domain.models.common + +data class PlaceShort( + val id: Long, + val name: String, + val cover: String? = null, + val rating: Double? = null, + val excerpt: String? = null, + val isFavorite: Boolean = false, +) diff --git a/android/app/src/main/java/app/tourism/domain/models/details/PlaceFull.kt b/android/app/src/main/java/app/tourism/domain/models/details/PlaceFull.kt new file mode 100644 index 0000000000..f76041a361 --- /dev/null +++ b/android/app/src/main/java/app/tourism/domain/models/details/PlaceFull.kt @@ -0,0 +1,42 @@ +package app.tourism.domain.models.details + +import app.tourism.data.db.entities.PlaceEntity +import app.tourism.data.dto.PlaceLocation +import app.tourism.domain.models.common.PlaceShort + +data class PlaceFull( + val id: Long, + val name: String, + val rating: Double, + val excerpt: String, + val description: String, + val placeLocation: PlaceLocation?, + val cover: String, + val pics: List = emptyList(), + val reviews: List? = null, + val isFavorite: Boolean, + val language: String +) { + fun toPlaceShort() = PlaceShort( + id = id, + name = name, + cover = cover, + rating = rating, + excerpt = excerpt, + isFavorite = isFavorite + ) + + fun toPlaceEntity(categoryId: Long) = PlaceEntity( + id = id, + categoryId = categoryId, + name = name, + rating = rating, + excerpt = excerpt, + description = description, + gallery = pics, + coordinates = placeLocation?.toCoordinatesEntity(), + cover = cover, + isFavorite = isFavorite, + language = language, + ) +} diff --git a/android/app/src/main/java/app/tourism/domain/models/details/Review.kt b/android/app/src/main/java/app/tourism/domain/models/details/Review.kt new file mode 100644 index 0000000000..456d987246 --- /dev/null +++ b/android/app/src/main/java/app/tourism/domain/models/details/Review.kt @@ -0,0 +1,25 @@ +package app.tourism.domain.models.details + +import app.tourism.data.db.entities.ReviewEntity + +data class Review( + val id: Long, + val placeId: Long, + val rating: Int, + val user: User, + val date: String? = null, + val comment: String? = null, + val picsUrls: List = emptyList(), + val deletionPlanned: Boolean = false, +) { + fun toReviewEntity() = ReviewEntity( + id = id, + user = user.toUserEntity(), + comment = comment ?: "", + placeId = placeId, + date = date ?: "", + rating = rating, + images = picsUrls, + deletionPlanned = deletionPlanned + ) +} diff --git a/android/app/src/main/java/app/tourism/domain/models/details/ReviewToPost.kt b/android/app/src/main/java/app/tourism/domain/models/details/ReviewToPost.kt new file mode 100644 index 0000000000..4d8d56adfe --- /dev/null +++ b/android/app/src/main/java/app/tourism/domain/models/details/ReviewToPost.kt @@ -0,0 +1,22 @@ +package app.tourism.domain.models.details + +import app.tourism.data.db.entities.ReviewPlannedToPostEntity +import java.io.File + +data class ReviewToPost( + val placeId: Long, + val comment: String, + val rating: Int, + val images: List, +) { + fun toReviewPlannedToPostEntity(compressedImages: List): ReviewPlannedToPostEntity { + val imagesPaths = compressedImages.map { it.path } + + return ReviewPlannedToPostEntity( + placeId = placeId, + comment = comment, + rating = rating, + images = imagesPaths + ) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/domain/models/details/User.kt b/android/app/src/main/java/app/tourism/domain/models/details/User.kt new file mode 100644 index 0000000000..a6f425ed3a --- /dev/null +++ b/android/app/src/main/java/app/tourism/domain/models/details/User.kt @@ -0,0 +1,14 @@ +package app.tourism.domain.models.details + +import app.tourism.data.db.entities.JustUser + +data class User( + val id: Long, + val name: String, + val pfpUrl: String? = null, + val countryCodeName: String, +) { + fun toUserEntity() = JustUser( + userId = id, fullName = name, avatar = pfpUrl, country = countryCodeName + ) +} diff --git a/android/app/src/main/java/app/tourism/domain/models/profile/CurrencyRates.kt b/android/app/src/main/java/app/tourism/domain/models/profile/CurrencyRates.kt new file mode 100644 index 0000000000..afd19ede98 --- /dev/null +++ b/android/app/src/main/java/app/tourism/domain/models/profile/CurrencyRates.kt @@ -0,0 +1,7 @@ +package app.tourism.domain.models.profile + +import app.tourism.data.db.entities.CurrencyRatesEntity + +data class CurrencyRates(val usd: Double, val eur: Double, val rub: Double) { + fun toCurrencyRatesEntity() = CurrencyRatesEntity(1, usd, eur, rub) +} diff --git a/android/app/src/main/java/app/tourism/domain/models/profile/PersonalData.kt b/android/app/src/main/java/app/tourism/domain/models/profile/PersonalData.kt new file mode 100644 index 0000000000..2b6eab5ef0 --- /dev/null +++ b/android/app/src/main/java/app/tourism/domain/models/profile/PersonalData.kt @@ -0,0 +1,11 @@ +package app.tourism.domain.models.profile + +data class PersonalData( + val id: Long, + val fullName: String, + val country: String, + val pfpUrl: String?, + val email: String, + val language: String?, + val theme: String?, +) diff --git a/android/app/src/main/java/app/tourism/domain/models/resource/DownloadProgress.kt b/android/app/src/main/java/app/tourism/domain/models/resource/DownloadProgress.kt new file mode 100644 index 0000000000..c068e0aa71 --- /dev/null +++ b/android/app/src/main/java/app/tourism/domain/models/resource/DownloadProgress.kt @@ -0,0 +1,36 @@ +package app.tourism.domain.models.resource + +sealed class DownloadProgress(val stats: DownloadStats? = null, val message: String? = null) { + class Idle : DownloadProgress() + class Loading(stats: DownloadStats) : DownloadProgress(stats) + class Finished(stats: DownloadStats, message: String? = null) : DownloadProgress(stats, message) + class Error(message: String) : DownloadProgress(message = message) +} + +class DownloadStats( + val filesTotalNum: Int, + var filesDownloaded: Int, + var filesFailedToDownload: Int, +) { + var percentagesCompleted: Int = 0 + + init { + updatePercentage() + } + + fun updatePercentage() { + percentagesCompleted = calculatePercentage() + } + + fun isAllFilesProcessed() = + filesTotalNum == filesDownloaded + filesFailedToDownload + + + private fun calculatePercentage(): Int { + return if (filesTotalNum == 0) 0 else (filesDownloaded * 100) / filesTotalNum + } + + override fun toString(): String { + return "DownloadStats(percentagesCompleted=$percentagesCompleted, filesDownloaded=$filesDownloaded, filesTotalNum=$filesTotalNum, filesFailedToDownload=$filesFailedToDownload)" + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/domain/models/resource/Resource.kt b/android/app/src/main/java/app/tourism/domain/models/resource/Resource.kt new file mode 100644 index 0000000000..a3fcd8f2c6 --- /dev/null +++ b/android/app/src/main/java/app/tourism/domain/models/resource/Resource.kt @@ -0,0 +1,8 @@ +package app.tourism.domain.models.resource + +sealed class Resource(val data: T? = null, val message: String? = null) { + class Idle: Resource() + class Loading(data: T? = null): Resource(data) + class Success(data: T?, message: String? = null): Resource(data) + class Error(message: String, data: T? = null): Resource(data, message) +} diff --git a/android/app/src/main/java/app/tourism/domain/models/resource/ResponseResource.kt b/android/app/src/main/java/app/tourism/domain/models/resource/ResponseResource.kt new file mode 100644 index 0000000000..14ac84f030 --- /dev/null +++ b/android/app/src/main/java/app/tourism/domain/models/resource/ResponseResource.kt @@ -0,0 +1,6 @@ +package app.tourism.domain.models.resource + +sealed class ResponseResource(val data: T? = null, val message: String? = null) { + class Success(data: T?): ResponseResource(data) + class Error(message: String, data: T? = null): ResponseResource(data, message) +} diff --git a/android/app/src/main/java/app/tourism/ui/ComposeUtils.kt b/android/app/src/main/java/app/tourism/ui/ComposeUtils.kt new file mode 100644 index 0000000000..befb47c452 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/ComposeUtils.kt @@ -0,0 +1,22 @@ +package app.tourism.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +@Composable +fun ObserveAsEvents(flow: Flow, onEvent: (T) -> Unit) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(flow, lifecycleOwner.lifecycle) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + withContext(Dispatchers.Main.immediate) { + flow.collect(onEvent) + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/common/BorderedItem.kt b/android/app/src/main/java/app/tourism/ui/common/BorderedItem.kt new file mode 100644 index 0000000000..ba0f34170e --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/BorderedItem.kt @@ -0,0 +1,42 @@ +package app.tourism.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import app.tourism.ui.theme.TextStyles +import app.tourism.ui.theme.getSelectedColor +import app.tourism.ui.theme.getSelectedTextColor + +@Composable +fun BorderedItem( + modifier: Modifier = Modifier, + label: String, + highlighted: Boolean = false, + onClick: () -> Unit +) { + val shape = RoundedCornerShape(16.dp) + Text( + modifier = Modifier + .background( + color = if (highlighted) getSelectedColor() + else MaterialTheme.colorScheme.background, + shape = shape + ) + .clip(shape) + .clickable { + onClick() + } + .padding(12.dp) + .then(modifier), + text = label, + color = getSelectedTextColor(), + style = TextStyles.h4 + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/common/Checkboxes.kt b/android/app/src/main/java/app/tourism/ui/common/Checkboxes.kt new file mode 100644 index 0000000000..fe029bdf5a --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/Checkboxes.kt @@ -0,0 +1,65 @@ +package app.tourism.ui.common + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +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.res.painterResource +import androidx.compose.ui.unit.dp +import app.organicmaps.R +import app.tourism.ui.theme.TextStyles + +@Composable +fun SingleChoiceCheckBoxes( + modifier: Modifier = Modifier, + selectedItemName: String?, + itemNames: List, + onItemChecked: (String) -> Unit +) { + Column(Modifier.then(modifier)) { + itemNames.forEach { name -> + CheckBoxItem( + name = name, + checked = if(selectedItemName != null) selectedItemName == name else false, + onItemChecked = { onItemChecked(name) }, + ) + } + } +} + +@Composable +fun CheckBoxItem( + modifier: Modifier = Modifier, + name: String, + checked: Boolean, + onItemChecked: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onItemChecked() } + .padding(16.dp) + .then(modifier), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = name, + style = TextStyles.h4, + color = MaterialTheme.colorScheme.onBackground + ) + Icon( + painter = painterResource(id = if (checked) R.drawable.check_circle_fill else R.drawable.unchecked), + tint = MaterialTheme.colorScheme.primary, + contentDescription = null, + ) + } +} diff --git a/android/app/src/main/java/app/tourism/ui/common/ImagePicker.kt b/android/app/src/main/java/app/tourism/ui/common/ImagePicker.kt new file mode 100644 index 0000000000..a5e87bfe76 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/ImagePicker.kt @@ -0,0 +1,69 @@ +package app.tourism.ui.common + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage + +@Composable +fun ImagePicker( + modifier: Modifier = Modifier, + onSuccess: ((Uri?) -> Unit)? = null, + showPreview: Boolean = true, + previewContentScale: ContentScale = ContentScale.Fit, + content: @Composable () -> Unit, +) { + var hasImage by remember { + mutableStateOf(false) + } + var imageUri by rememberSaveable { + mutableStateOf(null) + } + + val imagePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent(), + onResult = { uri -> + hasImage = uri != null + imageUri = uri + if (uri != null) { + onSuccess?.invoke(imageUri) + } + } + ) + Column( + modifier = Modifier.then(modifier), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (showPreview && hasImage && imageUri != null) { + AsyncImage( + model = imageUri, + contentScale = previewContentScale, + contentDescription = null, + ) + VerticalSpace(height = 10.dp) + } + + Row( + Modifier.clickable { + hasImage = false + imageUri = null + imagePicker.launch("image/*") + }, + ) { + content() + } + } +} diff --git a/android/app/src/main/java/app/tourism/ui/common/LoadImage.kt b/android/app/src/main/java/app/tourism/ui/common/LoadImage.kt new file mode 100644 index 0000000000..e923aed2d5 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/LoadImage.kt @@ -0,0 +1,75 @@ +package app.tourism.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.CircleShape +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.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import app.organicmaps.R +import app.tourism.ui.theme.TextStyles +import coil.compose.AsyncImage +import coil.request.ImageRequest + +@Composable +fun LoadImg( + url: String?, + modifier: Modifier = Modifier, + backgroundColor: Color = Color.Transparent, + contentScale: ContentScale = ContentScale.Crop +) { + if (!url.isNullOrBlank()) + CoilImg( + modifier = modifier, + url = url, + backgroundColor = backgroundColor, + contentScale = contentScale + ) + else + Column( + Modifier + .background(color = MaterialTheme.colorScheme.surface, shape = CircleShape) + .then(modifier), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(id = R.string.no_image), + style = TextStyles.b3, + textAlign = TextAlign.Center + ) + } +} + +@Composable +fun CoilImg( + modifier: Modifier = Modifier, + url: String, + backgroundColor: Color, + contentScale: ContentScale +) { + AsyncImage( + modifier = Modifier + .background(color = backgroundColor) + .then(modifier) + .fillMaxSize(), + model = ImageRequest.Builder(LocalContext.current) + .data(url) + .crossfade(200) + .error(R.drawable.error_centered) + .build(), + placeholder = painterResource(R.drawable.placeholder), + contentDescription = null, + contentScale = contentScale + ) +} diff --git a/android/app/src/main/java/app/tourism/ui/common/SearchBar.kt b/android/app/src/main/java/app/tourism/ui/common/SearchBar.kt new file mode 100644 index 0000000000..8714e62a73 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/SearchBar.kt @@ -0,0 +1,80 @@ +package app.tourism.ui.common + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import app.organicmaps.R +import app.tourism.ui.theme.TextStyles +import app.tourism.ui.theme.getHintColor + +@Composable +fun AppSearchBar( + modifier: Modifier = Modifier, + query: String, + onQueryChanged: (String) -> Unit, + onSearchClicked: ((String) -> Unit)? = null, + onClearClicked: () -> Unit, +) { + var isActive by remember { mutableStateOf(false) } + + val searchLabel = stringResource(id = R.string.search) + + OutlinedTextField( + modifier = Modifier + .clickable { isActive = true } + .then(modifier), + value = query, + onValueChange = onQueryChanged, + placeholder = { + Text( + text = searchLabel, + style = TextStyles.h4.copy(color = getHintColor()), + ) + }, + singleLine = true, + maxLines = 1, + leadingIcon = { + IconButton(onClick = { onSearchClicked?.invoke(query) }) { + Icon( + painter = painterResource(id = R.drawable.search), + contentDescription = searchLabel, + tint = getHintColor() + ) + } + }, + trailingIcon = { + if (query.isNotEmpty()) + IconButton(onClick = { onClearClicked() }) { + Icon( + painter = painterResource(id = R.drawable.ic_clear_rounded), + contentDescription = stringResource(id = R.string.clear_search_field), + ) + } + }, + shape = RoundedCornerShape(16.dp), + keyboardActions = KeyboardActions(onSearch = { onSearchClicked?.invoke(query) }), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedIndicatorColor = MaterialTheme.colorScheme.surface + ), + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/common/SpaceForNavBar.kt b/android/app/src/main/java/app/tourism/ui/common/SpaceForNavBar.kt new file mode 100644 index 0000000000..976abdfa72 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/SpaceForNavBar.kt @@ -0,0 +1,13 @@ +package app.tourism.ui.common + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun ColumnScope.SpaceForNavBar() { + Spacer(modifier = Modifier.height(120.dp)) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/common/Spacer.kt b/android/app/src/main/java/app/tourism/ui/common/Spacer.kt new file mode 100644 index 0000000000..aab9af5edb --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/Spacer.kt @@ -0,0 +1,14 @@ +package app.tourism.ui.common + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp + +@Composable +fun HorizontalSpace(width: Dp) = Spacer(modifier = Modifier.width(width)) + +@Composable +fun VerticalSpace(height: Dp) = Spacer(modifier = Modifier.height(height)) \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/common/WebView.kt b/android/app/src/main/java/app/tourism/ui/common/WebView.kt new file mode 100644 index 0000000000..ab60b13be8 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/WebView.kt @@ -0,0 +1,20 @@ +package app.tourism.ui.common + +import android.webkit.WebView +import androidx.compose.runtime.Composable +import androidx.compose.ui.viewinterop.AndroidView + +@Composable +fun WebView(data: String) { + AndroidView( + factory = { context -> + WebView(context).apply { + settings.loadWithOverviewMode = true + loadData(data, "text/html", "UTF-8") + } + }, + update = { + it.loadData(data, "text/html", "UTF-8") + } + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/common/buttons/PrimaryButton.kt b/android/app/src/main/java/app/tourism/ui/common/buttons/PrimaryButton.kt new file mode 100644 index 0000000000..974c6412db --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/buttons/PrimaryButton.kt @@ -0,0 +1,57 @@ +package app.tourism.ui.common.buttons + +import ButtonLoading +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.tourism.ui.theme.TextStyles + +@Composable +fun PrimaryButton( + modifier: Modifier = Modifier, + label: String, + onClick: () -> Unit, + isLoading: Boolean = false, + enabled: Boolean = true, + backgroundColor: Color = MaterialTheme.colorScheme.primary +) { + Button( + modifier = Modifier.fillMaxWidth().height(56.dp).then(modifier), + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors(containerColor = backgroundColor), + elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp) + ) { + Box(modifier = Modifier.padding(vertical = 3.dp)) { + if (isLoading) + ButtonLoading() + else + ButtonText(buttonLabel = label) + } + } +} + +@Composable +fun ButtonText(buttonLabel: String) { + Text( + text = buttonLabel, + style = TextStyles.h4, + fontWeight = FontWeight.W700, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onPrimary + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/common/buttons/SecondaryButton.kt b/android/app/src/main/java/app/tourism/ui/common/buttons/SecondaryButton.kt new file mode 100644 index 0000000000..b12e1cadd3 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/buttons/SecondaryButton.kt @@ -0,0 +1,73 @@ +package app.tourism.ui.common.buttons + +import ButtonLoading +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +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.draw.clip +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.organicmaps.R +import app.tourism.ui.common.HorizontalSpace +import app.tourism.ui.theme.TextStyles + +@Composable +fun SecondaryButton( + modifier: Modifier = Modifier, + label: String, + loading: Boolean = false, + icon: (@Composable () -> Unit)? = null, + onClick: () -> Unit +) { + val shape = RoundedCornerShape(16.dp) + + Box( + modifier = Modifier + .height(height = 56.dp) + .background(color = colorResource(id = R.color.transparent), shape = shape) + .border(width = 1.dp, color = MaterialTheme.colorScheme.primary, shape = shape) + .clip(shape) + .clickable { onClick() } + .then(modifier), + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + if (!loading) { + icon?.apply { + invoke() + HorizontalSpace(width = 8.dp) + } + Text( + text = label, + style = TextStyles.h4, + textAlign = TextAlign.Center, + fontSize = 16.sp, + fontWeight = FontWeight.W600, + color = MaterialTheme.colorScheme.primary + ) + } else { + ButtonLoading() + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/common/nav/AppTopBar.kt b/android/app/src/main/java/app/tourism/ui/common/nav/AppTopBar.kt new file mode 100644 index 0000000000..3311ab2023 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/nav/AppTopBar.kt @@ -0,0 +1,83 @@ +package app.tourism.ui.common.nav + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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 +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import app.tourism.ui.common.VerticalSpace +import app.tourism.ui.theme.TextStyles + +@Composable +fun AppTopBar( + modifier: Modifier = Modifier, + title: String, + onBackClick: (() -> Unit)? = null, + actions: List = emptyList() +) { + Column( + Modifier + .padding(horizontal = 16.dp, vertical = 12.dp) + .then(modifier) + ) { + Box(Modifier.fillMaxWidth()) { + onBackClick?.let { + BackButton( + modifier.align(Alignment.CenterStart), + onBackClick = onBackClick + ) + } + Row(modifier.align(Alignment.CenterEnd)) { + actions.forEach { + TopBarAction( + iconDrawable = it.iconDrawable, + color = it.color, + onClick = it.onClick + ) + } + } + } + VerticalSpace(height = 12.dp) + + Text( + text = title, + style = TextStyles.h1, + color = MaterialTheme.colorScheme.onBackground + ) + } +} + +data class TopBarActionData( + @DrawableRes val iconDrawable: Int, + val color: Color? = null, + val onClick: () -> Unit +) + +@Composable +fun TopBarAction( + @DrawableRes iconDrawable: Int, + color: Color? = null, + onClick: () -> Unit, +) { + IconButton(onClick = onClick) { + Icon( + modifier = Modifier + .size(30.dp), + painter = painterResource(id = iconDrawable), + tint = color ?: MaterialTheme.colorScheme.onBackground, + contentDescription = null, + ) + } +} diff --git a/android/app/src/main/java/app/tourism/ui/common/nav/BackButton.kt b/android/app/src/main/java/app/tourism/ui/common/nav/BackButton.kt new file mode 100644 index 0000000000..87d11d411b --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/nav/BackButton.kt @@ -0,0 +1,32 @@ +package app.tourism.ui.common.nav + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.organicmaps.R + +@Composable +fun BackButton( + modifier: Modifier = Modifier, + size: Dp? = null, + onBackClick: () -> Unit, + tint: Color = MaterialTheme.colorScheme.onBackground +) { + Icon( + modifier = Modifier + .size(size ?: 24.dp) + .clickable { onBackClick() } + .then(modifier), + painter = painterResource(id = R.drawable.back), + tint = tint, + contentDescription = null + ) + +} diff --git a/android/app/src/main/java/app/tourism/ui/common/nav/BackButtonWithText.kt b/android/app/src/main/java/app/tourism/ui/common/nav/BackButtonWithText.kt new file mode 100644 index 0000000000..39bcff56a6 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/nav/BackButtonWithText.kt @@ -0,0 +1,34 @@ +package app.tourism.ui.common.nav + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.organicmaps.R +import app.tourism.ui.common.HorizontalSpace + +@Composable +fun BackButtonWithText(modifier: Modifier = Modifier, onBackClick: () -> Unit) { + TextButton( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + onClick = { onBackClick() }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.back), + contentDescription = stringResource(id = R.string.back) + ) + HorizontalSpace(width = 16.dp) + Text(text = stringResource(id = R.string.back)) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/common/nav/PlaceTopBar.kt b/android/app/src/main/java/app/tourism/ui/common/nav/PlaceTopBar.kt new file mode 100644 index 0000000000..4de71737b0 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/nav/PlaceTopBar.kt @@ -0,0 +1,127 @@ +package app.tourism.ui.common.nav + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +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.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.organicmaps.R +import app.tourism.ui.common.LoadImg +import app.tourism.ui.theme.TextStyles + +@Composable +fun PlaceTopBar( + modifier: Modifier = Modifier, + title: String, + picUrl: String?, + onBackClick: (() -> Unit)? = null, + isFavorite: Boolean, + onFavoriteChanged: (Boolean) -> Unit, + onMapClick: () -> Unit +) { + val height = 144.dp + Box( + Modifier + .fillMaxWidth() + .height(height) + .clip( + RoundedCornerShape( + topStart = 0.dp, + topEnd = 0.dp, + bottomStart = 20.dp, + bottomEnd = 20.dp + ) + ) + .then(modifier) + ) { + LoadImg( + modifier = Modifier + .fillMaxWidth() + .height(height), + url = picUrl, + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background(color = Color.Black.copy(alpha = 0.3f)), + ) + + val padding = 16.dp + Box( + Modifier + .fillMaxWidth() + .align(alignment = Alignment.TopCenter) + .padding(start = padding, end = padding, top = padding) + ) { + onBackClick?.let { + PlaceTopBarAction( + modifier.align(Alignment.CenterStart), + iconDrawable = R.drawable.back, + onClick = { onBackClick() }, + ) + } + Row(modifier.align(Alignment.CenterEnd)) { + PlaceTopBarAction( + iconDrawable = if (isFavorite) R.drawable.heart_selected else R.drawable.heart, + onClick = { onFavoriteChanged(!isFavorite) }, + ) + PlaceTopBarAction( + iconDrawable = R.drawable.map, + onClick = onMapClick, + ) + } + } + + Text( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = padding, end = padding, bottom = padding), + text = title, + style = TextStyles.h2, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun PlaceTopBarAction( + modifier: Modifier = Modifier, + @DrawableRes iconDrawable: Int, + onClick: () -> Unit, +) { + val shape = CircleShape + IconButton(modifier = Modifier.then(modifier), onClick = onClick) { + Icon( + modifier = Modifier + .clickable { onClick() } + .background(color = Color.White.copy(alpha = 0.2f), shape = shape) + .clip(shape) + .size(40.dp) + .padding(8.dp), + painter = painterResource(id = iconDrawable), + tint = Color.White, + contentDescription = null, + ) + } +} diff --git a/android/app/src/main/java/app/tourism/ui/common/nav/SearchTopBar.kt b/android/app/src/main/java/app/tourism/ui/common/nav/SearchTopBar.kt new file mode 100644 index 0000000000..581aa5a855 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/nav/SearchTopBar.kt @@ -0,0 +1,70 @@ +package app.tourism.ui.common.nav + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import app.organicmaps.R +import app.tourism.ui.theme.TextStyles +import app.tourism.ui.theme.getHintColor + +@Composable +fun SearchTopBar( + modifier: Modifier = Modifier, + query: String, + onQueryChanged: (String) -> Unit, + onSearchClicked: ((String) -> Unit)? = null, + onClearClicked: () -> Unit, + onBackClicked: () -> Unit +) { + val searchLabel = stringResource(id = R.string.search) + + TextField( + modifier = Modifier + .fillMaxWidth() + .then(modifier), + value = query, + onValueChange = onQueryChanged, + placeholder = { + Text( + text = searchLabel, + style = TextStyles.h4.copy(color = getHintColor()), + ) + }, + singleLine = true, + maxLines = 1, + leadingIcon = { + IconButton(onClick = { onBackClicked() }) { + Icon( + painter = painterResource(id = R.drawable.back), + contentDescription = stringResource(id = R.string.back), + ) + } + }, + trailingIcon = { + if (query.isNotEmpty()) + IconButton(onClick = { onClearClicked() }) { + Icon( + painter = painterResource(id = R.drawable.ic_clear_rounded), + contentDescription = stringResource(id = R.string.clear_search_field), + ) + } + }, + keyboardActions = KeyboardActions(onSearch = { onSearchClicked?.invoke(query) }), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.background, + unfocusedContainerColor = MaterialTheme.colorScheme.background, + ) + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/common/special/CountryAsLabel.kt b/android/app/src/main/java/app/tourism/ui/common/special/CountryAsLabel.kt new file mode 100644 index 0000000000..4bef3e92b7 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/special/CountryAsLabel.kt @@ -0,0 +1,26 @@ +package app.tourism.ui.common.special + +import android.view.LayoutInflater +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import app.organicmaps.R +import com.hbb20.CountryCodePicker + +@Composable +fun CountryAsLabel(modifier: Modifier = Modifier, countryCodeName: String, contentColor: Int) { + AndroidView( + modifier = Modifier.then(modifier), + factory = { context -> + val view = LayoutInflater.from(context) + .inflate(R.layout.ccp_as_country_label, null, false) + val ccp = view.findViewById(R.id.ccp) + ccp.contentColor = contentColor + ccp.setCountryForNameCode("BO") + ccp.setCountryForNameCode(countryCodeName) + ccp.showArrow(false) + ccp.setCcpClickable(false) + view + } + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/common/special/CountryFlag.kt b/android/app/src/main/java/app/tourism/ui/common/special/CountryFlag.kt new file mode 100644 index 0000000000..e41a615ae5 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/special/CountryFlag.kt @@ -0,0 +1,22 @@ +package app.tourism.ui.common.special + +import android.view.LayoutInflater +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import app.organicmaps.R +import com.hbb20.CountryCodePicker + +@Composable +fun CountryFlag(modifier: Modifier = Modifier, countryCodeName: String) { + AndroidView( + modifier = Modifier.then(modifier), + factory = { context -> + val view = LayoutInflater.from(context) + .inflate(R.layout.ccp_country_flag, null, false) + val ccp = view.findViewById(R.id.ccp) + ccp.setCountryForNameCode(countryCodeName) + view + } + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/common/special/PlacesItem.kt b/android/app/src/main/java/app/tourism/ui/common/special/PlacesItem.kt new file mode 100644 index 0000000000..f25c893236 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/special/PlacesItem.kt @@ -0,0 +1,113 @@ +package app.tourism.ui.common.special + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.organicmaps.R +import app.tourism.applyAppBorder +import app.tourism.domain.models.common.PlaceShort +import app.tourism.ui.common.HorizontalSpace +import app.tourism.ui.common.LoadImg +import app.tourism.ui.theme.HeartRed +import app.tourism.ui.theme.TextStyles +import app.tourism.ui.theme.getStarColor +import app.tourism.utils.getAnnotatedStringFromHtml + +@Composable +fun PlacesItem( + modifier: Modifier = Modifier, + place: PlaceShort, + onPlaceClick: () -> Unit, + isFavorite: Boolean, + onFavoriteChanged: (Boolean) -> Unit +) { + val height = 130.dp + val shape = RoundedCornerShape(20.dp) + Row( + Modifier + .fillMaxWidth() + .height(height) + .applyAppBorder() + .clip(shape) + .clickable { onPlaceClick() } + .then(modifier) + ) { + LoadImg( + modifier = Modifier + .size(height) + .clip(shape), + url = place.cover, + ) + Column( + Modifier + .fillMaxHeight(0.9f) + .padding(8.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f, fill = true), + text = place.name, + style = TextStyles.h3, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + IconButton( + modifier = Modifier.size(28.dp), + onClick = { + onFavoriteChanged(!isFavorite) + }, + ) { + Icon( + painterResource(id = if (isFavorite) R.drawable.heart_selected else R.drawable.heart), + contentDescription = stringResource(id = R.string.add_to_favorites), + tint = HeartRed, + ) + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = "%.1f".format(place.rating), style = TextStyles.b1) + HorizontalSpace(width = 8.dp) + Icon( + modifier = Modifier.size(12.dp), + painter = painterResource(id = R.drawable.star), + contentDescription = null, + tint = getStarColor(), + ) + } + + place.excerpt?.let { + Text( + text = it.getAnnotatedStringFromHtml(), + style = TextStyles.b1, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/common/special/RatingBar.kt b/android/app/src/main/java/app/tourism/ui/common/special/RatingBar.kt new file mode 100644 index 0000000000..279ee8ed94 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/special/RatingBar.kt @@ -0,0 +1,44 @@ +package app.tourism.ui.common.special + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.organicmaps.R +import app.tourism.ui.theme.getStarColor + +@Composable +fun RatingBar( + rating: Int, + size: Dp = 30.dp, + maxRating: Int = 5, + onRatingChanged: ((Float) -> Unit)? = null, +) { + Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + for (i in 1..maxRating) { + Icon( + modifier = Modifier + .size(size) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { + onRatingChanged?.invoke(i.toFloat()) + }, + ), + painter = + painterResource(id = if (i <= rating) R.drawable.star else R.drawable.star_border), + contentDescription = null, + tint = getStarColor() + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/common/textfields/AppEditText.kt b/android/app/src/main/java/app/tourism/ui/common/textfields/AppEditText.kt new file mode 100644 index 0000000000..248c19bc5d --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/textfields/AppEditText.kt @@ -0,0 +1,46 @@ +package app.tourism.ui.common.textfields + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.tourism.ui.theme.TextStyles + +@Composable +fun AppEditText( + value: String, + onValueChange: (String) -> Unit, + hint: String = "", + isError: () -> Boolean = { false }, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + maxLines: Int = 1, +) { + EditText( + value = value, + onValueChange = onValueChange, + hint = hint, + hintColor = MaterialTheme.colorScheme.onBackground, + isError = isError, + textFieldHeight = 50.dp, + textFieldPadding = PaddingValues(vertical = 8.dp), + hintFontSizeInt = 15, + textSize = 17.sp, + textStyle = TextStyles.h3.copy( + textAlign = TextAlign.Start, + color = MaterialTheme.colorScheme.onBackground, + ), + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + focusedColor = MaterialTheme.colorScheme.onBackground, + unfocusedColor = MaterialTheme.colorScheme.onBackground, + errorColor = MaterialTheme.colorScheme.onError, + maxLines = maxLines, + ) +} diff --git a/android/app/src/main/java/app/tourism/ui/common/textfields/AuthEditText.kt b/android/app/src/main/java/app/tourism/ui/common/textfields/AuthEditText.kt new file mode 100644 index 0000000000..4b3ac4d8a1 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/textfields/AuthEditText.kt @@ -0,0 +1,54 @@ +package app.tourism.ui.common.textfields + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun AuthEditText( + value: String, + onValueChange: (String) -> Unit, + hint: String = "", + isError: () -> Boolean = { false }, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + visualTransformation: VisualTransformation = VisualTransformation.None +) { + EditText( + value = value, + onValueChange = onValueChange, + hint = hint, + hintColor = Color.White, + isError = isError, + textFieldHeight = 50.dp, + textFieldPadding = PaddingValues(vertical = 8.dp), + hintFontSizeInt = 16, + textSize = 16.sp, + textStyle = TextStyle( + fontWeight = FontWeight.W900, + textAlign = TextAlign.Start, + color = Color.White, + ), + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + cursorBrush = SolidColor(Color.White), + focusedColor = Color.White, + unfocusedColor = Color.White, + errorColor = MaterialTheme.colorScheme.onError, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + visualTransformation = visualTransformation + ) +} diff --git a/android/app/src/main/java/app/tourism/ui/common/textfields/EditText.kt b/android/app/src/main/java/app/tourism/ui/common/textfields/EditText.kt new file mode 100644 index 0000000000..835fd0e6e7 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/textfields/EditText.kt @@ -0,0 +1,146 @@ +package app.tourism.ui.common.textfields + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateIntAsState +import androidx.compose.animation.core.animateOffsetAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.math.roundToInt + +enum class EtState { Focused, Unfocused, Error } + +@Composable +fun EditText( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + hint: String = "", + hintColor: Color = Color.Gray, + isError: () -> Boolean = { false }, + errorColor: Color = Color.Red, + textFieldHeight: Dp = 50.dp, + textFieldPadding: PaddingValues = PaddingValues(0.dp), + textSize: TextUnit = 18.sp, + hintFontSizeInt: Int = 18, + textStyle: TextStyle = TextStyle( + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onBackground + ), + cursorBrush: Brush = SolidColor(MaterialTheme.colorScheme.primary), + maxLines: Int = 1, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + visualTransformation: VisualTransformation = VisualTransformation.None, + focusedColor: Color = Color.Green, + unfocusedColor: Color = Color.Black, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, +) { + var etState by remember { mutableStateOf(EtState.Unfocused) } + + val hintCondition = etState == EtState.Unfocused && value.isEmpty() + val hintOffset by animateOffsetAsState( + targetValue = if (hintCondition) Offset(0f, hintFontSizeInt * 1.6f) + else Offset(0f, 0f) + ) + val hintSize by animateIntAsState( + targetValue = if (hintCondition) hintFontSizeInt else (hintFontSizeInt * 0.8).roundToInt() + ) + + val heightModifier = + if (maxLines > 1) Modifier.height(IntrinsicSize.Min) else Modifier.height(textFieldHeight) + + Column(modifier) { + BasicTextField( + modifier = Modifier + .padding(textFieldPadding) + .onFocusChanged { + etState = if (it.hasFocus) EtState.Focused else EtState.Unfocused + } + .fillMaxWidth() + .then(heightModifier), + value = value, + onValueChange = { + onValueChange(it) + etState = if (isError()) EtState.Error else EtState.Focused + }, + cursorBrush = cursorBrush, + maxLines = maxLines, + textStyle = textStyle.copy( + fontSize = textSize, + textAlign = TextAlign.Start + ), + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + visualTransformation = visualTransformation, + decorationBox = { + Row(verticalAlignment = Alignment.Bottom) { + leadingIcon?.invoke() + Column(Modifier.fillMaxSize().weight(1f)) { + Text( + modifier = Modifier.offset(hintOffset.x.dp, hintOffset.y.dp), + text = hint, + fontSize = hintSize.sp, + color = hintColor, + ) + it() + } + trailingIcon?.invoke() + } + } + ) + EtLine(etState, focusedColor, unfocusedColor, errorColor) + } +} + +@Composable +fun EtLine(etState: EtState, focusedColor: Color, unfocusedColor: Color, errorColor: Color) { + + val etColor by animateColorAsState( + targetValue = when (etState) { + EtState.Focused -> focusedColor + EtState.Unfocused -> unfocusedColor + else -> errorColor + }, + animationSpec = tween(durationMillis = 500), label = "", + ) + Divider( + modifier = Modifier.fillMaxWidth(), + color = etColor, + thickness = 1.dp + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/common/textfields/PasswordEditText.kt b/android/app/src/main/java/app/tourism/ui/common/textfields/PasswordEditText.kt new file mode 100644 index 0000000000..a71d689cf6 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/textfields/PasswordEditText.kt @@ -0,0 +1,50 @@ +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +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.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import app.organicmaps.R +import app.tourism.ui.common.textfields.AuthEditText + +@Composable +fun PasswordEditText( + value: String, + onValueChange: (String) -> Unit, + hint: String, + keyboardActions: KeyboardActions = KeyboardActions.Default, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default +) { + var passwordVisible by remember { mutableStateOf(false) } + AuthEditText( + value = value, + onValueChange = onValueChange, + hint = hint, + keyboardActions = keyboardActions, + keyboardOptions = keyboardOptions, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton( + modifier = Modifier.size(24.dp), + onClick = { passwordVisible = !passwordVisible }, + ) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(id = if (passwordVisible) R.drawable.baseline_visibility_24 else com.google.android.material.R.drawable.design_ic_visibility_off), + tint = Color.White, + contentDescription = null + ) + } + } + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/common/ui_state/ButtonLoading.kt b/android/app/src/main/java/app/tourism/ui/common/ui_state/ButtonLoading.kt new file mode 100644 index 0000000000..0a321ddc02 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/ui_state/ButtonLoading.kt @@ -0,0 +1,15 @@ +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun ButtonLoading() { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(25.dp), + color = MaterialTheme.colorScheme.background + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/common/ui_state/EmptyList.kt b/android/app/src/main/java/app/tourism/ui/common/ui_state/EmptyList.kt new file mode 100644 index 0000000000..90082d8e13 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/ui_state/EmptyList.kt @@ -0,0 +1,24 @@ +package app.tourism.ui.common.ui_state + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.organicmaps.R +import app.tourism.ui.theme.TextStyles + + +@Composable +fun EmptyList(modifier: Modifier = Modifier) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(id = R.string.empty_list), + style = TextStyles.h2 + ) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/common/ui_state/Error.kt b/android/app/src/main/java/app/tourism/ui/common/ui_state/Error.kt new file mode 100644 index 0000000000..48eeaf5b2b --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/ui_state/Error.kt @@ -0,0 +1,89 @@ +package app.tourism.ui.common.ui_state + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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 +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign.Companion.Center +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.organicmaps.R +import app.tourism.Constants +import app.tourism.ui.common.VerticalSpace +import app.tourism.ui.common.buttons.PrimaryButton +import app.tourism.ui.theme.TextStyles + +@Composable +fun Error( + modifier: Modifier = Modifier, + errorMessage: String? = null, + status: Boolean = true, + onEntireScreen: Boolean = true, + onRetry: (() -> Unit)? = null +) { + if (status) { + Column( + modifier = if (onEntireScreen) modifier + .fillMaxSize() + .padding(Constants.SCREEN_PADDING) else modifier.padding(Constants.SCREEN_PADDING), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + if (onEntireScreen) + Icon( + modifier = Modifier + .size(64.dp), + painter = painterResource(id = R.drawable.error), + tint = MaterialTheme.colorScheme.primary, + contentDescription = null + ) + + Spacer(modifier = Modifier.size(16.dp)) + + Text( + text = errorMessage + ?: stringResource(id = if (onEntireScreen) R.string.no_network else R.string.smth_went_wrong), + style = TextStyles.h3, + textAlign = Center + ) + + if (onRetry != null) + if (onEntireScreen) { + Spacer(modifier = Modifier.size(16.dp)) + PrimaryButton( + label = stringResource(id = R.string.retry), + onClick = { onRetry.invoke() } + ) + } else { + IconButton(onClick = { onRetry() }) { + Icon( + painter = painterResource(id = R.drawable.baseline_refresh_24), + tint = MaterialTheme.colorScheme.primary, + contentDescription = null + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun NetworkError_preview() { + Column { + Error(status = true, onEntireScreen = false) {} + VerticalSpace(height = 16.dp) + Error(status = true) {} + } +} diff --git a/android/app/src/main/java/app/tourism/ui/common/ui_state/Loading.kt b/android/app/src/main/java/app/tourism/ui/common/ui_state/Loading.kt new file mode 100644 index 0000000000..8095d2bb77 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/common/ui_state/Loading.kt @@ -0,0 +1,29 @@ +package app.tourism.ui.common.ui_state + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun Loading(modifier: Modifier = Modifier, status: Boolean = true, onEntireScreen: Boolean = true) { + AnimatedVisibility( + visible = status, + enter = fadeIn(), + exit = fadeOut() + ) { + Box( + modifier = if (onEntireScreen) modifier.fillMaxSize() else modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/models/SingleChoiceItem.kt b/android/app/src/main/java/app/tourism/ui/models/SingleChoiceItem.kt new file mode 100644 index 0000000000..ad7f825977 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/models/SingleChoiceItem.kt @@ -0,0 +1,6 @@ +package app.tourism.ui.models + +data class SingleChoiceItem( + val key: Any, + val label: String +) \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/auth/AuthNavigation.kt b/android/app/src/main/java/app/tourism/ui/screens/auth/AuthNavigation.kt new file mode 100644 index 0000000000..88ba88abf5 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/auth/AuthNavigation.kt @@ -0,0 +1,100 @@ +package app.tourism.ui.screens.auth + +import android.content.Context +import android.content.Intent +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import app.organicmaps.downloader.CountryItem +import app.tourism.MainActivity +import app.tourism.data.prefs.UserPreferences +import app.tourism.ui.screens.auth.sign_in.SignInScreen +import app.tourism.ui.screens.auth.sign_up.SignUpScreen +import app.tourism.ui.screens.auth.welcome.WelcomeScreen +import app.tourism.ui.screens.language.LanguageScreen +import com.jakewharton.processphoenix.ProcessPhoenix +import kotlinx.serialization.Serializable + +// Routes +@Serializable +object Welcome + +@Serializable +object SignIn + +@Serializable +object SignUp + +@Serializable +object Language + +@Composable +fun AuthNavigation() { + val context = LocalContext.current + val navController = rememberNavController() + + val navigateUp: () -> Unit = { navController.navigateUp() } + + NavHost( + navController = navController, + startDestination = Welcome, + enterTransition = { + EnterTransition.None + }, + exitTransition = { + ExitTransition.None + } + ) { + composable() { + WelcomeScreen( + onLanguageClicked = { navController.navigate(route = Language) }, + onSignInClicked = { navController.navigate(route = SignIn) }, + onSignUpClicked = { navController.navigate(route = SignUp) }, + ) + } + composable { + SignInScreen( + onSignInComplete = { + afterSuccessfulSignIn(context) + }, + onBackClick = navigateUp + ) + } + composable { + SignUpScreen( + onSignUpComplete = { + afterSuccessfulSignIn(context) + }, + onBackClick = navigateUp + ) + } + composable { + LanguageScreen( + onBackClick = navigateUp + ) + } + } +} + +private fun afterSuccessfulSignIn(context: Context) { + val userPreferences = UserPreferences(context) + val mCurrentCountry = CountryItem.fill("Tajikistan") + + // well country should be there already, but mCurrentCountry.present will be false, + // after rebirth it should be present + if (!mCurrentCountry.present && userPreferences.getIsEverythingSetup()) + ProcessPhoenix.triggerRebirth(context) + else + navigateToMainActivity(context) +} + +fun navigateToMainActivity(context: Context) { + val intent = Intent(context, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) + ContextCompat.startActivity(context, intent, null) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/auth/sign_in/ForgotPasswordViewModel.kt b/android/app/src/main/java/app/tourism/ui/screens/auth/sign_in/ForgotPasswordViewModel.kt new file mode 100644 index 0000000000..ddaa398243 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/auth/sign_in/ForgotPasswordViewModel.kt @@ -0,0 +1,60 @@ +package app.tourism.ui.screens.auth.sign_in + +import android.content.Context +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.organicmaps.R +import app.tourism.data.repositories.AuthRepository +import app.tourism.domain.models.SimpleResponse +import app.tourism.domain.models.resource.Resource +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ForgotPasswordViewModel @Inject constructor( + @ApplicationContext val context: Context, + private val authRepository: AuthRepository, +) : ViewModel() { + private val uiChannel = Channel() + val uiEventsChannelFlow = uiChannel.receiveAsFlow() + + private val _email = MutableStateFlow("") + val email = _email.asStateFlow() + + fun setEmail(value: String) { + _email.value = value + } + + private val _forgotPasswordResponse = + MutableStateFlow>(Resource.Idle()) + val forgotPasswordResponse = _forgotPasswordResponse.asStateFlow() + + fun sendEmailForPasswordReset() { + viewModelScope.launch { + authRepository.sendEmailForPasswordReset(email.value) + .collectLatest { resource -> + _forgotPasswordResponse.value = resource + + if (resource is Resource.Success) { + uiChannel.send(ForgotPasswordUiEvent.PopDialog) + uiChannel.send(ForgotPasswordUiEvent.ShowToast(context.getString(R.string.we_sent_you_password_reset_email))) + } else if (resource is Resource.Error) { + uiChannel.send(ForgotPasswordUiEvent.ShowToast(resource.message ?: "")) + } + } + } + } +} + +sealed interface ForgotPasswordUiEvent { + data object PopDialog : ForgotPasswordUiEvent + data class ShowToast(val message: String) : ForgotPasswordUiEvent +} diff --git a/android/app/src/main/java/app/tourism/ui/screens/auth/sign_in/SignInScreen.kt b/android/app/src/main/java/app/tourism/ui/screens/auth/sign_in/SignInScreen.kt new file mode 100644 index 0000000000..2175d8018d --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/auth/sign_in/SignInScreen.kt @@ -0,0 +1,252 @@ +package app.tourism.ui.screens.auth.sign_in + +import PasswordEditText +import androidx.compose.foundation.Image +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.hilt.navigation.compose.hiltViewModel +import app.organicmaps.R +import app.tourism.Constants +import app.tourism.domain.models.resource.Resource +import app.tourism.drawDarkContainerBehind +import app.tourism.drawOverlayForTextBehind +import app.tourism.ui.ObserveAsEvents +import app.tourism.ui.common.HorizontalSpace +import app.tourism.ui.common.VerticalSpace +import app.tourism.ui.common.buttons.PrimaryButton +import app.tourism.ui.common.buttons.SecondaryButton +import app.tourism.ui.common.nav.BackButton +import app.tourism.ui.common.textfields.AuthEditText +import app.tourism.ui.theme.TextStyles +import app.tourism.ui.utils.showToast +import app.tourism.utils.openUrlInBrowser + +@Composable +fun SignInScreen( + onSignInComplete: () -> Unit, + onBackClick: () -> Unit, + vm: SignInViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + + var showForgotPasswordDialog by remember { mutableStateOf(false) } + + val email = vm.email.collectAsState().value + val password = vm.password.collectAsState().value + + val signInResponse = vm.signInResponse.collectAsState().value + + ObserveAsEvents(flow = vm.uiEventsChannelFlow) { event -> + when (event) { + is UiEvent.NavigateToMainActivity -> onSignInComplete() + is UiEvent.ShowToast -> context.showToast(event.message) + } + } + + Box(modifier = Modifier.fillMaxSize()) { + Image( + modifier = Modifier.fillMaxSize(), + painter = painterResource(id = R.drawable.splash_background), + contentScale = ContentScale.Crop, + contentDescription = null + ) + + Box(Modifier.padding(Constants.SCREEN_PADDING)) { + BackButton( + modifier = Modifier.align(Alignment.TopStart), + onBackClick = onBackClick, + tint = Color.White + ) + } + + Column( + Modifier + .fillMaxWidth() + .align(Alignment.TopCenter) + ) { + VerticalSpace(height = 80.dp) + + Box( + Modifier + .padding(Constants.SCREEN_PADDING) + .drawDarkContainerBehind() + ) { + Column(Modifier.padding(36.dp)) { + Text( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = stringResource(id = R.string.sign_in_title), + style = TextStyles.h2, + color = Color.White + ) + VerticalSpace(height = 32.dp) + AuthEditText( + value = email, + onValueChange = { vm.setEmail(it) }, + hint = stringResource(id = R.string.email), + keyboardActions = KeyboardActions( + onNext = { + focusManager.moveFocus(FocusDirection.Next) + }, + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + ) + VerticalSpace(height = 16.dp) + PasswordEditText( + value = password, + onValueChange = { vm.setPassword(it) }, + hint = stringResource(id = R.string.password), + keyboardActions = KeyboardActions(onDone = { onSignInComplete() }), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + ) + VerticalSpace(height = 32.dp) + PrimaryButton( + modifier = Modifier.fillMaxWidth(), + label = stringResource(id = R.string.sign_in), + isLoading = signInResponse is Resource.Loading, + onClick = { vm.signIn() }, + ) + VerticalSpace(height = 16.dp) + + TextButton( + onClick = { + showForgotPasswordDialog = true + }, + ) { + Text( + text = stringResource(id = R.string.forgot_password), + color = Color.White + ) + } + } + } + } + + Text( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomStart) + .drawOverlayForTextBehind() + .padding(Constants.SCREEN_PADDING) + .clickable { openUrlInBrowser(context = context, url = "https://rebus.tj") }, + text = stringResource(id = R.string.developed_by_label), + textAlign = TextAlign.End, + color = Color.White, + style = TextStyles.h4.copy() + ) + + if (showForgotPasswordDialog) { + Dialog( + onDismissRequest = { + showForgotPasswordDialog = false + }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + ForgotPasswordDialog(dismissDialog = { + showForgotPasswordDialog = false + }) + } + } + } +} + +@Composable +fun ForgotPasswordDialog( + vm: ForgotPasswordViewModel = hiltViewModel(), + dismissDialog: () -> Unit +) { + val context = LocalContext.current + + val email = vm.email.collectAsState().value + + val forgotPasswordResponse = vm.forgotPasswordResponse.collectAsState().value + + ObserveAsEvents(flow = vm.uiEventsChannelFlow) { event -> + when (event) { + is ForgotPasswordUiEvent.PopDialog -> dismissDialog() + is ForgotPasswordUiEvent.ShowToast -> context.showToast(event.message) + } + } + + Column( + modifier = Modifier + .padding(16.dp) + .clip(RoundedCornerShape(16.dp)) + .background(color = Color.Black) + .padding(32.dp) + ) { + Text( + text = stringResource(R.string.send_email_for_password_reset), + style = TextStyles.h3, + color = Color.White, + textAlign = TextAlign.Center + ) + + AuthEditText( + value = email, + onValueChange = { vm.setEmail(it) }, + hint = stringResource(id = R.string.email), + keyboardActions = KeyboardActions( + onDone = { + vm.sendEmailForPasswordReset() + }, + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + ) + VerticalSpace(32.dp) + + Row { + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + label = stringResource(id = R.string.send), + isLoading = forgotPasswordResponse is Resource.Loading, + onClick = { + vm.sendEmailForPasswordReset() + }, + ) + HorizontalSpace(16.dp) + + SecondaryButton( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + label = stringResource(id = R.string.cancel), + onClick = dismissDialog, + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/auth/sign_in/SignInViewModel.kt b/android/app/src/main/java/app/tourism/ui/screens/auth/sign_in/SignInViewModel.kt new file mode 100644 index 0000000000..6ec81a4148 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/auth/sign_in/SignInViewModel.kt @@ -0,0 +1,63 @@ +package app.tourism.ui.screens.auth.sign_in + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.tourism.data.prefs.UserPreferences +import app.tourism.data.repositories.AuthRepository +import app.tourism.domain.models.auth.AuthResponse +import app.tourism.domain.models.resource.Resource +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SignInViewModel @Inject constructor( + private val authRepository: AuthRepository, + private val userPreferences: UserPreferences +) : ViewModel() { + private val uiChannel = Channel() + val uiEventsChannelFlow = uiChannel.receiveAsFlow() + + private val _email = MutableStateFlow("") + val email = _email.asStateFlow() + + fun setEmail(value: String) { + _email.value = value + } + + private val _password = MutableStateFlow("") + val password = _password.asStateFlow() + + fun setPassword(value: String) { + _password.value = value + } + + + private val _signInResponse = MutableStateFlow>(Resource.Idle()) + val signInResponse = _signInResponse.asStateFlow() + + fun signIn() { + viewModelScope.launch { + authRepository.signIn(email.value, password.value) + .collectLatest { resource -> + _signInResponse.value = resource + if (resource is Resource.Success) { + userPreferences.setToken(resource.data?.token) + uiChannel.send(UiEvent.NavigateToMainActivity) + } else if (resource is Resource.Error) { + uiChannel.send(UiEvent.ShowToast(resource.message ?: "")) + } + } + } + } +} + +sealed interface UiEvent { + data object NavigateToMainActivity : UiEvent + data class ShowToast(val message: String) : UiEvent +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/auth/sign_up/SignUpScreen.kt b/android/app/src/main/java/app/tourism/ui/screens/auth/sign_up/SignUpScreen.kt new file mode 100644 index 0000000000..3c4f4c489e --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/auth/sign_up/SignUpScreen.kt @@ -0,0 +1,195 @@ +package app.tourism.ui.screens.auth.sign_up + +import PasswordEditText +import android.view.LayoutInflater +import androidx.compose.foundation.Image +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.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.hilt.navigation.compose.hiltViewModel +import app.organicmaps.R +import app.tourism.Constants +import app.tourism.domain.models.resource.Resource +import app.tourism.drawDarkContainerBehind +import app.tourism.drawOverlayForTextBehind +import app.tourism.ui.ObserveAsEvents +import app.tourism.ui.common.VerticalSpace +import app.tourism.ui.common.buttons.PrimaryButton +import app.tourism.ui.common.nav.BackButton +import app.tourism.ui.common.textfields.AuthEditText +import app.tourism.ui.theme.TextStyles +import app.tourism.ui.utils.showToast +import app.tourism.utils.openUrlInBrowser +import com.hbb20.CountryCodePicker + +@Composable +fun SignUpScreen( + onSignUpComplete: () -> Unit, + onBackClick: () -> Unit, + vm: SignUpViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + + val registrationData = vm.registrationData.collectAsState().value + val fullName = registrationData?.fullName + var countryNameCode = registrationData?.country + val email = registrationData?.email + val password = registrationData?.password + val confirmPassword = registrationData?.passwordConfirmation + + val signUpResponse = vm.signUpResponse.collectAsState().value + + ObserveAsEvents(flow = vm.uiEventsChannelFlow) { event -> + when (event) { + is UiEvent.NavigateToMainActivity -> onSignUpComplete() + is UiEvent.ShowToast -> context.showToast(event.message) + } + } + + Box( + modifier = Modifier.fillMaxSize() + ) { + Image( + modifier = Modifier.fillMaxSize(), + painter = painterResource(id = R.drawable.splash_background), + contentScale = ContentScale.Crop, + contentDescription = null + ) + + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .align(alignment = Alignment.TopCenter) + ) { + Box(modifier = Modifier.padding(16.dp)) { + BackButton( + onBackClick = onBackClick, + tint = Color.White + ) + } + VerticalSpace(height = 16.dp) + + Box( + Modifier + .padding(Constants.SCREEN_PADDING) + .drawDarkContainerBehind() + ) { + Column( + Modifier.padding(36.dp) + ) { + Text( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = stringResource(id = R.string.sign_up_title), + style = TextStyles.h2, + color = Color.White + ) + VerticalSpace(height = 16.dp) + AuthEditText( + value = fullName ?: "", + onValueChange = { vm.setFullName(it) }, + hint = stringResource(id = R.string.full_name), + keyboardActions = KeyboardActions( + onNext = { + focusManager.moveFocus(FocusDirection.Next) + }, + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + ) + VerticalSpace(height = 16.dp) + AndroidView( + factory = { context -> + val view = LayoutInflater.from(context) + .inflate(R.layout.ccp_auth, null, false) + val ccp = view.findViewById(R.id.ccp) + ccp.setCountryForNameCode("TJ") + ccp.setOnCountryChangeListener { + vm.setCountryNameCode(ccp.selectedCountryNameCode) + } + view + }) + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + color = Color.White, + thickness = 1.dp + ) + AuthEditText( + value = email ?: "", + onValueChange = { vm.setEmail(it) }, + hint = stringResource(id = R.string.email), + keyboardActions = KeyboardActions( + onNext = { + focusManager.moveFocus(FocusDirection.Next) + }, + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + ) + PasswordEditText( + value = password ?: "", + onValueChange = { vm.setPassword(it) }, + hint = stringResource(id = R.string.password), + keyboardActions = KeyboardActions( + onNext = { + focusManager.moveFocus(FocusDirection.Next) + }, + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + ) + PasswordEditText( + value = confirmPassword ?: "", + onValueChange = { vm.setConfirmPassword(it) }, + hint = stringResource(id = R.string.confirm_password), + keyboardActions = KeyboardActions(onDone = { vm.signUp() }), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + ) + VerticalSpace(height = 48.dp) + PrimaryButton( + modifier = Modifier.fillMaxWidth(), + label = stringResource(id = R.string.sign_up), + isLoading = signUpResponse is Resource.Loading, + onClick = { vm.signUp() }, + ) + } + } + } + + Text( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomStart) + .drawOverlayForTextBehind() + .padding(Constants.SCREEN_PADDING) + .clickable { openUrlInBrowser(context = context, url = "https://rebus.tj") }, + text = stringResource(id = R.string.developed_by_label), + textAlign = TextAlign.End, + color = Color.White, + style = TextStyles.h4.copy() + ) + } +} + diff --git a/android/app/src/main/java/app/tourism/ui/screens/auth/sign_up/SignUpViewModel.kt b/android/app/src/main/java/app/tourism/ui/screens/auth/sign_up/SignUpViewModel.kt new file mode 100644 index 0000000000..7950093de4 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/auth/sign_up/SignUpViewModel.kt @@ -0,0 +1,115 @@ +package app.tourism.ui.screens.auth.sign_up + +import android.content.Context +import android.util.Patterns.EMAIL_ADDRESS +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.organicmaps.R +import app.tourism.data.prefs.UserPreferences +import app.tourism.data.repositories.AuthRepository +import app.tourism.domain.models.auth.AuthResponse +import app.tourism.domain.models.auth.RegistrationData +import app.tourism.domain.models.resource.Resource +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SignUpViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val authRepository: AuthRepository, + private val userPreferences: UserPreferences +) : ViewModel() { + private val uiChannel = Channel() + val uiEventsChannelFlow = uiChannel.receiveAsFlow() + + private val _registrationData = + MutableStateFlow( + RegistrationData( + "", + "", + "", + "", + "TJ" + ), + ) + val registrationData = _registrationData.asStateFlow() + + fun setFullName(value: String) { + _registrationData.value = _registrationData.value?.copy(fullName = value) + } + + fun setCountryNameCode(value: String) { + _registrationData.value = _registrationData.value?.copy(country = value) + } + + fun setEmail(value: String) { + _registrationData.value = _registrationData.value?.copy(email = value) + } + + fun setPassword(value: String) { + _registrationData.value = _registrationData.value?.copy(password = value) + } + + fun setConfirmPassword(value: String) { + _registrationData.value = _registrationData.value?.copy(passwordConfirmation = value) + } + + private val _signUpResponse = MutableStateFlow>(Resource.Idle()) + val signUpResponse = _signUpResponse.asStateFlow() + + fun signUp() { + viewModelScope.launch { + registrationData.value?.let { + if (validateEverything()) { + authRepository.signUp(it).collectLatest { resource -> + _signUpResponse.value = resource + if (resource is Resource.Success) { + userPreferences.setToken(resource.data?.token) + uiChannel.send(UiEvent.NavigateToMainActivity) + } else if (resource is Resource.Error) { + uiChannel.send(UiEvent.ShowToast(resource.message ?: "")) + } + } + } + } + } + } + + private fun validateEverything(): Boolean { + return validatePasswordIsTheSame() && validateEmail() + } + + private fun validatePasswordIsTheSame(): Boolean { + if (registrationData.value?.password == registrationData.value?.passwordConfirmation) { + return true + } else { + viewModelScope.launch { + uiChannel.send(UiEvent.ShowToast(context.getString(R.string.passwords_not_same))) + } + return false + } + } + + private fun validateEmail(): Boolean { + if (EMAIL_ADDRESS.matcher(registrationData.value?.email ?: "").matches()) + return true + else { + viewModelScope.launch { + uiChannel.send(UiEvent.ShowToast(context.getString(R.string.wrong_email_format))) + } + return false + } + } +} + +sealed interface UiEvent { + data object NavigateToMainActivity : UiEvent + data class ShowToast(val message: String) : UiEvent +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/auth/welcome/WelcomeScreen.kt b/android/app/src/main/java/app/tourism/ui/screens/auth/welcome/WelcomeScreen.kt new file mode 100644 index 0000000000..829acdbcae --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/auth/welcome/WelcomeScreen.kt @@ -0,0 +1,105 @@ +package app.tourism.ui.screens.auth.welcome + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +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.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.organicmaps.R +import app.tourism.Constants +import app.tourism.drawOverlayForTextBehind +import app.tourism.ui.common.HorizontalSpace +import app.tourism.ui.common.VerticalSpace +import app.tourism.ui.common.buttons.PrimaryButton +import app.tourism.ui.theme.TextStyles + +@Composable +fun WelcomeScreen( + onLanguageClicked: () -> Unit, + onSignInClicked: () -> Unit, + onSignUpClicked: () -> Unit, +) { + Box(modifier = Modifier.fillMaxSize()) { + Image( + modifier = Modifier.fillMaxSize(), + painter = painterResource(id = R.drawable.splash_background), + contentScale = ContentScale.Crop, + contentDescription = null + ) + + Row( + Modifier + .align(Alignment.TopCenter) + .padding(top = 16.dp) + .clickable { + onLanguageClicked() + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = stringResource(id = R.string.current_language), color = Color.White) + HorizontalSpace(width = 8.dp) + Icon( + painter = painterResource(id = R.drawable.globe), + tint = Color.White, + contentDescription = null, + ) + } + + Column( + Modifier + .align(Alignment.BottomStart) + .drawOverlayForTextBehind() + .padding(Constants.SCREEN_PADDING) + ) { + Text( + text = stringResource(id = R.string.welcome_to_tjk), + color = Color.White, + style = TextStyles.humongous.copy() + ) + VerticalSpace(height = 24.dp) + Row(Modifier.fillMaxWidth()) { + PrimaryButton( + modifier = Modifier.weight(1f), + label = stringResource(id = R.string.sign_in), + onClick = { onSignInClicked() }, + ) + HorizontalSpace(width = 16.dp) + PrimaryButton( + modifier = Modifier.weight(1f), + label = stringResource(id = R.string.sign_up), + onClick = { onSignUpClicked() }, + ) + } + VerticalSpace(height = 24.dp) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "©", + color = Color.White, + style = TextStyles.h1.copy() + ) + HorizontalSpace(width = 8.dp) + Text( + text = stringResource(id = R.string.organization_name), + color = Color.White, + style = TextStyles.h4.copy() + ) + } + VerticalSpace(height = 36.dp) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/language/LanguageScreen.kt b/android/app/src/main/java/app/tourism/ui/screens/language/LanguageScreen.kt new file mode 100644 index 0000000000..ae1f13f994 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/language/LanguageScreen.kt @@ -0,0 +1,65 @@ +package app.tourism.ui.screens.language + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import app.organicmaps.R +import app.tourism.ui.common.SingleChoiceCheckBoxes +import app.tourism.ui.common.VerticalSpace +import app.tourism.ui.common.nav.AppTopBar +import app.tourism.utils.LocaleHelper +import com.jakewharton.processphoenix.ProcessPhoenix +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun LanguageScreen( + onBackClick: () -> Unit, + vm: LanguageViewModel = hiltViewModel() +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val languages by vm.languages.collectAsState() + val selectedLanguage by vm.selectedLanguage.collectAsState() + + Scaffold( + topBar = { + AppTopBar( + title = stringResource(id = R.string.chose_language), + onBackClick = { + onBackClick() + } + ) + }, + containerColor = MaterialTheme.colorScheme.background, + ) { paddingValues -> + Column(Modifier.padding(paddingValues)) { + VerticalSpace(height = 16.dp) + SingleChoiceCheckBoxes( + itemNames = languages.map { it.name }, + selectedItemName = if (selectedLanguage != null) selectedLanguage?.name else null, + onItemChecked = { name -> + val language = languages.first { it.name == name } + vm.updateLanguage(language) + scope.launch { + LocaleHelper.setLocale(context, language.code) + // this delay is here to make sure that language changes in time + delay(timeMillis = 500L) + ProcessPhoenix.triggerRebirth(context) + } + } + ) + } + } +} + diff --git a/android/app/src/main/java/app/tourism/ui/screens/language/LanguageViewModel.kt b/android/app/src/main/java/app/tourism/ui/screens/language/LanguageViewModel.kt new file mode 100644 index 0000000000..461a2ebbeb --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/language/LanguageViewModel.kt @@ -0,0 +1,32 @@ +package app.tourism.ui.screens.language + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.tourism.data.prefs.Language +import app.tourism.data.prefs.UserPreferences +import app.tourism.data.repositories.ProfileRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class LanguageViewModel @Inject constructor( + private val profileRepository: ProfileRepository, + private val userPreferences: UserPreferences +) : ViewModel() { + private val _languages = MutableStateFlow(userPreferences.languages) + val languages = _languages.asStateFlow() + + private val _selectedLanguage = MutableStateFlow(userPreferences.getLanguage()) + val selectedLanguage = _selectedLanguage.asStateFlow() + + fun updateLanguage(value: Language) { + _selectedLanguage.value = value + userPreferences.setLanguage(value.code) + viewModelScope.launch { + profileRepository.updateLanguage(value.code) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/MainNavigation.kt b/android/app/src/main/java/app/tourism/ui/screens/main/MainNavigation.kt new file mode 100644 index 0000000000..59671770cc --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/MainNavigation.kt @@ -0,0 +1,222 @@ +package app.tourism.ui.screens.main + +import FullscreenImageScreen +import android.content.Context +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import app.tourism.AuthActivity +import app.tourism.ui.screens.auth.Language +import app.tourism.ui.screens.language.LanguageScreen +import app.tourism.ui.screens.main.categories.categories.CategoriesScreen +import app.tourism.ui.screens.main.categories.categories.CategoriesViewModel +import app.tourism.ui.screens.main.favorites.favorites.FavoritesScreen +import app.tourism.ui.screens.main.home.HomeScreen +import app.tourism.ui.screens.main.place_details.PlaceDetailsScreen +import app.tourism.ui.screens.main.profile.personal_data.PersonalDataScreen +import app.tourism.ui.screens.main.profile.profile.ProfileScreen +import app.tourism.ui.screens.main.profile.profile.ProfileViewModel +import app.tourism.ui.screens.main.search.SearchScreen +import app.tourism.utils.navigateToMap +import app.tourism.utils.navigateToMapForRoute +import kotlinx.serialization.Serializable + +// home +@Serializable +object Home + +@Serializable +data class Search(val query: String) + +// categories +@Serializable +object Categories + +// favorites +@Serializable +object Favorites + +// profile +@Serializable +object Profile + +@Serializable +object PersonalData + +// place details +@Serializable +data class PlaceDetails(val id: Long) + +@Serializable +data class FullscreenImageViewer(val selectedImageUrl: String, val imageUrls: List) + +@Composable +fun MainNavigation(rootNavController: NavHostController, themeVM: ThemeViewModel) { + val context = LocalContext.current + + val categoriesVM: CategoriesViewModel = hiltViewModel() + + val onPlaceClick: (id: Long) -> Unit = { id -> + rootNavController.navigate(PlaceDetails(id = id)) + } + val onPlaceImageClick: (String, List) -> Unit = { selectedImage, imageUrls -> + rootNavController.navigate(FullscreenImageViewer(selectedImage, imageUrls)) + } + val onSearchClick: (q: String) -> Unit = { q -> + if (q.isNotEmpty()) { + rootNavController.navigate(Search(query = q)) + } + } + val onMapClick = { navigateToMap(context) } + val onBackClick: () -> Unit = { rootNavController.navigateUp() } + + NavHost(rootNavController, startDestination = "home_tab") { + composable("home_tab") { + HomeNavHost( + onPlaceClick, + onSearchClick, + onMapClick, + onCategoryClicked = { + rootNavController.navigate("categories_tab") { + popUpTo(rootNavController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + categoriesVM, + ) + } + composable("categories_tab") { + CategoriesNavHost(onPlaceClick, onSearchClick, onMapClick, categoriesVM) + } + composable("favorites_tab") { + FavoritesNavHost(onPlaceClick) + } + composable("profile_tab") { + ProfileNavHost(themeVM = themeVM) + } + composable { backStackEntry -> + val placeDetails = backStackEntry.toRoute() + PlaceDetailsScreen( + id = placeDetails.id, + onPlaceImageClick = onPlaceImageClick, + onBackClick = onBackClick, + onMapClick = onMapClick, + onCreateRoute = { placeLocation -> + navigateToMapForRoute(context, placeLocation) + } + ) + } + composable { backStackEntry -> + val fullscreenImageViewer = backStackEntry.toRoute() + FullscreenImageScreen( + onBackClick = onBackClick, + selectedImageUrl = fullscreenImageViewer.selectedImageUrl, + imageUrls = fullscreenImageViewer.imageUrls + ) + } + composable { backStackEntry -> + val search = backStackEntry.toRoute() + SearchScreen( + onPlaceClick = onPlaceClick, + onBackClick = onBackClick, + onMapClick = onMapClick, + queryArg = search.query, + ) + } + } +} + +@Composable +fun HomeNavHost( + onPlaceClick: (id: Long) -> Unit, + onSearchClick: (String) -> Unit, + onMapClick: () -> Unit, + onCategoryClicked: () -> Unit, + categoriesVM: CategoriesViewModel, +) { + val homeNavController = rememberNavController() + NavHost(homeNavController, startDestination = Home) { + composable { + HomeScreen( + onSearchClick = onSearchClick, + onPlaceClick = onPlaceClick, + onMapClick = onMapClick, + onCategoryClicked = onCategoryClicked, + categoriesVM = categoriesVM + ) + } + } +} + +@Composable +fun CategoriesNavHost( + onPlaceClick: (id: Long) -> Unit, + onSearchClick: (String) -> Unit, + onMapClick: () -> Unit, + categoriesVM: CategoriesViewModel, +) { + val categoriesNavController = rememberNavController() + NavHost(categoriesNavController, startDestination = Categories) { + composable { + CategoriesScreen(onPlaceClick, onSearchClick, onMapClick, categoriesVM) + } + } +} + +@Composable +fun FavoritesNavHost(onPlaceClick: (id: Long) -> Unit) { + val favoritesNavController = rememberNavController() + NavHost(favoritesNavController, startDestination = Favorites) { + composable { + FavoritesScreen(onPlaceClick) + } + } +} + +@Composable +fun ProfileNavHost(themeVM: ThemeViewModel, profileVM: ProfileViewModel = hiltViewModel()) { + val context = LocalContext.current + val profileNavController = rememberNavController() + val onBackClick: () -> Unit = { profileNavController.navigateUp() } + + NavHost(profileNavController, startDestination = Profile) { + composable { + ProfileScreen( + onPersonalDataClick = { + profileNavController.navigate(PersonalData) + }, + onLanguageClick = { + profileNavController.navigate(Language) + }, + onSignOutComplete = { + navigateToAuth(context) + }, + profileVM = profileVM, + themeVM = themeVM + ) + } + composable { + PersonalDataScreen(onBackClick, profileVM) + } + composable { + LanguageScreen(onBackClick) + } + } +} + +private fun navigateToAuth(context: Context) { + val intent = Intent(context, AuthActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) + ContextCompat.startActivity(context, intent, null) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/MainSection.kt b/android/app/src/main/java/app/tourism/ui/screens/main/MainSection.kt new file mode 100644 index 0000000000..a6aca97042 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/MainSection.kt @@ -0,0 +1,146 @@ +package app.tourism.ui.screens.main + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemColors +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import app.organicmaps.R +import app.tourism.ui.common.VerticalSpace +import app.tourism.ui.theme.TextStyles + +@Composable +fun MainSection(themeVM: ThemeViewModel) { + val rootNavController = rememberNavController() + val navBackStackEntry by rootNavController.currentBackStackEntryAsState() + val items = getNavItems() + Scaffold { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + MainNavigation(rootNavController = rootNavController, themeVM = themeVM) + + Column(modifier = Modifier.align(alignment = Alignment.BottomCenter)) { + val destination = + rootNavController.currentBackStackEntryAsState().value?.destination + val isCurrentATopScreen = items.any { it.route == destination?.route } + if (isCurrentATopScreen) + NavigationBar( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .clip(shape = RoundedCornerShape(50.dp)), + containerColor = MaterialTheme.colorScheme.primary, + windowInsets = WindowInsets( + left = 24.dp, + right = 24.dp, + bottom = 0.dp, + top = 0.dp + ) + ) { + items.forEach { item -> + val isSelected = item.route == navBackStackEntry?.destination?.route + val title = stringResource(id = item.title) + NavigationBarItem( + colors = NavigationBarItemColors( + disabledIconColor = MaterialTheme.colorScheme.onPrimary, + disabledTextColor = MaterialTheme.colorScheme.onPrimary, + selectedIconColor = MaterialTheme.colorScheme.onPrimary, + selectedTextColor = MaterialTheme.colorScheme.onPrimary, + unselectedIconColor = MaterialTheme.colorScheme.onPrimary, + unselectedTextColor = MaterialTheme.colorScheme.onPrimary, + selectedIndicatorColor = Color.Transparent, + ), + selected = isSelected, + + label = { + Text(text = title, style = TextStyles.b3) + }, + icon = { + Icon( + painter = painterResource( + if (isSelected) item.selectedIcon else item.unselectedIcon + ), + contentDescription = title, + ) + }, + onClick = { + rootNavController.navigate(item.route) { + popUpTo(rootNavController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + ) + } + } + VerticalSpace(height = 0.dp) + } + } + } + +} + +data class BottomNavigationItem( + val route: String, + @StringRes val title: Int, + @DrawableRes val unselectedIcon: Int, + @DrawableRes val selectedIcon: Int +) + +@Composable +fun getNavItems(): List { + return listOf( + BottomNavigationItem( + route = "home_tab", + title = R.string.home, + selectedIcon = R.drawable.home_selected, + unselectedIcon = R.drawable.home, + ), + BottomNavigationItem( + route = "categories_tab", + title = R.string.categories, + selectedIcon = R.drawable.categories_selected, + unselectedIcon = R.drawable.categories, + ), + BottomNavigationItem( + route = "favorites_tab", + title = R.string.favorites, + selectedIcon = R.drawable.heart_selected, + unselectedIcon = R.drawable.heart, + ), + BottomNavigationItem( + route = "profile_tab", + title = R.string.profile_tourism, + selectedIcon = R.drawable.profile_selected, + unselectedIcon = R.drawable.profile, + ), + ) +} diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/ThemeViewModel.kt b/android/app/src/main/java/app/tourism/ui/screens/main/ThemeViewModel.kt new file mode 100644 index 0000000000..386268dc29 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/ThemeViewModel.kt @@ -0,0 +1,31 @@ +package app.tourism.ui.screens.main + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.tourism.data.prefs.UserPreferences +import app.tourism.data.repositories.ProfileRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ThemeViewModel @Inject constructor( + private val profileRepository: ProfileRepository, + private val userPreferences: UserPreferences, +) : ViewModel() { + private val _theme = MutableStateFlow(userPreferences.getTheme()) + val theme = _theme.asStateFlow() + + fun setTheme(themeCode: String) { + _theme.value = userPreferences.themes.first { it.code == themeCode } + userPreferences.setTheme(themeCode) + } + + fun updateThemeOnServer(themeCode: String){ + viewModelScope.launch { + profileRepository.updateTheme(themeCode) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/categories/categories/CategoriesScreen.kt b/android/app/src/main/java/app/tourism/ui/screens/main/categories/categories/CategoriesScreen.kt new file mode 100644 index 0000000000..137b33bc0c --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/categories/categories/CategoriesScreen.kt @@ -0,0 +1,112 @@ +package app.tourism.ui.screens.main.categories.categories + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import app.organicmaps.R +import app.tourism.Constants +import app.tourism.ui.common.AppSearchBar +import app.tourism.ui.common.SpaceForNavBar +import app.tourism.ui.common.VerticalSpace +import app.tourism.ui.common.nav.AppTopBar +import app.tourism.ui.common.nav.TopBarActionData +import app.tourism.ui.common.special.PlacesItem + +@Composable +fun CategoriesScreen( + onPlaceClick: (id: Long) -> Unit, + onSearchClick: (String) -> Unit, + onMapClick: () -> Unit, + categoriesVM: CategoriesViewModel = hiltViewModel() +) { + categoriesVM.apply { + val query = query.collectAsState().value + val categories = categories.collectAsState().value + val selectedCategory = selectedCategory.collectAsState().value + val places = places.collectAsState().value + + LaunchedEffect(true) { + if (selectedCategory == null) + categoriesVM.setSelectedCategory(categories.first()) + } + + Scaffold( + topBar = { + AppTopBar( + title = stringResource(id = R.string.categories), + actions = listOf( + TopBarActionData( + iconDrawable = R.drawable.map, + color = MaterialTheme.colorScheme.primary, + onClick = onMapClick + ), + ), + ) + }, + contentWindowInsets = WindowInsets(bottom = 0.dp) + ) { paddingValues -> + LazyColumn(Modifier.padding(paddingValues)) { + item { + Column { + Column(modifier = Modifier.padding(horizontal = Constants.SCREEN_PADDING)) { + VerticalSpace(height = 16.dp) + + AppSearchBar( + modifier = Modifier.fillMaxWidth(), + query = query, + onQueryChanged = ::setQuery, + onSearchClicked = onSearchClick, + onClearClicked = ::clearSearchField, + ) + VerticalSpace(height = 16.dp) + } + HorizontalSingleChoice( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = Constants.SCREEN_PADDING), + items = categories, + selected = selectedCategory, + onSelectedChanged = ::setSelectedCategory, + ) + VerticalSpace(height = 16.dp) + } + } + + items(places) { item -> + Column(modifier = Modifier.padding(horizontal = Constants.SCREEN_PADDING)) { + PlacesItem( + place = item, + onPlaceClick = { onPlaceClick(item.id) }, + isFavorite = item.isFavorite, + onFavoriteChanged = { isFavorite -> + setFavoriteChanged(item, isFavorite) + }, + ) + VerticalSpace(height = 16.dp) + } + } + + item { + Column { + SpaceForNavBar() + } + } + } + } + } +} diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/categories/categories/CategoriesViewModel.kt b/android/app/src/main/java/app/tourism/ui/screens/main/categories/categories/CategoriesViewModel.kt new file mode 100644 index 0000000000..d5814a7197 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/categories/categories/CategoriesViewModel.kt @@ -0,0 +1,118 @@ +package app.tourism.ui.screens.main.categories.categories + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.tourism.data.prefs.UserPreferences +import app.tourism.data.repositories.PlacesRepository +import app.tourism.domain.models.categories.PlaceCategory +import app.tourism.domain.models.common.PlaceShort +import app.tourism.domain.models.resource.Resource +import app.tourism.ui.models.SingleChoiceItem +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class CategoriesViewModel @Inject constructor( + @ApplicationContext val context: Context, + private val placesRepository: PlacesRepository, +) : ViewModel() { + private val uiChannel = Channel() + val uiEventsChannelFlow = uiChannel.receiveAsFlow() + + // region search query + private val _query = MutableStateFlow("") + val query = _query.asStateFlow() + + fun setQuery(value: String) { + _query.value = value + } + + fun clearSearchField() { + _query.value = "" + } + // endregion search query + + + private val _selectedCategory = MutableStateFlow(null) + val selectedCategory = _selectedCategory.asStateFlow() + + fun setSelectedCategory(value: SingleChoiceItem?) { + _selectedCategory.value = value + } + + private val _categories = MutableStateFlow>(emptyList()) + val categories = _categories.asStateFlow() + + + private val _places = MutableStateFlow>(emptyList()) + val places = _places.asStateFlow() + + fun setFavoriteChanged(item: PlaceShort, isFavorite: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + placesRepository.setFavorite(item.id, isFavorite) + } + } + + private fun onCategoryChangeGetPlaces() { + viewModelScope.launch { + _selectedCategory.collectLatest { item -> + item?.key?.let { id -> + val categoryId = id as Long + placesRepository.getPlacesByCategoryFromDbFlow(categoryId) + .collectLatest { resource -> + if (resource is Resource.Success) { + resource.data?.let { _places.value = it } + } + } + } + } + } + } + + private fun onCategoryChangeGetPlacesFromApiAlso() { + viewModelScope.launch { + _selectedCategory.collectLatest { item -> + item?.key?.let { id -> + val categoryId = id as Long + placesRepository.getPlacesByCategoryFromApiIfThereIsChange(categoryId) + } + } + } + } + + init { + // todo find better solution + val language = UserPreferences(context).getLanguage() + val isRussian = language?.code == "ru" + _categories.value = listOf( + SingleChoiceItem( + PlaceCategory.Sights.id, + if (isRussian) "Достопримечательности" else "Sights" + ), + SingleChoiceItem( + PlaceCategory.Restaurants.id, + if (isRussian) "Рестораны" else "Restaurants" + ), + SingleChoiceItem( + PlaceCategory.Hotels.id, + if (isRussian) "Отели" else "Hotels" + ), + ) + _selectedCategory.value = categories.value.first() + onCategoryChangeGetPlaces() + onCategoryChangeGetPlacesFromApiAlso() + } +} + +sealed interface UiEvent { + data class ShowToast(val message: String) : UiEvent +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/categories/categories/HorizontalSingleChoice.kt b/android/app/src/main/java/app/tourism/ui/screens/main/categories/categories/HorizontalSingleChoice.kt new file mode 100644 index 0000000000..5a91136eec --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/categories/categories/HorizontalSingleChoice.kt @@ -0,0 +1,76 @@ +package app.tourism.ui.screens.main.categories.categories + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import app.tourism.applyAppBorder +import app.tourism.ui.models.SingleChoiceItem +import app.tourism.ui.theme.TextStyles +import app.tourism.ui.theme.getSelectedColor +import app.tourism.ui.theme.getSelectedTextColor + +@Composable +fun HorizontalSingleChoice( + modifier: Modifier = Modifier, + items: List, + selected: SingleChoiceItem?, + onSelectedChanged: (SingleChoiceItem) -> Unit, + selectedColor: Color = getSelectedColor(), + unselectedColor: Color = MaterialTheme.colorScheme.background, + itemModifier: Modifier = Modifier, +) { + Row(Modifier.then(modifier), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + items.forEach { + SingleChoiceItem( + modifier = itemModifier, + item = it, + isSelected = it.key == selected?.key, + onClick = { + onSelectedChanged(it) + }, + selectedColor = selectedColor, + unselectedColor = unselectedColor + ) + } + } +} + +@Composable +private fun SingleChoiceItem( + modifier: Modifier = Modifier, + item: SingleChoiceItem, + isSelected: Boolean, + onClick: () -> Unit, + selectedColor: Color, + unselectedColor: Color, +) { + val shape = RoundedCornerShape(16.dp) + Text( + modifier = Modifier + .applyAppBorder() + .clickable { + onClick() + } + .clip(shape) + .background( + color = if (isSelected) selectedColor + else unselectedColor, + shape = shape + ) + .padding(12.dp) + .then(modifier), + text = item.label, + color = if (isSelected) getSelectedTextColor() else MaterialTheme.colorScheme.onBackground, + style = TextStyles.h4 + ) +} diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/favorites/favorites/FavoritesScreen.kt b/android/app/src/main/java/app/tourism/ui/screens/main/favorites/favorites/FavoritesScreen.kt new file mode 100644 index 0000000000..1160b85598 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/favorites/favorites/FavoritesScreen.kt @@ -0,0 +1,117 @@ +package app.tourism.ui.screens.main.favorites.favorites + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import app.organicmaps.R +import app.tourism.Constants +import app.tourism.ui.common.SpaceForNavBar +import app.tourism.ui.common.VerticalSpace +import app.tourism.ui.common.nav.AppTopBar +import app.tourism.ui.common.nav.SearchTopBar +import app.tourism.ui.common.nav.TopBarActionData +import app.tourism.ui.common.special.PlacesItem +import app.tourism.ui.common.ui_state.EmptyList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun FavoritesScreen( + onPlaceClick: (id: Long) -> Unit, + favoritesVM: FavoritesViewModel = hiltViewModel() +) { + val scope = rememberCoroutineScope() + + var isSearchActive by remember { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + + val query = favoritesVM.query.collectAsState().value + val places = favoritesVM.places.collectAsState().value + + Scaffold( + topBar = { + if (isSearchActive) { + Column { + SearchTopBar( + modifier = Modifier.focusRequester(focusRequester), + query = query, + onQueryChanged = { favoritesVM.setQuery(it) }, + onClearClicked = { favoritesVM.clearSearchField() }, + onBackClicked = { + isSearchActive = false + favoritesVM.setQuery("") + }, + ) + } + } else { + AppTopBar( + title = stringResource(id = R.string.favorites), + actions = listOf( + TopBarActionData( + iconDrawable = R.drawable.search, + onClick = { + isSearchActive = true + scope.launch(context = Dispatchers.Main) { + /*This delay is here so our textfield would first become enabled for editing + and only then it should get receive focus*/ + delay(100L) + focusRequester.requestFocus() + } + } + ), + ), + ) + } + }, + contentWindowInsets = Constants.USUAL_WINDOW_INSETS + ) { paddingValues -> + LazyColumn(Modifier.padding(paddingValues)) { + item { + VerticalSpace(16.dp) + } + + if (places.isNotEmpty()) + items(places, key = { it.id }) { item -> + Column(Modifier.animateItem()) { + PlacesItem( + place = item, + onPlaceClick = { onPlaceClick(item.id) }, + isFavorite = item.isFavorite, + onFavoriteChanged = { isFavorite -> + favoritesVM.setFavoriteChanged(item, isFavorite) + }, + ) + VerticalSpace(height = 16.dp) + } + } + else + item { + EmptyList() + } + + item { + Column { + SpaceForNavBar() + } + } + } + } +} diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/favorites/favorites/FavoritesViewModel.kt b/android/app/src/main/java/app/tourism/ui/screens/main/favorites/favorites/FavoritesViewModel.kt new file mode 100644 index 0000000000..84a4be7a9b --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/favorites/favorites/FavoritesViewModel.kt @@ -0,0 +1,66 @@ +package app.tourism.ui.screens.main.favorites.favorites + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.tourism.data.repositories.PlacesRepository +import app.tourism.domain.models.common.PlaceShort +import app.tourism.domain.models.resource.Resource +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class FavoritesViewModel @Inject constructor( + private val placesRepository: PlacesRepository +) : ViewModel() { + private val uiChannel = Channel() + val uiEventsChannelFlow = uiChannel.receiveAsFlow() + + // region search query + private val _query = MutableStateFlow("") + val query = _query.asStateFlow() + + fun setQuery(value: String) { + _query.value = value + } + + fun clearSearchField() { + _query.value = "" + } + // endregion search query + + private val _places = MutableStateFlow>(emptyList()) + val places = _places.asStateFlow() + + fun setFavoriteChanged(item: PlaceShort, isFavorite: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + placesRepository.setFavorite(item.id, isFavorite) + } + } + + private fun getFavorites() { + viewModelScope.launch(Dispatchers.IO) { + _query.collectLatest { + placesRepository.getFavorites(it).collectLatest { resource -> + if (resource is Resource.Success) { + resource.data?.let { _places.value = it } + } + } + } + } + } + + init { + getFavorites() + } +} + +sealed interface UiEvent { + data class ShowToast(val message: String) : UiEvent +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/home/HomeScreen.kt b/android/app/src/main/java/app/tourism/ui/screens/main/home/HomeScreen.kt new file mode 100644 index 0000000000..c86eccf597 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/home/HomeScreen.kt @@ -0,0 +1,324 @@ +package app.tourism.ui.screens.main.home + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import app.organicmaps.R +import app.tourism.Constants +import app.tourism.domain.models.common.PlaceShort +import app.tourism.domain.models.resource.Resource +import app.tourism.drawOverlayForTextBehind +import app.tourism.ui.ObserveAsEvents +import app.tourism.ui.common.AppSearchBar +import app.tourism.ui.common.BorderedItem +import app.tourism.ui.common.HorizontalSpace +import app.tourism.ui.common.LoadImg +import app.tourism.ui.common.SpaceForNavBar +import app.tourism.ui.common.VerticalSpace +import app.tourism.ui.common.nav.AppTopBar +import app.tourism.ui.common.nav.TopBarActionData +import app.tourism.ui.common.ui_state.Error +import app.tourism.ui.common.ui_state.Loading +import app.tourism.ui.screens.main.categories.categories.CategoriesViewModel +import app.tourism.ui.screens.main.categories.categories.HorizontalSingleChoice +import app.tourism.ui.theme.TextStyles +import app.tourism.ui.theme.getStarColor +import app.tourism.ui.utils.showToast +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +@Composable +fun HomeScreen( + onSearchClick: (String) -> Unit, + onPlaceClick: (id: Long) -> Unit, + onMapClick: () -> Unit, + onCategoryClicked: () -> Unit, + homeVM: HomeViewModel = hiltViewModel(), + categoriesVM: CategoriesViewModel, +) { + val context = LocalContext.current + + val query = homeVM.query.collectAsState().value + val sights = homeVM.sights.collectAsState().value + val restaurants = homeVM.restaurants.collectAsState().value + + val downloadResponse = homeVM.downloadResponse.collectAsState().value + + ObserveAsEvents(flow = homeVM.uiEventsChannelFlow) { event -> + when (event) { + is UiEvent.ShowToast -> context.showToast(event.message) + } + } + + LaunchedEffect(true) { + categoriesVM.setSelectedCategory(null) + } + + Scaffold( + topBar = { + AppTopBar( + title = stringResource(id = R.string.tjk), + actions = listOf( + TopBarActionData( + iconDrawable = R.drawable.map, + color = MaterialTheme.colorScheme.primary, + onClick = onMapClick + ), + ), + ) + }, + contentWindowInsets = WindowInsets(left = 0.dp, right = 0.dp, top = 0.dp, bottom = 0.dp) + ) { paddingValues -> + if (downloadResponse is Resource.Success || downloadResponse is Resource.Idle) + Column( + Modifier + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + ) { + StartImagesDownloadIfNecessary(homeVM) + + Column(Modifier.padding(horizontal = Constants.SCREEN_PADDING)) { + VerticalSpace(height = 16.dp) + + AppSearchBar( + modifier = Modifier.fillMaxWidth(), + query = query, + onQueryChanged = { homeVM.setQuery(it) }, + onSearchClicked = onSearchClick, + onClearClicked = { homeVM.clearSearchField() }, + ) + } + VerticalSpace(height = 16.dp) + + Categories(categoriesVM, onCategoryClicked) + VerticalSpace(height = 24.dp) + + HorizontalPlaces( + title = stringResource(id = R.string.sights), + items = sights, + onPlaceClick = { item -> + onPlaceClick(item.id) + }, + setFavoriteChanged = { item, isFavorite -> + homeVM.setFavoriteChanged(item, isFavorite) + }, + ) + VerticalSpace(height = 24.dp) + + HorizontalPlaces( + title = stringResource(id = R.string.restaurants), + items = restaurants, + onPlaceClick = { item -> + onPlaceClick(item.id) + }, + setFavoriteChanged = { item, isFavorite -> + homeVM.setFavoriteChanged(item, isFavorite) + }, + ) + + SpaceForNavBar() + } + if (downloadResponse is Resource.Loading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = stringResource(id = R.string.plz_wait_dowloading)) + VerticalSpace(height = 16.dp) + Loading(onEntireScreen = false) + } + } + } + if (downloadResponse is Resource.Error) { + Error( + errorMessage = downloadResponse.message + ?: stringResource(id = R.string.smth_went_wrong), + ) + } + } +} + +@Composable +private fun Categories(categoriesVM: CategoriesViewModel, onCategoryClicked: () -> Unit) { + categoriesVM.apply { + val categories = categories.collectAsState().value + val selectedCategory = selectedCategory.collectAsState().value + + Row( + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + ) { + HorizontalSpace(width = 16.dp) + BorderedItem( + label = stringResource(id = R.string.top30), + highlighted = true, + onClick = { /*Nothing... Yes! Nothing!*/ }, + ) + HorizontalSpace(width = 12.dp) + + HorizontalSingleChoice( + items = categories, + selected = selectedCategory, + onSelectedChanged = { + setSelectedCategory(it) + onCategoryClicked() + }, + ) + } + } +} + +@Composable +private fun HorizontalPlaces( + modifier: Modifier = Modifier, + title: String, + items: List, + onPlaceClick: (PlaceShort) -> Unit, + setFavoriteChanged: (PlaceShort, Boolean) -> Unit, +) { + Column(Modifier.then(modifier)) { + Column(Modifier.padding(horizontal = Constants.SCREEN_PADDING)) { + Text(text = title, style = TextStyles.h2) + VerticalSpace(height = 12.dp) + } + LazyRow(contentPadding = PaddingValues(horizontal = 16.dp)) { + items(items) { + Row { + Place( + place = it, + onPlaceClick = { onPlaceClick(it) }, + isFavorite = it.isFavorite, + onFavoriteChanged = { isFavorite -> + setFavoriteChanged(it, isFavorite) + }, + ) + HorizontalSpace(width = 12.dp) + } + } + } + } +} + +@Composable +private fun Place( + modifier: Modifier = Modifier, + place: PlaceShort, + onPlaceClick: () -> Unit, + isFavorite: Boolean, + onFavoriteChanged: (Boolean) -> Unit +) { + val textStyle = TextStyle( + fontSize = 15.sp, + fontWeight = FontWeight.W600, + color = Color.White + ) + + Box( + modifier = Modifier + .width(230.dp) + .height(250.dp) + .clip(RoundedCornerShape(16.dp)) + .clickable { onPlaceClick() } + .then(modifier), + ) { + LoadImg(url = place.cover) + + Column( + Modifier + .fillMaxWidth() + .drawOverlayForTextBehind() + .align(Alignment.BottomCenter) + .padding(12.dp), + ) { + Text( + text = place.name, + style = textStyle, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + VerticalSpace(height = 4.dp) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = "%.1f".format(place.rating), style = textStyle) + HorizontalSpace(width = 2.dp) + Icon( + modifier = Modifier.size(12.dp), + painter = painterResource(id = R.drawable.star), + contentDescription = null, + tint = getStarColor(), + ) + } + } + + IconButton( + modifier = Modifier + .padding(12.dp) + .background(Color.White.copy(alpha = 0.2f), CircleShape) + .align(Alignment.TopEnd), + onClick = { + onFavoriteChanged(!isFavorite) + }, + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(id = if (isFavorite) R.drawable.heart_selected else R.drawable.heart), + contentDescription = stringResource(id = R.string.add_to_favorites), + tint = Color.White, + ) + } + } +} + +@Composable +fun StartImagesDownloadIfNecessary(homeVM: HomeViewModel) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(Unit, lifecycleOwner) { + // this delay is here because it might navigate to map to download it + delay(3000L) + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + withContext(Dispatchers.Main.immediate) { + homeVM.startDownloadServiceIfNecessary() + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/home/HomeViewModel.kt b/android/app/src/main/java/app/tourism/ui/screens/main/home/HomeViewModel.kt new file mode 100644 index 0000000000..390455a5ce --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/home/HomeViewModel.kt @@ -0,0 +1,133 @@ +package app.tourism.ui.screens.main.home + +import android.content.Context +import android.content.Intent +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.organicmaps.R +import app.tourism.ImagesDownloadService +import app.tourism.data.repositories.PlacesRepository +import app.tourism.domain.models.SimpleResponse +import app.tourism.domain.models.categories.PlaceCategory +import app.tourism.domain.models.common.PlaceShort +import app.tourism.domain.models.resource.Resource +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class HomeViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val placesRepository: PlacesRepository +) : ViewModel() { + private val uiChannel = Channel() + val uiEventsChannelFlow = uiChannel.receiveAsFlow() + + private val _downloadServiceAlreadyRunning = MutableStateFlow(false) + + // region search query + private val _query = MutableStateFlow("") + val query = _query.asStateFlow() + + fun setQuery(value: String) { + _query.value = value + } + + fun clearSearchField() { + _query.value = "" + } + // endregion search query + + + private val _sights = MutableStateFlow>(emptyList()) + val sights = _sights.asStateFlow() + private fun getTopSights() { + val categoryId = PlaceCategory.Sights.id + viewModelScope.launch(Dispatchers.IO) { + placesRepository.getTopPlaces(categoryId) + .collectLatest { resource -> + if (resource is Resource.Success) { + resource.data?.let { + _sights.value = it + } + } + } + } + viewModelScope.launch(Dispatchers.IO) { + placesRepository.getPlacesByCategoryFromApiIfThereIsChange(categoryId) + } + } + + + private val _restaurants = MutableStateFlow>(emptyList()) + val restaurants = _restaurants.asStateFlow() + private fun getTopRestaurants() { + val categoryId = PlaceCategory.Restaurants.id + viewModelScope.launch(Dispatchers.IO) { + placesRepository.getTopPlaces(categoryId) + .collectLatest { resource -> + if (resource is Resource.Success) { + resource.data?.let { + _restaurants.value = it + } + } + } + } + viewModelScope.launch(Dispatchers.IO) { + placesRepository.getPlacesByCategoryFromApiIfThereIsChange(categoryId) + } + } + + private fun markAllImagesAsNotDownloadedIfCacheWasCleared() { + viewModelScope.launch(Dispatchers.IO) { + placesRepository.markAllImagesAsNotDownloadedIfCacheWasCleared() + } + } + + private val _downloadResponse = MutableStateFlow>(Resource.Idle()) + val downloadResponse = _downloadResponse.asStateFlow() + private fun downloadAllData() { + viewModelScope.launch(Dispatchers.IO) { + placesRepository.downloadAllData().collectLatest { + _downloadResponse.value = it + } + } + } + + fun startDownloadServiceIfNecessary() { + if (!_downloadServiceAlreadyRunning.value) { + _downloadServiceAlreadyRunning.value = true + viewModelScope.launch(Dispatchers.IO) { + if (placesRepository.shouldDownloadImages()) { + uiChannel.send(UiEvent.ShowToast(context.getString(R.string.downloading_images))) + val intent = Intent(context, ImagesDownloadService::class.java) + context.startService(intent) + } + } + } + } + + fun setFavoriteChanged(item: PlaceShort, isFavorite: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + placesRepository.setFavorite(item.id, isFavorite) + } + } + + init { + markAllImagesAsNotDownloadedIfCacheWasCleared() + downloadAllData() + getTopSights() + getTopRestaurants() + } +} + +sealed interface UiEvent { + data class ShowToast(val message: String) : UiEvent +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/place_details/PlaceScreen.kt b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/PlaceScreen.kt new file mode 100644 index 0000000000..26c8125085 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/PlaceScreen.kt @@ -0,0 +1,121 @@ +package app.tourism.ui.screens.main.place_details + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import app.tourism.Constants +import app.tourism.data.dto.PlaceLocation +import app.tourism.ui.common.VerticalSpace +import app.tourism.ui.common.nav.PlaceTopBar +import app.tourism.ui.screens.main.place_details.description.DescriptionScreen +import app.tourism.ui.screens.main.place_details.gallery.GalleryNavigation +import app.tourism.ui.screens.main.place_details.reviews.ReviewsNavigation +import kotlinx.coroutines.launch + +@Composable +fun PlaceDetailsScreen( + id: Long, + onPlaceImageClick: (selectedImage: String, imageUrls: List) -> Unit, + onBackClick: () -> Unit, + onMapClick: () -> Unit, + onCreateRoute: (PlaceLocation) -> Unit, + placeVM: PlaceViewModel = hiltViewModel() +) { + val scope = rememberCoroutineScope() + + val place = placeVM.place.collectAsState().value + + LaunchedEffect(true) { + placeVM.observePlace(id) + } + + Scaffold( + topBar = { + place?.let { + PlaceTopBar( + title = it.name, + picUrl = it.cover, + isFavorite = it.isFavorite, + onFavoriteChanged = { isFavorite -> + placeVM.setFavoriteChanged( + id, + isFavorite + ) + }, + onMapClick = onMapClick, + onBackClick = onBackClick, + ) + } + }, + contentWindowInsets = WindowInsets.statusBars + ) { paddingValues -> + place?.let { + Column(Modifier.padding(paddingValues)) { + val pagerState = rememberPagerState(pageCount = { 3 }) + + VerticalSpace(height = 16.dp) + Box(modifier = Modifier.padding(horizontal = Constants.SCREEN_PADDING)) { + PlaceTabRow( + tabIndex = pagerState.currentPage, + onTabIndexChanged = { + scope.launch { + pagerState.scrollToPage(it) + } + }, + ) + } + + + HorizontalPager( + modifier = Modifier.fillMaxSize(), + state = pagerState, + verticalAlignment = Alignment.Top, + ) { page -> + when (page) { + 0 -> { + DescriptionScreen( + description = place.description, + onCreateRoute = { + place.placeLocation?.let { it1 -> onCreateRoute(it1) } + }, + ) + } + + 1 -> { + GalleryNavigation( + urls = place.pics, + onItemClick = { item -> + onPlaceImageClick(item, place.pics) + }, + ) + } + + 2 -> { + ReviewsNavigation( + placeId = place.id, rating = place.rating, + onImageClick = { selectedImageUrl, imageUrls -> + onPlaceImageClick(selectedImageUrl, imageUrls) + }, + ) + } + } + } + } + } + } +} + diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/place_details/PlaceTabRow.kt b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/PlaceTabRow.kt new file mode 100644 index 0000000000..f780c01c37 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/PlaceTabRow.kt @@ -0,0 +1,90 @@ +package app.tourism.ui.screens.main.place_details + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.organicmaps.R +import app.tourism.ui.models.SingleChoiceItem +import app.tourism.ui.theme.DarkestBlue +import app.tourism.ui.theme.SelectedDay +import app.tourism.ui.theme.SelectedNight +import app.tourism.ui.theme.SelectedTextDay +import app.tourism.ui.theme.SelectedTextNight +import app.tourism.ui.theme.TextStyles +import app.tourism.ui.theme.getSelectedColor +import app.tourism.ui.theme.getSelectedTextColor + +@Composable +fun PlaceTabRow(modifier: Modifier = Modifier, tabIndex: Int, onTabIndexChanged: (Int) -> Unit) { + val tabs = listOf( + SingleChoiceItem(0, stringResource(id = R.string.description_tourism)), + SingleChoiceItem(1, stringResource(id = R.string.gallery)), + SingleChoiceItem(2, stringResource(id = R.string.reviews)), + ) + + val shape = RoundedCornerShape(50.dp) + + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surface, + shape + ) + .then(modifier) + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + tabs.forEach { + SingleChoiceItem( + item = it, + isSelected = it.key == tabIndex, + onClick = { + onTabIndexChanged(it.key as Int) + }, + ) + } + } +} + +@Composable +private fun SingleChoiceItem( + item: SingleChoiceItem, + isSelected: Boolean, + onClick: () -> Unit, +) { + val shape = RoundedCornerShape(50.dp) + val notSelectedColor = MaterialTheme.colorScheme.surface + val notSelectedTextColor = MaterialTheme.colorScheme.onBackground + val selectedColor = getSelectedColor() + val selectedTextColor = getSelectedTextColor() + + Text( + modifier = Modifier + .wrapContentSize() + .clip(shape) + .clickable { onClick() } + .background( + color = if (isSelected) selectedColor else notSelectedColor, + shape = shape + ) + .padding(8.dp), + text = item.label, + style = TextStyles.b1, + color = if (isSelected) selectedTextColor else notSelectedTextColor, + maxLines = 1 + ) +} diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/place_details/PlaceViewModel.kt b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/PlaceViewModel.kt new file mode 100644 index 0000000000..cc7f29cf8c --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/PlaceViewModel.kt @@ -0,0 +1,47 @@ +package app.tourism.ui.screens.main.place_details + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.tourism.data.repositories.PlacesRepository +import app.tourism.domain.models.details.PlaceFull +import app.tourism.domain.models.resource.Resource +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class PlaceViewModel @Inject constructor( + private val placesRepository: PlacesRepository +) : ViewModel() { + private val uiChannel = Channel() + val uiEventsChannelFlow = uiChannel.receiveAsFlow() + + private val _place = MutableStateFlow(null) + val place = _place.asStateFlow() + + fun setFavoriteChanged(itemId: Long, isFavorite: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + placesRepository.setFavorite(itemId, isFavorite) + } + } + + fun observePlace(id: Long) { + viewModelScope.launch(Dispatchers.IO) { + placesRepository.getPlaceById(id).collectLatest { + if(it is Resource.Success) { + _place.value = it.data + } + } + } + } +} + +sealed interface UiEvent { + data class ShowToast(val message: String) : UiEvent +} diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/place_details/description/Description.kt b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/description/Description.kt new file mode 100644 index 0000000000..c81a1dcad8 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/description/Description.kt @@ -0,0 +1,66 @@ +package app.tourism.ui.screens.main.place_details.description + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.organicmaps.R +import app.tourism.Constants +import app.tourism.ui.common.VerticalSpace +import app.tourism.ui.common.buttons.PrimaryButton +import app.tourism.ui.theme.TextStyles +import app.tourism.ui.utils.enableLocation +import app.tourism.utils.getAnnotatedStringFromHtml + +@Composable +fun DescriptionScreen( + description: String?, + onCreateRoute: (() -> Unit)?, +) { + val context = LocalContext.current + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = Constants.SCREEN_PADDING) + ) { + description?.let { + Column(Modifier.verticalScroll(rememberScrollState())) { + VerticalSpace(height = 16.dp) + Text( + text = it.getAnnotatedStringFromHtml(), + style = TextStyles.b1, + fontSize = 14.sp + ) + VerticalSpace(height = 100.dp) + } + } + + onCreateRoute?.let { + PrimaryButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .offset(y = (-32).dp), + label = stringResource(id = R.string.show_route), + onClick = { + enableLocation( + context = context, + onSuccess = { + onCreateRoute() + }, + ) + }, + ) + } + } +} diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/place_details/gallery/AllGalleryScreen.kt b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/gallery/AllGalleryScreen.kt new file mode 100644 index 0000000000..0605749fe4 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/gallery/AllGalleryScreen.kt @@ -0,0 +1,39 @@ +package app.tourism.ui.screens.main.place_details.gallery + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import app.tourism.Constants +import app.tourism.ui.common.LoadImg +import app.tourism.ui.common.nav.BackButtonWithText + +@Composable +fun AllGalleryScreen(urls: List, onItemClick: (String) -> Unit, onBackClick: () -> Unit) { + Scaffold( + topBar = { + BackButtonWithText { onBackClick() } + } + ) { paddingValues -> + LazyVerticalGrid( + modifier = Modifier.padding(paddingValues), + columns = GridCells.Fixed(2), + contentPadding = PaddingValues(Constants.SCREEN_PADDING), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(urls) { + LoadImg( + modifier = Modifier.clickable { onItemClick(it) }.propertiesForSmallImage(), url = it + ) + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/place_details/gallery/FullscreenImageViewer.kt b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/gallery/FullscreenImageViewer.kt new file mode 100644 index 0000000000..64adaebd9e --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/gallery/FullscreenImageViewer.kt @@ -0,0 +1,120 @@ +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PageSize +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import app.tourism.ui.common.nav.BackButton +import coil.compose.AsyncImage +import kotlinx.coroutines.launch + +@Composable +fun FullscreenImageScreen( + onBackClick: () -> Unit, + selectedImageUrl: String, + imageUrls: List +) { + val indexOfSelectedImage = imageUrls.indexOfFirst { it == selectedImageUrl } + val pagerState = + rememberPagerState(initialPage = if (indexOfSelectedImage != -1) indexOfSelectedImage else 0) { imageUrls.size } + val scope = rememberCoroutineScope() + + Box(modifier = Modifier.fillMaxSize()) { + + + HorizontalPager( + pageSize = PageSize.Fill, + state = pagerState, + modifier = Modifier.fillMaxSize() + ) { page -> + var scale by remember { mutableStateOf(1f) } + var offset by remember { mutableStateOf(Offset.Zero) } + var zooming by remember { mutableStateOf(false) } + + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTransformGestures { centroid, pan, zoom, _ -> + if (zoom != 1f) zooming = true + scale = (scale * zoom).coerceIn(1f, 3f) + if (scale > 1f) { + val newOffset = offset + pan + val maxX = size.width * (scale - 1) / 2 + val maxY = size.height * (scale - 1) / 2 + offset = Offset( + newOffset.x.coerceIn(-maxX, maxX), + newOffset.y.coerceIn(-maxY, maxY) + ) + } else { + offset = Offset.Zero + if (!zooming) { + // Allow swiping when not zoomed + scope.launch { + if (pan.x > 0 && pagerState.currentPage > 0) { + pagerState.animateScrollToPage(pagerState.currentPage - 1) + } else if (pan.x < 0 && pagerState.currentPage < imageUrls.size - 1) { + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } + } + } + } + zooming = false + } + } + ) { + AsyncImage( + model = imageUrls[page], + contentDescription = "Full screen image", + contentScale = ContentScale.FillWidth, + modifier = Modifier + .fillMaxSize() + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offset.x, + translationY = offset.y + ) + ) + } + } + Box(Modifier.padding(16.dp)) { + BackButton( + size = 30.dp, + onBackClick = onBackClick, + ) + } + // Page indicator + Row( + Modifier + .height(50.dp) + .fillMaxWidth() + .align(Alignment.BottomCenter), + horizontalArrangement = Arrangement.Center + ) { + repeat(imageUrls.size) { iteration -> + val color = + if (pagerState.currentPage == iteration) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.primary.copy(alpha = 0.25f) + Box( + modifier = Modifier + .padding(2.dp) + .clip(CircleShape) + .background(color) + .size(8.dp) + ) + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/place_details/gallery/GalleryNavigation.kt b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/gallery/GalleryNavigation.kt new file mode 100644 index 0000000000..a7b0c0ae87 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/gallery/GalleryNavigation.kt @@ -0,0 +1,39 @@ +package app.tourism.ui.screens.main.place_details.gallery + +import androidx.compose.runtime.Composable +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import kotlinx.serialization.Serializable + +@Serializable +object Gallery + +@Serializable +object AllGallery + +@Composable +fun GalleryNavigation(urls: List, onItemClick: (String) -> Unit) { + val navController = rememberNavController() + + NavHost(navController = navController, startDestination = Gallery) { + composable { + GalleryScreen( + urls = urls, + onItemClick = onItemClick, + onMoreClick = { + navController.navigate(AllGallery) + }, + ) + } + composable { + AllGalleryScreen( + urls = urls, + onItemClick = onItemClick, + onBackClick = { + navController.navigateUp() + }, + ) + } + } +} diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/place_details/gallery/GalleryScreen.kt b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/gallery/GalleryScreen.kt new file mode 100644 index 0000000000..3012075622 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/gallery/GalleryScreen.kt @@ -0,0 +1,79 @@ +package app.tourism.ui.screens.main.place_details.gallery + +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.Row +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import app.tourism.Constants +import app.tourism.ui.common.HorizontalSpace +import app.tourism.ui.common.LoadImg +import app.tourism.ui.common.VerticalSpace +import app.tourism.ui.theme.TextStyles + +@Composable +fun GalleryScreen(urls: List, onItemClick: (String) -> Unit, onMoreClick: () -> Unit) { + Column(Modifier.padding(Constants.SCREEN_PADDING)) { + if (urls.isNotEmpty()) { + LoadImg( + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .clickable { + onItemClick(urls.first()) + } + .clip(imageShape), + url = urls.first(), + ) + VerticalSpace(height = 16.dp) + + Row { + if (urls.size > 1) { + LoadImg( + modifier = Modifier + .weight(1f) + .clickable { onItemClick(urls[1]) } + .propertiesForSmallImage(), + url = urls[1], + ) + if (urls.size > 2) { + HorizontalSpace(16.dp) + Box( + modifier = Modifier + .weight(1f) + .clickable { onMoreClick() } + .propertiesForSmallImage(), + contentAlignment = Alignment.Center + ) { + LoadImg(url = urls[2]) + Box( + modifier = Modifier + .fillMaxSize() + .background( + color = Color.Black.copy(alpha = 0.5f), + shape = imageShape + ), + ) + Text( + text = "+${urls.size - 3}", + style = TextStyles.h1, + color = Color.White, + ) + } + } + } + } + } + } +} diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/place_details/gallery/Utils.kt b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/gallery/Utils.kt new file mode 100644 index 0000000000..577846eca6 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/gallery/Utils.kt @@ -0,0 +1,16 @@ +package app.tourism.ui.screens.main.place_details.gallery + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp + +@Composable +fun Modifier.propertiesForSmallImage() = + this + .height(150.dp) + .clip(imageShape) + +val imageShape = RoundedCornerShape(10.dp) \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/AllReviewsScreen.kt b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/AllReviewsScreen.kt new file mode 100644 index 0000000000..4dca4716d3 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/AllReviewsScreen.kt @@ -0,0 +1,39 @@ +package app.tourism.ui.screens.main.place_details.reviews + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import app.tourism.Constants +import app.tourism.ui.common.nav.BackButtonWithText +import app.tourism.ui.screens.main.place_details.reviews.components.Review + +@Composable +fun AllReviewsScreen( + reviewsVM: ReviewsViewModel = hiltViewModel(), + onImageClick: (selectedImage: String, imageUrls: List) -> Unit, + onBackClick: () -> Unit, + onMoreClick: (picsUrls: List) -> Unit, +) { + val reviews = reviewsVM.reviews.collectAsState().value + + Scaffold( + topBar = { + BackButtonWithText { onBackClick() } + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier.padding(paddingValues), + contentPadding = PaddingValues(Constants.SCREEN_PADDING), + ) { + items(reviews) { + Review(review = it, onMoreClick = onMoreClick, onImageClick = onImageClick) + } + } + } +} diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/PostReviewViewModel.kt b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/PostReviewViewModel.kt new file mode 100644 index 0000000000..002a72e559 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/PostReviewViewModel.kt @@ -0,0 +1,98 @@ +package app.tourism.ui.screens.main.place_details.reviews + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.organicmaps.R +import app.tourism.data.repositories.ReviewsRepository +import app.tourism.domain.models.SimpleResponse +import app.tourism.domain.models.details.ReviewToPost +import app.tourism.domain.models.resource.Resource +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.io.File +import javax.inject.Inject + +@HiltViewModel +class PostReviewViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val reviewsRepository: ReviewsRepository +) : ViewModel() { + private val uiChannel = Channel() + val uiEventsChannelFlow = uiChannel.receiveAsFlow() + + private val _rating = MutableStateFlow(5f) + val rating = _rating.asStateFlow() + + fun setRating(value: Float) { + _rating.value = value + } + + private val _comment = MutableStateFlow("") + val comment = _comment.asStateFlow() + + fun setComment(value: String) { + _comment.value = value + } + + + private val _files = MutableStateFlow>(emptyList()) + val files = _files.asStateFlow() + + fun addFile(file: File) { + _files.update { + val list = _files.value.toMutableList() + list.add(file) + list + } + } + + fun removeFile(file: File) { + _files.update { + val list = _files.value.toMutableList() + list.remove(file) + list + } + } + + private val _postReviewResponse = MutableStateFlow?>(null) + val postReviewResponse = _postReviewResponse.asStateFlow() + + fun postReview(id: Long) { + viewModelScope.launch(Dispatchers.Unconfined) { + reviewsRepository.postReview( + ReviewToPost( + placeId = id, + comment = _comment.value, + rating = _rating.value.toInt(), + images = _files.value + ) + ).collectLatest { + _postReviewResponse.value = it + if (it is Resource.Success) { + uiChannel.send( + UiEvent.ShowToast( + it.message ?: context.getString(R.string.post_review_success) + ) + ) + uiChannel.send(UiEvent.CloseReviewBottomSheet) + } else if (it is Resource.Error) { + uiChannel.send( + UiEvent.ShowToast(it.message ?: context.getString(R.string.smth_went_wrong)) + ) + if (it.message == context.getString(R.string.review_will_be_published_when_online)) { + uiChannel.send(UiEvent.CloseReviewBottomSheet) + } + } + } + } + } +} diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/ReviewPicsScreen.kt b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/ReviewPicsScreen.kt new file mode 100644 index 0000000000..6f41caf8be --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/ReviewPicsScreen.kt @@ -0,0 +1,46 @@ +package app.tourism.ui.screens.main.place_details.reviews + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import app.tourism.Constants +import app.tourism.ui.common.LoadImg +import app.tourism.ui.common.nav.BackButtonWithText +import app.tourism.ui.screens.main.place_details.gallery.propertiesForSmallImage + +@Composable +fun ReviewPicsScreen( + urls: List, + onImageClick: (selectedImage: String, imageUrls: List) -> Unit, + onBackClick: () -> Unit +) { + Scaffold( + topBar = { + BackButtonWithText { onBackClick() } + } + ) { paddingValues -> + LazyVerticalGrid( + modifier = Modifier.padding(paddingValues), + columns = GridCells.Fixed(2), + contentPadding = PaddingValues(Constants.SCREEN_PADDING), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(urls) { + LoadImg( + modifier = Modifier + .clickable { onImageClick(it, urls) } + .propertiesForSmallImage(), url = it + ) + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/ReviewsNavigation.kt b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/ReviewsNavigation.kt new file mode 100644 index 0000000000..5952ebd902 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/ReviewsNavigation.kt @@ -0,0 +1,69 @@ +package app.tourism.ui.screens.main.place_details.reviews + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import kotlinx.serialization.Serializable + +@Serializable +object Reviews + +@Serializable +object AllReviews + +@Serializable +data class ReviewPics(val urls: List) + +@Composable +fun ReviewsNavigation( + placeId: Long, + rating: Double?, + onImageClick: (selectedImage: String, imageUrls: List) -> Unit, + reviewsVM: ReviewsViewModel = hiltViewModel(), +) { + val navController = rememberNavController() + + val onBackClick: () -> Unit = { navController.navigateUp() } + val onMoreClick: (picsUrls: List) -> Unit = { + navController.navigate(ReviewPics(urls = it)) + } + + LaunchedEffect(Unit) { + reviewsVM.getReviews(placeId) + } + + NavHost(navController = navController, startDestination = Reviews) { + composable { + ReviewsScreen( + placeId, + rating, + onImageClick = onImageClick, + onSeeAllClick = { + navController.navigate(AllReviews) + }, + onMoreClick = onMoreClick, + reviewsVM = reviewsVM, + ) + } + composable { + AllReviewsScreen( + reviewsVM = reviewsVM, + onImageClick = onImageClick, + onBackClick = onBackClick, + onMoreClick = onMoreClick + ) + } + composable { navBackStackEntry -> + val reviewPics = navBackStackEntry.toRoute() + ReviewPicsScreen( + urls = reviewPics.urls, + onImageClick = onImageClick, + onBackClick = onBackClick + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/ReviewsScreen.kt b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/ReviewsScreen.kt new file mode 100644 index 0000000000..a0a5b75932 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/ReviewsScreen.kt @@ -0,0 +1,164 @@ +package app.tourism.ui.screens.main.place_details.reviews + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import app.organicmaps.R +import app.tourism.Constants +import app.tourism.ui.ObserveAsEvents +import app.tourism.ui.common.HorizontalSpace +import app.tourism.ui.common.VerticalSpace +import app.tourism.ui.screens.main.place_details.reviews.components.PostReview +import app.tourism.ui.screens.main.place_details.reviews.components.Review +import app.tourism.ui.theme.TextStyles +import app.tourism.ui.theme.getStarColor +import app.tourism.ui.utils.showToast +import app.tourism.ui.utils.showYesNoAlertDialog +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReviewsScreen( + placeId: Long, + rating: Double?, + onImageClick: (selectedImage: String, imageUrls: List) -> Unit, + onSeeAllClick: () -> Unit, + onMoreClick: (picsUrls: List) -> Unit, + reviewsVM: ReviewsViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val sheetState = rememberModalBottomSheetState() + var showReviewBottomSheet by remember { mutableStateOf(false) } + + val userReview = reviewsVM.userReview.collectAsState().value + val reviews = reviewsVM.reviews.collectAsState().value + + val isThereReviewPlannedToPublish = + reviewsVM.isThereReviewPlannedToPublish.collectAsState().value + + ObserveAsEvents(flow = reviewsVM.uiEventsChannelFlow) { event -> + if (event is UiEvent.ShowToast) context.showToast(event.message) + } + + LazyColumn( + contentPadding = PaddingValues(Constants.SCREEN_PADDING), + ) { + rating?.let { + item { + Column { + VerticalSpace(height = 16.dp) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + modifier = Modifier.size(30.dp), + painter = painterResource(id = R.drawable.star), + contentDescription = null, + tint = getStarColor(), + ) + HorizontalSpace(width = 8.dp) + Text(text = "%.1f".format(rating) + "/5", style = TextStyles.h1) + } + + if (userReview == null && !isThereReviewPlannedToPublish) { + TextButton( + onClick = { + showReviewBottomSheet = true + scope.launch { + // Have to do add this delay, because bottom sheet doesn't expand fully itself + // and expands with duration after showReviewBottomSheet is set to true + delay(300L) + sheetState.expand() + } + }, + ) { + Text(text = stringResource(id = R.string.compose_review)) + } + } + } + + TextButton( + modifier = Modifier.align(Alignment.End), + onClick = { + onSeeAllClick() + }, + ) { + Text(text = stringResource(id = R.string.see_all)) + } + } + } + } + + userReview?.let { + item { + Review( + review = it, + onImageClick = onImageClick, + onMoreClick = onMoreClick, + onDeleteClick = { + showYesNoAlertDialog( + context = context, + title = context.getString(R.string.deletion_warning), + onPositiveButtonClick = { reviewsVM.deleteReview() }, + ) + } + ) + } + } + + if (reviews.firstOrNull() != null) + item { + Review( + review = reviews[0], + onMoreClick = onMoreClick, + onImageClick = onImageClick, + ) + } + } + + if (showReviewBottomSheet) + ModalBottomSheet( + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.background, + onDismissRequest = { showReviewBottomSheet = false }, + ) { + PostReview( + placeId, + onPostReviewSuccess = { + showReviewBottomSheet = false + }, + ) + } +} diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/ReviewsViewModel.kt b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/ReviewsViewModel.kt new file mode 100644 index 0000000000..98ae80d81a --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/ReviewsViewModel.kt @@ -0,0 +1,82 @@ +package app.tourism.ui.screens.main.place_details.reviews + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.organicmaps.R +import app.tourism.data.prefs.UserPreferences +import app.tourism.data.repositories.ReviewsRepository +import app.tourism.domain.models.details.Review +import app.tourism.domain.models.resource.Resource +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ReviewsViewModel @Inject constructor( + @ApplicationContext val context: Context, + private val reviewsRepository: ReviewsRepository, + private val userPreferences: UserPreferences, +) : ViewModel() { + private val uiChannel = Channel() + val uiEventsChannelFlow = uiChannel.receiveAsFlow() + + private val _reviews = MutableStateFlow>(emptyList()) + val reviews = _reviews.asStateFlow() + + private val _userReview = MutableStateFlow(null) + val userReview = _userReview.asStateFlow() + + fun getReviews(id: Long) { + viewModelScope.launch(Dispatchers.IO) { + reviewsRepository.getReviewsForPlace(id).collectLatest { resource -> + if (resource is Resource.Success) { + resource.data?.let { reviewList -> + _reviews.value = reviewList + _userReview.value = reviewList.firstOrNull { + it.user.id == userPreferences.getUserId()?.toLong() + } + } + } + } + } + viewModelScope.launch(Dispatchers.IO) { + reviewsRepository.getReviewsFromApi(id) + } + } + + fun deleteReview() { + viewModelScope.launch(Dispatchers.IO) { + _userReview.value?.id?.let { + reviewsRepository.deleteReview(it).collectLatest { + if (it is Resource.Success) { + uiChannel.send(UiEvent.ShowToast(context.getString(R.string.review_deleted))) + } + } + } + } + } + + + private val _isThereReviewPlannedToPublish = MutableStateFlow(false) + val isThereReviewPlannedToPublish = _isThereReviewPlannedToPublish.asStateFlow() + + init { + viewModelScope.launch(Dispatchers.IO) { + userReview.value?.id?.let { placeId -> + reviewsRepository.isThereReviewPlannedToPublish(placeId).collectLatest { + _isThereReviewPlannedToPublish.value = it + } + } + } + } +} + +enum class DeleteReviewStatus { DELETED, IN_PROCESS } \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/UiEvent.kt b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/UiEvent.kt new file mode 100644 index 0000000000..2459cf729c --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/UiEvent.kt @@ -0,0 +1,6 @@ +package app.tourism.ui.screens.main.place_details.reviews + +sealed interface UiEvent { + data object CloseReviewBottomSheet : UiEvent + data class ShowToast(val message: String) : UiEvent +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/components/PostReview.kt b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/components/PostReview.kt new file mode 100644 index 0000000000..7147c51998 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/components/PostReview.kt @@ -0,0 +1,208 @@ +package app.tourism.ui.screens.main.place_details.reviews.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import app.organicmaps.R +import app.tourism.Constants +import app.tourism.domain.models.resource.Resource +import app.tourism.ui.ObserveAsEvents +import app.tourism.ui.common.ImagePicker +import app.tourism.ui.common.VerticalSpace +import app.tourism.ui.common.buttons.PrimaryButton +import app.tourism.ui.common.special.RatingBar +import app.tourism.ui.common.textfields.AppEditText +import app.tourism.ui.screens.main.place_details.reviews.PostReviewViewModel +import app.tourism.ui.screens.main.place_details.reviews.UiEvent +import app.tourism.ui.theme.TextStyles +import app.tourism.ui.theme.getBorderColor +import app.tourism.ui.utils.showToast +import app.tourism.utils.FileUtils +import coil.compose.AsyncImage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.File +import kotlin.math.roundToInt + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun PostReview( + placeId: Long, + modifier: Modifier = Modifier, + onPostReviewSuccess: () -> Unit, + postReviewVM: PostReviewViewModel = hiltViewModel(), +) { + val scope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current + val context = LocalContext.current + + val rating = postReviewVM.rating.collectAsState().value + val comment = postReviewVM.comment.collectAsState().value + val files = postReviewVM.files.collectAsState().value + + val postReviewResponse = postReviewVM.postReviewResponse.collectAsState().value + + ObserveAsEvents(flow = postReviewVM.uiEventsChannelFlow) { event -> + when (event) { + UiEvent.CloseReviewBottomSheet -> onPostReviewSuccess() + is UiEvent.ShowToast -> context.showToast(event.message) + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Constants.SCREEN_PADDING) + .then(modifier), + ) { + Text(text = stringResource(id = R.string.review_title), style = TextStyles.h2) + VerticalSpace(height = 32.dp) + + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = stringResource(id = R.string.tap_to_rate), style = TextStyles.b3) + VerticalSpace(height = 4.dp) + RatingBar( + rating = rating.roundToInt(), + onRatingChanged = { postReviewVM.setRating(it) }, + ) + } + VerticalSpace(height = 16.dp) + + AppEditText( + value = comment, onValueChange = { postReviewVM.setComment(it) }, + hint = stringResource(id = R.string.text), + maxLines = 10 + ) + VerticalSpace(height = 32.dp) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + files.forEach { + ImagePreview( + model = it, + onDelete = { + postReviewVM.removeFile(it) + }, + ) + } + if (files.size <= 10) + ImagePicker( + showPreview = false, + onSuccess = { uri -> + scope.launch(Dispatchers.IO) { + postReviewVM.addFile( + File(FileUtils(context).getPath(uri)) + ) + } + focusManager.clearFocus() + } + ) { + AddPhoto() + } + } + VerticalSpace(height = 32.dp) + + PrimaryButton( + label = stringResource(id = R.string.send), + onClick = { postReviewVM.postReview(placeId) }, + isLoading = postReviewResponse is Resource.Loading, + enabled = postReviewResponse !is Resource.Loading + ) + VerticalSpace(height = 64.dp) + } +} + +@Composable +fun AddPhoto() { + Box( + modifier = Modifier + .getPhotoBoxProperties() + .background(color = MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + painter = painterResource(id = R.drawable.add), + contentDescription = null, + tint = MaterialTheme.colorScheme.onBackground + ) + VerticalSpace(height = 8.dp) + Text( + text = stringResource(id = R.string.upload_photo), + style = TextStyles.b2, + color = MaterialTheme.colorScheme.onBackground + ) + } + } +} + +@Composable +fun ImagePreview(model: Any?, onDelete: () -> Unit) { + Box { + AsyncImage( + modifier = Modifier + .getPhotoBoxProperties(), + model = model, + contentScale = ContentScale.Crop, + contentDescription = null + ) + + Icon( + modifier = Modifier + .size(30.dp) + .align(alignment = Alignment.TopEnd) + .clickable { onDelete() } + .offset(x = 12.dp, y = (-12).dp), + painter = painterResource(id = R.drawable.ic_route_remove), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } +} + +@Composable +fun Modifier.getPhotoBoxProperties() = + this + .width(104.dp) + .aspectRatio(1f) + .border( + width = 1.dp, + color = getBorderColor(), + shape = photoShape + ) + .clip(photoShape) + +val photoShape = RoundedCornerShape(12.dp) diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/components/Review.kt b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/components/Review.kt new file mode 100644 index 0000000000..1f7e670c3a --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/place_details/reviews/components/Review.kt @@ -0,0 +1,248 @@ +package app.tourism.ui.screens.main.place_details.reviews.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.organicmaps.R +import app.tourism.domain.models.details.Review +import app.tourism.domain.models.details.User +import app.tourism.ui.common.HorizontalSpace +import app.tourism.ui.common.LoadImg +import app.tourism.ui.common.VerticalSpace +import app.tourism.ui.common.special.CountryFlag +import app.tourism.ui.common.special.RatingBar +import app.tourism.ui.theme.TextStyles +import app.tourism.ui.theme.getHintColor + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun Review( + modifier: Modifier = Modifier, + review: Review, + onImageClick: (selectedImage: String, imageUrls: List) -> Unit, + onMoreClick: (picsUrls: List) -> Unit, + onDeleteClick: (() -> Unit)? = null, +) { + Column { + HorizontalDivider(color = MaterialTheme.colorScheme.surface) + VerticalSpace(height = 16.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .then(modifier), + horizontalArrangement = Arrangement.SpaceBetween + ) { + User(modifier = Modifier.weight(1f), user = review.user) + if (review.deletionPlanned) { + Text(stringResource(id = R.string.deletionPlanned)) + } else { + review.date?.let { + Text(text = it, style = TextStyles.b2, color = getHintColor()) + } + } + } + VerticalSpace(height = 16.dp) + + RatingBar( + rating = review.rating, + size = 24.dp, + ) + VerticalSpace(height = 16.dp) + + val maxPics = 3 + val theresMore = review.picsUrls.size > maxPics + val first3pics = if (theresMore) review.picsUrls.take(3) else review.picsUrls + if (first3pics.isNotEmpty()) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + first3pics.forEachIndexed { index, url -> + if (index == maxPics - 1 && theresMore) { + ShowMore( + url = url, + onClick = { + onMoreClick(review.picsUrls) + }, + remaining = review.picsUrls.size - 3 + ) + } else { + ReviewPic(modifier = Modifier.clickable { + onImageClick( + url, + review.picsUrls + ) + }, url = url) + } + } + } + VerticalSpace(height = 16.dp) + } + + if (!review.comment.isNullOrBlank()) { + Comment(comment = review.comment) + VerticalSpace(height = 16.dp) + } + + onDeleteClick?.let { + TextButton( + onClick = { onDeleteClick() }, + ) { + Text(text = stringResource(id = R.string.delete_review)) + } + VerticalSpace(height = 16.dp) + } + } +} + + +@Composable +fun User(modifier: Modifier = Modifier, user: User) { + Row( + modifier = Modifier + .size(66.dp) + .then(modifier), + verticalAlignment = Alignment.CenterVertically, + ) { + LoadImg( + modifier = Modifier + .fillMaxHeight() + .aspectRatio(1f) + .clip(CircleShape), + url = user.pfpUrl, + ) + HorizontalSpace(width = 12.dp) + Row(modifier = Modifier) { + Column { + VerticalSpace(3.dp) + CountryFlag( + modifier = Modifier.width(50.dp), + countryCodeName = user.countryCodeName, + ) + } + Text( + modifier = Modifier + .weight(1f) + .width(IntrinsicSize.Min), + text = user.name, + style = TextStyles.h4, + fontWeight = FontWeight.W600, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +fun Comment(modifier: Modifier = Modifier, comment: String) { + var hasOverflown by remember { mutableStateOf(false) } + var expanded by remember { mutableStateOf(false) } + + val shape = RoundedCornerShape(20.dp) + val onClick = { expanded = !expanded } + Column( + Modifier + .fillMaxWidth() + .background(color = MaterialTheme.colorScheme.surface, shape = shape) + .clip(shape) + .clickable { onClick() } + .padding(start = 16.dp, end = 16.dp, top = 16.dp) + .then(modifier), + ) { + Text( + text = comment, + style = TextStyles.h4.copy(fontWeight = FontWeight.W400), + maxLines = if (expanded) 6969 else 2, + overflow = TextOverflow.Ellipsis, + onTextLayout = { + if (it.hasVisualOverflow) hasOverflown = true + } + ) + if (hasOverflown) { + TextButton(onClick = { onClick() }, contentPadding = PaddingValues(0.dp)) { + Text(text = stringResource(id = if (expanded) R.string.less else R.string.more)) + } + } else { + VerticalSpace(height = 16.dp) + } + } +} + + +@Composable +fun ReviewPic(modifier: Modifier = Modifier, url: String) { + LoadImg( + modifier = Modifier + .width(73.dp) + .height(65.dp) + .clip(localImageShape) + .then(modifier), + url = url, + ) +} + +@Composable +fun ShowMore(url: String, onClick: () -> Unit, remaining: Int) { + Box( + modifier = Modifier + .clickable { onClick() } + .getImageProperties(), + contentAlignment = Alignment.Center + ) { + ReviewPic(url = url) + Box( + modifier = Modifier + .fillMaxSize() + .background( + color = Color.Black.copy(alpha = 0.5f), + shape = localImageShape + ), + ) + Text( + text = "+$remaining", + style = TextStyles.h3, + color = Color.White, + ) + } +} + +@Composable +fun Modifier.getImageProperties() = + this + .width(73.dp) + .height(65.dp) + .clip(localImageShape) + +val localImageShape = RoundedCornerShape(4.dp) \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/profile/personal_data/PersonalDataScreen.kt b/android/app/src/main/java/app/tourism/ui/screens/main/profile/personal_data/PersonalDataScreen.kt new file mode 100644 index 0000000000..c8a73f3064 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/profile/personal_data/PersonalDataScreen.kt @@ -0,0 +1,221 @@ +package app.tourism.ui.screens.main.profile.personal_data + +import android.view.LayoutInflater +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import app.organicmaps.R +import app.tourism.Constants +import app.tourism.domain.models.resource.Resource +import app.tourism.ui.ObserveAsEvents +import app.tourism.ui.common.HorizontalSpace +import app.tourism.ui.common.ImagePicker +import app.tourism.ui.common.LoadImg +import app.tourism.ui.common.SpaceForNavBar +import app.tourism.ui.common.VerticalSpace +import app.tourism.ui.common.buttons.PrimaryButton +import app.tourism.ui.common.nav.AppTopBar +import app.tourism.ui.common.textfields.AppEditText +import app.tourism.ui.screens.main.profile.profile.ProfileViewModel +import app.tourism.ui.screens.main.profile.profile.UiEvent +import app.tourism.ui.theme.TextStyles +import app.tourism.ui.utils.showToast +import app.tourism.utils.FileUtils +import coil.compose.AsyncImage +import com.hbb20.CountryCodePicker +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.File + +@Composable +fun PersonalDataScreen(onBackClick: () -> Unit, profileVM: ProfileViewModel) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + val coroutineScope = rememberCoroutineScope() + + var imageChanged by remember { mutableStateOf(false) } + + val personalData = profileVM.profileDataResource.collectAsState().value + val pfpFile = profileVM.pfpFile.collectAsState().value + val fullName = profileVM.fullName.collectAsState().value + val email = profileVM.email.collectAsState().value + val countryCodeName = profileVM.countryCodeName.collectAsState().value + + ObserveAsEvents(flow = profileVM.uiEventsChannelFlow) { event -> + if (event is UiEvent.ShowToast) context.showToast(event.message) + } + + Scaffold( + topBar = { + AppTopBar( + title = stringResource(id = R.string.personal_data), + onBackClick = onBackClick, + ) + }, + contentWindowInsets = Constants.USUAL_WINDOW_INSETS + ) { paddingValues -> + if (personalData is Resource.Success && personalData.data != null) { + val data = personalData.data + Column( + Modifier + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + ) { + VerticalSpace(height = 32.dp) + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + val pfpModifier = + Modifier + .size(100.dp) + .clip(CircleShape) + if (!imageChanged) + LoadImg(modifier = pfpModifier, url = data.pfpUrl) + else + AsyncImage( + modifier = Modifier + .size(100.dp) + .clip(CircleShape), + model = pfpFile, + contentScale = ContentScale.Crop, + contentDescription = null, + ) + HorizontalSpace(width = 20.dp) + ImagePicker( + showPreview = false, + onSuccess = { uri -> + coroutineScope.launch(Dispatchers.IO) { + profileVM.setPfpFile( + File(FileUtils(context).getPath(uri)) + ) + imageChanged = true + } + } + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val uploadPhotoText = stringResource(id = R.string.upload_photo) + Icon( + painter = painterResource(id = R.drawable.image_down), + contentDescription = uploadPhotoText, + ) + HorizontalSpace(width = 8.dp) + Text(text = uploadPhotoText, style = TextStyles.h4) + } + } + + } + VerticalSpace(height = 24.dp) + + AppEditText( + value = fullName, onValueChange = { profileVM.setFullName(it) }, + hint = stringResource(id = R.string.full_name), + keyboardActions = KeyboardActions( + onNext = { + focusManager.moveFocus(FocusDirection.Next) + }, + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + ) + SpaceBetweenTextFields() + + AppEditText( + value = email, onValueChange = { profileVM.setEmail(it) }, + hint = stringResource(id = R.string.email), + keyboardActions = KeyboardActions( + onNext = { + focusManager.moveFocus(FocusDirection.Next) + }, + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + ) + SpaceBetweenTextFields() + + Text( + text = stringResource(id = R.string.country), + fontSize = 12.sp + ) + val backgroundColor = MaterialTheme.colorScheme.background.toArgb() + val lContentColor = MaterialTheme.colorScheme.onBackground.toArgb() + AndroidView( + factory = { context -> + val view = LayoutInflater.from(context) + .inflate(R.layout.ccp_profile, null, false) + val ccp = view.findViewById(R.id.ccp) + + ccp.apply { + setDialogBackgroundColor(Color.Transparent.toArgb()) + this.contentColor = lContentColor + setDialogTextColor(lContentColor) + setArrowColor(lContentColor) + + setCountryForNameCode(countryCodeName) + setDialogBackgroundColor(backgroundColor) + setOnCountryChangeListener { + profileVM.setCountryCodeName(ccp.selectedCountryNameCode) + } + } + view + } + ) + VerticalSpace(height = 10.dp) + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onBackground, + thickness = 1.dp + ) + VerticalSpace(height = 48.dp) + + PrimaryButton( + label = stringResource(id = R.string.save), + onClick = { profileVM.save() }, + ) + + SpaceForNavBar() + } + } + } +} + +@Composable +fun ColumnScope.SpaceBetweenTextFields() { + VerticalSpace(height = 24.dp) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/profile/profile/ProfileScreen.kt b/android/app/src/main/java/app/tourism/ui/screens/main/profile/profile/ProfileScreen.kt new file mode 100644 index 0000000000..2a3d63e1ec --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/profile/profile/ProfileScreen.kt @@ -0,0 +1,312 @@ +package app.tourism.ui.screens.main.profile.profile + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import app.organicmaps.R +import app.tourism.Constants +import app.tourism.applyAppBorder +import app.tourism.domain.models.profile.CurrencyRates +import app.tourism.domain.models.profile.PersonalData +import app.tourism.domain.models.resource.Resource +import app.tourism.ui.ObserveAsEvents +import app.tourism.ui.common.HorizontalSpace +import app.tourism.ui.common.LoadImg +import app.tourism.ui.common.SpaceForNavBar +import app.tourism.ui.common.VerticalSpace +import app.tourism.ui.common.buttons.PrimaryButton +import app.tourism.ui.common.buttons.SecondaryButton +import app.tourism.ui.common.nav.AppTopBar +import app.tourism.ui.common.special.CountryAsLabel +import app.tourism.ui.common.special.CountryFlag +import app.tourism.ui.common.ui_state.Loading +import app.tourism.ui.screens.main.ThemeViewModel +import app.tourism.ui.theme.TextStyles +import app.tourism.ui.theme.getBorderColor +import app.tourism.ui.utils.showToast + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfileScreen( + onPersonalDataClick: () -> Unit, + onLanguageClick: () -> Unit, + onSignOutComplete: () -> Unit, + profileVM: ProfileViewModel, + themeVM: ThemeViewModel, +) { + val context = LocalContext.current + val personalData = profileVM.profileDataResource.collectAsState().value + val currencyRates = profileVM.currencyRates.collectAsState().value + val signOutResponse = profileVM.signOutResponse.collectAsState().value + + ObserveAsEvents(flow = profileVM.uiEventsChannelFlow) { event -> + when (event) { + is UiEvent.NavigateToAuth -> onSignOutComplete() + is UiEvent.ShowToast -> context.showToast(event.message) + } + } + + Scaffold( + topBar = { + AppTopBar( + title = stringResource(id = R.string.profile_tourism), + ) + }, + contentWindowInsets = Constants.USUAL_WINDOW_INSETS + ) { paddingValues -> + Column( + Modifier + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + ) { + VerticalSpace(height = 32.dp) + if (personalData is Resource.Success) { + personalData.data?.let { + ProfileBar(it) + VerticalSpace(height = 32.dp) + } + } + if (currencyRates != null) { + CurrencyRates(currencyRates = currencyRates) + VerticalSpace(height = 20.dp) + } + GenericProfileItem( + label = stringResource(R.string.personal_data), + icon = R.drawable.profile, + onClick = onPersonalDataClick + ) + VerticalSpace(height = 20.dp) + GenericProfileItem( + label = stringResource(R.string.language), + icon = R.drawable.globe, + onClick = onLanguageClick + ) + VerticalSpace(height = 20.dp) + ThemeSwitch(themeVM = themeVM) + VerticalSpace(height = 20.dp) + val sheetState = rememberModalBottomSheetState() + var isSheetOpen by rememberSaveable { mutableStateOf(false) } + GenericProfileItem( + label = stringResource(R.string.sign_out), + icon = R.drawable.sign_out, + isLoading = signOutResponse is Resource.Loading, + onClick = { isSheetOpen = true } + ) + + if (isSheetOpen) { + ModalBottomSheet( + containerColor = MaterialTheme.colorScheme.background, + sheetState = sheetState, + onDismissRequest = { + isSheetOpen = false + }, + ) { + SignOutWarning( + onSignOutClick = { profileVM.signOut() }, + onCancelClick = { isSheetOpen = false }, + ) + } + } + + SpaceForNavBar() + } + } +} + +@Composable +fun ProfileBar(personalData: PersonalData) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + LoadImg( + modifier = Modifier + .size(100.dp) + .clip(CircleShape), + url = personalData.pfpUrl + ) + HorizontalSpace(width = 16.dp) + Column { + Text(text = personalData.fullName, style = TextStyles.h2) + CountryAsLabel( + Modifier.fillMaxWidth(), + personalData.country, + contentColor = MaterialTheme.colorScheme.onBackground.toArgb(), + ) + } + } +} + +@Composable +fun CurrencyRates(modifier: Modifier = Modifier, currencyRates: CurrencyRates) { + Row( + modifier = Modifier + .fillMaxWidth() + .applyAppBorder() + .padding(horizontal = 15.dp, vertical = 18.dp) + .then(modifier), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically + ) { + CurrencyRatesItem( + countryCode = "US", + value = "%.2f".format(currencyRates.usd), + ) + CurrencyRatesItem( + countryCode = "EU", + value = "%.2f".format(currencyRates.eur), + ) + CurrencyRatesItem( + countryCode = "RU", + value = "%.2f".format(currencyRates.rub), + ) + } +} + +@Composable +fun CurrencyRatesItem(countryCode: String, value: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (countryCode == "EU") + Row { + Image( + modifier = Modifier.height(21.dp), + painter = painterResource(id = R.drawable.eu_flag), + contentDescription = null + ) + HorizontalSpace(width = 8.dp) + } + else + CountryFlag(countryCodeName = countryCode) + Text(text = value, style = TextStyles.b1.copy(fontWeight = FontWeight.SemiBold)) + } +} + +@Composable +fun GenericProfileItem( + modifier: Modifier = Modifier, + label: String, + @DrawableRes icon: Int, + onClick: () -> Unit, + isLoading: Boolean = false, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .applyAppBorder() + .clickable { onClick() } + .padding(horizontal = 15.dp, vertical = 20.dp) + .then(modifier), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = label, style = TextStyles.h4) + if (isLoading) + Loading(Modifier.size(22.dp)) + else + Icon( + modifier = Modifier.size(22.dp), + painter = painterResource(id = icon), + tint = getBorderColor(), + contentDescription = label, + ) + } +} + +@Composable +fun ThemeSwitch(modifier: Modifier = Modifier, themeVM: ThemeViewModel) { + val isDark = themeVM.theme.collectAsState().value?.code == "dark" + Row( + modifier = Modifier + .fillMaxWidth() + .applyAppBorder() + .padding(horizontal = 15.dp, vertical = 6.dp) + .then(modifier), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = stringResource(id = R.string.dark_theme), style = TextStyles.h4) + Switch( + checked = isDark, + onCheckedChange = { isDark -> + val themeCode = if (isDark) "dark" else "light" + themeVM.setTheme(themeCode) + themeVM.updateThemeOnServer(themeCode) + }, + colors = SwitchDefaults.colors(uncheckedTrackColor = MaterialTheme.colorScheme.background) + ) + } +} + +@Composable +fun SignOutWarning( + modifier: Modifier = Modifier, + onSignOutClick: () -> Unit, + onCancelClick: () -> Unit, +) { + Column( + Modifier + .padding(top = 0.dp, bottom = 48.dp, start = 32.dp, end = 32.dp) + .then(modifier), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(id = R.string.sign_out_title), + style = TextStyles.h3.copy(fontWeight = FontWeight.W700) + ) + VerticalSpace(height = 24.dp) + Text( + text = stringResource(id = R.string.sign_out_warning), + style = TextStyles.h3.copy(fontWeight = FontWeight.W500), + textAlign = TextAlign.Center + ) + VerticalSpace(height = 32.dp) + Row { + SecondaryButton( + modifier = Modifier.weight(1f), + label = stringResource(id = R.string.cancel), + onClick = onCancelClick, + ) + HorizontalSpace(width = 16.dp) + PrimaryButton( + modifier = Modifier.weight(1f), + label = stringResource(id = R.string.sign_out), + onClick = onSignOutClick, + ) + } + } +} diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/profile/profile/ProfileViewModel.kt b/android/app/src/main/java/app/tourism/ui/screens/main/profile/profile/ProfileViewModel.kt new file mode 100644 index 0000000000..11a5048ae5 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/profile/profile/ProfileViewModel.kt @@ -0,0 +1,171 @@ +package app.tourism.ui.screens.main.profile.profile + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.organicmaps.R +import app.tourism.data.prefs.UserPreferences +import app.tourism.data.repositories.AuthRepository +import app.tourism.data.repositories.CurrencyRepository +import app.tourism.data.repositories.ProfileRepository +import app.tourism.domain.models.SimpleResponse +import app.tourism.domain.models.profile.CurrencyRates +import app.tourism.domain.models.profile.PersonalData +import app.tourism.domain.models.resource.Resource +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import java.io.File +import javax.inject.Inject + +@HiltViewModel +class ProfileViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val currencyRepository: CurrencyRepository, + private val profileRepository: ProfileRepository, + private val authRepository: AuthRepository, + private val userPreferences: UserPreferences +) : ViewModel() { + private val uiChannel = Channel() + val uiEventsChannelFlow = uiChannel.receiveAsFlow() + + // region fields to update + private val _pfpFile = MutableStateFlow(null) + val pfpFile = _pfpFile.asStateFlow() + + fun setPfpFile(pfpFile: File) { + _pfpFile.value = pfpFile + } + + private val _fullName = MutableStateFlow("") + val fullName = _fullName.asStateFlow() + + fun setFullName(value: String) { + _fullName.value = value + } + + + private val _email = MutableStateFlow("") + val email = _email.asStateFlow() + + private var currentEmail = "" + + fun setEmail(value: String) { + _email.value = value + } + + + private val _countryCodeName = MutableStateFlow(null) + val countryCodeName = _countryCodeName.asStateFlow() + + fun setCountryCodeName(value: String) { + _countryCodeName.value = value + } + // endregion fields to update + + // region requests + private val _personalDataResource = MutableStateFlow>(Resource.Idle()) + val profileDataResource = _personalDataResource.asStateFlow() + + fun getPersonalData() { + viewModelScope.launch { + profileRepository.getPersonalData() + .collectLatest { resource -> + _personalDataResource.value = resource + if (resource is Resource.Success) { + resource.data?.let { updatePersonalDataInMemory(it) } + } + if (resource is Resource.Error) { + uiChannel.send(UiEvent.ShowToast(resource.message ?: "")) + } + } + } + } + + + fun save() { + viewModelScope.launch { + if (_personalDataResource.value is Resource.Success) { + profileRepository.updateProfile( + fullName = fullName.value, + country = countryCodeName.value ?: "", + email = email.value, + pfpFile.value + ).collectLatest { resource -> + if (resource is Resource.Success) { + resource.data?.let { updatePersonalDataInMemory(it) } + uiChannel.send(UiEvent.ShowToast(context.getString(R.string.saved))) + } + if (resource is Resource.Error) { + uiChannel.send(UiEvent.ShowToast(resource.message ?: "")) + } + } + } + } + } + + + private fun updatePersonalDataInMemory(personalData: PersonalData) { + personalData.let { + _personalDataResource.value = Resource.Success(it) + setFullName(it.fullName) + it.email.let { email -> + setEmail(email) + currentEmail = email + } + setCountryCodeName(it.country) + } + } + + + private val _signOutResponse = MutableStateFlow>(Resource.Idle()) + val signOutResponse = _signOutResponse.asStateFlow() + + fun signOut() { + viewModelScope.launch { + authRepository.signOut() + .collectLatest { resource -> + _signOutResponse.value = resource + if (resource is Resource.Success) { + userPreferences.setToken(null) + uiChannel.send(UiEvent.NavigateToAuth) + uiChannel.send(UiEvent.ShowToast(resource.data?.message ?: "")) + } + if (resource is Resource.Error) { + uiChannel.send(UiEvent.ShowToast(resource.message ?: "")) + } + } + } + } + // endregion requests + + // region currency + private val _currencyRates = MutableStateFlow(null) + val currencyRates = _currencyRates.asStateFlow() + + fun getCurrency() { + viewModelScope.launch { + currencyRepository.getCurrency().collectLatest { + if (it is Resource.Success) { + _currencyRates.value = it.data + } + } + } + } + // endregion currency + + init { + getPersonalData() + getCurrency() + } +} + +sealed interface UiEvent { + data object NavigateToAuth : UiEvent + data class ShowToast(val message: String) : UiEvent +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/search/SearchScreen.kt b/android/app/src/main/java/app/tourism/ui/screens/main/search/SearchScreen.kt new file mode 100644 index 0000000000..752cf7510c --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/search/SearchScreen.kt @@ -0,0 +1,110 @@ +package app.tourism.ui.screens.main.search + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import app.organicmaps.R +import app.tourism.Constants +import app.tourism.ui.common.AppSearchBar +import app.tourism.ui.common.SpaceForNavBar +import app.tourism.ui.common.VerticalSpace +import app.tourism.ui.common.nav.AppTopBar +import app.tourism.ui.common.nav.TopBarActionData +import app.tourism.ui.common.special.PlacesItem +import app.tourism.ui.theme.TextStyles + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SearchScreen( + onPlaceClick: (id: Long) -> Unit, + onBackClick: () -> Unit, + onMapClick: () -> Unit, + queryArg: String, + searchVM: SearchViewModel = hiltViewModel() +) { + val query = searchVM.query.collectAsState().value + val places = searchVM.places.collectAsState().value + val itemsNumber = searchVM.itemsNumber.collectAsState().value + + LaunchedEffect(Unit) { + searchVM.setQuery(queryArg) + } + + Scaffold( + topBar = { + AppTopBar( + title = stringResource(id = R.string.tjk), + actions = listOf( + TopBarActionData( + iconDrawable = R.drawable.map, + color = MaterialTheme.colorScheme.primary, + onClick = onMapClick + ), + ), + onBackClick = onBackClick + ) + }, + contentWindowInsets = Constants.USUAL_WINDOW_INSETS + ) { paddingValues -> + LazyColumn(Modifier.padding(paddingValues)) { + stickyHeader { + Column { + VerticalSpace(height = 16.dp) + + AppSearchBar( + modifier = Modifier.fillMaxWidth(), + query = query, + onQueryChanged = { searchVM.setQuery(it) }, + onClearClicked = { searchVM.clearSearchField() }, + ) + VerticalSpace(height = 16.dp) + } + } + + item { + itemsNumber?.let { + Column { + Text( + text = "${stringResource(id = R.string.found)} $it", + style = TextStyles.h3, + ) + VerticalSpace(height = 16.dp) + } + } + } + + items(places) { item -> + Column { + PlacesItem( + place = item, + onPlaceClick = { onPlaceClick(item.id) }, + isFavorite = item.isFavorite, + onFavoriteChanged = { isFavorite -> + searchVM.setFavoriteChanged(item, isFavorite) + }, + ) + VerticalSpace(height = 16.dp) + } + } + + item { + Column { + SpaceForNavBar() + } + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/screens/main/search/SearchViewModel.kt b/android/app/src/main/java/app/tourism/ui/screens/main/search/SearchViewModel.kt new file mode 100644 index 0000000000..114aca1c37 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/screens/main/search/SearchViewModel.kt @@ -0,0 +1,69 @@ +package app.tourism.ui.screens.main.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.tourism.data.repositories.PlacesRepository +import app.tourism.domain.models.common.PlaceShort +import app.tourism.domain.models.resource.Resource +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SearchViewModel @Inject constructor( + private val placesRepository: PlacesRepository +) : ViewModel() { + private val uiChannel = Channel() + val uiEventsChannelFlow = uiChannel.receiveAsFlow() + + // region search query + private val _query = MutableStateFlow("") + val query = _query.asStateFlow() + + fun setQuery(value: String) { + _query.value = value + } + + fun clearSearchField() { + _query.value = "" + } + // endregion search query + + private val _places = MutableStateFlow>(emptyList()) + val places = _places.asStateFlow() + + private val _itemsNumber = MutableStateFlow(null) + val itemsNumber = _itemsNumber.asStateFlow() + + fun setFavoriteChanged(item: PlaceShort, isFavorite: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + placesRepository.setFavorite(item.id, isFavorite) + } + } + + private fun observeSearch() { + viewModelScope.launch(Dispatchers.IO) { + _query.collectLatest { + placesRepository.search(it).collectLatest { resource -> + if (resource is Resource.Success) { + resource.data?.let { _places.value = it } + } + } + } + } + } + + init { + observeSearch() + } +} + +sealed interface UiEvent { + data class ShowToast(val message: String) : UiEvent +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/theme/Color.kt b/android/app/src/main/java/app/tourism/ui/theme/Color.kt new file mode 100644 index 0000000000..f87a4c0a3d --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/theme/Color.kt @@ -0,0 +1,47 @@ +package app.tourism.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +val Blue = Color(0xFF0688E7) +val LightBlue = Color(0xFF3FAAF8) +val LighterBlue = Color(0xFFCADCF9) +val LightestBlue = Color(0xFFEEF4FF) +val DarkerBlue = Color(0xFF272f46) +val DarkestBlue = Color(0xFF101832) +val HeartRed = Color(0xFFFF6C61) +val StarYellow = Color(0xFFF8D749) +val DarkGrayForText = Color(0xFF78787F) +val Gray = Color(0xFFD5D5D6) +val LightGray = Color(0xFFF4F4F4) +val DarkForText = Color(0xFF2B2D33) +val WhiteForText = Color(0xFFFFFFFF) + +val BorderDay = Color(0xFFC9D4E7) +val BorderNight = Color(0xFFFFFFFF) +@Composable +fun getBorderColor() = if (isSystemInDarkTheme()) BorderNight else BorderDay + +val HintDay = Color(0xFFAAABAD) +val HintNight = Color(0xFFAAABAD) +@Composable +fun getHintColor() = if (isSystemInDarkTheme()) HintNight else HintDay + +val SelectedDay = Blue.copy(alpha = 0.1f) +val SelectedNight = Color(0xFFFFFFFF) +@Composable +fun getSelectedColor() = if (isDark()) SelectedNight else SelectedDay + +val SelectedTextDay = Color.Black +val SelectedTextNight = Color.Black +@Composable +fun getSelectedTextColor() = if (isDark()) SelectedTextNight else SelectedTextDay + +@Composable +fun getStarColor() = StarYellow + +// for some reason isSystemInDarkTheme() not working here +@Composable +fun isDark(): Boolean = MaterialTheme.colorScheme.background == DarkestBlue \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/theme/Theme.kt b/android/app/src/main/java/app/tourism/ui/theme/Theme.kt new file mode 100644 index 0000000000..04c7e56f91 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/theme/Theme.kt @@ -0,0 +1,50 @@ +package app.tourism.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val lightColors = lightColorScheme( + primary = Blue, + onPrimary = Color.White, + surface = LightestBlue, + onSurface = DarkForText, + background = Color.White, + onBackground = DarkForText, + error = Color.Transparent, + onError = Color.Red, +) + +private val darkColors = darkColorScheme( + primary = Blue, + onPrimary = Color.White, + surface = DarkerBlue, + onSurface = WhiteForText, + background = DarkestBlue, + onBackground = WhiteForText, + error = Color.Transparent, + onError = Color.Red, +) + + +@Composable +fun OrganicMapsTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + darkTheme -> darkColors + else -> lightColors + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/theme/Type.kt b/android/app/src/main/java/app/tourism/ui/theme/Type.kt new file mode 100644 index 0000000000..9ac4c0ee30 --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/theme/Type.kt @@ -0,0 +1,91 @@ +package app.tourism.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import app.organicmaps.R + +object Fonts { + val gilroy_regular = FontFamily( + Font(R.font.gilroy_regular) + ) +} + + +object TextStyles { + private val genericStyle = TextStyle( + fontFamily = Fonts.gilroy_regular, + fontWeight = FontWeight.Normal, + ) + + val humongous = genericStyle.copy( + fontWeight = FontWeight.ExtraBold, + fontSize = 36.sp, + lineHeight = 40.sp, + ) + + val h1 = genericStyle.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 32.sp, + lineHeight = 36.sp, + ) + + val h2 = genericStyle.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 36.sp, + ) + + val h3 = genericStyle.copy( + fontSize = 20.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.SemiBold, + ) + + val h4 = genericStyle.copy( + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + ) + + val b1 = genericStyle.copy( + fontSize = 14.sp + ) + + val b2 = genericStyle.copy( + fontSize = 12.sp + ) + + val b3 = genericStyle.copy( + fontSize = 10.sp + ) +} + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/ui/utils/AlertDialogs.kt b/android/app/src/main/java/app/tourism/ui/utils/AlertDialogs.kt new file mode 100644 index 0000000000..261e0e8f0f --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/utils/AlertDialogs.kt @@ -0,0 +1,22 @@ +package app.tourism.ui.utils + +import android.content.Context +import app.organicmaps.R + +fun showYesNoAlertDialog(context: Context, title: String, onPositiveButtonClick: () -> Unit) { + android.app.AlertDialog.Builder(context) + .setMessage(title) + .setNegativeButton(context.getString(R.string.no)) { _, _ -> } + .setPositiveButton(context.getString(R.string.yes)) { _, _ -> + onPositiveButtonClick() + } + .create().show() +} + +fun showMessageInAlertDialog(context: Context, title: String, message: String) { + android.app.AlertDialog.Builder(context) + .setTitle(title) + .setMessage(message) + .setPositiveButton(context.getString(R.string.ok)) { _, _ -> } + .create().show() +} diff --git a/android/app/src/main/java/app/tourism/ui/utils/enableLocation.kt b/android/app/src/main/java/app/tourism/ui/utils/enableLocation.kt new file mode 100644 index 0000000000..e1df0bdaef --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/utils/enableLocation.kt @@ -0,0 +1,33 @@ +package app.tourism.ui.utils + +import android.app.AlertDialog +import android.content.Context +import android.content.Context.LOCATION_SERVICE +import android.content.Intent +import android.location.LocationManager +import android.provider.Settings +import androidx.core.content.ContextCompat.startActivity +import app.organicmaps.R + +fun enableLocation(context: Context, onSuccess: () -> Unit) { + val locationManager = context.getSystemService(LOCATION_SERVICE) as LocationManager + if (!locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { + showEnableLocationDialog(context) + } else { + onSuccess() + } +} + +private fun showEnableLocationDialog(context: Context) { + AlertDialog.Builder(context) + .setTitle(context.getString(R.string.enable_location)) + .setMessage(context.getString(R.string.enable_location_longer)) + .setPositiveButton(context.getString(R.string.ok)) { _, _ -> + // Open location settings + val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) + startActivity(context, intent, null) + } + .setNegativeButton(context.getString(R.string.cancel), null) + .create() + .show() +} diff --git a/android/app/src/main/java/app/tourism/ui/utils/showToast.kt b/android/app/src/main/java/app/tourism/ui/utils/showToast.kt new file mode 100644 index 0000000000..f647c0a4ca --- /dev/null +++ b/android/app/src/main/java/app/tourism/ui/utils/showToast.kt @@ -0,0 +1,34 @@ +package app.tourism.ui.utils + +import android.content.Context +import android.os.Build +import android.text.Html +import android.text.Html.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE +import android.widget.Toast +import androidx.fragment.app.Fragment + +fun Fragment.showToast(text: String) { + getAppToast(requireContext(), text).show() +} + +fun Context.showToast(text: String) { + getAppToast(this, text).show() +} + +private fun getAppToast(context: Context, text: String): Toast { + val htmlText = "$text" + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Toast.makeText( + context, + Html.fromHtml(htmlText, TO_HTML_PARAGRAPH_LINES_CONSECUTIVE), + Toast.LENGTH_SHORT + ) + } else { + Toast.makeText( + context, + Html.fromHtml(htmlText), + Toast.LENGTH_SHORT + ) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/utils/DateUtils.kt b/android/app/src/main/java/app/tourism/utils/DateUtils.kt new file mode 100644 index 0000000000..07908e5621 --- /dev/null +++ b/android/app/src/main/java/app/tourism/utils/DateUtils.kt @@ -0,0 +1,40 @@ +package app.tourism.utils + +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale + +fun String.toUserFriendlyDate(dateFormat: String = "yyyy-MM-dd"): String { + var userFriendlyDate = "" + + val currentLocale = Locale.getDefault() + val formatter = SimpleDateFormat(dateFormat, currentLocale) + + var date: Date? = null + + try { + date = formatter.parse(this) + } catch (e: ParseException) { + userFriendlyDate = this + } + val givenDate = Calendar.getInstance() + + if (date != null) { + givenDate.time = date + + givenDate.isLenient = false + val givenDay = givenDate.get(Calendar.DAY_OF_MONTH) + val givenMonth = givenDate.getDisplayName(Calendar.MONTH, Calendar.LONG, currentLocale) + val givenYear = givenDate.get(Calendar.YEAR) + + userFriendlyDate = "$givenDay $givenMonth $givenYear" + } + return userFriendlyDate +} + +fun getCurrentDate(dateFormat: String = "yyyy-MM-dd"): String { + val sdf = SimpleDateFormat(dateFormat) + return sdf.format(Date()) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/utils/FakeDataUtils.kt b/android/app/src/main/java/app/tourism/utils/FakeDataUtils.kt new file mode 100644 index 0000000000..04bc020263 --- /dev/null +++ b/android/app/src/main/java/app/tourism/utils/FakeDataUtils.kt @@ -0,0 +1,7 @@ +package app.tourism.utils + +fun makeLongListOfTheSameItem(item: T, itemsNum: Int = 20): List { + val list = mutableListOf() + repeat(itemsNum) { list.add(item) } + return list +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/utils/FileUtils.java b/android/app/src/main/java/app/tourism/utils/FileUtils.java new file mode 100644 index 0000000000..1ad63822ed --- /dev/null +++ b/android/app/src/main/java/app/tourism/utils/FileUtils.java @@ -0,0 +1,401 @@ +package app.tourism.utils; + +import android.annotation.SuppressLint; +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.DocumentsContract; +import android.provider.MediaStore; +import android.provider.OpenableColumns; +import android.text.TextUtils; +import android.util.Log; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; + +/* + Trying to get path from uri is pretty hard in Android, in the end I just decided + to use this comprehensive method from stackoverflow, tried to be concise, but + really tired of this shit, it just took too much time. Works like charm, btw. + */ + +public class FileUtils { + private static Uri contentUri = null; + + static Context context; + + public FileUtils(Context context) { + this.context=context; + } + + @SuppressLint("NewApi") + public String getPath( final Uri uri) { + // check here to KITKAT or new version + final boolean isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; + String selection = null; + String[] selectionArgs = null; + // DocumentProvider + if (isKitKat ) { + // ExternalStorageProvider + + if (isExternalStorageDocument(uri)) { + final String docId = DocumentsContract.getDocumentId(uri); + final String[] split = docId.split(":"); + final String type = split[0]; + + String fullPath = getPathFromExtSD(split); + if (fullPath != "") { + return fullPath; + } else { + return null; + } + } + + + // DownloadsProvider + + if (isDownloadsDocument(uri)) { + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + final String id; + Cursor cursor = null; + try { + cursor = context.getContentResolver().query(uri, new String[]{MediaStore.MediaColumns.DISPLAY_NAME}, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + String fileName = cursor.getString(0); + String path = Environment.getExternalStorageDirectory().toString() + "/Download/" + fileName; + if (!TextUtils.isEmpty(path)) { + return path; + } + } + } + finally { + if (cursor != null) + cursor.close(); + } + id = DocumentsContract.getDocumentId(uri); + if (!TextUtils.isEmpty(id)) { + if (id.startsWith("raw:")) { + return id.replaceFirst("raw:", ""); + } + String[] contentUriPrefixesToTry = new String[]{ + "content://downloads/public_downloads", + "content://downloads/my_downloads" + }; + for (String contentUriPrefix : contentUriPrefixesToTry) { + try { + final Uri contentUri = ContentUris.withAppendedId(Uri.parse(contentUriPrefix), Long.valueOf(id)); + + + return getDataColumn(context, contentUri, null, null); + } catch (NumberFormatException e) { + //In Android 8 and Android P the id is not a number + return uri.getPath().replaceFirst("^/document/raw:", "").replaceFirst("^raw:", ""); + } + } + + + } + } + else { + final String id = DocumentsContract.getDocumentId(uri); + + if (id.startsWith("raw:")) { + return id.replaceFirst("raw:", ""); + } + try { + contentUri = ContentUris.withAppendedId( + Uri.parse("content://downloads/public_downloads"), Long.valueOf(id)); + } + catch (NumberFormatException e) { + e.printStackTrace(); + } + if (contentUri != null) { + + return getDataColumn(context, contentUri, null, null); + } + } + } + + + // MediaProvider + if (isMediaDocument(uri)) { + final String docId = DocumentsContract.getDocumentId(uri); + final String[] split = docId.split(":"); + final String type = split[0]; + + Uri contentUri = null; + + if ("image".equals(type)) { + contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + } else if ("video".equals(type)) { + contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + } else if ("audio".equals(type)) { + contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; + } + selection = "_id=?"; + selectionArgs = new String[]{split[1]}; + + + return getDataColumn(context, contentUri, selection, + selectionArgs); + } + + if (isGoogleDriveUri(uri)) { + return getDriveFilePath(uri); + } + + if(isWhatsAppFile(uri)){ + return getFilePathForWhatsApp(uri); + } + + + if ("content".equalsIgnoreCase(uri.getScheme())) { + + if (isGooglePhotosUri(uri)) { + return uri.getLastPathSegment(); + } + if (isGoogleDriveUri(uri)) { + return getDriveFilePath(uri); + } + if( Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + { + + // return getFilePathFromURI(context,uri); + return copyFileToInternalStorage(uri,"userfiles"); + // return getRealPathFromURI(context,uri); + } + else + { + return getDataColumn(context, uri, null, null); + } + + } + if ("file".equalsIgnoreCase(uri.getScheme())) { + return uri.getPath(); + } + } + else { + + if(isWhatsAppFile(uri)){ + return getFilePathForWhatsApp(uri); + } + + if ("content".equalsIgnoreCase(uri.getScheme())) { + String[] projection = { + MediaStore.Images.Media.DATA + }; + Cursor cursor = null; + try { + cursor = context.getContentResolver() + .query(uri, projection, selection, selectionArgs, null); + int column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA); + if (cursor.moveToFirst()) { + return cursor.getString(column_index); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + } + + + + + return null; + } + + private static boolean fileExists(String filePath) { + File file = new File(filePath); + + return file.exists(); + } + + private static String getPathFromExtSD(String[] pathData) { + final String type = pathData[0]; + final String relativePath = "/" + pathData[1]; + String fullPath = ""; + + // on my Sony devices (4.4.4 & 5.1.1), `type` is a dynamic string + // something like "71F8-2C0A", some kind of unique id per storage + // don't know any API that can get the root path of that storage based on its id. + // + // so no "primary" type, but let the check here for other devices + if ("primary".equalsIgnoreCase(type)) { + fullPath = Environment.getExternalStorageDirectory() + relativePath; + if (fileExists(fullPath)) { + return fullPath; + } + } + + // Environment.isExternalStorageRemovable() is `true` for external and internal storage + // so we cannot relay on it. + // + // instead, for each possible path, check if file exists + // we'll start with secondary storage as this could be our (physically) removable sd card + fullPath = System.getenv("SECONDARY_STORAGE") + relativePath; + if (fileExists(fullPath)) { + return fullPath; + } + + fullPath = System.getenv("EXTERNAL_STORAGE") + relativePath; + if (fileExists(fullPath)) { + return fullPath; + } + + return fullPath; + } + + private static String getDriveFilePath(Uri uri) { + Uri returnUri = uri; + Cursor returnCursor = context.getContentResolver().query(returnUri, null, null, null, null); + /* + * Get the column indexes of the data in the Cursor, + * * move to the first row in the Cursor, get the data, + * * and display it. + * */ + int nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + int sizeIndex = returnCursor.getColumnIndex(OpenableColumns.SIZE); + returnCursor.moveToFirst(); + String name = (returnCursor.getString(nameIndex)); + String size = (Long.toString(returnCursor.getLong(sizeIndex))); + File file = new File(context.getCacheDir(), name); + try { + InputStream inputStream = context.getContentResolver().openInputStream(uri); + FileOutputStream outputStream = new FileOutputStream(file); + int read = 0; + int maxBufferSize = 1 * 1024 * 1024; + int bytesAvailable = inputStream.available(); + + //int bufferSize = 1024; + int bufferSize = Math.min(bytesAvailable, maxBufferSize); + + final byte[] buffers = new byte[bufferSize]; + while ((read = inputStream.read(buffers)) != -1) { + outputStream.write(buffers, 0, read); + } + Log.e("File Size", "Size " + file.length()); + inputStream.close(); + outputStream.close(); + Log.e("File Path", "Path " + file.getPath()); + Log.e("File Size", "Size " + file.length()); + } catch (Exception e) { + Log.e("Exception", e.getMessage()); + } + return file.getPath(); + } + + /*** + * Used for Android Q+ + * @param uri + * @param newDirName if you want to create a directory, you can set this variable + * @return + */ + private static String copyFileToInternalStorage(Uri uri, String newDirName) { + Uri returnUri = uri; + + Cursor returnCursor = context.getContentResolver().query(returnUri, new String[]{ + OpenableColumns.DISPLAY_NAME,OpenableColumns.SIZE + }, null, null, null); + + + /* + * Get the column indexes of the data in the Cursor, + * * move to the first row in the Cursor, get the data, + * * and display it. + * */ + int nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + int sizeIndex = returnCursor.getColumnIndex(OpenableColumns.SIZE); + returnCursor.moveToFirst(); + String name = (returnCursor.getString(nameIndex)); + String size = (Long.toString(returnCursor.getLong(sizeIndex))); + + File output; + if(!newDirName.equals("")) { + File dir = new File(context.getFilesDir() + "/" + newDirName); + if (!dir.exists()) { + dir.mkdir(); + } + output = new File(context.getFilesDir() + "/" + newDirName + "/" + name); + } + else{ + output = new File(context.getFilesDir() + "/" + name); + } + try { + InputStream inputStream = context.getContentResolver().openInputStream(uri); + FileOutputStream outputStream = new FileOutputStream(output); + int read = 0; + int bufferSize = 1024; + final byte[] buffers = new byte[bufferSize]; + while ((read = inputStream.read(buffers)) != -1) { + outputStream.write(buffers, 0, read); + } + + inputStream.close(); + outputStream.close(); + + } + catch (Exception e) { + + Log.e("Exception", e.getMessage()); + } + + return output.getPath(); + } + + private static String getFilePathForWhatsApp(Uri uri){ + return copyFileToInternalStorage(uri,"whatsapp"); + } + + private static String getDataColumn(Context context, Uri uri, String selection, String[] selectionArgs) { + Cursor cursor = null; + final String column = "_data"; + final String[] projection = {column}; + + try { + cursor = context.getContentResolver().query(uri, projection, + selection, selectionArgs, null); + + if (cursor != null && cursor.moveToFirst()) { + final int index = cursor.getColumnIndexOrThrow(column); + return cursor.getString(index); + } + } + finally { + if (cursor != null) + cursor.close(); + } + + return null; + } + + private static boolean isExternalStorageDocument(Uri uri) { + return "com.android.externalstorage.documents".equals(uri.getAuthority()); + } + + private static boolean isDownloadsDocument(Uri uri) { + return "com.android.providers.downloads.documents".equals(uri.getAuthority()); + } + + private static boolean isMediaDocument(Uri uri) { + return "com.android.providers.media.documents".equals(uri.getAuthority()); + } + + private static boolean isGooglePhotosUri(Uri uri) { + return "com.google.android.apps.photos.content".equals(uri.getAuthority()); + } + + public static boolean isWhatsAppFile(Uri uri){ + return "com.whatsapp.provider.media".equals(uri.getAuthority()); + } + + private static boolean isGoogleDriveUri(Uri uri) { + return "com.google.android.apps.docs.storage".equals(uri.getAuthority()) || "com.google.android.apps.docs.storage.legacy".equals(uri.getAuthority()); + } + + +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/utils/FileUtils.kt b/android/app/src/main/java/app/tourism/utils/FileUtils.kt new file mode 100644 index 0000000000..bda2790367 --- /dev/null +++ b/android/app/src/main/java/app/tourism/utils/FileUtils.kt @@ -0,0 +1,22 @@ +package app.tourism.utils + +import android.content.Context +import android.graphics.Bitmap +import id.zelory.compressor.Compressor +import id.zelory.compressor.constraint.format +import id.zelory.compressor.constraint.size +import java.io.File + +suspend fun saveToInternalStorage(files: List, context: Context) { + val filesDir = context.filesDir + files.forEach { file -> + val destinationFile = File(filesDir, file.name) + file.inputStream().copyTo(destinationFile.outputStream()) + } +} + +suspend fun compress(file: File, context: Context): File = + Compressor.compress(context, file) { + format(Bitmap.CompressFormat.JPEG) + size(900_000) + } \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/utils/HtmlUtils.kt b/android/app/src/main/java/app/tourism/utils/HtmlUtils.kt new file mode 100644 index 0000000000..e9e1c75aa3 --- /dev/null +++ b/android/app/src/main/java/app/tourism/utils/HtmlUtils.kt @@ -0,0 +1,57 @@ +package app.tourism.utils + +import android.graphics.Typeface +import android.os.Build +import android.text.Html +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.text.style.UnderlineSpan +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration + +fun String.getAnnotatedStringFromHtml(): AnnotatedString { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Html.fromHtml(this, Html.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE).toAnnotatedString() + } else { + Html.fromHtml(this).toAnnotatedString() + } +} + +fun Spanned.toAnnotatedString(): AnnotatedString = buildAnnotatedString { + val spanned = this@toAnnotatedString + append(spanned.toString()) + getSpans(0, spanned.length, Any::class.java).forEach { span -> + val start = getSpanStart(span) + val end = getSpanEnd(span) + when (span) { + is StyleSpan -> when (span.style) { + Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) + Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end) + Typeface.BOLD_ITALIC -> addStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Italic + ), start, end + ) + } + + is UnderlineSpan -> addStyle( + SpanStyle(textDecoration = TextDecoration.Underline), + start, + end + ) + + is ForegroundColorSpan -> addStyle( + SpanStyle(color = Color(span.foregroundColor)), + start, + end + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/utils/LocaleHelper.java b/android/app/src/main/java/app/tourism/utils/LocaleHelper.java new file mode 100644 index 0000000000..4ad5cc0ddd --- /dev/null +++ b/android/app/src/main/java/app/tourism/utils/LocaleHelper.java @@ -0,0 +1,29 @@ +package app.tourism.utils; + +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; + +import java.util.Locale; + +public class LocaleHelper { + public static Context localeUpdateResources(Context context, String languageCode) { + Locale locale = new Locale(languageCode); + Locale.setDefault(locale); + + Resources resources = context.getResources(); + Configuration config = new Configuration(resources.getConfiguration()); + + config.setLocale(locale); + return context.createConfigurationContext(config); + } + + public static void setLocale(Context context, String languageCode) { + Locale locale = new Locale(languageCode); + Locale.setDefault(locale); + Resources resources = context.getResources(); + Configuration config = resources.getConfiguration(); + config.setLocale(locale); + resources.updateConfiguration(config, resources.getDisplayMetrics()); + } +} diff --git a/android/app/src/main/java/app/tourism/utils/MapFileMover.kt b/android/app/src/main/java/app/tourism/utils/MapFileMover.kt new file mode 100644 index 0000000000..26935f79d4 --- /dev/null +++ b/android/app/src/main/java/app/tourism/utils/MapFileMover.kt @@ -0,0 +1,64 @@ +package app.tourism.utils + +import android.content.Context +import android.util.Log +import app.organicmaps.BuildConfig +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +private const val TAG = "MapFileMover" + +private const val SOURCE_FILE_NAME = "tajikistan.mwm" +private const val SOURCE_FILE_NEW_NAME = "Tajikistan.mwm" + +class MapFileMover { + + private fun moveFile(context: Context, destinationPath: String): Boolean { + return try { + val destinationFile = File(destinationPath, SOURCE_FILE_NEW_NAME) + + val inputStream = BufferedInputStream(context.assets.open(SOURCE_FILE_NAME)) + val outputStream = BufferedOutputStream(FileOutputStream(destinationFile)) + + val buffer = ByteArray(1024) + var read: Int + while (inputStream.read(buffer).also { read = it } != -1) { + outputStream.write(buffer, 0, read) + } + + inputStream.close() + outputStream.flush() + outputStream.close() + + Log.d(TAG, "File moved successfully to: ${destinationFile.absolutePath}") + + true + } catch (e: IOException) { + e.printStackTrace() + Log.e(TAG, "Error moving file: ${e.message}") + false + } + } + + fun main(context: Context): Boolean { + val higherPath = "/storage/emulated/0/Android/data/tj.tourism.rebus" + val lowerPath = "/files/240429/" + var destinationPath = higherPath + lowerPath + if (BuildConfig.BUILD_TYPE == "debug") { + destinationPath = higherPath + ".debug" + lowerPath + } + + val destinationDir = File(destinationPath) + if (!destinationDir.exists()) { + if (!destinationDir.mkdirs()) { + Log.e(TAG, "Failed to create destination directory.") + return false + } + } + + return moveFile(context, destinationPath) + } +} diff --git a/android/app/src/main/java/app/tourism/utils/MapUtils.kt b/android/app/src/main/java/app/tourism/utils/MapUtils.kt new file mode 100644 index 0000000000..935961344a --- /dev/null +++ b/android/app/src/main/java/app/tourism/utils/MapUtils.kt @@ -0,0 +1,37 @@ +package app.tourism.utils + +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat +import app.organicmaps.MwmActivity +import app.tourism.data.dto.PlaceLocation + +fun navigateToMap(context: Context, clearBackStack: Boolean = false) { + val intent = Intent(context, MwmActivity::class.java) + if (clearBackStack) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) + ContextCompat.startActivity(context, intent, null) +} + +fun navigateToMapForRoute(context: Context, placeLocation: PlaceLocation) { + val intent = Intent(context, MwmActivity::class.java) + intent.putExtra("end_point", placeLocation) + ContextCompat.startActivity(context, intent, null) +} + +fun isInsideTajikistan(latitude: Double, longitude: Double): Boolean { + val minLatitude = 36.4 + val maxLatitude = 41.3 + val minLongitude = 67.1 + val maxLongitude = 75.5 + + if (latitude < minLatitude || latitude > maxLatitude) { + return false + } + + if (longitude < minLongitude || longitude > maxLongitude) { + return false + } + + return true +} diff --git a/android/app/src/main/java/app/tourism/utils/isNotAbsent.kt b/android/app/src/main/java/app/tourism/utils/isNotAbsent.kt new file mode 100644 index 0000000000..96dd4bf594 --- /dev/null +++ b/android/app/src/main/java/app/tourism/utils/isNotAbsent.kt @@ -0,0 +1,3 @@ +package app.tourism.utils + +fun String?.isNotAbsent() = !this.isNullOrBlank() diff --git a/android/app/src/main/java/app/tourism/utils/openUrlInBrowser.kt b/android/app/src/main/java/app/tourism/utils/openUrlInBrowser.kt new file mode 100644 index 0000000000..c220bcea68 --- /dev/null +++ b/android/app/src/main/java/app/tourism/utils/openUrlInBrowser.kt @@ -0,0 +1,10 @@ +package app.tourism.utils + +import android.content.Context +import android.content.Intent +import android.net.Uri + +fun openUrlInBrowser(context: Context, url: String) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + context.startActivity(intent) +} \ No newline at end of file diff --git a/android/app/src/main/java/app/tourism/utils/triggerRebirth.kt b/android/app/src/main/java/app/tourism/utils/triggerRebirth.kt new file mode 100644 index 0000000000..5a023a1c26 --- /dev/null +++ b/android/app/src/main/java/app/tourism/utils/triggerRebirth.kt @@ -0,0 +1,11 @@ +package app.tourism.utils + +import android.content.Context +import android.content.Intent + +fun triggerRebirth(context: Context, myClass: Class<*>?) { + val intent = Intent(context, myClass) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + context.startActivity(intent) + Runtime.getRuntime().exit(0) +} \ No newline at end of file diff --git a/android/app/src/main/res/drawable-xhdpi/splash_background.png b/android/app/src/main/res/drawable-xhdpi/splash_background.png new file mode 100644 index 0000000000..bab37574db Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splash_background.png differ diff --git a/android/app/src/main/res/drawable/add.xml b/android/app/src/main/res/drawable/add.xml new file mode 100644 index 0000000000..b2e81771a6 --- /dev/null +++ b/android/app/src/main/res/drawable/add.xml @@ -0,0 +1,13 @@ + + + + diff --git a/android/app/src/main/res/drawable/arrow_left.xml b/android/app/src/main/res/drawable/arrow_left.xml new file mode 100644 index 0000000000..4ee5e08fc5 --- /dev/null +++ b/android/app/src/main/res/drawable/arrow_left.xml @@ -0,0 +1,11 @@ + + + diff --git a/android/app/src/main/res/drawable/back.xml b/android/app/src/main/res/drawable/back.xml new file mode 100644 index 0000000000..661f14d132 --- /dev/null +++ b/android/app/src/main/res/drawable/back.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/baseline_refresh_24.xml b/android/app/src/main/res/drawable/baseline_refresh_24.xml new file mode 100644 index 0000000000..86504d0ef4 --- /dev/null +++ b/android/app/src/main/res/drawable/baseline_refresh_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/baseline_visibility_24.xml b/android/app/src/main/res/drawable/baseline_visibility_24.xml new file mode 100644 index 0000000000..f843e2910b --- /dev/null +++ b/android/app/src/main/res/drawable/baseline_visibility_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/baseline_visibility_off_24.xml b/android/app/src/main/res/drawable/baseline_visibility_off_24.xml new file mode 100644 index 0000000000..5993ca393d --- /dev/null +++ b/android/app/src/main/res/drawable/baseline_visibility_off_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/brand_logo.png b/android/app/src/main/res/drawable/brand_logo.png new file mode 100644 index 0000000000..447089c93f Binary files /dev/null and b/android/app/src/main/res/drawable/brand_logo.png differ diff --git a/android/app/src/main/res/drawable/categories.xml b/android/app/src/main/res/drawable/categories.xml new file mode 100644 index 0000000000..3087b14779 --- /dev/null +++ b/android/app/src/main/res/drawable/categories.xml @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/categories_selected.xml b/android/app/src/main/res/drawable/categories_selected.xml new file mode 100644 index 0000000000..db29817e1b --- /dev/null +++ b/android/app/src/main/res/drawable/categories_selected.xml @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/check_circle_fill.xml b/android/app/src/main/res/drawable/check_circle_fill.xml new file mode 100644 index 0000000000..8bdd31ac05 --- /dev/null +++ b/android/app/src/main/res/drawable/check_circle_fill.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/chevron_down.xml b/android/app/src/main/res/drawable/chevron_down.xml new file mode 100644 index 0000000000..043c41fadf --- /dev/null +++ b/android/app/src/main/res/drawable/chevron_down.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/app/src/main/res/drawable/close.xml b/android/app/src/main/res/drawable/close.xml new file mode 100644 index 0000000000..7c27776a72 --- /dev/null +++ b/android/app/src/main/res/drawable/close.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/drawable/error.xml b/android/app/src/main/res/drawable/error.xml new file mode 100644 index 0000000000..0e4f020eb0 --- /dev/null +++ b/android/app/src/main/res/drawable/error.xml @@ -0,0 +1,11 @@ + + + + diff --git a/android/app/src/main/res/drawable/error_centered.xml b/android/app/src/main/res/drawable/error_centered.xml new file mode 100644 index 0000000000..b1f2e893ec --- /dev/null +++ b/android/app/src/main/res/drawable/error_centered.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/eu_flag.png b/android/app/src/main/res/drawable/eu_flag.png new file mode 100644 index 0000000000..434ad41006 Binary files /dev/null and b/android/app/src/main/res/drawable/eu_flag.png differ diff --git a/android/app/src/main/res/drawable/globe.xml b/android/app/src/main/res/drawable/globe.xml new file mode 100644 index 0000000000..ce2cb86c2c --- /dev/null +++ b/android/app/src/main/res/drawable/globe.xml @@ -0,0 +1,31 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/heart.xml b/android/app/src/main/res/drawable/heart.xml new file mode 100644 index 0000000000..a02a6f43c6 --- /dev/null +++ b/android/app/src/main/res/drawable/heart.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/app/src/main/res/drawable/heart_selected.xml b/android/app/src/main/res/drawable/heart_selected.xml new file mode 100644 index 0000000000..66c82b992d --- /dev/null +++ b/android/app/src/main/res/drawable/heart_selected.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/app/src/main/res/drawable/home.xml b/android/app/src/main/res/drawable/home.xml new file mode 100644 index 0000000000..d66fbb4956 --- /dev/null +++ b/android/app/src/main/res/drawable/home.xml @@ -0,0 +1,20 @@ + + + + diff --git a/android/app/src/main/res/drawable/home_selected.xml b/android/app/src/main/res/drawable/home_selected.xml new file mode 100644 index 0000000000..e1409dffb6 --- /dev/null +++ b/android/app/src/main/res/drawable/home_selected.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/app/src/main/res/drawable/image.xml b/android/app/src/main/res/drawable/image.xml new file mode 100644 index 0000000000..0a863e3c50 --- /dev/null +++ b/android/app/src/main/res/drawable/image.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/image_down.xml b/android/app/src/main/res/drawable/image_down.xml new file mode 100644 index 0000000000..52b1127aeb --- /dev/null +++ b/android/app/src/main/res/drawable/image_down.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/map.xml b/android/app/src/main/res/drawable/map.xml new file mode 100644 index 0000000000..25c2aec579 --- /dev/null +++ b/android/app/src/main/res/drawable/map.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/android/app/src/main/res/drawable/placeholder.xml b/android/app/src/main/res/drawable/placeholder.xml new file mode 100644 index 0000000000..e117107b5e --- /dev/null +++ b/android/app/src/main/res/drawable/placeholder.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/app/src/main/res/drawable/profile.xml b/android/app/src/main/res/drawable/profile.xml new file mode 100644 index 0000000000..9fed2c1159 --- /dev/null +++ b/android/app/src/main/res/drawable/profile.xml @@ -0,0 +1,20 @@ + + + + diff --git a/android/app/src/main/res/drawable/profile_selected.xml b/android/app/src/main/res/drawable/profile_selected.xml new file mode 100644 index 0000000000..29d8a744b4 --- /dev/null +++ b/android/app/src/main/res/drawable/profile_selected.xml @@ -0,0 +1,20 @@ + + + + diff --git a/android/app/src/main/res/drawable/search.xml b/android/app/src/main/res/drawable/search.xml new file mode 100644 index 0000000000..e7988d437b --- /dev/null +++ b/android/app/src/main/res/drawable/search.xml @@ -0,0 +1,20 @@ + + + + diff --git a/android/app/src/main/res/drawable/sign_out.xml b/android/app/src/main/res/drawable/sign_out.xml new file mode 100644 index 0000000000..33148293b4 --- /dev/null +++ b/android/app/src/main/res/drawable/sign_out.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/android/app/src/main/res/drawable/splash_background.png b/android/app/src/main/res/drawable/splash_background.png new file mode 100644 index 0000000000..bab37574db Binary files /dev/null and b/android/app/src/main/res/drawable/splash_background.png differ diff --git a/android/app/src/main/res/drawable/star.xml b/android/app/src/main/res/drawable/star.xml new file mode 100644 index 0000000000..62c9b1f4f7 --- /dev/null +++ b/android/app/src/main/res/drawable/star.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/star_border.xml b/android/app/src/main/res/drawable/star_border.xml new file mode 100644 index 0000000000..8a774dd6a4 --- /dev/null +++ b/android/app/src/main/res/drawable/star_border.xml @@ -0,0 +1,11 @@ + + + diff --git a/android/app/src/main/res/drawable/unchecked.xml b/android/app/src/main/res/drawable/unchecked.xml new file mode 100644 index 0000000000..c42a0e9d7c --- /dev/null +++ b/android/app/src/main/res/drawable/unchecked.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/font/gilroy_regular.ttf b/android/app/src/main/res/font/gilroy_regular.ttf new file mode 100644 index 0000000000..ad17f71cbe Binary files /dev/null and b/android/app/src/main/res/font/gilroy_regular.ttf differ diff --git a/android/app/src/main/res/layout-h400dp/map_buttons_layout_navigation.xml b/android/app/src/main/res/layout-h400dp/map_buttons_layout_navigation.xml index f20833f98b..88abf3ce83 100644 --- a/android/app/src/main/res/layout-h400dp/map_buttons_layout_navigation.xml +++ b/android/app/src/main/res/layout-h400dp/map_buttons_layout_navigation.xml @@ -14,7 +14,7 @@ android:layout_height="match_parent" android:visibility="gone" tools:visibility="visible" /> - - + android:layout_centerInParent="true"/> - + android:layout_centerVertical="true"/> + - - + + + + android:padding="@dimen/nav_frame_padding" + android:visibility="gone"> + + + + + + android:visibility="gone" /> \ No newline at end of file diff --git a/android/app/src/main/res/layout/activity_map.xml b/android/app/src/main/res/layout/activity_map.xml index abe4998ff8..bcefe2fb3d 100644 --- a/android/app/src/main/res/layout/activity_map.xml +++ b/android/app/src/main/res/layout/activity_map.xml @@ -1,10 +1,11 @@ - + - - + + + diff --git a/android/app/src/main/res/layout/activity_splash.xml b/android/app/src/main/res/layout/activity_splash.xml index 94da3281a2..14bce8994e 100644 --- a/android/app/src/main/res/layout/activity_splash.xml +++ b/android/app/src/main/res/layout/activity_splash.xml @@ -1,47 +1,45 @@ - + - + - + + android:gravity="center" + android:orientation="vertical"> - + - + - + + + diff --git a/android/app/src/main/res/layout/ccp_as_country_label.xml b/android/app/src/main/res/layout/ccp_as_country_label.xml new file mode 100644 index 0000000000..2161e45105 --- /dev/null +++ b/android/app/src/main/res/layout/ccp_as_country_label.xml @@ -0,0 +1,17 @@ + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/ccp_auth.xml b/android/app/src/main/res/layout/ccp_auth.xml new file mode 100644 index 0000000000..97c03741be --- /dev/null +++ b/android/app/src/main/res/layout/ccp_auth.xml @@ -0,0 +1,21 @@ + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/ccp_country_flag.xml b/android/app/src/main/res/layout/ccp_country_flag.xml new file mode 100644 index 0000000000..c0246a4ebe --- /dev/null +++ b/android/app/src/main/res/layout/ccp_country_flag.xml @@ -0,0 +1,15 @@ + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/ccp_profile.xml b/android/app/src/main/res/layout/ccp_profile.xml new file mode 100644 index 0000000000..63e94d073e --- /dev/null +++ b/android/app/src/main/res/layout/ccp_profile.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/layout_nav_bottom.xml b/android/app/src/main/res/layout/layout_nav_bottom.xml index 643d15a3f5..5ac4d35d7c 100644 --- a/android/app/src/main/res/layout/layout_nav_bottom.xml +++ b/android/app/src/main/res/layout/layout_nav_bottom.xml @@ -59,6 +59,7 @@ android:layout_weight="0.2" android:background="?selectableItemBackgroundBorderless" android:scaleType="center" + android:visibility="gone" app:srcCompat="@drawable/ic_menu_settings" app:tint="?iconTint" /> diff --git a/android/app/src/main/res/layout/map_buttons_layout_navigation.xml b/android/app/src/main/res/layout/map_buttons_layout_navigation.xml index ad3fac1b19..97743a754e 100644 --- a/android/app/src/main/res/layout/map_buttons_layout_navigation.xml +++ b/android/app/src/main/res/layout/map_buttons_layout_navigation.xml @@ -25,14 +25,6 @@ android:padding="@dimen/nav_frame_padding" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent"> - - + android:layout_alignParentBottom="true" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent"/> - + + + + android:padding="@dimen/nav_frame_padding" + android:visibility="gone"> + + + + + + android:visibility="gone" /> \ No newline at end of file diff --git a/android/app/src/main/res/layout/onmap_downloader.xml b/android/app/src/main/res/layout/onmap_downloader.xml index df6c3b856f..b64020475f 100644 --- a/android/app/src/main/res/layout/onmap_downloader.xml +++ b/android/app/src/main/res/layout/onmap_downloader.xml @@ -1,33 +1,24 @@ - - - + diff --git a/android/app/src/main/res/layout/place_page_details.xml b/android/app/src/main/res/layout/place_page_details.xml index 3b6ba48746..e8b2b65fc1 100644 --- a/android/app/src/main/res/layout/place_page_details.xml +++ b/android/app/src/main/res/layout/place_page_details.xml @@ -19,6 +19,7 @@ android:id="@+id/place_page_bookmark_fragment" android:layout_width="match_parent" android:layout_height="wrap_content" + android:visibility="gone" tools:layout="@layout/place_page_bookmark_fragment" /> diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 65291b96e8..036d09bc5f 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,5 +2,4 @@ - \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index cd97ff66a8..0000000000 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000..99d6611e80 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png deleted file mode 100644 index 18f0eebcc9..0000000000 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000000..1a03d09455 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index fcc540971f..0000000000 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..a42965f883 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 204e74be21..0000000000 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000..fac48239e6 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png deleted file mode 100644 index 6599daac93..0000000000 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000000..d97a4f2338 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png deleted file mode 100644 index f6caea0ef7..0000000000 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..429b479f8d Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index d43591034c..0000000000 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000..0b8d34b645 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index 3d42fe7fad..0000000000 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000000..6746396c4c Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100644 index 54f863ed94..0000000000 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..62c8cb2f89 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 1d62d6f83b..0000000000 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..6350b643e0 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index be6bb91613..0000000000 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000000..99c9732425 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100644 index 0df45ff75a..0000000000 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..4ee2802962 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index c3d72d8648..0000000000 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..fc552ac075 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 5d82bdb103..0000000000 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000000..673134a442 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100644 index 8e880871cf..0000000000 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..8357185eca Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/raw/tourismmapcert.pem b/android/app/src/main/res/raw/tourismmapcert.pem new file mode 100644 index 0000000000..226c71aeb1 --- /dev/null +++ b/android/app/src/main/res/raw/tourismmapcert.pem @@ -0,0 +1,42 @@ +-----BEGIN CERTIFICATE----- +MIIHYzCCBkugAwIBAgIMXhThZAqVmQqeYSEVMA0GCSqGSIb3DQEBCwUAMFUxCzAJ +BgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMSswKQYDVQQDEyJH +bG9iYWxTaWduIEdDQyBSNiBBbHBoYVNTTCBDQSAyMDIzMB4XDTI0MDkyMDA0NDgw +MFoXDTI1MTAyMjA0NDc1OVowHDEaMBgGA1UEAxMRd3d3LnRvdXJpc21tYXAudGow +ggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCr4as9Bpww6SGd2Wpa9Q8k +dkp6xxI10IQZvRD8LEFbSReVUv8SmgMzoO2muzlcyc1keMqzUf02VVN9G+jBAcGL +389ZTPzLulBehPFUeND6Yye5VQ9UHVyhRiayOFs4CSoP3NLtQJ5x2Qt0W6diGd+5 +ctyT6IMVX0BijRz8/0ZeZ4AeK3OHaMQWQByu+qiZzbJZ2IacFCsbDBJ3hnkSz65k +eLrjIFJDCx/pNF9cs13rvxtxUGBPNExjbTzKE1E1/u0Suui+aSh7wZwnwgv8qZ+P +Aq5+TW8Yrv42jTsESzzsoiCLJ6/JQ+0LMFXYknHfgFBAw8Mh1itMsZRp8KTEcXmW +/xmGVHdpKlKEF4IxJVyfImcLuoEHv2rcgcl33ffK8TuKpGYmnmTQrbtAOfoKO4ex +aJIOmuokSmMkBQFVHXlv3pQ4GpkCJX5Xzc4XPCpsv+eEd4Fl7BvlSobgZ20LkXEz +vcnXIR8YJOAHYay2tFEnzHGGK5OvbpacZBZeXDs0/165tRXKlxKyBUSiqrwKwYp2 +ui20qHslIHRf6SDGeh1eY0kZA4AbmKAOUlp/x+zmpTcFvAjFKLJNFabNWJGQhTSE +39RUKqe/2CtzYeqCPeNZirStKLIqpdl0lnP0rRMGvtuEPh0PHJtHnPKkOdMnhMi0 +oU4ZuodPiFs130GJfgONwQIDAQABo4IDajCCA2YwDgYDVR0PAQH/BAQDAgWgMAwG +A1UdEwEB/wQCMAAwgZkGCCsGAQUFBwEBBIGMMIGJMEkGCCsGAQUFBzAChj1odHRw +Oi8vc2VjdXJlLmdsb2JhbHNpZ24uY29tL2NhY2VydC9nc2djY3I2YWxwaGFzc2xj +YTIwMjMuY3J0MDwGCCsGAQUFBzABhjBodHRwOi8vb2NzcC5nbG9iYWxzaWduLmNv +bS9nc2djY3I2YWxwaGFzc2xjYTIwMjMwVwYDVR0gBFAwTjAIBgZngQwBAgEwQgYK +KwYBBAGgMgoBAzA0MDIGCCsGAQUFBwIBFiZodHRwczovL3d3dy5nbG9iYWxzaWdu +LmNvbS9yZXBvc2l0b3J5LzBEBgNVHR8EPTA7MDmgN6A1hjNodHRwOi8vY3JsLmds +b2JhbHNpZ24uY29tL2dzZ2NjcjZhbHBoYXNzbGNhMjAyMy5jcmwwKwYDVR0RBCQw +IoIRd3d3LnRvdXJpc21tYXAudGqCDXRvdXJpc21tYXAudGowHQYDVR0lBBYwFAYI +KwYBBQUHAwEGCCsGAQUFBwMCMB8GA1UdIwQYMBaAFL0Ft/OKkzxzy3n6D4USoXeW +GJF0MB0GA1UdDgQWBBTjXudIsXVmQbpDPgZ5wyqUeToHzjCCAX0GCisGAQQB1nkC +BAIEggFtBIIBaQFnAHcArxgaKNaMo+CpikycZ6sJ+Lu8IrquvLE4o6Gd0/m2Aw0A +AAGSDcIuKwAABAMASDBGAiEAq/qq7FL99UTNMZYdmBr/IoBqvMLXnhFjYKgwbT6O +GVgCIQD3WUqOtyIdQdbfV+9R7nf7exuR6uVA+O273jiqswadvwB1ABLxTjS9U3JM +hAYZw48/ehP457Vih4icbTAFhOvlhiY6AAABkg3CLg4AAAQDAEYwRAIgZ8ykwcty +yoi8RYHuWKjdZ28nek+XGP7rJlibuZQ6n2ECIBIyHG0F8I8tSE4Jk+QYfVrXgwYs +8gj/iPWWhYdO8fRaAHUADeHyMCvTDcFAYhIJ6lUu/Ed0fLHX6TDvDkIetH5OqjQA +AAGSDcIuMwAABAMARjBEAiBtpPHpQL77e3VMeIBizbzUfK99e7mP2RF18VigQLGz +iQIgecypF1QoVvuneoK1KMU8vPPu4MYByUjb/oBXE4e5b6swDQYJKoZIhvcNAQEL +BQADggEBAMec0urB3xzwNmrZMEo/tqkY2k+ivvDgxK/bg/PkfxM5dYk8X99/amN1 +SaLDU/XWJqoEPD53omG5WgUQdDtyt0XUGK1aoVMVsiOphx+Gwdvm2PUmizw6kqyl +bo7IjHG+kw6XacBXExk4eV3laO8K3/Bw/2tws+ZU65kLAwIN8NGMbvFLjZlSpQGI +lOGJJEqDsz6WY2DWnhPmNqgI/nW6IVDjTs4ZVR3u3YSRoL7dfqgb3U/fMuWFF/OT +PkOgeEB9DlcAUrlTumfYxttQpT93ZTJfeTG407t+VznO95i0YUJnSHBWUkXMALfB +6OAuibGCsCPlA6rMp3Hc/qKgrbDavJM= +-----END CERTIFICATE----- diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 40a4ceb909..6d11651df1 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -1,2163 +1,2247 @@ - - + - - - Назад - - Отмена - - Удалить - Загрузить карты - - Ошибка загрузки. Нажмите, чтобы повторить попытку - - Загружается… - - Километры - - МБ - ГБ - - Мили - - Мое местоположение - - Не сейчас - - Поиск - - Поиск на карте - - Геолокация выключена в настройках устройства. Пожалуйста, включите её для удобного использования программы. - - Показать на карте - - Ошибка загрузки - - Попробуйте ещё раз - Про Organic Maps - - Бесплатно для всех, сделано с любовью - - • Без рекламы, без трекинга, без слежки - - • Не разряжает батарею, работает в автономном режиме - - • Быстрые, минималистичные, разработанные сообществом - - Приложение с открытым исходным кодом, созданное энтузиастами и волонтёрами. - - Настройки местоположения - Закрыть - Для работы приложения необходим аппаратно ускоренный OpenGL. К сожалению, ваше устройство не поддерживается. - Загрузить - - Отключите USB кабель или вставьте SD-карту - - Недостаточно свободного места на SD карте/в памяти устройства для использования программы - Перед началом работы разрешите нам загрузить общую карту мира на ваше устройство.\nЭто потребует %s данных. - Перейти на карту - Пока загружается %s,\nвы можете пользоваться картой. - Загрузить %s? - Обновить %s? - - Приостановить - - Продолжить - - Не удалось загрузить %s - - Добавить список - - Цвет метки - - Название списка меток - - Метки - - Метки и треки - - Мои Метки - - Название - - Адрес - - Список - - Настройки - - Сохранять карты в - - Выберите место, где будут храниться загруженные карты - - Загруженные карты - - Внутренний скрытый накопитель - - Внутренний общий накопитель - - SD-карта - - Внешний общий накопитель - - %1$s свободно из %2$s - - Переместить карты? - - Ошибка перемещения файлов карт - - Это может занять несколько минут.\nПожалуйста, подождите… - - Единицы измерения - - Использовать километры или мили + + + Назад + + Отмена + + Удалить + Загрузить карты + + Ошибка загрузки. Нажмите, чтобы повторить попытку + + Загружается… + + Километры + + МБ + ГБ + + Мили + + Мое местоположение + + Не сейчас + + Поиск + + Поиск на карте + + Геолокация выключена в настройках устройства. Пожалуйста, включите её для удобного использования программы. + + Показать на карте + + Ошибка загрузки + + Попробуйте ещё раз + Про Organic Maps + + Бесплатно для всех, сделано с любовью + + • Без рекламы, без трекинга, без слежки + + • Не разряжает батарею, работает в автономном режиме + + • Быстрые, минималистичные, разработанные сообществом + + Приложение с открытым исходным кодом, созданное энтузиастами и волонтёрами. + + Настройки местоположения + Закрыть + Для работы приложения необходим аппаратно ускоренный OpenGL. К сожалению, ваше устройство не поддерживается. + Загрузить + + Отключите USB кабель или вставьте SD-карту + + Недостаточно свободного места на SD карте/в памяти устройства для использования программы + Перед началом работы разрешите нам загрузить общую карту мира на ваше устройство.\nЭто потребует %s данных. + Перейти на карту + Пока загружается %s,\nвы можете пользоваться картой. + Загрузить %s? + Обновить %s? + + Приостановить + + Продолжить + + Не удалось загрузить %s + + Добавить список + + Цвет метки + + Название списка меток + + Метки + + Метки и треки + + Мои Метки + + Название + + Адрес + + Список + + Настройки + + Сохранять карты в + + Выберите место, где будут храниться загруженные карты + + Загруженные карты + + Внутренний скрытый накопитель + + Внутренний общий накопитель + + SD-карта + + Внешний общий накопитель + + %1$s свободно из %2$s + + Переместить карты? + + Ошибка перемещения файлов карт + + Это может занять несколько минут.\nПожалуйста, подождите… + + Единицы измерения + + Использовать километры или мили - - - Где поесть - - Продукты - - Транспорт - - Заправка - - Парковка - - Шоппинг - - Секонд-хенд - - Гостиница - - Достопримечательности - - Развлечения - - Банкомат - - Ночная жизнь - - Отдых с детьми - - Банк - - Аптека - - Больница - - Туалет - - Почта - - Полиция - - WiFi - - Переработка - - Вода - - Для автодомов + + + Где поесть + + Продукты + + Транспорт + + Заправка + + Парковка + + Шоппинг + + Секонд-хенд + + Гостиница + + Достопримечательности + + Развлечения + + Банкомат + + Ночная жизнь + + Отдых с детьми + + Банк + + Аптека + + Больница + + Туалет + + Почта + + Полиция + + WiFi + + Переработка + + Вода + + Для автодомов - - - Примечание - - С вами поделились метками Organic Maps - Здравствуйте!\n\nВ прикреплённом файле мои метки из офлайновых карт Organic Maps. Для того чтобы открыть этот файл, вам потребуется приложение Organic Maps, которое можно установить по ссылке: https://omaps.app/get?kmz\n\nСпасибо! - - Загрузка меток - - Метки успешно загружены! Вы можете увидеть их на карте или в ваших сохранённых метках. - - Файл с метками не был загружен. Возможно, файл повреждён или неисправен. - - Тип файла не поддерживается приложением:\n%1$s - - Не удалось открыть файл %1$s\n\n%2$s - - Редактировать - - Ваше местоположение ещё не определено - - Извините, настройки места хранения карт сейчас недоступны. - - Идет процесс загрузки карт. - - Смотри где я сейчас. Жми %1$s или %2$s - - Смотри мою метку на карте Organic Maps - - Посмотри на карте Organic Maps, где я сейчас нахожусь - - Привет!\n\nЯ сейчас здесь: %1$s. Чтобы увидеть это место на карте Organic Maps, открой эту ссылку %2$s или эту %3$s\n\nСпасибо. - - Поделиться - - Email - - Скопировано в буфер обмена: %s - - Готово - - Данные OpenStreetMap: %s - - Вы уверены, что хотите продолжить? - - Треки - - Длина - Поделиться местоположением - - Общие настройки - - Информация - Навигация - Кнопки масштаба - Показать на карте - - Ночной режим - - Выключен - - Включен - - Автоматически - - Перспективный вид - - 3D здания - - 3D-здания отключаются в режиме энергосбережения - - Голосовые инструкции - - Проговаривать названия улиц - - При включении название улицы или съезда, на который нужно повернуть, будет произноситься вслух. - - Язык подсказок - - Проверить голосовые подсказки (TTS, Text-To-Speech). - - Проверьте громкость или системные настройки преобразования текста в речь, если сейчас ничего не слышно. - - Не доступны - Автозум - Выключено - 1 час - 2 часа - 6 часов - 12 часов - 1 сутки - Расстояние - Посмотреть на карте - - Меню - - Вебсайт - - Новости - - Связаться с нами - - Оценить приложение - - Справка - - Вопросы и ответы - - Поддержать деньгами - - Помочь проекту - - Копирайт - - Сообщить о проблеме - - Улучшите направление стрелки, перемещая телефон восьмёркой, чтобы откалибровать компас. - - Двигайте телефон восьмёркой, чтобы откалибровать компас и зафиксировать направление стрелки на карте. - - Обновить все - - Отменить все - - Загруженные - - В очереди - Возле меня - - Карт - Загрузить все - Загружается: - - Чтобы удалить карту, пожалуйста, остановите навигацию. - - Маршрут может быть проложен только внутри карты одного региона. - - Загрузить карту - - Повторить - - Удалить карту - - Обновить карту - - Службы определения местоположения в Google Play - - Быстрое определение приблизительного местоположения с помощью Bluetooth, WiFi или мобильной сети - - Загрузите все карты по пути следования - - Для создания маршрута необходимо загрузить и обновить все карты на пути следования. - - Недостаточно места - - Пожалуйста, включите геолокацию - Сохранить - Ваше описание (текст или html) - создать - - Красный - - Жёлтый - - Синий - - Зелёный - - Пурпурный - - Оранжевый - - Коричневый - - Розовый - - Тёмно-пурпурный - - Голубой - - Сине-зелёный - - Изумрудный - - Лайм - - Тёмно-оранжевый - - Серый - - Серо-голубой + + + Примечание + + С вами поделились метками Organic Maps + Здравствуйте!\n\nВ прикреплённом файле мои метки из офлайновых карт Organic Maps. Для того чтобы открыть этот файл, вам потребуется приложение Organic Maps, которое можно установить по ссылке: https://omaps.app/get?kmz\n\nСпасибо! + + Загрузка меток + + Метки успешно загружены! Вы можете увидеть их на карте или в ваших сохранённых метках. + + Файл с метками не был загружен. Возможно, файл повреждён или неисправен. + + Тип файла не поддерживается приложением:\n%1$s + + Не удалось открыть файл %1$s\n\n%2$s + + Редактировать + + Ваше местоположение ещё не определено + + Извините, настройки места хранения карт сейчас недоступны. + + Идет процесс загрузки карт. + + Смотри где я сейчас. Жми %1$s или %2$s + + Смотри мою метку на карте Organic Maps + + Посмотри на карте Organic Maps, где я сейчас нахожусь + + Привет!\n\nЯ сейчас здесь: %1$s. Чтобы увидеть это место на карте Organic Maps, открой эту ссылку %2$s или эту %3$s\n\nСпасибо. + + Поделиться + + Email + + Скопировано в буфер обмена: %s + + Готово + + Данные OpenStreetMap: %s + + Вы уверены, что хотите продолжить? + + Треки + + Длина + Поделиться местоположением + + Общие настройки + + Информация + Навигация + Кнопки масштаба + Показать на карте + + Ночной режим + + Выключен + + Включен + + Автоматически + + Перспективный вид + + 3D здания + + 3D-здания отключаются в режиме энергосбережения + + Голосовые инструкции + + Проговаривать названия улиц + + При включении название улицы или съезда, на который нужно повернуть, будет произноситься вслух. + + Язык подсказок + + Проверить голосовые подсказки (TTS, Text-To-Speech). + + Проверьте громкость или системные настройки преобразования текста в речь, если сейчас ничего не слышно. + + Не доступны + Автозум + Выключено + 1 час + 2 часа + 6 часов + 12 часов + 1 сутки + Расстояние + Посмотреть на карте + + Меню + + Вебсайт + + Новости + + Связаться с нами + + Оценить приложение + + Справка + + Вопросы и ответы + + Поддержать деньгами + + Помочь проекту + + Копирайт + + Сообщить о проблеме + + Улучшите направление стрелки, перемещая телефон восьмёркой, чтобы откалибровать компас. + + Двигайте телефон восьмёркой, чтобы откалибровать компас и зафиксировать направление стрелки на карте. + + Обновить все + + Отменить все + + Загруженные + + В очереди + Возле меня + + Карт + Загрузить все + Загружается: + + Чтобы удалить карту, пожалуйста, остановите навигацию. + + Маршрут может быть проложен только внутри карты одного региона. + + Загрузить карту + + Повторить + + Удалить карту + + Обновить карту + + Службы определения местоположения в Google Play + + Быстрое определение приблизительного местоположения с помощью Bluetooth, WiFi или мобильной сети + + Загрузите все карты по пути следования + + Для создания маршрута необходимо загрузить и обновить все карты на пути следования. + + Недостаточно места + + Пожалуйста, включите геолокацию + Сохранить + Ваше описание (текст или html) + создать + + Красный + + Жёлтый + + Синий + + Зелёный + + Пурпурный + + Оранжевый + + Коричневый + + Розовый + + Тёмно-пурпурный + + Голубой + + Сине-зелёный + + Изумрудный + + Лайм + + Тёмно-оранжевый + + Серый + + Серо-голубой - - При движении по маршруту помните: - — Дорожная обстановка, ПДД и знаки приоритетнее советов приложения; - — Карта может быть неточной, а предложенный маршрут не всегда оптимален; - — Предлагаемые маршруты — лишь рекомендации; - — Будьте внимательны с маршрутами в приграничных зонах: в построенных программой маршрутах иногда возможны пересечения границ в неположенных местах; - Будьте внимательны на дорогах и берегите себя! - Проверьте сигнал GPS - Маршрут не построен. Текущая геопозиция не определена. - Пожалуйста, проверьте сигнал GPS. Для улучшения точности геопозиции включите Wi-Fi. - Включите режим определения геопозиции - Текущая геопозиция не определена. Для построения маршрута включите режим определения геопозиции. - Маршрут не найден - Не получилось построить маршрут. - Пожалуйста, измените начальную или конечную точку маршрута. - Измените начальную точку маршрута - Маршрут не построен. Не определена начальная точка маршрута. - Пожалуйста, выберите начальную точку маршрута ближе к дороге. - Измените конечную точку маршрута - Маршрут не построен. Не определена конечная точка маршрута. - Пожалуйста, выберите конечную точку маршрута ближе к дороге. - Не определена промежуточная точка маршрута. - Пожалуйста, измените промежуточную точку маршрута. - Системная ошибка - Не удалось проложить маршрут из-за ошибки приложения. - Попробуйте снова - Загрузить карту и построить более оптимальный маршрут с пересечением границы карты? - Для построения более оптимального маршрута с пересечением границы требуется загрузить карту. + + При движении по маршруту помните: + — Дорожная обстановка, ПДД и знаки приоритетнее советов приложения; + — Карта может быть неточной, а предложенный маршрут не всегда оптимален; + — Предлагаемые маршруты — лишь рекомендации; + — Будьте внимательны с маршрутами в приграничных зонах: в построенных программой маршрутах иногда возможны пересечения границ в неположенных местах; + Будьте внимательны на дорогах и берегите себя! + Проверьте сигнал GPS + Маршрут не построен. Текущая геопозиция не определена. + Пожалуйста, проверьте сигнал GPS. Для улучшения точности геопозиции включите Wi-Fi. + Включите режим определения геопозиции + Текущая геопозиция не определена. Для построения маршрута включите режим определения геопозиции. + Маршрут не найден + Не получилось построить маршрут. + Пожалуйста, измените начальную или конечную точку маршрута. + Измените начальную точку маршрута + Маршрут не построен. Не определена начальная точка маршрута. + Пожалуйста, выберите начальную точку маршрута ближе к дороге. + Измените конечную точку маршрута + Маршрут не построен. Не определена конечная точка маршрута. + Пожалуйста, выберите конечную точку маршрута ближе к дороге. + Не определена промежуточная точка маршрута. + Пожалуйста, измените промежуточную точку маршрута. + Системная ошибка + Не удалось проложить маршрут из-за ошибки приложения. + Попробуйте снова + Загрузить карту и построить более оптимальный маршрут с пересечением границы карты? + Для построения более оптимального маршрута с пересечением границы требуется загрузить карту. - - Для поиска мест и построения маршрутов скачайте карту, и интернет вам больше не понадобится. - Выбрать карту - - Показать - - Скрыть - Категории - История - К сожалению, мы ничего не нашли. - Попробуйте изменить условия поиска. - История поиска - Быстрый доступ к последним поисковым запросам. - Очистить историю поиска - - Википедия - Ваше местоположение - Начать - Отсюда - Сюда - Навигация возможна только из текущего местоположения. - Хотите перестроить маршрут от вашего местоположения? - - Далее - - С - - До - Добавить расписание - Удалить расписание - - Весь день (24 часа) - Открыто - Закрыто - Добавить перерыв - Время работы - Расширенный режим - Простой режим - Перерыв - Примеры значений - Исправьте ошибку - Местоположение - Пожалуйста, напишите подробно о проблеме, чтобы сообщество OpenStreetMap исправило ошибку. - Или сделайте это самостоятельно на сайте https://www.openstreetmap.org/ - Отправить - Проблема - Места не существует - Закрыто на ремонт - Повторяющееся место - Автоматическая загрузка - - Ежедневно - Круглосуточно - Сегодня закрыто - Закрыто - Сегодня - Открывается через %s - Закроется через %s - Закрыто - Редактировать время работы - Не зарегистрированы в OpenStreetMap? - Зарегистрироваться - Войти - Войти в OpenStreetMap - Пароль - Забыли пароль? - Выйти - Редактировать место - Добавить язык - Улица - - Номер дома - Подробнее - - Добавить улицу - - Введите название улицы - Выбрать язык - Выбрать улицу - Почтовый индекс - Кухня - Выбрать кухню - - Эл. почта или имя пользователя - Добавить телефон - Этаж - Вместе с картой удалятся и внесённые вами правки на этой карте. - Обновите карты - Для построения маршрутов необходимо обновить все карты и построить маршрут заново. - Найти карту - Проверьте настройки и убедитесь, что устройство подключено к интернету. - Недостаточно места - Удалите ненужные данные - Произошла ошибка при авторизации. - Учтённые правки - Потяните карту, чтобы выбрать правильное местоположение объекта. - Редактирование - Добавление - Название места - - На местном языке - Категория - Подробное описание проблемы - Другая проблема - Добавить организацию - Объект не может находиться в этом месте - - Созданные сообществом данные OpenStreetMap по состоянию на %s. Узнайте больше о том, как редактировать и обновлять карту, на сайте OpenStreetMap.org. - Авторизуйтесь в OpenStreetMap.org, чтобы ваши изменения увидели другие пользователи. - - %1$d из %2$d - Загрузить через сотовую связь? - На некоторых тарифных планах или в роуминге это может привести к значительным расходам. - Введите корректный номер дома - Количество этажей (максимум %d) - - Количество этажей не должно превышать %d - Почтовый индекс - Введите корректный почтовый индекс - - Неизвестное место - Отправить заметку редакторам OSM - Подробный комментарий - Предложенные вами изменения на карте будут отправлены в OpenStreetMap. Опишите дополнительные сведения об объекте, которые Organic Maps не позволяет отредактировать. - Подробнее об OpenStreetMap - Владелец - Нет подходящей категории? - Organic Maps позволяет добавлять на карту только простые типы объектов, то есть никаких городов, дорог, озер, контуров зданий. Пожалуйста, добавляйте такие категории на сайте OpenStreetMap.org. Также рекомендуем ознакомиться с нашими подробными пошаговыми инструкциями и другими приложениями для редактирования карты. - У вас нет загруженных карт - Загрузите необходимые карты, чтобы находить места и пользоваться навигацией без интернета. - - м - - км - - км/ч - - ми - - фут - ми/ч - ч - мин - Ещё - - Фотографии, отзывы, бронирование - - Реферальный бонус, полученный за каждое бронирование по этой ссылке, идёт на разработку Organic Maps. - - Подробнее на Kayak - Редактировать метку - Коментарий… - Сбросить все локальные правки? - Сбросить - Удалить добавленный вами объект? - Удалить - Места не существует - - Пожалуйста, укажите причину удаления - - Введите корректный номер телефона - Введите корректный веб-адрес - Введите корректный email - Введите корректный веб-адрес Facebook страницы или имя пользователя - Введите корректный веб-адрес Instagram страницы или имя пользователя - Введите корректный веб-адрес Twitter страницы или имя пользователя - Введите корректный веб-адрес VK страницы или имя пользователя - Введите корректный веб-адрес LINE страницы или LINE ID - Добавить место в OpenStreetMap - - Отправить всем пользователям? - - Убедитесь, что вы не ввели личные данные. - Редакторы OpenStreetMap проверят изменения и свяжутся с вами, если у них возникнут вопросы. - Cтоп - - Принять - - Отклонить - Загружать дополнительную информацию через мобильный интернет? - Всегда - Только сегодня - Не сегодня - Мобильный интернет - - Мобильный интернет требуется для уведомлений об обновлении карты и для отображения более подробной информации о местах и метках. - Никогда не использовать - Всегда спрашивать - Для отображения пробок необходимо обновить карты. - Увеличить шрифт на карте - Обновите Organic Maps - - Данные о пробках недоступны - Включить запись логов - - Отправить отзыв - Подсказки озвучиваются системным синтезатором речи (TTS). На многих устройствах используется Google TTS, его можно загрузить или обновить в Google Play (https://play.google.com/store/apps/details?id=com.google.android.tts) - Для некоторых языков, возможно, необходимо установить дополнительный синтезатор речи (TTS) из магазина приложений (Google Play, Galaxy Store, App Gallery, FDroid).\nЧтобы настроить синтезатор речи, перейдите в Настройки → Язык и ввод → Синтез речи.\nЗдесь можно установить дополнительные языковые пакеты или выбрать синтезатор речи. - Более подробная информация — в этом руководстве. - Латинская транслитерация - Узнать больше - - Выход - Добавьте стартовую точку, чтобы построить маршрут - Добавьте конечную точку, чтобы построить маршрут - Удалить - Заехать - - Пожалуйста войдите в аккаунт OpenStreetMap.org, чтобы публиковать ваши изменения карты. Подробности по ссылке. - Проблема с доступом к хранилищу - Внешняя память устройства недоступна, возможно SD карта была удалена, повреждена или файловая система доступна только для чтения. Проверьте это и свяжитесь, пожалуйста, с нами support\@organicmaps.app - Эмуляция ошибки с внешней памятью - Вход - Пожалуйста, введите название правильно - Списки - - Спрятать все - Показать все - Создать новый список - - Импортировать метки и треки - Не удалось поделиться из-за ошибки приложения - Ошибка при попытке поделиться - Нельзя делиться пустыми списками - Имя списка не может быть пустым - Введите имя списка, пожалуйста - Новый список - Такое имя уже занято - Выберите, пожалуйста, другое имя - Пожалуйста, подождите… - Номер телефона - Профиль OpenStreetMap - - %d файла были найдены. Вы увидите их после конвертации. - %d файл был найден. Вы увидите его после конвертации. - %d файлов было найдено. Вы увидите их после конвертации. - - - %d объекта - %d объект - %d объектов - - - %d места - %d место - %d мест - - - %d трека - %d трек - %d треков - - - Приватность - Политика конфиденциальности - Условия использования - Пробки - Метро - Стили и слои карты - Карта метро недоступна - Список пустой - Чтобы добавить метку, нажмите на место на карте, а затем на иконку звёздочки - …ещё - Экспорт KMZ - Экспорт GPX - Удалить список - Публичный доступ - Ограниченный доступ - Добавьте описание (текст или html) - Личный - Камеры скорости - Описание места - - Загрузка карт - - Предупреждать при превышении скорости - - Всегда предупреждать - - Никогда не предупреждать - Режим энергосбережения - Если режим энергосбережения включён, приложение будет отключать энергозатратные функции в зависимости от текущего заряда телефона - Никогда - Авто - Максимальное энергосбережение - Данная настройка включается для записи действий в целях диагностики, чтобы помочь нашей команде выявить проблемы с приложением. Временно включайте эту настройку только для отправки детальной информации о найденной вами проблеме в приложении через кнопку \"Сообщить о проблеме\". - Редактируется онлайн - Настройки объезда - - Избегать платных дорог - - Избегать грунтовые дороги - - Избегать паромы - Избегать автомагистрали - Невозможно построить маршрут - К сожалению, мы не смогли построить маршрут с выбранными опциями. Измените настройки и повторите попытку - Настроить пути объезда - Настройки объезда включены - Платная дорога - Грунтовая дорога - Паромная переправа - - Да - - Нет - - Есть - - Нет - - Вместимость: %s - Вы прибыли! - Ок - - Сортировать… - - Сортировать метки - - По умолчанию - - По типу - - По расстоянию - - По дате - - По имени - Неделю назад - Месяц назад - Больше месяца назад - Больше года назад - Рядом со мной - Другие + + Для поиска мест и построения маршрутов скачайте карту, и интернет вам больше не понадобится. + Выбрать карту + + Показать + + Скрыть + Категории + История + К сожалению, мы ничего не нашли. + Попробуйте изменить условия поиска. + История поиска + Быстрый доступ к последним поисковым запросам. + Очистить историю поиска + + Википедия + Ваше местоположение + Начать + Отсюда + Сюда + Навигация возможна только из текущего местоположения. + Хотите перестроить маршрут от вашего местоположения? + + Далее + + С + + До + Добавить расписание + Удалить расписание + + Весь день (24 часа) + Открыто + Закрыто + Добавить перерыв + Время работы + Расширенный режим + Простой режим + Перерыв + Примеры значений + Исправьте ошибку + Местоположение + Пожалуйста, напишите подробно о проблеме, чтобы сообщество OpenStreetMap исправило ошибку. + Или сделайте это самостоятельно на сайте https://www.openstreetmap.org/ + Отправить + Проблема + Места не существует + Закрыто на ремонт + Повторяющееся место + Автоматическая загрузка + + Ежедневно + Круглосуточно + Сегодня закрыто + Закрыто + Сегодня + Открывается через %s + Закроется через %s + Закрыто + Редактировать время работы + Не зарегистрированы в OpenStreetMap? + Зарегистрироваться + Войти + Войти в OpenStreetMap + Пароль + Забыли пароль? + Отправьте свой email, чтобы мы отправили вам ссылку для восстановления пароля + Мы отправили вам письмо для восстановления пароля + Выйти + Редактировать место + Добавить язык + Улица + + Номер дома + Подробнее + + Добавить улицу + + Введите название улицы + Выбрать язык + Выбрать улицу + Почтовый индекс + Кухня + Выбрать кухню + + Эл. почта или имя пользователя + Добавить телефон + Этаж + Вместе с картой удалятся и внесённые вами правки на этой карте. + Обновите карты + Для построения маршрутов необходимо обновить все карты и построить маршрут заново. + Найти карту + Проверьте настройки и убедитесь, что устройство подключено к интернету. + Недостаточно места + Удалите ненужные данные + Произошла ошибка при авторизации. + Учтённые правки + Потяните карту, чтобы выбрать правильное местоположение объекта. + Редактирование + Добавление + Название места + + На местном языке + Категория + Подробное описание проблемы + Другая проблема + Добавить организацию + Объект не может находиться в этом месте + + Созданные сообществом данные OpenStreetMap по состоянию на %s. Узнайте больше о том, как редактировать и обновлять карту, на сайте OpenStreetMap.org. + Авторизуйтесь в OpenStreetMap.org, чтобы ваши изменения увидели другие пользователи. + + %1$d из %2$d + Загрузить через сотовую связь? + На некоторых тарифных планах или в роуминге это может привести к значительным расходам. + Введите корректный номер дома + Количество этажей (максимум %d) + + Количество этажей не должно превышать %d + Почтовый индекс + Введите корректный почтовый индекс + + Неизвестное место + Отправить заметку редакторам OSM + Подробный комментарий + Предложенные вами изменения на карте будут отправлены в OpenStreetMap. Опишите дополнительные сведения об объекте, которые Organic Maps не позволяет отредактировать. + Подробнее об OpenStreetMap + Владелец + Нет подходящей категории? + Organic Maps позволяет добавлять на карту только простые типы объектов, то есть никаких городов, дорог, озер, контуров зданий. Пожалуйста, добавляйте такие категории на сайте OpenStreetMap.org. Также рекомендуем ознакомиться с нашими подробными пошаговыми инструкциями и другими приложениями для редактирования карты. + У вас нет загруженных карт + Загрузите необходимые карты, чтобы находить места и пользоваться навигацией без интернета. + + м + + км + + км/ч + + ми + + фут + ми/ч + ч + мин + Ещё + + Фотографии, отзывы, бронирование + + Реферальный бонус, полученный за каждое бронирование по этой ссылке, идёт на разработку Organic Maps. + + Подробнее на Kayak + Редактировать метку + Коментарий… + Сбросить все локальные правки? + Сбросить + Удалить добавленный вами объект? + Удалить + Места не существует + + Пожалуйста, укажите причину удаления + + Введите корректный номер телефона + Введите корректный веб-адрес + Введите корректный email + Введите корректный веб-адрес Facebook страницы или имя пользователя + Введите корректный веб-адрес Instagram страницы или имя пользователя + Введите корректный веб-адрес Twitter страницы или имя пользователя + Введите корректный веб-адрес VK страницы или имя пользователя + Введите корректный веб-адрес LINE страницы или LINE ID + Добавить место в OpenStreetMap + + Отправить всем пользователям? + + Убедитесь, что вы не ввели личные данные. + Редакторы OpenStreetMap проверят изменения и свяжутся с вами, если у них возникнут вопросы. + Cтоп + + Принять + + Отклонить + Загружать дополнительную информацию через мобильный интернет? + Всегда + Только сегодня + Не сегодня + Мобильный интернет + + Мобильный интернет требуется для уведомлений об обновлении карты и для отображения более подробной информации о местах и метках. + Никогда не использовать + Всегда спрашивать + Для отображения пробок необходимо обновить карты. + Увеличить шрифт на карте + Обновите Organic Maps + + Данные о пробках недоступны + Включить запись логов + + Отправить отзыв + Подсказки озвучиваются системным синтезатором речи (TTS). На многих устройствах используется Google TTS, его можно загрузить или обновить в Google Play (https://play.google.com/store/apps/details?id=com.google.android.tts) + Для некоторых языков, возможно, необходимо установить дополнительный синтезатор речи (TTS) из магазина приложений (Google Play, Galaxy Store, App Gallery, FDroid).\nЧтобы настроить синтезатор речи, перейдите в Настройки → Язык и ввод → Синтез речи.\nЗдесь можно установить дополнительные языковые пакеты или выбрать синтезатор речи. + Более подробная информация — в этом руководстве. + Латинская транслитерация + Узнать больше + + Выход + Добавьте стартовую точку, чтобы построить маршрут + Добавьте конечную точку, чтобы построить маршрут + Удалить + Заехать + + Пожалуйста войдите в аккаунт OpenStreetMap.org, чтобы публиковать ваши изменения карты. Подробности по ссылке. + Проблема с доступом к хранилищу + Внешняя память устройства недоступна, возможно SD карта была удалена, повреждена или файловая система доступна только для чтения. Проверьте это и свяжитесь, пожалуйста, с нами support\@organicmaps.app + Эмуляция ошибки с внешней памятью + Вход + Пожалуйста, введите название правильно + Списки + + Спрятать все + Показать все + Создать новый список + + Импортировать метки и треки + Не удалось поделиться из-за ошибки приложения + Ошибка при попытке поделиться + Нельзя делиться пустыми списками + Имя списка не может быть пустым + Введите имя списка, пожалуйста + Новый список + Такое имя уже занято + Выберите, пожалуйста, другое имя + Пожалуйста, подождите… + Номер телефона + Профиль OpenStreetMap + + %d файла были найдены. Вы увидите их после конвертации. + %d файл был найден. Вы увидите его после конвертации. + %d файлов было найдено. Вы увидите их после конвертации. + + + %d объекта + %d объект + %d объектов + + + %d места + %d место + %d мест + + + %d трека + %d трек + %d треков + + + Приватность + Политика конфиденциальности + Условия использования + Пробки + Метро + Стили и слои карты + Карта метро недоступна + Список пустой + Чтобы добавить метку, нажмите на место на карте, а затем на иконку звёздочки + …ещё + Экспорт KMZ + Экспорт GPX + Удалить список + Публичный доступ + Ограниченный доступ + Добавьте описание (текст или html) + Личный + Камеры скорости + Описание места + + Загрузка карт + + Предупреждать при превышении скорости + + Всегда предупреждать + + Никогда не предупреждать + Режим энергосбережения + Если режим энергосбережения включён, приложение будет отключать энергозатратные функции в зависимости от текущего заряда телефона + Никогда + Авто + Максимальное энергосбережение + Данная настройка включается для записи действий в целях диагностики, чтобы помочь нашей команде выявить проблемы с приложением. Временно включайте эту настройку только для отправки детальной информации о найденной вами проблеме в приложении через кнопку \"Сообщить о проблеме\". + Редактируется онлайн + Настройки объезда + + Избегать платных дорог + + Избегать грунтовые дороги + + Избегать паромы + Избегать автомагистрали + Невозможно построить маршрут + К сожалению, мы не смогли построить маршрут с выбранными опциями. Измените настройки и повторите попытку + Настроить пути объезда + Настройки объезда включены + Платная дорога + Грунтовая дорога + Паромная переправа + + Да + + Нет + + Есть + + Нет + + Вместимость: %s + Вы прибыли! + Ок + + Сортировать… + + Сортировать метки + + По умолчанию + + По типу + + По расстоянию + + По дате + + По имени + Неделю назад + Месяц назад + Больше месяца назад + Больше года назад + Рядом со мной + Другие - - Еда - Достопримечательности - Музеи - Парки - Плавание - Горы - Животные - Отели - Здания - Деньги - Магазины - Парковки - Заправки - Медицина - Искать в списке - Святые места - Выбрать список - Навигация на метро ещё недоступна в данном регионе - Маршрут метро не найден - Выберите начальную или конечную точку маршрута ближе к станции метро - Высоты - Чтобы воспользоваться линиями высот, обновите или загрузите карту нужной местности - Линии высот пока не доступны в этом регионе - Подъём - Спуск - Мин. высота - Макс. высота - Сложность - Расст.: - В пути: - Увеличьте карту, чтобы увидеть изолинии - Загрузка - Скачать карту мира - - Не могу создать папку и переместить файлы на устройстве - - Ошибка диска - - Ошибка подключения - - Отсоедините USB кабель - Держи экран включённым - - Если эта функция включена, то при отображении карты экран будет всегда включен. - - Показывать на экране блокировки - - Если эта функция включена, вам не нужно каждый раз разблокировать устройство во время работы приложения. - - Картографические данные из OpenStreetMap - - https://t.me/OrganicMapsRu - - https://organicmaps.app/ru/ - - https://wiki.openstreetmap.org/wiki/RU:О_проекте - - Спасибо, что пользуетесь нашими картами, созданными сообществом! - - Благодаря вашим пожертвованиям и поддержке мы сможем создать лучшие карты на свете! - - Вам нравится наше приложение? Поддержите его развитие деньгами! Пока ещё не нравится? Пожалуйста, сообщите нам, почему, и мы это исправим! - - Если вы знаете толкового разработчика программного обеспечения, попросите его реализовать нужную вам функцию для приложения. - - Знаете ли вы, что любое место на карте можно выбрать, подержав там палец в течение секунды? - - А вы знали, что своё местоположение на карте можно выбрать? - - Вы можете помочь перевести наше приложение на ваш язык. - - Наше приложение разработано несколькими энтузиастами и сообществом. - - Вы можете легко исправить и улучшить данные карты. - - Наша главная цель — создать быстрые, конфиденциальные и простые в использовании карты, которые вам понравятся. - - Сейчас Вы используете Organic Maps на экране телефона - - Сейчас Вы используете Organic Maps на экране автомобиля - - Вы подключены к Android Auto - - Продолжить в телефоне - - Продолжить в авто - - Этому приложению необходим доступ к вашему местоположению для навигации. - - Разрешить - - Подключено к автомобилю - - Активный отдых - - Веб-браузер недоступен - Громкость - - Экспортировать все метки и треки - - Системные настройки синтеза речи - - Настройки синтеза речи не найдены, вы уверены, что ваше устройство поддерживает их? - С окошком для водителей - Очистите поиск - Приблизить - - Отдалить - - Ссылка на меню - - Посмотреть меню + + Еда + Достопримечательности + Музеи + Парки + Плавание + Горы + Животные + Отели + Здания + Деньги + Магазины + Парковки + Заправки + Медицина + Искать в списке + Святые места + Выбрать список + Навигация на метро ещё недоступна в данном регионе + Маршрут метро не найден + Выберите начальную или конечную точку маршрута ближе к станции метро + Высоты + Чтобы воспользоваться линиями высот, обновите или загрузите карту нужной местности + Линии высот пока не доступны в этом регионе + Подъём + Спуск + Мин. высота + Макс. высота + Сложность + Расст.: + В пути: + Увеличьте карту, чтобы увидеть изолинии + Загрузка + Скачать карту мира + + Не могу создать папку и переместить файлы на устройстве + + Ошибка диска + + Ошибка подключения + + Отсоедините USB кабель + Держи экран включённым + + Если эта функция включена, то при отображении карты экран будет всегда включен. + + Показывать на экране блокировки + + Если эта функция включена, вам не нужно каждый раз разблокировать устройство во время работы приложения. + + Картографические данные из OpenStreetMap + + https://t.me/OrganicMapsRu + + https://organicmaps.app/ru/ + + https://wiki.openstreetmap.org/wiki/RU:О_проекте + + Спасибо, что пользуетесь нашими картами, созданными сообществом! + + Благодаря вашим пожертвованиям и поддержке мы сможем создать лучшие карты на свете! + + Вам нравится наше приложение? Поддержите его развитие деньгами! Пока ещё не нравится? Пожалуйста, сообщите нам, почему, и мы это исправим! + + Если вы знаете толкового разработчика программного обеспечения, попросите его реализовать нужную вам функцию для приложения. + + Знаете ли вы, что любое место на карте можно выбрать, подержав там палец в течение секунды? + + А вы знали, что своё местоположение на карте можно выбрать? + + Вы можете помочь перевести наше приложение на ваш язык. + + Наше приложение разработано несколькими энтузиастами и сообществом. + + Вы можете легко исправить и улучшить данные карты. + + Наша главная цель — создать быстрые, конфиденциальные и простые в использовании карты, которые вам понравятся. + + Сейчас Вы используете Organic Maps на экране телефона + + Сейчас Вы используете Organic Maps на экране автомобиля + + Вы подключены к Android Auto + + Продолжить в телефоне + + Продолжить в авто + + Этому приложению необходим доступ к вашему местоположению для навигации. + + Разрешить + + Подключено к автомобилю + + Активный отдых + + Веб-браузер недоступен + Громкость + + Экспортировать все метки и треки + + Системные настройки синтеза речи + + Настройки синтеза речи не найдены, вы уверены, что ваше устройство поддерживает их? + С окошком для водителей + Очистите поиск + Приблизить + + Отдалить + + Ссылка на меню + + Посмотреть меню - - Адрес/Блок - Адрес/Блок - Адрес/Блок - Канатная дорога - Канатная дорога - Кресельная канатная дорога - Бугельная канатная дорога - Канатная дорога - Канатная дорога - Канатная дорога - Аэрокосмическая инфраструктура - Аэропорт - Международный аэропорт - Перрон - Выход на посадку - Вертолётная площадка - Взлётно-посадочная полоса - Рулёжная дорожка - Терминал - Объекты инфраструктуры - Центр искусств - Банкомат - Банк - Бар - Барбекю-гриль - Скамейка - Велопарковка - Велопрокат - Станция ремонта велосипедов - Пивная под открытым небом - Бордель - Обмен валюты - Автовокзал - Кафе - Прокат авто - Каршеринг - Автомойка - Казино - Азартные игры - Игровой центр для взрослых - Аркада - Зарядная станция - Станция зарядки велосипедов - Зарядная станция для автомобилей - Детская комната - Кинотеатр - Боулинг - Поликлиника - Колледж - Культурно-досуговый центр - Сжатый воздух - Конференц-центр - Суд - Стоматология - Врач - Питьевая вода - Питьевая вода - Автошкола - Выставочный центр - Денежные переводы - Музыкальная школа - Языковая школа - Посольство - Фастфуд - Паром - Пожарная часть - Ресторанный дворик - Фонтан - АЗС - - Кладбище - - Христианское кладбище - Больница - Охотничья вышка - Мороженое - Интернет-кафе - Детсад - Библиотека - Погрузочный док - Рынок - Мотопарковка - Ночной клуб - Дом престарелых - Парковка - Парковка - Многоэтажная парковка - Многоэтажная парковка - Частная парковка - Частная парковка - Частная парковка - Парковка - Подземный паркинг - Подземный паркинг - Частная подземная парковка - Придорожная парковка - Придорожная парковка - Частная придорожная парковка - Парковочная полоса - Парковочная полоса - Частная парковочная полоса - Въезд на парковку - Въезд на частную парковку - Въезд на парковку - Парковочное место - Парковочное место - Парковочное место - Парковочное место - Парковочное место для инвалидов - Терминал оплаты - Аптека - Храм - Храм - Церковь - Церковь Иисуса Христа Святых последних дней - Зала Царства Свидетелей Иеговы - Храм - Синагога - Мечеть - Святилище - Храм - Полиция - Почтовый ящик - Почта - Тюрьма - Паб - Книгообмен + + Адрес/Блок + Адрес/Блок + Адрес/Блок + Канатная дорога + Канатная дорога + Кресельная канатная дорога + Бугельная канатная дорога + Канатная дорога + Канатная дорога + Канатная дорога + Аэрокосмическая инфраструктура + Аэропорт + Международный аэропорт + Перрон + Выход на посадку + Вертолётная площадка + Взлётно-посадочная полоса + Рулёжная дорожка + Терминал + Объекты инфраструктуры + Центр искусств + Банкомат + Банк + Бар + Барбекю-гриль + Скамейка + Велопарковка + Велопрокат + Станция ремонта велосипедов + Пивная под открытым небом + Бордель + Обмен валюты + Автовокзал + Кафе + Прокат авто + Каршеринг + Автомойка + Казино + Азартные игры + Игровой центр для взрослых + Аркада + Зарядная станция + Станция зарядки велосипедов + Зарядная станция для автомобилей + Детская комната + Кинотеатр + Боулинг + Поликлиника + Колледж + Культурно-досуговый центр + Сжатый воздух + Конференц-центр + Суд + Стоматология + Врач + Питьевая вода + Питьевая вода + Автошкола + Выставочный центр + Денежные переводы + Музыкальная школа + Языковая школа + Посольство + Фастфуд + Паром + Пожарная часть + Ресторанный дворик + Фонтан + АЗС + + Кладбище + + Христианское кладбище + Больница + Охотничья вышка + Мороженое + Интернет-кафе + Детсад + Библиотека + Погрузочный док + Рынок + Мотопарковка + Ночной клуб + Дом престарелых + Парковка + Парковка + Многоэтажная парковка + Многоэтажная парковка + Частная парковка + Частная парковка + Частная парковка + Парковка + Подземный паркинг + Подземный паркинг + Частная подземная парковка + Придорожная парковка + Придорожная парковка + Частная придорожная парковка + Парковочная полоса + Парковочная полоса + Частная парковочная полоса + Въезд на парковку + Въезд на частную парковку + Въезд на парковку + Парковочное место + Парковочное место + Парковочное место + Парковочное место + Парковочное место для инвалидов + Терминал оплаты + Аптека + Храм + Храм + Церковь + Церковь Иисуса Христа Святых последних дней + Зала Царства Свидетелей Иеговы + Храм + Синагога + Мечеть + Святилище + Храм + Полиция + Почтовый ящик + Почта + Тюрьма + Паб + Книгообмен - - Приём вторсырья - Контейнер для вторсырья - Контейнер для вторсырья - Батарейки - Одежда - Стеклотара - Бумага - Пластик - Пластиковые бутылки - Металлолом - Электроотходы - Картон - Жестяные и алюминиевые банки - Обувь - Органика / Пищевые отходы - Тетрапак и аналоги - Ресторан - Слив для туалетов транспортных средств - Школа - - Навес - - Навес - - Хижина для ночлега - - Бивачный навес - Общественная баня - Душ - Стриптиз-клуб - Такси - Телефон - Театр - Туалет - Туалет - Администрация - Университет - Торговый автомат - Автомат с сигаретами - Кофейный автомат - Автомат с презервативами - Автомат с напитками - Автомат с едой - Газетный автомат - Паркомат - Автомат по продаже билетов - Автомат со сладостями - Пакеты для экскрементов - Почтомат - Техосмотр автомобиля - Топливная колонка - Ветеринарная клиника - Урна - Мусорный контейнер - Станция перевалки отходов - Вода для автодомов - Преграда - Блок - Столбик - Погранконтроль - Цепь - Городская стена - Велосипедный барьер - Дренажная канава - Оборонительный ров - Сточные воды - Проход - Забор - Ворота - Живая изгородь - Ворота - Шлагбаум - Поддерживающая стена - Перелаз - Турникет - Шлагбаум - Пункт оплаты - Стена - Граница - Административная граница - - Граница страны - - Граница региона - - Граница региона - Национальный парк - Земли коренных народов - Заповедная зона - Заповедная зона - Заповедная зона - Заповедная зона - Заповедная зона - Заповедная зона - Заповедная зона - Здание - - Адрес - Здание - Здание - Гараж - Ж/д вокзал - Склад - Могила - Мастерская - Пчеловод - Кузница - Крафтовая пивоварня - Кейтеринг - Столяр - Кондитер - Электрик - Ремонт электроники - Садовник - Мельница - Ремесленная мастерская - - Отопление, вентиляция и кондиционирование - Изготовление ключей - Слесарь - Металлоконструкции - Маляр - Фотограф - Магазин фотоаппаратов - Сантехник - Лесопилка - Ремонт обуви - Винодельня - Ателье - Африканская кухня - Американская кухня - Арабская кухня - Аргентинская кухня - Азиатская кухня - Австрийская кухня - Бeйглы - Балканская кухня - Барбекю - Баварская кухня - Говядина - Бразильская кухня - Завтраки - Чай с шариками - Бургеры - Домашняя таверна (бушеншанк) - Кондитерская - Карибская кухня - Блюда из курицы - Китайская кухня - Кофе - Тонкие блинчики - Хорватская кухня - Блюда карри - Деликатесы - Дайнер - Пончики - Эфиопская кухня - Филиппинская кухня - Ресторан высокой кухни - Рыбный ресторан - Рыба и картофель фри - Французская кухня - Блюда во фритюре - Грузинская кухня - Немецкая кухня - Греческая кухня - Гриль - Винная таверна (хойригер) - Хот-доги - Венгерская кухня - Кафе-мороженое - Индийская кухня - Индонезийская кухня - Международная кухня - Ирландская кухня - Итальянская кухня - Итальянская, пиццерия - Японская кухня - Кебабы - Корейская - Лаосская кухня - Ливанская кухня - Местная кухня - Мадагаскарская кухня - Малазийская кухня - Средиземноморская кухня - Мексиканская кухня - Марокканская кухня - Лапшичная - Восточная кухня - Блинная - Паста - Иранская кухня - Перуанская кухня - Пиццерия - Польская кухня - Португальская кухня - Рамен - Региональная кухня - Русская кухня - Сэндвичи - Сосисочная - Несладкие блинчики - Морепродукты - Соба - Испанская кухня - Стейк-хаус - Суши - Тапас-бар - Чайная - Тайская кухня - Турецкая кухня - Веганская кухня - Вегетарианская кухня - Вьетнамская кухня - Экстренная служба - Пункт аварийного сбора - Дефибриллятор - Пожарный гидрант - Телефон для экстренных вызовов - - Вход - - Главный вход - Выход - Бесплатно - Медицинская лаборатория - Физиотерапевт - Альтернативная медицина - Аудиология - Центр донорства крови - Оптометрия - Подиатрия - Психотерапия - Сбор анализов - Логопедия + + Приём вторсырья + Контейнер для вторсырья + Контейнер для вторсырья + Батарейки + Одежда + Стеклотара + Бумага + Пластик + Пластиковые бутылки + Металлолом + Электроотходы + Картон + Жестяные и алюминиевые банки + Обувь + Органика / Пищевые отходы + Тетрапак и аналоги + Ресторан + Слив для туалетов транспортных средств + Школа + + Навес + + Навес + + Хижина для ночлега + + Бивачный навес + Общественная баня + Душ + Стриптиз-клуб + Такси + Телефон + Театр + Туалет + Туалет + Администрация + Университет + Торговый автомат + Автомат с сигаретами + Кофейный автомат + Автомат с презервативами + Автомат с напитками + Автомат с едой + Газетный автомат + Паркомат + Автомат по продаже билетов + Автомат со сладостями + Пакеты для экскрементов + Почтомат + Техосмотр автомобиля + Топливная колонка + Ветеринарная клиника + Урна + Мусорный контейнер + Станция перевалки отходов + Вода для автодомов + Преграда + Блок + Столбик + Погранконтроль + Цепь + Городская стена + Велосипедный барьер + Дренажная канава + Оборонительный ров + Сточные воды + Проход + Забор + Ворота + Живая изгородь + Ворота + Шлагбаум + Поддерживающая стена + Перелаз + Турникет + Шлагбаум + Пункт оплаты + Стена + Граница + Административная граница + + Граница страны + + Граница региона + + Граница региона + Национальный парк + Земли коренных народов + Заповедная зона + Заповедная зона + Заповедная зона + Заповедная зона + Заповедная зона + Заповедная зона + Заповедная зона + Здание + + Адрес + Здание + Здание + Гараж + Ж/д вокзал + Склад + Могила + Мастерская + Пчеловод + Кузница + Крафтовая пивоварня + Кейтеринг + Столяр + Кондитер + Электрик + Ремонт электроники + Садовник + Мельница + Ремесленная мастерская + + Отопление, вентиляция и кондиционирование + Изготовление ключей + Слесарь + Металлоконструкции + Маляр + Фотограф + Магазин фотоаппаратов + Сантехник + Лесопилка + Ремонт обуви + Винодельня + Ателье + Африканская кухня + Американская кухня + Арабская кухня + Аргентинская кухня + Азиатская кухня + Австрийская кухня + Бeйглы + Балканская кухня + Барбекю + Баварская кухня + Говядина + Бразильская кухня + Завтраки + Чай с шариками + Бургеры + Домашняя таверна (бушеншанк) + Кондитерская + Карибская кухня + Блюда из курицы + Китайская кухня + Кофе + Тонкие блинчики + Хорватская кухня + Блюда карри + Деликатесы + Дайнер + Пончики + Эфиопская кухня + Филиппинская кухня + Ресторан высокой кухни + Рыбный ресторан + Рыба и картофель фри + Французская кухня + Блюда во фритюре + Грузинская кухня + Немецкая кухня + Греческая кухня + Гриль + Винная таверна (хойригер) + Хот-доги + Венгерская кухня + Кафе-мороженое + Индийская кухня + Индонезийская кухня + Международная кухня + Ирландская кухня + Итальянская кухня + Итальянская, пиццерия + Японская кухня + Кебабы + Корейская + Лаосская кухня + Ливанская кухня + Местная кухня + Мадагаскарская кухня + Малазийская кухня + Средиземноморская кухня + Мексиканская кухня + Марокканская кухня + Лапшичная + Восточная кухня + Блинная + Паста + Иранская кухня + Перуанская кухня + Пиццерия + Польская кухня + Португальская кухня + Рамен + Региональная кухня + Русская кухня + Сэндвичи + Сосисочная + Несладкие блинчики + Морепродукты + Соба + Испанская кухня + Стейк-хаус + Суши + Тапас-бар + Чайная + Тайская кухня + Турецкая кухня + Веганская кухня + Вегетарианская кухня + Вьетнамская кухня + Экстренная служба + Пункт аварийного сбора + Дефибриллятор + Пожарный гидрант + Телефон для экстренных вызовов + + Вход + + Главный вход + Выход + Бесплатно + Медицинская лаборатория + Физиотерапевт + Альтернативная медицина + Аудиология + Центр донорства крови + Оптометрия + Подиатрия + Психотерапия + Сбор анализов + Логопедия - - Дорога - Конная дорожка - - Мост - Конная дорожка - - Тоннель - Выделенная автобусная дорога - - Мост - - Тоннель - Остановка - Строящаяся дорога - Велодорожка - - Мост - Велодорожка - - Велотоннель - Лифт - Пешеходная дорожка - Тротуар - Пешеходный переход - Пешеходная зона - - Пешеходный мост - - Пешеходный тоннель - Брод - Жилая зона - - Мост - - Тоннель - Автомагистраль - - Автомобильный мост - - Автомобильный тоннель - Съезд - Съезд с автомагистрали - - Мост - - Тоннель - Тропа - - Сложная или плохо видимая тропа - - Очень сложная или неразличимая тропа - Велопешеходная дорожка - Велопешеходная дорожка - - Мост - Конная тропа - - Тоннель - Пешеходная улица - Пешеходная зона - - Пешеходный мост - - Пешеходный тоннель - Шоссе - - Мост - - Тоннель - Съезд с шоссе - - Мост - - Тоннель - Гоночный трек - Улица - Улица - - Мост - - Тоннель - Зона отдыха - Дорога - - Мост - - Мост - - Тоннель - Автодорога - - Мост - - Тоннель - Съезд с автодороги - - Мост - - Тоннель - Проезд - Проезд - - Мост - Подъезд - Парковочный проезд - - Тоннель - Зона обслуживания - Камера скорости - Лестница - - Мост - - Тоннель - Дорога - - Мост - - Тоннель - Съезд с дороги - - Мост - - Тоннель - Грунтовка - Грунтовка - - Мост - Грунтовка - Грунтовка - - Тоннель - Светофор - Трасса - - Мост - - Тоннель - Съезд с трассы - - Мост - - Тоннель - Небольшая дорога - Небольшая дорога - - Мост - - Тоннель - Велодорожка - Пешеходная дорожка - Жилая зона - Автомагистраль - Тропа - Пешеходная улица - Шоссе - Улица - Автодорога - Проезд - Дорога - Лестница - Грунтовка - Трасса - Небольшая дорога + + Дорога + Конная дорожка + + Мост + Конная дорожка + + Тоннель + Выделенная автобусная дорога + + Мост + + Тоннель + Остановка + Строящаяся дорога + Велодорожка + + Мост + Велодорожка + + Велотоннель + Лифт + Пешеходная дорожка + Тротуар + Пешеходный переход + Пешеходная зона + + Пешеходный мост + + Пешеходный тоннель + Брод + Жилая зона + + Мост + + Тоннель + Автомагистраль + + Автомобильный мост + + Автомобильный тоннель + Съезд + Съезд с автомагистрали + + Мост + + Тоннель + Тропа + + Сложная или плохо видимая тропа + + Очень сложная или неразличимая тропа + Велопешеходная дорожка + Велопешеходная дорожка + + Мост + Конная тропа + + Тоннель + Пешеходная улица + Пешеходная зона + + Пешеходный мост + + Пешеходный тоннель + Шоссе + + Мост + + Тоннель + Съезд с шоссе + + Мост + + Тоннель + Гоночный трек + Улица + Улица + + Мост + + Тоннель + Зона отдыха + Дорога + + Мост + + Мост + + Тоннель + Автодорога + + Мост + + Тоннель + Съезд с автодороги + + Мост + + Тоннель + Проезд + Проезд + + Мост + Подъезд + Парковочный проезд + + Тоннель + Зона обслуживания + Камера скорости + Лестница + + Мост + + Тоннель + Дорога + + Мост + + Тоннель + Съезд с дороги + + Мост + + Тоннель + Грунтовка + Грунтовка + + Мост + Грунтовка + Грунтовка + + Тоннель + Светофор + Трасса + + Мост + + Тоннель + Съезд с трассы + + Мост + + Тоннель + Небольшая дорога + Небольшая дорога + + Мост + + Тоннель + Велодорожка + Пешеходная дорожка + Жилая зона + Автомагистраль + Тропа + Пешеходная улица + Шоссе + Улица + Автодорога + Проезд + Дорога + Лестница + Грунтовка + Трасса + Небольшая дорога - - Исторический объект - Исторический самолет - Исторический якорь - Археологический памятник - Поле боя - Пограничный камень - Пушка - Замок - Каструм - Замок - Укреплённая церковь - Крепость - Городище - Кремль - Усадьба - Дворец - Замки Японии - Шато - Городские ворота - Городская стена - Форт - Виселица - Исторический локомотив - Мемориал - Памятный крест - Памятная доска - Скульптура - Статуя - Камни преткновения - Исторический камень - Военный мемориал - Историческая шахта - Памятник - Позорный столб - Руины - Корабль - Исторический танк - Гробница - Христианский крест - Святыня - Кораблекрушение - Интернет - Интернет - Перекрёсток - Кольцо - Кольцо - Землепользование - Земельные участки - Резервуар - Земля для застройки - - Кладбище - - Христианское кладбище - Церковный двор - Коммерческая застройка - Стройка - Образовательные учреждения - Сельскохозяйственная земля - Сельскохозяйственная земля - Поле - Клумба - Лес - Хвойный лес - Лиственный лес - Смешанный лес - Гаражи - Газон - Земля для застройки - Теплицы - Промзона - Свалка - Луг - Военная зона - Сад - Карьер - Железнодорожные сооружения - База отдыха - Водоём - Жилая зона - Зона торговли - Соляной пруд - Парк - Виноградник - Место для отдыха - Общественная земля - Место для выгула собак - Фитнес-клуб - Спортивные снаряды - Танцпол - Сад - Частный сад - Площадка для гольфа - Минигольф - Хакерспейс - Каток - Причал - Заповедник - Сидения на открытом воздухе - Парк - Парк - Парк - Парк - Стол для пикника - Спортплощадка - Детская площадка - Зона для отдыха - Сауна - Лодочный спуск - Спорткомплекс - Скалодром - Йога-центр - Стадион - Плавательный бассейн - Плавательный бассейн - Беговая дорожка - Беговая дорожка - Аквапарк - Пляжный курорт - Искусственное сооружение - Волнорез - Тур - Заводская труба - Просека - Геодезический пункт - Флагшток - Маяк - Мачта/вышка - Пирс - Трубопровод - Наземный трубопровод - Элеватор - Резервуар - Камера наблюдения - Башня - Вышка связи - Очистные сооружения - Водопроводный кран - Водонапорная башня - Колодец - Ветряная мельница - Промышленное производство - Военные объекты - Бункер - Перевал - Природа - - Каменная порода - - Галька - - Каменистая осыпь - Залив - Пляж - Песчаный пляж - Галечный пляж - Мыс - Пещера - Утёс - Обрыв - Насыпь - Береговая линия - Пустыня - Гейзер - Ледник - Луг - Пустошь - Горячий источник - Озеро - Шлюзовая камера - Пруд - Водохранилище - Резервуар - Река - Суша - Луг - Сад - Гора - Седловина - Камень - Заросли - Родник - Пролив - Ряд деревьев - Виноградник - Вулкан - Водоём - Болотистая местность - Торфяное болото - Болотистая местность - Тупик - Офис - Организация - Агентство недвижимости - Госучреждение - Страховая компания - Адвокат - Общественная организация - Телекоммуникационная компания - Эко - Эко - Город - Столица - Город - Город - Столица - Город - Город - Город - Город - Город - Город - Город - Континент - Страна - Округ - Ферма - Посёлок - Остров - Остров - Хутор - Местность - Микрорайон - Океан - Район - Море - Площадь - Штат - Штат - Район - Город - Деревня - Энергетика - Генератор - Солнечный генератор - Ветрогенератор - Газотурбинная электростанция - Гидроэлектростанция - Линия электропередач - Подземная линия электропередач - Линия электропередачи низкого напряжения - Электростанция - Угольная электростанция - Газотурбинная электростанция - Гидроэлектростанция - Солнечная электростанция - Ветряная электростанция - Электростанция - Подстанция - Опора ЛЭП - Поверхность - Плохая асфальтированная - Хорошая асфальтированная - Плохая неасфальтированная - Хорошая неасфальтированная - Общественный транспорт - Платформа - Ж/Д - Заброшенная железная дорога - Заброшенный железнодорожный мост - Заброшенный железнодорожный туннель - Строящаяся железная дорога - Пешеходный переход - Неиспользуемая железная дорога - Фуникулер - Фуникулер - Фуникулер - Ж/д станция - Железнодорожный переезд - Скоростной трамвай - Скоростной трамвай - Скоростной трамвай - Монорельсовая железная дорога - Монорельсовая железная дорога - Монорельсовая железная дорога - Узкоколейка - Узкоколейка - Узкоколейка - Железнодорожная платформа - Законсервированная Ж/Д - Законсервированная Ж/Д - Законсервированная Ж/Д - Железнодорожный путь - Высокоскоростная железная дорога - Туристическая железная дорога - Железная дорога - - Второстепенная железная дорога - - Служебная железная дорога - Подъездной Ж/Д путь - - Вспомогательный Ж/Д путь - Железнодорожный мост - Железнодорожный мост - Железнодорожный мост - Железнодорожный мост - Железнодорожный мост - Железнодорожный мост - Железнодорожный мост - Железнодорожный мост - Железнодорожный туннель - Железнодорожный туннель - Железнодорожный туннель - Железнодорожный туннель - Железнодорожный туннель - Железнодорожный туннель - Железнодорожный туннель - Железнодорожный туннель - Ж/д станция - Фуникулер - Ж/д станция - Ж/д станция - Ж/д станция - Ж/д станция - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Метро - Ветка метро - Ветка метро - Ветка метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Вход в метро - Трамвай - Трамвай - Трамвай - Остановка трамвая - Маршрут - Паромная переправа - Магазин - Магазин алкоголя - Булочная - Мебель для ванной - Салон красоты - Напитки - Веломагазин - Букмекерская контора - Книжный магазин - Мясная лавка - Магазин каннабиса - Автосалон - Автомобильные запчасти - СТО - Шиномонтаж - Продажа автодомов - Ковры - Бытовая химия - Магазин шоколада - Магазин одежды - Магазин кофе - Компьютерный магазин - Кондитерская - Продуктовый магазин - Копировальный центр - Косметика - Шторы - Магазин деликатесов - Универмаг - Строительный магазин - Химчистка - Электротехника - Секс-шоп - Магазин тканей - Фермерский магазин - Модные аксессуары - Цветочный магазин - Ритуальные услуги - Магазин мебели - Садовые товары - Газовый магазин - Магазин сувениров - Овощи и фрукты - Бакалея - Парикмахерская - Хозяйственный магазин - Магазин здоровой еды - Магазин трав - Hi-Fi аудио - Бытовые товары - Ювелирный магазин - Киоск - Кухонный магазин - Прачечная - Торговый центр - Массажный салон - Мобильные телефоны - Ростовщик - Магазин мотоциклов - Ремонт мотоциклов - Музыкальный магазин - Музыкальные инструменты - Газетный киоск - Оптика - Магазин снаряжения - Пункт выдачи заказов - Выпечка - Ломбард - Зоотовары - Груминг - Фототовары - Прокат - Прокат велосипедов - Рыбный магазин - Секонд-хенд магазин - Магазин обуви - Магазин спорттоваров - Канцелярский магазин - Супермаркет - Тату-салон - Чайный магазин - Билетная касса - Магазин игрушек - Турагентство - Магазин шин - Магазин полезных мелочей - Магазин видео - Магазин видеоигр - Винный магазин - Сельскохозяйственный магазин - Антиквариат - Магазин бытовой техники - - Художественный магазин - Детский магазин - Магазин сумок - Магазин кроватей - Бутик - Благотворительный магазин - Магазин сыра - Искусства и ремесла - Молочные продукты - Магазин электротоваров - Рыболовный магазин - Украшения для интерьера - Лотерейные билеты - Медикаменты - Пищевые добавки - Краски - Парфюмерия - Швейные принадлежности - Аренда склада - Табак - Торговые поставки - Часы - Оптовый магазин - Спорт - Американский футбол - Стрельба из лука - Лёгкая атлетика - Австралийский футбол - Бейсбол - Баскетбол - Пляжный волейбол - Боулз - Шахматы - Крикет - Кёрлинг - Конный спорт - Гольф - Гимнастика - Гандбол - Различные виды спорта - - Место для дайвинга - Стрельба - Скейтбординг - Лыжи - Футбол - Плавание - Настольный теннис - Теннисный корт - Волейбол - Боулинг - Боулинг - Падель - Футзал - Хоккей с шайбой - Хоккей на траве - Бадминтон - Баскская пелота - Туризм - Аквариум - - Горный приют с обслуживанием - Квартира для отдыха - Произведение искусства - Произведение искусства - Произведение искусства - Произведение искусства - Произведение искусства - Достопримечательность - Вольер для животных - Достопримечательность - Кемпинг - Кемпинг для автодомов - - Коттедж для отдыха - Галерея - Гостевой дом - Хостел - Гостиница - Туринформация - Информационный щит - Указательный столб - Карта - Туристический офис - Центр для посетителей - Мотель - Музей - Место для пикника - Дом отдыха - Парк развлечений - Обзорная площадка - - Домик для туристов - Зоопарк - Контактный зоопарк - Лежачий полицейский - Лежачий полицейский - Лежачий полицейский - Водный путь - Канал - Канал - Рыбоход - Дамба - Ров - Дренажная канава - Водопропускная труба - Причал - Водоотвод - Водоотвод - Водопропускная труба - Шлюз - Река - Река - Река - Река - Река - Река - Водопад - Плотина - Инвалидная коляска - Частично оборудовано для инвалидов - Не оборудовано для инвалидов - Оборудовано для инвалидов - Бугельный подъёмник - Ленточный конвейер - Бугельный подъёмник - Бугельный подъёмник - Бугельный подъёмник - Горнолыжная трасса - Горнолыжная трасса - Продвинутая горнолыжная трасса - Продвинутая горнолыжная трасса - Лёгкая горнолыжная трасса - Лёгкая горнолыжная трасса - Горнолыжная трасса для экспертов - Горнолыжная трасса для экспертов - Горнолыжная трасса для фрирайда - Горнолыжная трасса средней сложности - Горнолыжная трасса средней сложности - Горнолыжная трасса для новичков - Горнолыжная трасса для новичков - Лыжня - Трасса для саней - Трасса для саней - Снежный парк - Зимняя тропа - Соединение между трассами - Маршрут для скитура - Место проведения мероприятий - Аукцион - Коллекции + + Исторический объект + Исторический самолет + Исторический якорь + Археологический памятник + Поле боя + Пограничный камень + Пушка + Замок + Каструм + Замок + Укреплённая церковь + Крепость + Городище + Кремль + Усадьба + Дворец + Замки Японии + Шато + Городские ворота + Городская стена + Форт + Виселица + Исторический локомотив + Мемориал + Памятный крест + Памятная доска + Скульптура + Статуя + Камни преткновения + Исторический камень + Военный мемориал + Историческая шахта + Памятник + Позорный столб + Руины + Корабль + Исторический танк + Гробница + Христианский крест + Святыня + Кораблекрушение + Интернет + Интернет + Перекрёсток + Кольцо + Кольцо + Землепользование + Земельные участки + Резервуар + Земля для застройки + + Кладбище + + Христианское кладбище + Церковный двор + Коммерческая застройка + Стройка + Образовательные учреждения + Сельскохозяйственная земля + Сельскохозяйственная земля + Поле + Клумба + Лес + Хвойный лес + Лиственный лес + Смешанный лес + Гаражи + Газон + Земля для застройки + Теплицы + Промзона + Свалка + Луг + Военная зона + Сад + Карьер + Железнодорожные сооружения + База отдыха + Водоём + Жилая зона + Зона торговли + Соляной пруд + Парк + Виноградник + Место для отдыха + Общественная земля + Место для выгула собак + Фитнес-клуб + Спортивные снаряды + Танцпол + Сад + Частный сад + Площадка для гольфа + Минигольф + Хакерспейс + Каток + Причал + Заповедник + Сидения на открытом воздухе + Парк + Парк + Парк + Парк + Стол для пикника + Спортплощадка + Детская площадка + Зона для отдыха + Сауна + Лодочный спуск + Спорткомплекс + Скалодром + Йога-центр + Стадион + Плавательный бассейн + Плавательный бассейн + Беговая дорожка + Беговая дорожка + Аквапарк + Пляжный курорт + Искусственное сооружение + Волнорез + Тур + Заводская труба + Просека + Геодезический пункт + Флагшток + Маяк + Мачта/вышка + Пирс + Трубопровод + Наземный трубопровод + Элеватор + Резервуар + Камера наблюдения + Башня + Вышка связи + Очистные сооружения + Водопроводный кран + Водонапорная башня + Колодец + Ветряная мельница + Промышленное производство + Военные объекты + Бункер + Перевал + Природа + + Каменная порода + + Галька + + Каменистая осыпь + Залив + Пляж + Песчаный пляж + Галечный пляж + Мыс + Пещера + Утёс + Обрыв + Насыпь + Береговая линия + Пустыня + Гейзер + Ледник + Луг + Пустошь + Горячий источник + Озеро + Шлюзовая камера + Пруд + Водохранилище + Резервуар + Река + Суша + Луг + Сад + Гора + Седловина + Камень + Заросли + Родник + Пролив + Ряд деревьев + Виноградник + Вулкан + Водоём + Болотистая местность + Торфяное болото + Болотистая местность + Тупик + Офис + Организация + Агентство недвижимости + Госучреждение + Страховая компания + Адвокат + Общественная организация + Телекоммуникационная компания + Эко + Эко + Город + Столица + Город + Город + Столица + Город + Город + Город + Город + Город + Город + Город + Континент + Страна + Округ + Ферма + Посёлок + Остров + Остров + Хутор + Местность + Микрорайон + Океан + Район + Море + Площадь + Штат + Штат + Район + Город + Деревня + Энергетика + Генератор + Солнечный генератор + Ветрогенератор + Газотурбинная электростанция + Гидроэлектростанция + Линия электропередач + Подземная линия электропередач + Линия электропередачи низкого напряжения + Электростанция + Угольная электростанция + Газотурбинная электростанция + Гидроэлектростанция + Солнечная электростанция + Ветряная электростанция + Электростанция + Подстанция + Опора ЛЭП + Поверхность + Плохая асфальтированная + Хорошая асфальтированная + Плохая неасфальтированная + Хорошая неасфальтированная + Общественный транспорт + Платформа + Ж/Д + Заброшенная железная дорога + Заброшенный железнодорожный мост + Заброшенный железнодорожный туннель + Строящаяся железная дорога + Пешеходный переход + Неиспользуемая железная дорога + Фуникулер + Фуникулер + Фуникулер + Ж/д станция + Железнодорожный переезд + Скоростной трамвай + Скоростной трамвай + Скоростной трамвай + Монорельсовая железная дорога + Монорельсовая железная дорога + Монорельсовая железная дорога + Узкоколейка + Узкоколейка + Узкоколейка + Железнодорожная платформа + Законсервированная Ж/Д + Законсервированная Ж/Д + Законсервированная Ж/Д + Железнодорожный путь + Высокоскоростная железная дорога + Туристическая железная дорога + Железная дорога + + Второстепенная железная дорога + + Служебная железная дорога + Подъездной Ж/Д путь + + Вспомогательный Ж/Д путь + Железнодорожный мост + Железнодорожный мост + Железнодорожный мост + Железнодорожный мост + Железнодорожный мост + Железнодорожный мост + Железнодорожный мост + Железнодорожный мост + Железнодорожный туннель + Железнодорожный туннель + Железнодорожный туннель + Железнодорожный туннель + Железнодорожный туннель + Железнодорожный туннель + Железнодорожный туннель + Железнодорожный туннель + Ж/д станция + Фуникулер + Ж/д станция + Ж/д станция + Ж/д станция + Ж/д станция + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Метро + Ветка метро + Ветка метро + Ветка метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Вход в метро + Трамвай + Трамвай + Трамвай + Остановка трамвая + Маршрут + Паромная переправа + Магазин + Магазин алкоголя + Булочная + Мебель для ванной + Салон красоты + Напитки + Веломагазин + Букмекерская контора + Книжный магазин + Мясная лавка + Магазин каннабиса + Автосалон + Автомобильные запчасти + СТО + Шиномонтаж + Продажа автодомов + Ковры + Бытовая химия + Магазин шоколада + Магазин одежды + Магазин кофе + Компьютерный магазин + Кондитерская + Продуктовый магазин + Копировальный центр + Косметика + Шторы + Магазин деликатесов + Универмаг + Строительный магазин + Химчистка + Электротехника + Секс-шоп + Магазин тканей + Фермерский магазин + Модные аксессуары + Цветочный магазин + Ритуальные услуги + Магазин мебели + Садовые товары + Газовый магазин + Магазин сувениров + Овощи и фрукты + Бакалея + Парикмахерская + Хозяйственный магазин + Магазин здоровой еды + Магазин трав + Hi-Fi аудио + Бытовые товары + Ювелирный магазин + Киоск + Кухонный магазин + Прачечная + Торговый центр + Массажный салон + Мобильные телефоны + Ростовщик + Магазин мотоциклов + Ремонт мотоциклов + Музыкальный магазин + Музыкальные инструменты + Газетный киоск + Оптика + Магазин снаряжения + Пункт выдачи заказов + Выпечка + Ломбард + Зоотовары + Груминг + Фототовары + Прокат + Прокат велосипедов + Рыбный магазин + Секонд-хенд магазин + Магазин обуви + Магазин спорттоваров + Канцелярский магазин + Супермаркет + Тату-салон + Чайный магазин + Билетная касса + Магазин игрушек + Турагентство + Магазин шин + Магазин полезных мелочей + Магазин видео + Магазин видеоигр + Винный магазин + Сельскохозяйственный магазин + Антиквариат + Магазин бытовой техники + + Художественный магазин + Детский магазин + Магазин сумок + Магазин кроватей + Бутик + Благотворительный магазин + Магазин сыра + Искусства и ремесла + Молочные продукты + Магазин электротоваров + Рыболовный магазин + Украшения для интерьера + Лотерейные билеты + Медикаменты + Пищевые добавки + Краски + Парфюмерия + Швейные принадлежности + Аренда склада + Табак + Торговые поставки + Часы + Оптовый магазин + Спорт + Американский футбол + Стрельба из лука + Лёгкая атлетика + Австралийский футбол + Бейсбол + Баскетбол + Пляжный волейбол + Боулз + Шахматы + Крикет + Кёрлинг + Конный спорт + Гольф + Гимнастика + Гандбол + Различные виды спорта + + Место для дайвинга + Стрельба + Скейтбординг + Лыжи + Футбол + Плавание + Настольный теннис + Теннисный корт + Волейбол + Боулинг + Боулинг + Падель + Футзал + Хоккей с шайбой + Хоккей на траве + Бадминтон + Баскская пелота + Туризм + Аквариум + + Горный приют с обслуживанием + Квартира для отдыха + Произведение искусства + Произведение искусства + Произведение искусства + Произведение искусства + Произведение искусства + Достопримечательность + Вольер для животных + Достопримечательность + Кемпинг + Кемпинг для автодомов + + Коттедж для отдыха + Галерея + Гостевой дом + Хостел + Гостиница + Туринформация + Информационный щит + Указательный столб + Карта + Туристический офис + Центр для посетителей + Мотель + Музей + Место для пикника + Дом отдыха + Парк развлечений + Обзорная площадка + + Домик для туристов + Зоопарк + Контактный зоопарк + Лежачий полицейский + Лежачий полицейский + Лежачий полицейский + Водный путь + Канал + Канал + Рыбоход + Дамба + Ров + Дренажная канава + Водопропускная труба + Причал + Водоотвод + Водоотвод + Водопропускная труба + Шлюз + Река + Река + Река + Река + Река + Река + Водопад + Плотина + Инвалидная коляска + Частично оборудовано для инвалидов + Не оборудовано для инвалидов + Оборудовано для инвалидов + Бугельный подъёмник + Ленточный конвейер + Бугельный подъёмник + Бугельный подъёмник + Бугельный подъёмник + Горнолыжная трасса + Горнолыжная трасса + Продвинутая горнолыжная трасса + Продвинутая горнолыжная трасса + Лёгкая горнолыжная трасса + Лёгкая горнолыжная трасса + Горнолыжная трасса для экспертов + Горнолыжная трасса для экспертов + Горнолыжная трасса для фрирайда + Горнолыжная трасса средней сложности + Горнолыжная трасса средней сложности + Горнолыжная трасса для новичков + Горнолыжная трасса для новичков + Лыжня + Трасса для саней + Трасса для саней + Снежный парк + Зимняя тропа + Соединение между трассами + Маршрут для скитура + Место проведения мероприятий + Аукцион + Коллекции + Комитет по развитию туризма при Правительстве Республики Таджикистан + Упс, что-то пошло не так + Пожалуйста, подождите, идет загрузка карты Таджикистана. Не выходите из приложения + Добро пожаловать в Таджикистан + Developed by Rebus LLC + Войти + Регистрация + Вход + Регистрация + Логин + Ф.И.О + Страна + Повторите пароль + Главная + Избранное + Аккаунт + Изменить + Найдено + Популярное в Таджикистане + Популярное в + Описание + Фотогаллерея + Отзывы + Оставить отзыв + Все отзывы + Развернуть + Свернуть + Отзыв + Нажмите, чтобы оценить: + Текст + Загрузить фото + Отправить + Профиль + USD + EUR + RUB + Персональные данные + Язык + Русский + Темная тема + Светлая тема + Выход + Выход + Вы уверенны что хотите выйти? + Изменить данные + Номер телефона + Выберите язык + Попробовать заново + Не удается соединиться с сервером, проверьте интернет подключение + Нет фото + Таджикистан + Очистить поле поиска + Топ-30 мест + Достопримечательности + Рестораны + Отели + Добавить в избранное + Посмотреть маршрут + Пароли не схожи + Неправильный формат имейла + Сохранено + Данные успешно загружены + Отзыв успешно публикован + Отзыв успешно удален + Удалить отзыв + Вы уверены что хотите удалить это? + В процессе удаления + Пожалуйста подождите данные скачиваются + Пусто + Отзыв будет публикован когда будете онлайн + Отзыв был успешно опубликован + Не удалось публиковать отзыв + Поажалуйста, не выходите за рамки Таджикистана, вы должны быть в Таджикистане + Загрузка изображений + Загрузка изображений для оффлайн использования + Загрузка изображений + Изображений загружено + Ошибка загрузки + Все изображения былы загружены успешно + Большинство изображений было загружено + Ошибка, не все изображения былы загружены + Включить локацию + Пожалуйста, включите локацию чтоб использовать данную функцию diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index b8aa7bcecc..2c8d9af674 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -13,6 +13,7 @@ #FF1C85D6 #FF3C9BBE #1E249CF2 + #00000000 #FFF54137 #F51E30 @@ -61,7 +62,7 @@ #1E000000 #1EFFFFFF - + #006C35 @@ -135,4 +136,6 @@ #4BB9E6 #929292 + #2B2D33 + #FFFFFF diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml index a012476ad6..68e370e56d 100644 --- a/android/app/src/main/res/values/ic_launcher_background.xml +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -1,4 +1,4 @@ - @color/logo + #0688E6 \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 942d0ff0ec..28172127e1 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,2204 +1,2289 @@ - - + - - - Back - - Cancel - - Delete - Download Maps - - Download has failed. Touch to try again. - - Downloading… - - Kilometers - - MB - GB - - Miles - - My Position - - Later - - Search - - Search Map - - You currently have all Location Services for this device or application disabled. Please enable them in Settings. - - Show on the map - - Download has failed - - Try Again - About Organic Maps - - Free for everyone, made with love - - • No ads, no tracking, no data collection - - • No battery drain, works offline - - • Fast, minimalist, developed by community - - Open-source application created by enthusiasts and volunteers. - - Location Settings - Close - The app requires hardware accelerated OpenGL. Unfortunately, your device is not supported. - Download - - Please disconnect USB cable or insert memory card to use Organic Maps - - Please free up some space on the SD card/USB storage first in order to use the app - Before you start using the app, please download the general world map to your device.\nIt will use %s of storage. - Go to Map - Downloading %s. You can now\nproceed to the map. - Download %s? - Update %s? - - Pause - - Continue - - %s download has failed - - Add a New List - - Bookmark Color - - Bookmark List Name - - Bookmarks - - Bookmarks and Tracks - - My Places - - Name - - Address - - List - - Settings - - Save maps to - - Select the folder to download maps to. - - Downloaded maps - - Internal private storage - - Internal shared storage - - SD card - - External shared storage - - %1$s free of %2$s - - Move maps? - - Error moving map files - - This can take several minutes.\nPlease wait… - - Measurement units - - Choose between miles and kilometers + + + Back + + Cancel + + Delete + Download Maps + + Download has failed. Touch to try again. + + Downloading… + + Kilometers + + MB + GB + + Miles + + My Position + + Later + + Search + + Search Map + + You currently have all Location Services for this device or application disabled. Please enable them in Settings. + + Show on the map + + Download has failed + + Try Again + About Organic Maps + + Free for everyone, made with love + + • No ads, no tracking, no data collection + + • No battery drain, works offline + + • Fast, minimalist, developed by community + + Open-source application created by enthusiasts and volunteers. + + Location Settings + Close + The app requires hardware accelerated OpenGL. Unfortunately, your device is not supported. + Download + + Please disconnect USB cable or insert memory card to use Organic Maps + + Please free up some space on the SD card/USB storage first in order to use the app + Before you start using the app, please download the general world map to your device.\nIt will use %s of storage. + Go to Map + Downloading %s. You can now\nproceed to the map. + Download %s? + Update %s? + + Pause + + Continue + + %s download has failed + + Add a New List + + Bookmark Color + + Bookmark List Name + + Bookmarks + + Bookmarks and Tracks + + My Places + + Name + + Address + + List + + Settings + + Save maps to + + Select the folder to download maps to. + + Downloaded maps + + Internal private storage + + Internal shared storage + + SD card + + External shared storage + + %1$s free of %2$s + + Move maps? + + Error moving map files + + This can take several minutes.\nPlease wait… + + Measurement units + + Choose between miles and kilometers - - - Where to eat - - Groceries - - Transport - - Gas - - Parking - - Shopping - - Second Hand - - Hotel - - Sights - - Entertainment - - ATM - - Nightlife - - Family holiday - - Bank - - Pharmacy - - Hospital - - Toilet - - Post - - Police - - WiFi - - Recycling - - Water - - RV Facilities + + + Where to eat + + Groceries + + Transport + + Gas + + Parking + + Shopping + + Second Hand + + Hotel + + Sights + + Entertainment + + ATM + + Nightlife + + Family holiday + + Bank + + Pharmacy + + Hospital + + Toilet + + Post + + Police + + WiFi + + Recycling + + Water + + RV Facilities - - - Notes - - Organic Maps bookmarks were shared with you - Hello!\n\nAttached are my bookmarks; please open them in Organic Maps. If you don\'t have it installed you can download it here: https://omaps.app/get?kmz\n\nEnjoy travelling with Organic Maps! - - Loading Bookmarks - - Bookmarks loaded successfully! You can find them on the map or on the Bookmarks Manager screen. - - Failed to load bookmarks. The file may be corrupted or defective. - - The file type is not recognized by the app:\n%1$s - - Failed to open file %1$s\n\n%2$s - - Edit - - Your location hasn\'t been determined yet - - Sorry, Map Storage settings are currently disabled. - - Map download is in progress now. - - Check out my current location in Organic Maps! %1$s or %2$s Don\'t have offline maps? Download here: https://omaps.app/get - - Hey, check out my pin in Organic Maps! - - Hey, check out my current location on the Organic Maps map! - - Hi,\n\nI\'m here now: %1$s. Click this link %2$s or this one %3$s to see the place on the map.\n\nThanks. - - Share - - Email - - Copied to clipboard: %s - - Done - - OpenStreetMap data: %s - - Are you sure you want to continue? - - Tracks - - Length - Share My Location - - General settings - - Information - Navigation - Zoom buttons - Display on the map - - Night Mode - - Off - - On - - Auto - - Perspective view - - 3D buildings - - 3D buildings are disabled in power saving mode - - Voice Instructions - - Announce Street Names - - When enabled, the name of the street or exit to turn onto will be spoken aloud. - - Voice Language - - Test Voice Directions (TTS, Text-To-Speech) - - Check the volume or system Text-To-Speech settings if you don\'t hear the voice now. - - Not Available - Auto zoom - Off - 1 hour - 2 hours - 6 hours - 12 hours - 1 day - Distance - View on map - - Menu - - Website - - News - - GitHub - - Telegram - - [Matrix] - - Mastodon - - Facebook - - X (Twitter) - - Instagram - - VK - - LINE - - OpenStreetMap - - Feedback - - Rate the app - - Help - - Frequently Asked Questions - - Donate - - Support the project - - Copyright - - Report a bug - - Improve arrow direction by moving the phone in a figure-eight motion to calibrate the compass. - - Move the phone in a figure-eight motion to calibrate the compass and fix the arrow direction on the map. - - Update All - - Cancel All - - Downloaded - - Queued - Near me - - Maps - Download All - Downloading: - - To delete map, please stop navigation. - - Routes can only be created that are fully contained within a map of a single region. - - Download map - - Retry - - Delete Map - - Update Map - - Google Play Location Services - - Quickly determine your approximate location using Bluetooth, WiFi, or mobile network - - Download all of the maps along your route - - In order to create a route, we need to download and update all the maps from your location to your destination. - - Not enough space - - Please enable Location Services - Save - Your descriptions (text or html) - create - - Red - - Yellow - - Blue - - Green - - Purple - - Orange - - Brown - - Pink - - Deep Purple - - Light Blue - - Cyan - - Teal - - Lime - - Deep Orange - - Gray - - Blue Gray + + + Notes + + Organic Maps bookmarks were shared with you + Hello!\n\nAttached are my bookmarks; please open them in Organic Maps. If you don\'t have it installed you can download it here: https://omaps.app/get?kmz\n\nEnjoy travelling with Organic Maps! + + Loading Bookmarks + + Bookmarks loaded successfully! You can find them on the map or on the Bookmarks Manager screen. + + Failed to load bookmarks. The file may be corrupted or defective. + + The file type is not recognized by the app:\n%1$s + + Failed to open file %1$s\n\n%2$s + + Edit + + Your location hasn\'t been determined yet + + Sorry, Map Storage settings are currently disabled. + + Map download is in progress now. + + Check out my current location in Organic Maps! %1$s or %2$s Don\'t have offline maps? Download here: https://omaps.app/get + + Hey, check out my pin in Organic Maps! + + Hey, check out my current location on the Organic Maps map! + + Hi,\n\nI\'m here now: %1$s. Click this link %2$s or this one %3$s to see the place on the map.\n\nThanks. + + Share + + Email + + Copied to clipboard: %s + + Done + + OpenStreetMap data: %s + + Are you sure you want to continue? + + Tracks + + Length + Share My Location + + General settings + + Information + Navigation + Zoom buttons + Display on the map + + Night Mode + + Off + + On + + Auto + + Perspective view + + 3D buildings + + 3D buildings are disabled in power saving mode + + Voice Instructions + + Announce Street Names + + When enabled, the name of the street or exit to turn onto will be spoken aloud. + + Voice Language + + Test Voice Directions (TTS, Text-To-Speech) + + Check the volume or system Text-To-Speech settings if you don\'t hear the voice now. + + Not Available + Auto zoom + Off + 1 hour + 2 hours + 6 hours + 12 hours + 1 day + Distance + View on map + + Menu + + Website + + News + + GitHub + + Telegram + + [Matrix] + + Mastodon + + Facebook + + X (Twitter) + + Instagram + + VK + + LINE + + OpenStreetMap + + Feedback + + Rate the app + + Help + + Frequently Asked Questions + + Donate + + Support the project + + Copyright + + Report a bug + + Improve arrow direction by moving the phone in a figure-eight motion to calibrate the compass. + + Move the phone in a figure-eight motion to calibrate the compass and fix the arrow direction on the map. + + Update All + + Cancel All + + Downloaded + + Queued + Near me + + Maps + Download All + Downloading: + + To delete map, please stop navigation. + + Routes can only be created that are fully contained within a map of a single region. + + Download map + + Retry + + Delete Map + + Update Map + + Google Play Location Services + + Quickly determine your approximate location using Bluetooth, WiFi, or mobile network + + Download all of the maps along your route + + In order to create a route, we need to download and update all the maps from your location to your destination. + + Not enough space + + Please enable Location Services + Save + Your descriptions (text or html) + create + + Red + + Yellow + + Blue + + Green + + Purple + + Orange + + Brown + + Pink + + Deep Purple + + Light Blue + + Cyan + + Teal + + Lime + + Deep Orange + + Gray + + Blue Gray - - When following the route, please keep in mind: - — Road conditions, traffic laws, and road signs always take priority over the navigation hints; - — The map might be inaccurate, and the suggested route might not always be the most optimal way to reach the destination; - — Suggested routes should only be understood as recommendations; - — Exercise caution with routes in border zones: the routes created by our app may sometimes cross country borders in unauthorized places. - Please stay alert and safe on the roads! - Check GPS signal - Unable to create route. Current GPS coordinates could not be identified. - Please check your GPS signal. Enabling Wi-Fi will improve your location accuracy. - Enable location services - Unable to locate current GPS coordinates. Enable location services to calculate route. - Unable to locate route - Unable to create route. - Please adjust your starting point or destination. - Adjust starting point - Route was not created. Unable to locate starting point. - Please select a starting point closer to a road. - Adjust destination - Route was not created. Unable to locate the destination. - Please select a destination point located closer to a road. - Unable to locate the intermediate point. - Please adjust your intermediate point. - System error - Unable to create route due to an application error. - Please try again - Would you like to download the map and create a more optimal route spanning more than one map? - Download additional maps to create a better route that crosses the boundaries of this map. + + When following the route, please keep in mind: + — Road conditions, traffic laws, and road signs always take priority over the navigation hints; + — The map might be inaccurate, and the suggested route might not always be the most optimal way to reach the destination; + — Suggested routes should only be understood as recommendations; + — Exercise caution with routes in border zones: the routes created by our app may sometimes cross country borders in unauthorized places. + Please stay alert and safe on the roads! + Check GPS signal + Unable to create route. Current GPS coordinates could not be identified. + Please check your GPS signal. Enabling Wi-Fi will improve your location accuracy. + Enable location services + Unable to locate current GPS coordinates. Enable location services to calculate route. + Unable to locate route + Unable to create route. + Please adjust your starting point or destination. + Adjust starting point + Route was not created. Unable to locate starting point. + Please select a starting point closer to a road. + Adjust destination + Route was not created. Unable to locate the destination. + Please select a destination point located closer to a road. + Unable to locate the intermediate point. + Please adjust your intermediate point. + System error + Unable to create route due to an application error. + Please try again + Would you like to download the map and create a more optimal route spanning more than one map? + Download additional maps to create a better route that crosses the boundaries of this map. - - To start searching and creating routes, please download the map. After that you will no longer need an Internet connection. - Select Map - - Show - - Hide - Categories - History - Oops, no results found. - Try changing your search criteria. - Search History - View your recent searches. - Clear Search History - - Wikipedia - - Wikimedia Commons - Your Location - Start - Route from - Route to - Navigation is only available from your current location. - Do you want to plan a route from your current location? - - Next - - From - - To - Add Schedule - Delete Schedule - - All Day (24 hours) - Open - Closed - Add Non-Business Hours - Business Hours - Advanced Mode - Simple Mode - Non-Business Hours - Example Values - Correct mistake - Location - Please describe the problem in detail so that the OpenStreetMap community can fix it. - Or do it yourself at https://www.openstreetmap.org/ - Send - Issue - This place does not exist - Сlosed for maintenance - Duplicate place - Auto-download maps - - Daily - 24/7 - Closed today - Closed - Today - Opens in %s - Closes in %s - Closed - Edit business hours - Don\'t have an OpenStreetMap account? - Register at OpenStreetMap - Login - Login to OpenStreetMap - Password - Forgot your password? - Log Out - Edit Place - Add a language - Street - - Building number - Details - - Add a street - - Please enter a street name - Choose a language - Choose a street - Postal Code - Cuisine - Select cuisine - - Email or username - Add Phone - Floor - All of your map edits will be deleted with the map. - Update Maps - To create a route, you need to update all maps and then plan the route again. - Find map - Please make sure your device is connected to the Internet. - Not enough space - Please delete any unnecessary data - Login error. - Verified Changes - Drag the map to select the correct location of the object. - Editing - Adding - Name of the place - - As it is written in the local language - Category - Detailed description of the issue - Different problem - Add business - No object can be located here - - Community-created OpenStreetMap data as of %s. Learn more about how to edit and update the map at OpenStreetMap.org - Log in to openstreetmap.org to publish your changes to the world. - - %1$d of %2$d - Download over a cellular network connection? - This could be considerably expensive with some plans or if roaming. - Enter a valid building number - Number of floors (maximum of %d) - - The number of floors must non exceed %d - ZIP Code - Enter a valid ZIP code - - Unknown Place - Send a note to OSM editors - Detailed comment - Your suggested map changes will be sent to the OpenStreetMap community. Please describe any additional details that cannot be edited in Organic Maps. - More about OpenStreetMap - Owner - Can\'t find a suitable category? - Organic Maps allows to add simple point categories only, that means no towns, roads, lakes, building outlines, etc. Please add such categories directly to OpenStreetMap.org. Check our guide for detailed step by step instructions. - You haven\'t downloaded any maps - Download maps to search and navigate offline. - - m - - km - - km/h - - mi - - ft - mph - h - min - More - - Photos, reviews, booking - - The referral bonus received for each booking through this link goes towards the development of Organic Maps. - - Details on Kayak - Edit Bookmark - Comment… - Discard all local changes? - Discard - Delete added place? - Delete - Place does not exist - - Please indicate the reason for deleting the place - - Enter a valid phone number - Enter a valid web address - Enter a valid email - Enter a valid Facebook web address, account, or page name - Enter a valid Instagram username or web address - Enter a valid Twitter username or web address - Enter a valid VK username or web address - Enter a valid LINE ID or web address - Add Place to OpenStreetMap - - Do you want to send it to all users? - - Make sure you did not enter any private or personal data. - OpenStreetMap editors will check the changes and contact you if they have any questions. - Stop - - Accept - - Decline - Use mobile internet to show detailed information? - Use Always - Only Today - Do not Use Today - Mobile Internet - - Mobile internet is required for map update notifications and uploading edits. - Never Use - Always Ask - To display traffic data, maps must be updated. - Increase size for map labels - Please update Organic Maps - - Traffic data is not available - Enable logging - - General Feedback - We use system TTS for voice instructions. Many Android devices use Google TTS, you can download or update it from Google Play (https://play.google.com/store/apps/details?id=com.google.android.tts) - For some languages, you will need to install a speech synthesizer or an additional language pack from the app store (Google Play, Galaxy Store, App Gallery, FDroid).\nOpen your device\'s settings → Language and input → Speech → Text to speech output.\nHere you can manage settings for speech synthesis (for example, download language pack for offline use) and select another text-to-speech engine. - For more information please check this guide. - Transliteration into Latin alphabet - Learn more - - Exit - Add a starting point to plan a route - Add a destination to plan a route - Remove - Add Stop - - Please login to OpenStreetMap to automatically upload all your map edits. Learn more here. - Storage access problem - External storage is not accessible. The SD Card may have been removed, damaged, or the file system is read-only. Please, check your SD Card or contact us at support\@organicmaps.app - Emulate bad storage - Entrance - Please enter a correct name - Lists - - Hide all - Show all - Create a new list - - Import Bookmarks and Tracks - Unable to share due to an application error - Sharing error - Cannot share an empty list - The name can\'t be empty - Please enter the list name - New list - This name is already taken - Please choose another name - Please wait… - Phone number - OpenStreetMap profile - - %d file was found. You can see it after conversion. - %d files were found. You can see them after conversion. - - - %d object - %d objects - - - %d place - %d places - - - %d track - %d tracks - - - Privacy - Privacy policy - Terms of use - Traffic - Subway - Map Styles and Layers - Subway map is unavailable - This list is empty - To add a bookmark, tap a place on the map and then tap the star icon - …more - Export KMZ - Export GPX - Delete list - Public access - Limited access - Enter a description (text or html) - Private - Speed cameras - Place Description - - Map downloader - - Warn if speeding - - Always warn - - Never warn - Power saving mode - Try to reduce power usage at the expense of some functionality. - Never - When battery is low - Always - Enable this option temporarily to record and manually send detailed diagnostic logs about your issue to us using \"Report a bug\" in the Help dialog. Logs may include location info. - Online editing - Routing options - - Avoid tolls - - Avoid unpaved roads - - Avoid ferries - Avoid freeways - Unable to calculate route - A route could not be found. This may be caused by your routing options or incomplete OpenStreetMap data. Please change your routing options and retry. - Define roads to avoid - Routing options enabled - Toll road - Unpaved road - Ferry crossing - - Yes - - No - - Yes - - No - - Capacity: %s - You have arrived! - OK - - Sort… - - Sort bookmarks - - By default - - By type - - By distance - - By date - - By name - A week ago - A month ago - More than a month ago - More than a year ago - Near me - Others + + To start searching and creating routes, please download the map. After that you will no longer need an Internet connection. + Select Map + + Show + + Hide + Categories + History + Oops, no results found. + Try changing your search criteria. + Search History + View your recent searches. + Clear Search History + + Wikipedia + + Wikimedia Commons + Your Location + Start + Route from + Route to + Navigation is only available from your current location. + Do you want to plan a route from your current location? + + Next + + From + + To + Add Schedule + Delete Schedule + + All Day (24 hours) + Open + Closed + Add Non-Business Hours + Business Hours + Advanced Mode + Simple Mode + Non-Business Hours + Example Values + Correct mistake + Location + Please describe the problem in detail so that the OpenStreetMap community can fix it. + Or do it yourself at https://www.openstreetmap.org/ + Send + Issue + This place does not exist + Сlosed for maintenance + Duplicate place + Auto-download maps + + Daily + 24/7 + Closed today + Closed + Today + Opens in %s + Closes in %s + Closed + Edit business hours + Don\'t have an OpenStreetMap account? + Register at OpenStreetMap + Login + Login to OpenStreetMap + Password + Forgot your password? + Send your email, so you receive a link for password reset + We sent you email for password reset\n + Log Out + Edit Place + Add a language + Street + + Building number + Details + + Add a street + + Please enter a street name + Choose a language + Choose a street + Postal Code + Cuisine + Select cuisine + + Email or username + Add Phone + Floor + All of your map edits will be deleted with the map. + Update Maps + To create a route, you need to update all maps and then plan the route again. + Find map + Please make sure your device is connected to the Internet. + Not enough space + Please delete any unnecessary data + Login error. + Verified Changes + Drag the map to select the correct location of the object. + Editing + Adding + Name of the place + + As it is written in the local language + Category + Detailed description of the issue + Different problem + Add business + No object can be located here + + Community-created OpenStreetMap data as of %s. Learn more about how to edit and update the map at OpenStreetMap.org + Log in to openstreetmap.org to publish your changes to the world. + + %1$d of %2$d + Download over a cellular network connection? + This could be considerably expensive with some plans or if roaming. + Enter a valid building number + Number of floors (maximum of %d) + + The number of floors must non exceed %d + ZIP Code + Enter a valid ZIP code + + Unknown Place + Send a note to OSM editors + Detailed comment + Your suggested map changes will be sent to the OpenStreetMap community. Please describe any additional details that cannot be edited in Organic Maps. + More about OpenStreetMap + Owner + Can\'t find a suitable category? + Organic Maps allows to add simple point categories only, that means no towns, roads, lakes, building outlines, etc. Please add such categories directly to OpenStreetMap.org. Check our guide for detailed step by step instructions. + You haven\'t downloaded any maps + Download maps to search and navigate offline. + + m + + km + + km/h + + mi + + ft + mph + h + min + More + + Photos, reviews, booking + + The referral bonus received for each booking through this link goes towards the development of Organic Maps. + + Details on Kayak + Edit Bookmark + Comment… + Discard all local changes? + Discard + Delete added place? + Delete + Place does not exist + + Please indicate the reason for deleting the place + + Enter a valid phone number + Enter a valid web address + Enter a valid email + Enter a valid Facebook web address, account, or page name + Enter a valid Instagram username or web address + Enter a valid Twitter username or web address + Enter a valid VK username or web address + Enter a valid LINE ID or web address + Add Place to OpenStreetMap + + Do you want to send it to all users? + + Make sure you did not enter any private or personal data. + OpenStreetMap editors will check the changes and contact you if they have any questions. + Stop + + Accept + + Decline + Use mobile internet to show detailed information? + Use Always + Only Today + Do not Use Today + Mobile Internet + + Mobile internet is required for map update notifications and uploading edits. + Never Use + Always Ask + To display traffic data, maps must be updated. + Increase size for map labels + Please update Organic Maps + + Traffic data is not available + Enable logging + + General Feedback + We use system TTS for voice instructions. Many Android devices use Google TTS, you can download or update it from Google Play (https://play.google.com/store/apps/details?id=com.google.android.tts) + For some languages, you will need to install a speech synthesizer or an additional language pack from the app store (Google Play, Galaxy Store, App Gallery, FDroid).\nOpen your device\'s settings → Language and input → Speech → Text to speech output.\nHere you can manage settings for speech synthesis (for example, download language pack for offline use) and select another text-to-speech engine. + For more information please check this guide. + Transliteration into Latin alphabet + Learn more + + Exit + Add a starting point to plan a route + Add a destination to plan a route + Remove + Add Stop + + Please login to OpenStreetMap to automatically upload all your map edits. Learn more here. + Storage access problem + External storage is not accessible. The SD Card may have been removed, damaged, or the file system is read-only. Please, check your SD Card or contact us at support\@organicmaps.app + Emulate bad storage + Entrance + Please enter a correct name + Lists + + Hide all + Show all + Create a new list + + Import Bookmarks and Tracks + Unable to share due to an application error + Sharing error + Cannot share an empty list + The name can\'t be empty + Please enter the list name + New list + This name is already taken + Please choose another name + Please wait… + Phone number + OpenStreetMap profile + + %d file was found. You can see it after conversion. + %d files were found. You can see them after conversion. + + + %d object + %d objects + + + %d place + %d places + + + %d track + %d tracks + + + Privacy + Privacy policy + Terms of use + Traffic + Subway + Map Styles and Layers + Subway map is unavailable + This list is empty + To add a bookmark, tap a place on the map and then tap the star icon + …more + Export KMZ + Export GPX + Delete list + Public access + Limited access + Enter a description (text or html) + Private + Speed cameras + Place Description + + Map downloader + + Warn if speeding + + Always warn + + Never warn + Power saving mode + Try to reduce power usage at the expense of some functionality. + Never + When battery is low + Always + Enable this option temporarily to record and manually send detailed diagnostic logs about your issue to us using \"Report a bug\" in the Help dialog. Logs may include location info. + Online editing + Routing options + + Avoid tolls + + Avoid unpaved roads + + Avoid ferries + Avoid freeways + Unable to calculate route + A route could not be found. This may be caused by your routing options or incomplete OpenStreetMap data. Please change your routing options and retry. + Define roads to avoid + Routing options enabled + Toll road + Unpaved road + Ferry crossing + + Yes + + No + + Yes + + No + + Capacity: %s + You have arrived! + OK + + Sort… + + Sort bookmarks + + By default + + By type + + By distance + + By date + + By name + A week ago + A month ago + More than a month ago + More than a year ago + Near me + Others - - Food - Sights - Museums - Parks - Swim - Mountains - Animals - Hotels - Buildings - Money - Shops - Parking - Gas Stations - Medicine - Search in the list - Religious places - Select list - Subway navigation in this region is not available yet - Subway route is not found - Please choose a start or end point closer to a subway station - Contour Lines - Activating contour lines requires downloading map data for this area - Contour lines are not yet available in this area - Ascent - Descent - Min. altitude - Max. altitude - Difficulty - Dist.: - Time: - Zoom in to explore isolines - Downloading - Download the world map - - Unable to create folder and move files on internal device\'s memory or sdcard - - Disk error - - Connection failure - - Disconnect USB cable - Keep the screen on - - When enabled, the screen will always be on when displaying the map. - - Show on the lock screen - - When enabled, the app will work on the lockscreen even when the device is locked. - - Map data from OpenStreetMap - - https://t.me/OrganicMapsApp - - https://www.instagram.com/organicmaps.app - - https://organicmaps.app/ - - https://wiki.openstreetmap.org/wiki/About_OpenStreetMap - - %1$s, %2$s - - Thank you for using our community-built maps! - - With your donations and support, we can create the best maps in the World! - - Do you like our app? Please donate to support the development! Don\'t like it yet? Please let us know why, and we will fix it! - - If you know a software developer, you can ask him or her to implement a feature that you need. - - Do you know that you can long-tap any place on the map to select it? - - Did you know that your current location on the map can be selected? - - You can help to translate our app into your language. - - Our app is developed by a few enthusiasts and the community. - - You can easily fix and improve the map data. - - Our main goal is to build fast, privacy-focused, easy-to-use maps that you will love. - - You are now using Organic Maps on the phone screen - - You are now using Organic Maps on the car screen - - You are connected to Android Auto - - Continue on the phone - - To the car screen - - This application requires access to your location for navigation purposes. - - Grant Permissions - - Connected to car - - Outdoors - - Web browser is not available - Volume - - Export all Bookmarks and Tracks - - Speech synthesis system settings - - Speech Synthesis settings were not found, are you sure your device supports it? - Drive-through - Clear the search - Zoom in - - Zoom out - - Menu Link - - View Menu + + Food + Sights + Museums + Parks + Swim + Mountains + Animals + Hotels + Buildings + Money + Shops + Parking + Gas Stations + Medicine + Search in the list + Religious places + Select list + Subway navigation in this region is not available yet + Subway route is not found + Please choose a start or end point closer to a subway station + Contour Lines + Activating contour lines requires downloading map data for this area + Contour lines are not yet available in this area + Ascent + Descent + Min. altitude + Max. altitude + Difficulty + Dist.: + Time: + Zoom in to explore isolines + Downloading + Download the world map + + Unable to create folder and move files on internal device\'s memory or sdcard + + Disk error + + Connection failure + + Disconnect USB cable + Keep the screen on + + When enabled, the screen will always be on when displaying the map. + + Show on the lock screen + + When enabled, the app will work on the lockscreen even when the device is locked. + + Map data from OpenStreetMap + + https://t.me/OrganicMapsApp + + https://www.instagram.com/organicmaps.app + + https://organicmaps.app/ + + https://wiki.openstreetmap.org/wiki/About_OpenStreetMap + + %1$s, %2$s + + Thank you for using our community-built maps! + + With your donations and support, we can create the best maps in the World! + + Do you like our app? Please donate to support the development! Don\'t like it yet? Please let us know why, and we will fix it! + + If you know a software developer, you can ask him or her to implement a feature that you need. + + Do you know that you can long-tap any place on the map to select it? + + Did you know that your current location on the map can be selected? + + You can help to translate our app into your language. + + Our app is developed by a few enthusiasts and the community. + + You can easily fix and improve the map data. + + Our main goal is to build fast, privacy-focused, easy-to-use maps that you will love. + + You are now using Organic Maps on the phone screen + + You are now using Organic Maps on the car screen + + You are connected to Android Auto + + Continue on the phone + + To the car screen + + This application requires access to your location for navigation purposes. + + Grant Permissions + + Connected to car + + Outdoors + + Web browser is not available + Volume + + Export all Bookmarks and Tracks + + Speech synthesis system settings + + Speech Synthesis settings were not found, are you sure your device supports it? + Drive-through + Clear the search + Zoom in + + Zoom out + + Menu Link + + View Menu - - Address/Block - Address/Block - Address/Block - Aerialway - Cable Car - Chair Lift - Drag Lift - Gondola - Mixed Lift - Aerialway Station - Airspace Infrastructure - Airport - International Airport - Apron - Gate - Helipad - Runway - Taxiway - Terminal - Amenity - Arts Center - ATM - Bank - Bar - Barbecue Grill - Bench - Bicycle Parking - Bicycle Rental - Bicycle Repair Station - Biergarten - Brothel - Currency Exchange - Bus Station - Cafe - Car Rental - Car Sharing - Car Wash - Casino - Gambling - Adult Gaming Centre - Arcade - Charging Station - Bicycle Charging Station - Car Charging Station - Nursery - Cinema - Bowling Alley - Clinic - College - Community Centre - Compressed Air - Conference Center - Courthouse - Dentist - Doctor - Drinking Water - Drinking Water - Driving School - Exhibition Center - Money Transfer - Music School - Language School - Embassy - Fast Food - Ferry - Fire Station - Food Court - Fountain - Gas Station - - Graveyard - - Christian Graveyard - Hospital - Hunting Stand - Ice Cream - Internet Cafe - Kindergarten - Library - Loading Dock - Marketplace - Motorcycle Parking - Nightclub - Nursing Home - Parking - Parking - Multi Storey Parking - Multi Storey Parking - Private Parking - Private Parking - Private Parking - Park And Ride Parking - Underground Parking - Underground Parking - Private Underground Parking - Street-Side Parking - Street-Side Parking - Private Street-Side Parking - Lane Parking - Lane Parking - Private Lane Parking - Parking Entrance - Private Parking Entrance - Parking Entrance - Parking Space - Parking Space - Parking Space - Parking Space - Disabled Parking Space - Payment Terminal - Pharmacy - Place of Worship - Buddhist Temple - Church - Church of Jesus Christ of Latter Day Saints - Jehovah\'s Witnesses Kingdom Hall - Hindu Temple - Synagogue - Mosque - Shinto Shrine - Taoist Temple - Police - Mailbox - Post Office - Prison - Pub - Book Exchange + + Address/Block + Address/Block + Address/Block + Aerialway + Cable Car + Chair Lift + Drag Lift + Gondola + Mixed Lift + Aerialway Station + Airspace Infrastructure + Airport + International Airport + Apron + Gate + Helipad + Runway + Taxiway + Terminal + Amenity + Arts Center + ATM + Bank + Bar + Barbecue Grill + Bench + Bicycle Parking + Bicycle Rental + Bicycle Repair Station + Biergarten + Brothel + Currency Exchange + Bus Station + Cafe + Car Rental + Car Sharing + Car Wash + Casino + Gambling + Adult Gaming Centre + Arcade + Charging Station + Bicycle Charging Station + Car Charging Station + Nursery + Cinema + Bowling Alley + Clinic + College + Community Centre + Compressed Air + Conference Center + Courthouse + Dentist + Doctor + Drinking Water + Drinking Water + Driving School + Exhibition Center + Money Transfer + Music School + Language School + Embassy + Fast Food + Ferry + Fire Station + Food Court + Fountain + Gas Station + + Graveyard + + Christian Graveyard + Hospital + Hunting Stand + Ice Cream + Internet Cafe + Kindergarten + Library + Loading Dock + Marketplace + Motorcycle Parking + Nightclub + Nursing Home + Parking + Parking + Multi Storey Parking + Multi Storey Parking + Private Parking + Private Parking + Private Parking + Park And Ride Parking + Underground Parking + Underground Parking + Private Underground Parking + Street-Side Parking + Street-Side Parking + Private Street-Side Parking + Lane Parking + Lane Parking + Private Lane Parking + Parking Entrance + Private Parking Entrance + Parking Entrance + Parking Space + Parking Space + Parking Space + Parking Space + Disabled Parking Space + Payment Terminal + Pharmacy + Place of Worship + Buddhist Temple + Church + Church of Jesus Christ of Latter Day Saints + Jehovah\'s Witnesses Kingdom Hall + Hindu Temple + Synagogue + Mosque + Shinto Shrine + Taoist Temple + Police + Mailbox + Post Office + Prison + Pub + Book Exchange - - Recycling Center - Recycling Container - Recycling Container - Batteries - Clothes - Glass Bottles - Paper - Plastic - Plastic Bottles - Scrap Metal - Electronic Waste - Cardboard - Cans - Shoes - Green/Organic Waste - Cartons - Restaurant - Holding Tank Dump Station - School - - Shelter - - Shelter - - Bivouac Hut - - Lean-to Shelter - Public Bath - Shower - Stripclub - Taxi Stand - Phone - Theatre - Toilet - Toilet - Town Hall - University - Vending Machine - Cigarette Dispenser - Coffee Dispenser - Condoms Dispenser - Drinks Dispenser - Food Dispenser - Newspaper Dispenser - Parking Meter - Ticket Machine - Sweets Dispenser - Excrement Bags Dispenser - Parcel Locker - Vehicle Inspection - Fuel Pump - Veterinary Doctor - Trash Bin - Dumpster - Waste Transfer Station - Water Tank Refill Point - Barrier - Block - Bollard - Border Control - Chain - City Wall - Cycle Barrier - Drainage Ditch - Moat - Wastewater - Entrance - Fence - Gate - Hedge - Kissing Gate - Lift Gate - Retaining Wall - Stile - Turnstile - Swing Gate - Toll Booth - Wall - Boundary - Administrative Boundary - - National Border - - Regional Boundary - - Regional Boundary - National Park - Indigenous Lands - Protected Area - Protected Area - Protected Area - Protected Area - Protected Area - Protected Area - Protected Area - Building - - Address - Building - Building - Garage - Train Station - Warehouse - Grave - Craft - Beekeeper - Blacksmith - Craft Brewery - Caterer - Carpenter - Confectioner - Electrician - Electronics Repair - Gardener - Grinding Mill - Handicraft - - HVAC Shop - Key Cutting - Locksmith - Metal Worker - House Painter - Photographer - Camera Shop - Plumber - Sawmill - Shoe Repair - Winery - Tailor - African - American - Arab - Argentinian - Asian - Austrian - Bagel - Balkan - Barbecue - Bavarian - Beef Bowl - Brazilian - Breakfast - Bubble Tea - Burger - Buschenschank - Cake - Caribbean - Chicken - Chinese - Coffee - Crepe - Croatian - Curry - Deli - Diner - Donut - Ethiopian - Filipino - Fine Dining - Fish - Fish and Chips - French - Friture - Georgian - German - Greek - Grill - Heuriger - Hotdog - Hungarian - Ice Cream - Indian - Indonesian - International - Irish - Italian - Italian, Pizza - Japanese - Kebab - Korean - Lao - Lebanese - Local - Malagasy - Malaysian - Mediterranean - Mexican - Moroccan - Noodles - East Asian - Pancake - Pasta - Persian - Peruvian - Pizza - Polish - Portuguese - Ramen - Regional - Russian - Sandwich - Sausage - Savory Pancakes - Seafood - Soba - Spanish - Steak House - Sushi - Tapas - Tea - Thai - Turkish - Vegan - Vegetarian - Vietnamese - Emergency - Emergency Assembly Point - Defibrillator - Fire Hydrant - Emergency Phone - - Entrance - - Main Entrance - Exit - $ - Free - Medical Laboratory - Physiotherapist - Alternative Medicine - Audiologist - Blood Donation Center - Optometrist - Podiatrist - Psychotherapist - Sample Collection Centre - Logopedics + + Recycling Center + Recycling Container + Recycling Container + Batteries + Clothes + Glass Bottles + Paper + Plastic + Plastic Bottles + Scrap Metal + Electronic Waste + Cardboard + Cans + Shoes + Green/Organic Waste + Cartons + Restaurant + Holding Tank Dump Station + School + + Shelter + + Shelter + + Bivouac Hut + + Lean-to Shelter + Public Bath + Shower + Stripclub + Taxi Stand + Phone + Theatre + Toilet + Toilet + Town Hall + University + Vending Machine + Cigarette Dispenser + Coffee Dispenser + Condoms Dispenser + Drinks Dispenser + Food Dispenser + Newspaper Dispenser + Parking Meter + Ticket Machine + Sweets Dispenser + Excrement Bags Dispenser + Parcel Locker + Vehicle Inspection + Fuel Pump + Veterinary Doctor + Trash Bin + Dumpster + Waste Transfer Station + Water Tank Refill Point + Barrier + Block + Bollard + Border Control + Chain + City Wall + Cycle Barrier + Drainage Ditch + Moat + Wastewater + Entrance + Fence + Gate + Hedge + Kissing Gate + Lift Gate + Retaining Wall + Stile + Turnstile + Swing Gate + Toll Booth + Wall + Boundary + Administrative Boundary + + National Border + + Regional Boundary + + Regional Boundary + National Park + Indigenous Lands + Protected Area + Protected Area + Protected Area + Protected Area + Protected Area + Protected Area + Protected Area + Building + + Address + Building + Building + Garage + Train Station + Warehouse + Grave + Craft + Beekeeper + Blacksmith + Craft Brewery + Caterer + Carpenter + Confectioner + Electrician + Electronics Repair + Gardener + Grinding Mill + Handicraft + + HVAC Shop + Key Cutting + Locksmith + Metal Worker + House Painter + Photographer + Camera Shop + Plumber + Sawmill + Shoe Repair + Winery + Tailor + African + American + Arab + Argentinian + Asian + Austrian + Bagel + Balkan + Barbecue + Bavarian + Beef Bowl + Brazilian + Breakfast + Bubble Tea + Burger + Buschenschank + Cake + Caribbean + Chicken + Chinese + Coffee + Crepe + Croatian + Curry + Deli + Diner + Donut + Ethiopian + Filipino + Fine Dining + Fish + Fish and Chips + French + Friture + Georgian + German + Greek + Grill + Heuriger + Hotdog + Hungarian + Ice Cream + Indian + Indonesian + International + Irish + Italian + Italian, Pizza + Japanese + Kebab + Korean + Lao + Lebanese + Local + Malagasy + Malaysian + Mediterranean + Mexican + Moroccan + Noodles + East Asian + Pancake + Pasta + Persian + Peruvian + Pizza + Polish + Portuguese + Ramen + Regional + Russian + Sandwich + Sausage + Savory Pancakes + Seafood + Soba + Spanish + Steak House + Sushi + Tapas + Tea + Thai + Turkish + Vegan + Vegetarian + Vietnamese + Emergency + Emergency Assembly Point + Defibrillator + Fire Hydrant + Emergency Phone + + Entrance + + Main Entrance + Exit + $ + Free + Medical Laboratory + Physiotherapist + Alternative Medicine + Audiologist + Blood Donation Center + Optometrist + Podiatrist + Psychotherapist + Sample Collection Centre + Logopedics - - Highway - Bridle Path - - Bridge - Bridle Path - - Tunnel - Dedicated Bus Road - - Bridge - - Tunnel - Bus Stop - Road Under Construction - Cycle Path - - Bridge - Cycle Path - - Tunnel - Elevator - Foot Path - Sidewalk - Pedestrian Crossing - Pedestrian Area - - Pedestrian Bridge - - Pedestrian Tunnel - Ford - Living Street - - Bridge - - Tunnel - Motorway - - Motorway Bridge - - Motorway Tunnel - Road Exit - Motorway Ramp - - Bridge - - Tunnel - Path - - Difficult or Indistinct Trail - - Expert or Indiscernible Trail - Cycle & Foot Path - Cycle & Foot Path - - Bridge - Bridle Path - - Tunnel - Pedestrian Street - Pedestrian Area - - Pedestrian Bridge - - Pedestrian Tunnel - Primary Road - - Bridge - - Tunnel - Primary Road Ramp - - Bridge - - Tunnel - Racetrack - Residential Street - Residential Street - - Bridge - - Tunnel - Rest Area - Road - - Bridge - - Bridge - - Tunnel - Secondary Road - - Bridge - - Tunnel - Secondary Road Ramp - - Bridge - - Tunnel - Service Road - Service Road - - Bridge - Driveway - Parking Aisle - - Tunnel - Service Area - Speed Camera - Stairs - - Bridge - - Tunnel - Tertiary Road - - Bridge - - Tunnel - Tertiary Road Ramp - - Bridge - - Tunnel - Track - Track - - Bridge - Track - Track - - Tunnel - Traffic Lights - Trunk Road - - Bridge - - Tunnel - Trunk Road Ramp - - Bridge - - Tunnel - Minor Road - Minor Road - - Bridge - - Tunnel - Cycle Path - Foot Path - Living Street - Motorway - Path - Pedestrian Street - Primary Road - Residential Street - Secondary Road - Service Road - Tertiary Road - Stairs - Track - Trunk Road - Minor Road - highway-world_level - highway-world_towns_level + + Highway + Bridle Path + + Bridge + Bridle Path + + Tunnel + Dedicated Bus Road + + Bridge + + Tunnel + Bus Stop + Road Under Construction + Cycle Path + + Bridge + Cycle Path + + Tunnel + Elevator + Foot Path + Sidewalk + Pedestrian Crossing + Pedestrian Area + + Pedestrian Bridge + + Pedestrian Tunnel + Ford + Living Street + + Bridge + + Tunnel + Motorway + + Motorway Bridge + + Motorway Tunnel + Road Exit + Motorway Ramp + + Bridge + + Tunnel + Path + + Difficult or Indistinct Trail + + Expert or Indiscernible Trail + Cycle & Foot Path + Cycle & Foot Path + + Bridge + Bridle Path + + Tunnel + Pedestrian Street + Pedestrian Area + + Pedestrian Bridge + + Pedestrian Tunnel + Primary Road + + Bridge + + Tunnel + Primary Road Ramp + + Bridge + + Tunnel + Racetrack + Residential Street + Residential Street + + Bridge + + Tunnel + Rest Area + Road + + Bridge + + Bridge + + Tunnel + Secondary Road + + Bridge + + Tunnel + Secondary Road Ramp + + Bridge + + Tunnel + Service Road + Service Road + + Bridge + Driveway + Parking Aisle + + Tunnel + Service Area + Speed Camera + Stairs + + Bridge + + Tunnel + Tertiary Road + + Bridge + + Tunnel + Tertiary Road Ramp + + Bridge + + Tunnel + Track + Track + + Bridge + Track + Track + + Tunnel + Traffic Lights + Trunk Road + + Bridge + + Tunnel + Trunk Road Ramp + + Bridge + + Tunnel + Minor Road + Minor Road + + Bridge + + Tunnel + Cycle Path + Foot Path + Living Street + Motorway + Path + Pedestrian Street + Primary Road + Residential Street + Secondary Road + Service Road + Tertiary Road + Stairs + Track + Trunk Road + Minor Road + highway-world_level + highway-world_towns_level - - Historic Object - Historic Aircraft - Historic Anchor - Archaeological Site - Historic Battlefield - Boundary Stone - Cannon - Castle - Roman Fort - Stronghold Castle - Fortified Church - Fortress - Hillfort - Kremlin - Manor House - Palace - Japanese Castle - Stately Castle - City Gate - City Wall - Fort - Gallows - Historic Locomotive - Memorial - Memorial Cross - Commemorative Plaque - Sculpture - Statue - Stolperstein - Historic Stone - War Memorial - Historic Mine - Monument - Pillory - Historic Ruins - Ship - Historic Tank - Tomb - Wayside Cross - Wayside Shrine - Shipwreck - hwtag - hwtag-bidir_bicycle - hwtag-onedir_bicycle - hwtag-lit - hwtag-nobicycle - hwtag-nocar - hwtag-nofoot - hwtag-oneway - hwtag-private - hwtag-toll - hwtag-yesbicycle - hwtag-yescar - hwtag-yesfoot - Internet - Internet - Junction - Roundabout - Roundabout - Landuse - Allotments - Basin - Brownfield - - Cemetery - - Christian Cemetery - Churchyard - Commercial Area - Construction Area - Educational Facility - Farmland - Farmyard - Field - Flowerbed - Forest - Coniferous Forest - Deciduous Forest - Mixed-Leaf Forest - Garages - Grass - Greenfield - Greenhouse - Industrial Area - Landfill - Meadow - Military Area - Orchard - Quarry - Railway Premises - Recreation Ground - Reservoir - Residential Area - Retail Area - Salt Pond - Land - Vineyard - Leisure - Public Land - Dog Park - Fitness Centre - Fitness Station - Dance Hall - Garden - Residential Garden - Golf Course - Minigolf - Hackerspace - Ice Rink - Marina - Nature Reserve - Outdoor Seating - Park - Private Park - Park - Private Park - Picnic Table - Sport Pitch - Playground - Recreation Ground - Sauna - Slipway - Sports Center - Climbing Centre - Yoga Studio - Stadium - Swimming Pool - Swimming Pool - Track - Track - Water Park - Beach Resort - Man Made - Breakwater - Cairn - Chimney - Cutline - Survey Point - Flagpole - Lighthouse - Mast - Pier - Pipeline - Overground Pipeline - Silo - Storage Tank - Surveillance Camera - Tower - Communications Tower - Wastewater Treatment Plant - Water Tap - Water Tower - Water Well - Windmill - Industrial Works - mapswithme - mapswithme-grid - Military - Bunker - Mountain Pass - Nature - - Bare Rock - - Shingle - - Scree - Bay - Beach - Sandy Beach - Gravel Beach - Cape - Cave Entrance - Cliff - Earth Bank - Embankment - Coastline - Desert - Geyser - Glacier - Grassland - Heath - Hot Spring - Lake - Lock Chamber - Pond - Reservoir - Basin - River - Land - Meadow - Orchard - Peak - Mountain Saddle - Rock - Scrub - Natural Spring - Strait - Tree Row - Vineyard - Volcano - Water - Wetland - Bog - Marsh - Dead End - Office - Company Office - Estate Agent - Government Office - Insurance Office - Lawyer - Non-Governmental Organization - Telecom Company - Organic - Organic - City - Capital - City - City - Capital - City - City - City - City - City - City - City - Continent - Country - County - Farm - Hamlet - Island - Islet - Isolated Dwelling - Locality - Neighbourhood - Ocean - Region - Sea - Square - State - State - Suburb - Town - Village - Power - Power Generator - Solar Generator - Wind Generator - Gas Turbine Power Plant - Hydroelectric Power Plant - Power Line - Underground Power Line - Minor Power Line - Power Plant - Coal Power Plant - Gas Turbine Power Plant - Hydroelectric Power Plant - Solar Power Plant - Wind Power Plant - Power Station - Substation - Power Tower - psurface - psurface-paved_bad - psurface-paved_good - psurface-unpaved_bad - psurface-unpaved_good - Public Transport - Platform - Railway - Abandoned Railway - Abandoned Railway Bridge - Abandoned Railway Tunnel - Railway Construction - Railway Crossing - Disused Railway - Funicular - Funicular Bridge - Funicular Tunnel - Rail Halt - Level Crossing - Light Rail - Light Rail Bridge - Light Rail Tunnel - Monorail - Monorail Bridge - Monorail Tunnel - Narrow Gauge Rail - Narrow Gauge Rail Bridge - Narrow Gauge Rail Tunnel - Railway Platform - Preserved Rail - Preserved Rail Bridge - Preserved Rail Tunnel - Railway - High-Speed Railway - Touristic Railway - Railway - - Railway Branch - - Utility Railway - Railway Spur - - Service Rail Track - Railway Bridge - Railway Bridge - Railway Bridge - Railway Bridge - Railway Bridge - Railway Bridge - Railway Bridge - Railway Bridge - Railway Tunnel - Railway Tunnel - Railway Tunnel - Railway Tunnel - Railway Tunnel - Railway Tunnel - Railway Tunnel - Railway Tunnel - Train Station - Funicular - Light Rail Station - DLR Station - Porto Metro - Monorail Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Underground Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Station - Subway Line - Subway Line Bridge - Subway Line Tunnel - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Metro Station Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Subway Entrance - Tram Line - Tram Line Bridge - Tram Line Tunnel - Tram Stop - Route - Ferry - route-shuttle_train - Shop - Liquor Shop - Bakery - Bathroom Furnishings - Beauty Shop - Beverages - Bicycle Shop - Bookmaker - Bookstore - Butcher - Cannabis Shop - Car Dealership - Car Parts Shop - Car Repair Workshop - Tyre Repair - RV Dealership - Carpet Shop - Chemist - Chocolate Shop - Clothes Shop - Coffee Shop - Computer Store - Candy Shop - Convenience Store - Copyshop - Cosmetics Shop - Curtain Shop - Delicatessen - Department Store - Home Improvement Store - Dry Cleaner - Electronics Shop - Erotic Shop - Fabric Shop - Farm Food Shop - Fashion Accessories - Florist - Funeral Directors - Furniture Store - Garden Center - Gas Store - Gift Shop - Greengrocer - Grocery Store - Hairdresser - Hardware Store - Health Food Shop - Herbalist - HiFi Audio Shop - Housewares Store - Jewelry Store - Kiosk - Kitchen Store - Laundry - Mall - Massage Salon - Cell Phone Store - Money Lender - Motorcycle Shop - Motorcycle Repair - Record Store - Musical Instrument Shop - Newspaper Stand - Optician - Outdoor Equipment Shop - Pickup Point - Pastry Shop - Pawnbroker - Pet Store - Pet Grooming - Photo Shop - Rental Shop - Bicycle Rental Shop - Fishmonger - Second Hand Shop - Shoe Shop - Sports Shop - Stationery Shop - Supermarket - Tattoo Parlour - Tea Shop - Ticket Shop - Toy Store - Travel Agency - Tyre Shop - Variety Store - Video Shop - Video Game Shop - Wine Shop - Agricultural Shop - Antiques Shop - Appliance Shop - - Artwork Shop - Baby Goods Shop - Bag Shop - Bed Shop - Boutique - Charity Shop - Cheese Shop - Craft Supplies Store - Dairy Shop - Electrical Supplies Store - Fishing Store - Interior Decorations Store - Lottery Tickets - Medical Supplies Store - Nutrition Supplement Store - Paint Shop - Perfume Shop - Sewing Supplies Shop - Storage Rental - Smoke Shop - Trade Supplies - Watch Store - Wholesale Store - Sport - American Football - Archery - Athletics - Australian Football - Baseball - Basketball - Beach Volleyball - Bowls - Chess - Cricket - Curling - Equestrian Sports - Golf - Gymnastics - Handball - Various Sports - - Scuba Diving Site - Shooting - Skateboarding - Skiing - Soccer - Swimming - Table Tennis - Tennis Court - Volleyball - Bowling - Bowling - Padel - Futsal - Ice Hockey - Field Hockey - Badminton - Basque Pelota - Tourism - Aquarium - - Mountain Lodge - Holiday Apartment - Artwork - Architectural Artwork - Painting - Sculpture - Statue - Attraction - Animal Enclosure - Attraction - Campground - RV Park - - Holiday Cottage - Art Gallery - Guest House - Hostel - Hotel - Tourist Information - Information Board - Guidepost - Tourist Map - Tourist Office - Visitor Centre - Motel - Museum - Picnic Site - Resort - Theme Park - Viewpoint - - Wilderness Hut - Zoo - Petting Zoo - Traffic Calming - Traffic Bump - Traffic Hump - Waterway - Canal - Canal Tunnel - Fish Pass - Dam - Ditch - Drainage Ditch - Culvert - Waterway Dock - Drain - Drain - Culvert - Lock Gate - River - River - Stream - Ephemeral Stream - Intermittent Stream - Stream - Waterfall - Weir - Wheelchair - Limited Wheelchair Access - No Wheelchair Access - Full Wheelchair Access - J-bar Lift - Magic Carpet - Platter Lift - Rope Tow - T-bar Lift - Downhill Ski Run - Downhill Ski Run - Advanced Downhill Ski Run - Advanced Downhill Ski Run - Easy Downhill Ski Run - Easy Downhill Ski Run - Expert Downhill Ski Run - Expert Downhill Ski Run - Freeride Downhill Ski Run - Intermediate Downhill Ski Run - Intermediate Downhill Ski Run - Novice Downhill Ski Run - Novice Downhill Ski Run - Nordic Ski Trail - Sledding Piste - Sledding Piste - Snow Park - Snow Hiking Trail - Piste Connection - Skitour Trail - Events Venue - Auction - Collectables + + Historic Object + Historic Aircraft + Historic Anchor + Archaeological Site + Historic Battlefield + Boundary Stone + Cannon + Castle + Roman Fort + Stronghold Castle + Fortified Church + Fortress + Hillfort + Kremlin + Manor House + Palace + Japanese Castle + Stately Castle + City Gate + City Wall + Fort + Gallows + Historic Locomotive + Memorial + Memorial Cross + Commemorative Plaque + Sculpture + Statue + Stolperstein + Historic Stone + War Memorial + Historic Mine + Monument + Pillory + Historic Ruins + Ship + Historic Tank + Tomb + Wayside Cross + Wayside Shrine + Shipwreck + hwtag + hwtag-bidir_bicycle + hwtag-onedir_bicycle + hwtag-lit + hwtag-nobicycle + hwtag-nocar + hwtag-nofoot + hwtag-oneway + hwtag-private + hwtag-toll + hwtag-yesbicycle + hwtag-yescar + hwtag-yesfoot + Internet + Internet + Junction + Roundabout + Roundabout + Landuse + Allotments + Basin + Brownfield + + Cemetery + + Christian Cemetery + Churchyard + Commercial Area + Construction Area + Educational Facility + Farmland + Farmyard + Field + Flowerbed + Forest + Coniferous Forest + Deciduous Forest + Mixed-Leaf Forest + Garages + Grass + Greenfield + Greenhouse + Industrial Area + Landfill + Meadow + Military Area + Orchard + Quarry + Railway Premises + Recreation Ground + Reservoir + Residential Area + Retail Area + Salt Pond + Land + Vineyard + Leisure + Public Land + Dog Park + Fitness Centre + Fitness Station + Dance Hall + Garden + Residential Garden + Golf Course + Minigolf + Hackerspace + Ice Rink + Marina + Nature Reserve + Outdoor Seating + Park + Private Park + Park + Private Park + Picnic Table + Sport Pitch + Playground + Recreation Ground + Sauna + Slipway + Sports Center + Climbing Centre + Yoga Studio + Stadium + Swimming Pool + Swimming Pool + Track + Track + Water Park + Beach Resort + Man Made + Breakwater + Cairn + Chimney + Cutline + Survey Point + Flagpole + Lighthouse + Mast + Pier + Pipeline + Overground Pipeline + Silo + Storage Tank + Surveillance Camera + Tower + Communications Tower + Wastewater Treatment Plant + Water Tap + Water Tower + Water Well + Windmill + Industrial Works + mapswithme + mapswithme-grid + Military + Bunker + Mountain Pass + Nature + + Bare Rock + + Shingle + + Scree + Bay + Beach + Sandy Beach + Gravel Beach + Cape + Cave Entrance + Cliff + Earth Bank + Embankment + Coastline + Desert + Geyser + Glacier + Grassland + Heath + Hot Spring + Lake + Lock Chamber + Pond + Reservoir + Basin + River + Land + Meadow + Orchard + Peak + Mountain Saddle + Rock + Scrub + Natural Spring + Strait + Tree Row + Vineyard + Volcano + Water + Wetland + Bog + Marsh + Dead End + Office + Company Office + Estate Agent + Government Office + Insurance Office + Lawyer + Non-Governmental Organization + Telecom Company + Organic + Organic + City + Capital + City + City + Capital + City + City + City + City + City + City + City + Continent + Country + County + Farm + Hamlet + Island + Islet + Isolated Dwelling + Locality + Neighbourhood + Ocean + Region + Sea + Square + State + State + Suburb + Town + Village + Power + Power Generator + Solar Generator + Wind Generator + Gas Turbine Power Plant + Hydroelectric Power Plant + Power Line + Underground Power Line + Minor Power Line + Power Plant + Coal Power Plant + Gas Turbine Power Plant + Hydroelectric Power Plant + Solar Power Plant + Wind Power Plant + Power Station + Substation + Power Tower + psurface + psurface-paved_bad + psurface-paved_good + psurface-unpaved_bad + psurface-unpaved_good + Public Transport + Platform + Railway + Abandoned Railway + Abandoned Railway Bridge + Abandoned Railway Tunnel + Railway Construction + Railway Crossing + Disused Railway + Funicular + Funicular Bridge + Funicular Tunnel + Rail Halt + Level Crossing + Light Rail + Light Rail Bridge + Light Rail Tunnel + Monorail + Monorail Bridge + Monorail Tunnel + Narrow Gauge Rail + Narrow Gauge Rail Bridge + Narrow Gauge Rail Tunnel + Railway Platform + Preserved Rail + Preserved Rail Bridge + Preserved Rail Tunnel + Railway + High-Speed Railway + Touristic Railway + Railway + + Railway Branch + + Utility Railway + Railway Spur + + Service Rail Track + Railway Bridge + Railway Bridge + Railway Bridge + Railway Bridge + Railway Bridge + Railway Bridge + Railway Bridge + Railway Bridge + Railway Tunnel + Railway Tunnel + Railway Tunnel + Railway Tunnel + Railway Tunnel + Railway Tunnel + Railway Tunnel + Railway Tunnel + Train Station + Funicular + Light Rail Station + DLR Station + Porto Metro + Monorail Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Underground Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Station + Subway Line + Subway Line Bridge + Subway Line Tunnel + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Metro Station Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Subway Entrance + Tram Line + Tram Line Bridge + Tram Line Tunnel + Tram Stop + Route + Ferry + route-shuttle_train + Shop + Liquor Shop + Bakery + Bathroom Furnishings + Beauty Shop + Beverages + Bicycle Shop + Bookmaker + Bookstore + Butcher + Cannabis Shop + Car Dealership + Car Parts Shop + Car Repair Workshop + Tyre Repair + RV Dealership + Carpet Shop + Chemist + Chocolate Shop + Clothes Shop + Coffee Shop + Computer Store + Candy Shop + Convenience Store + Copyshop + Cosmetics Shop + Curtain Shop + Delicatessen + Department Store + Home Improvement Store + Dry Cleaner + Electronics Shop + Erotic Shop + Fabric Shop + Farm Food Shop + Fashion Accessories + Florist + Funeral Directors + Furniture Store + Garden Center + Gas Store + Gift Shop + Greengrocer + Grocery Store + Hairdresser + Hardware Store + Health Food Shop + Herbalist + HiFi Audio Shop + Housewares Store + Jewelry Store + Kiosk + Kitchen Store + Laundry + Mall + Massage Salon + Cell Phone Store + Money Lender + Motorcycle Shop + Motorcycle Repair + Record Store + Musical Instrument Shop + Newspaper Stand + Optician + Outdoor Equipment Shop + Pickup Point + Pastry Shop + Pawnbroker + Pet Store + Pet Grooming + Photo Shop + Rental Shop + Bicycle Rental Shop + Fishmonger + Second Hand Shop + Shoe Shop + Sports Shop + Stationery Shop + Supermarket + Tattoo Parlour + Tea Shop + Ticket Shop + Toy Store + Travel Agency + Tyre Shop + Variety Store + Video Shop + Video Game Shop + Wine Shop + Agricultural Shop + Antiques Shop + Appliance Shop + + Artwork Shop + Baby Goods Shop + Bag Shop + Bed Shop + Boutique + Charity Shop + Cheese Shop + Craft Supplies Store + Dairy Shop + Electrical Supplies Store + Fishing Store + Interior Decorations Store + Lottery Tickets + Medical Supplies Store + Nutrition Supplement Store + Paint Shop + Perfume Shop + Sewing Supplies Shop + Storage Rental + Smoke Shop + Trade Supplies + Watch Store + Wholesale Store + Sport + American Football + Archery + Athletics + Australian Football + Baseball + Basketball + Beach Volleyball + Bowls + Chess + Cricket + Curling + Equestrian Sports + Golf + Gymnastics + Handball + Various Sports + + Scuba Diving Site + Shooting + Skateboarding + Skiing + Soccer + Swimming + Table Tennis + Tennis Court + Volleyball + Bowling + Bowling + Padel + Futsal + Ice Hockey + Field Hockey + Badminton + Basque Pelota + Tourism + Aquarium + + Mountain Lodge + Holiday Apartment + Artwork + Architectural Artwork + Painting + Sculpture + Statue + Attraction + Animal Enclosure + Attraction + Campground + RV Park + + Holiday Cottage + Art Gallery + Guest House + Hostel + Hotel + Tourist Information + Information Board + Guidepost + Tourist Map + Tourist Office + Visitor Centre + Motel + Museum + Picnic Site + Resort + Theme Park + Viewpoint + + Wilderness Hut + Zoo + Petting Zoo + Traffic Calming + Traffic Bump + Traffic Hump + Waterway + Canal + Canal Tunnel + Fish Pass + Dam + Ditch + Drainage Ditch + Culvert + Waterway Dock + Drain + Drain + Culvert + Lock Gate + River + River + Stream + Ephemeral Stream + Intermittent Stream + Stream + Waterfall + Weir + Wheelchair + Limited Wheelchair Access + No Wheelchair Access + Full Wheelchair Access + J-bar Lift + Magic Carpet + Platter Lift + Rope Tow + T-bar Lift + Downhill Ski Run + Downhill Ski Run + Advanced Downhill Ski Run + Advanced Downhill Ski Run + Easy Downhill Ski Run + Easy Downhill Ski Run + Expert Downhill Ski Run + Expert Downhill Ski Run + Freeride Downhill Ski Run + Intermediate Downhill Ski Run + Intermediate Downhill Ski Run + Novice Downhill Ski Run + Novice Downhill Ski Run + Nordic Ski Trail + Sledding Piste + Sledding Piste + Snow Park + Snow Hiking Trail + Piste Connection + Skitour Trail + Events Venue + Auction + Collectables + //todo + Committee for Tourism Development under the Government of the Republic of Tajikistan + Error + Please wait, the map of Tajikistan is loading. Don\'t leave the app + Welcome to Tajikistan + Developed by Rebus LLC + Log in + Registration + Entry + Registration + Login + Full name + Country + Repeat the password + Home + Favorites + Account + Edit + Found it + Popular in Tajikistan + Popular in + Description + Photogallery + Feedbacks + Leave feedback + All reviews + Unfold + Show less + Feedback + Click to rate: + Text + Upload photo + Send + Profile + USD + EUR + RUB + Personal information + Language + English + Dark theme + Light theme + Exit + Exit + Are you sure you want to get out? + Edit data + Phone number + Select a language + Try again + Couldn\'t reach the server, please check connection + No image + Tajikistan + Clear search field + Top 30 places + Sights + Restaurants + Hotels + Add to favorites + Show route + Passwords are not the same + Wrong email format + Saved + Download was successful + Review was successfully published + Review was successfully deleted + Delete review + Are you sure you wanna delete this? + Deleting… + Please, wait, data being downloaded + Empty + Review will be published when you are online + Review was successfully published + Failed to publish review\n + Please, don\'t go out of Tajikistan, it\'s Tajikistan app + Downloading images + Downloading images for offline usage + Downloading images + Images downloaded + Download failed + All images were downloaded successfully + Most images were downloaded + Error, not all images were downloaded + Enable Location + Please enable location services to use this feature diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 87667859a4..aea849a7d6 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -41,6 +41,10 @@ 10dp + + diff --git a/android/build.gradle b/android/build.gradle index af0515fbfe..93f8e067f7 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,5 +1,10 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { id 'com.android.application' version '8.4.1' apply false +// id 'com.google.gms.google-services' version '4.4.2' apply false +// id 'com.google.firebase.crashlytics' version '3.0.2' apply false id 'com.android.library' version '8.4.1' apply false + id 'org.jetbrains.kotlin.android' version '1.9.0' apply false + id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.24' apply false + id 'com.google.dagger.hilt.android' version '2.51.1' apply false } diff --git a/android/gradle.properties b/android/gradle.properties index 91d7895ad5..b74e61c6e6 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,15 +1,24 @@ +## For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx1024m -XX:MaxPermSize=256m +# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +# +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +#Wed Jun 12 17:11:31 TJT 2024 +android.native.buildOutput=verbose +android.nonFinalResIds=false +android.nonTransitiveRClass=true +android.useAndroidX=true +enableVulkanDiagnostics=OFF +org.gradle.caching=true +org.gradle.jvmargs=-Xmx4096M -Dkotlin.daemon.jvm.options\="-Xmx4096M" -Xms1024m +propCompileSdkVersion=34 propMinSdkVersion=21 propTargetSdkVersion=34 -propCompileSdkVersion=34 - -org.gradle.caching=true -org.gradle.jvmargs=-Xmx1024m -Xms256m -android.useAndroidX=true -android.native.buildOutput=verbose -android.nonTransitiveRClass=true -android.nonFinalResIds=false - -enableVulkanDiagnostics=OFF - -# Autogenerated by tools/unix/generate_localizations.sh supportedLocalizations=af,ar,az,be,bg,ca,cs,da,de,el,en,en_GB,es,es_MX,et,eu,fa,fi,fr,fr_CA,iw,hi,hu,in,it,ja,ko,lt,mr,nb,nl,pl,pt,pt_BR,ro,ru,sk,sv,sw,th,tr,uk,vi,zh,zh_HK,zh_MO,zh_TW diff --git a/docs/COMMUNICATION.md b/docs/COMMUNICATION.md deleted file mode 100644 index 3bd3658e2b..0000000000 --- a/docs/COMMUNICATION.md +++ /dev/null @@ -1,17 +0,0 @@ -# Communication - -## Telegram Channel - -Please subscribe to our [Telegram Channel](https://t.me/OrganicMapsApp) for updates. - -## Telegram Group - -Please join our [Telegram Group](https://t.me/OrganicMaps) to discuss with other users. - -## GitHub Discussions - -If you have some ideas or want to request a new feature, please [start a discussion thread](https://github.com/organicmaps/organicmaps/discussions/categories/ideas). - -## Code of Conduct - -The Organic Maps community abides by the [CNCF code of conduct](CODE_OF_CONDUCT.md). diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md deleted file mode 100644 index f60e91cbf6..0000000000 --- a/docs/CONTRIBUTING.md +++ /dev/null @@ -1,107 +0,0 @@ -# Contributing - -Thank you for your interest in contributing to Organic Maps (OM)! - -## How Can I Contribute? - -There are many ways to contribute and OM needs a variety of talents: software engineers, graphic designers, translators, UI/UX experts, marketing/PR, etc. - -### Donate - -See https://organicmaps.app/donate/ - -### Bug Reports - -The simplest way to contribute is to [submit an issue](https://github.com/organicmaps/organicmaps/issues). -Please check if there are no similar issues already submitted by someone else, -and give developers as much information as possible: OS and application versions, -list of actions leading to a bug, a log file produced by the app. - -When using Organic Maps app on a device, use the built-in "Report a bug" option: -on Android it creates a new e-mail with a log file attached. Your issue will be processed much -faster if you send it to . Enabling logs in Organic Maps settings on Android -before sending the bug report also helps us a lot with debugging. - -### Feature Requests - -If you have some ideas or want to request a new feature, please [start a discussion thread](https://github.com/organicmaps/organicmaps/discussions/categories/ideas). - -### Translations - -OM is available in 35 languages already, but some of them are incomplete and existing translations need regular updates as the app evolves. -See [translations instructions](TRANSLATIONS.md) for details. - -### Map styling and icons - -We strive to have a functional, cohesive and pleasant to the eye map rendering style. -There is always something to improve, adding new map features, fine tuning the colour palette etc. -And every time we add a new map feature/POI we need a good and free-to-use icon. - -See [styles and icons instructions](STYLES.md) for details. - -### Code Contributions - -Please follow instructions in [INSTALL.md](INSTALL.md) to set up your development environment -and check the [developer's guidelines](#developers-guidelines). -You will find a list of issues for new contributors [here](https://github.com/organicmaps/organicmaps/labels/Good%20first%20issue) to help you get started with simple tasks. If you want to focus on the most important issues, please check our [Milestones](https://github.com/organicmaps/organicmaps/milestones). - -**Please do not ask for permission to work on the issue or to assign an issue to you**. We do not assign issues to first-time contributors. Any such comment notifies our contributors and the development team, and creates unnecessary noise that distracts us from the work. Just make a PR - and it will be reviewed. - -Reading everything in the [docs folder](./) of the repository is recommended. - -## Submitting your changes - -All contributions to Organic Maps repository should be submitted via -[Github pull requests](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork) -and signed-off with the [Developers Certificate of Origin](#legal-requirements). - -Each pull request is reviewed by OM maintainers to ensure its quality. -Sometimes the review process even for smallest commits can be -very thorough. - -### Legal Requirements - -When contributing to this project, you must agree that you have authored 100% -of the content, that you have the necessary rights to the content and that -the content you contribute may be provided under the project license. - -To contribute you must assure that you have read and are following the rules -stated in the [Developers Certificate of Origin](DCO.md) (DCO). We have -borrowed this procedure from the Linux kernel project to improve tracking of -who did what, and for legal reasons. - -To sign-off a patch, just add a line in the commit message saying: - - Signed-off-by: Some Developer - -Git has a flag that can sign a commit for you. An example using it is: - - git commit -s -m 'An example commit message' - -Use your real name or on some rare cases a company email address, but we -disallow pseudonyms or anonymous contributions. - -## Code of Conduct - -The OM community abides by the [CNCF code of conduct](CODE_OF_CONDUCT.md). - -## Developer's Guidelines - -Please [learn how to use `git rebase`](https://git-scm.com/book/en/v2/Git-Branching-Rebasing) to amend your commits -and have a clean history for your changes/branches. -Do not close/recreate Pull Requests if you want to edit commits. Use `git rebase` and `git commit --amend`, -or any git tool with a graphical interface ([Fork for Mac](https://git-fork.com/) is quite good) to make clean, -logical commits, properly signed with [DCO](DCO.md). - -- [Directories structure](STRUCTURE.md) -- [C++ Style Guide](CPP_STYLE.md). -- [Java Style Guide](JAVA_STYLE.md). -- [Objective-C Style Guide](OBJC_STYLE.md). -- [Pull Request Guide](PR_GUIDE.md). -- [How to write a commit message](COMMIT_MESSAGES.md). - -## Questions? - -For any questions about developing OM and relevant services - -virtually about anything related, please [contact us](COMMUNICATION.md), -we'll be happy to help. diff --git a/docs/GOVERNANCE.md b/docs/GOVERNANCE.md deleted file mode 100644 index 144ecdab31..0000000000 --- a/docs/GOVERNANCE.md +++ /dev/null @@ -1,36 +0,0 @@ -# Governance - -Organic Maps Project (organicmaps.app) is an open-source project. - -## The Governing Board - -The focus of the Governing Board is to assist and guide in the progress and development of Organic Maps, as well as to lead and promote Organic Maps. - -The Governing Board is the governing body responsible for the overall oversight of the Organic Maps Project. The Board also has the responsibility to ensure the goals, brands, and marks of Organic Maps and community are protected. The Board serves as the final authority within the Organic Maps Project. - -## Governing Board Responsibilities - -- Guidance and leadership over the ultimate Project roadmap. -- Community outreach. -- Maintenance of health and viability of the community. -- Maintenance of a healthy and proactive relationship with the Project users and consider those needs and uses in decisions. -- Coordination of Project messaging. -- Overall Project leadership as the final escalation point for decisions. -- Trademark and brand oversight. -- Appointment of Board Chair. -- Appointment of new Board members. -- Re-appointment of Board members after 12 month term of service. - -## Chair Responsibilities - -- Organize and run the Board meetings. -- Be the coordinating and lead voice for the Project. -- Coordinate the Board to set direction and articulation thereof. -- Focus on helping the Board to reach consensus. -- Guide the Board in transparency and practicing the open source way in leadership and decision making. - -## Current Sitting Board - -- [Alexander Borsuk](https://github.com/biodranik) -- [Victor Govako](https://github.com/vng) -- [Roman Tsisyk](https://github.com/rtsisyk) diff --git a/docs/STYLES.md b/docs/STYLES.md index dd3c017ff9..ac7cfd6947 100644 --- a/docs/STYLES.md +++ b/docs/STYLES.md @@ -7,9 +7,6 @@ Here is the basic workflow to update styles: 4. Commit your edits and files changed by the script 5. Send a pull request! -Please prepend `[styles]` to your commit message and add [Developers Certificate of Origin](CONTRIBUTING.md#legal-requirements) to it. -Files changed by the script should be added as a separate `[styles] Regenerated` commit. - Please check [a list of current styling issues](https://github.com/organicmaps/organicmaps/issues?q=is%3Aopen+is%3Aissue+label%3AStyles) and ["icons wanted" issues](https://github.com/organicmaps/organicmaps/issues?q=is%3Aopen+is%3Aissue+label%3AIcons+label%3A%22Good+first+issue%22). diff --git a/docs/TRANSLATIONS.md b/docs/TRANSLATIONS.md index 0bcb6fa42c..2167d8e070 100644 --- a/docs/TRANSLATIONS.md +++ b/docs/TRANSLATIONS.md @@ -5,8 +5,6 @@ Adding and updating translations is easy! 2. Commit your changes 3. Send a pull request! -Please prepend `[strings]` to your commit message and add [Developers Certificate of Origin](CONTRIBUTING.md#legal-requirements) to it. - Then run a `tools/unix/generate_localizations.sh` script and add the changes as a separate `[strings] Regenerated` commit. But if you can't run it - don't worry, its not mandatory! diff --git a/docs/badges/apple-appstore.png b/docs/badges/apple-appstore.png deleted file mode 100644 index 31351fa584..0000000000 Binary files a/docs/badges/apple-appstore.png and /dev/null differ diff --git a/docs/badges/fdroid.png b/docs/badges/fdroid.png deleted file mode 100644 index c1fc8492a9..0000000000 Binary files a/docs/badges/fdroid.png and /dev/null differ diff --git a/docs/badges/flathub.png b/docs/badges/flathub.png deleted file mode 100644 index 33c46c5130..0000000000 Binary files a/docs/badges/flathub.png and /dev/null differ diff --git a/docs/badges/google-play.png b/docs/badges/google-play.png deleted file mode 100644 index fa94991363..0000000000 Binary files a/docs/badges/google-play.png and /dev/null differ diff --git a/docs/badges/huawei-appgallery.png b/docs/badges/huawei-appgallery.png deleted file mode 100644 index 0ed453fa36..0000000000 Binary files a/docs/badges/huawei-appgallery.png and /dev/null differ diff --git a/docs/sponsors/futo.svg b/docs/sponsors/futo.svg deleted file mode 100644 index f768547168..0000000000 --- a/docs/sponsors/futo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/sponsors/gsoc.svg b/docs/sponsors/gsoc.svg deleted file mode 100644 index c79cb8c250..0000000000 --- a/docs/sponsors/gsoc.svg +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - image/svg+xml - - logo_lockup_summer_of_code_horizontal_Roboto - - - - - logo_lockup_summer_of_code_horizontal_Roboto - Created with Sketch. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/sponsors/mythic-beasts.png b/docs/sponsors/mythic-beasts.png deleted file mode 100644 index c8eb882ce9..0000000000 Binary files a/docs/sponsors/mythic-beasts.png and /dev/null differ diff --git a/docs/sponsors/nlnet.svg b/docs/sponsors/nlnet.svg deleted file mode 100644 index f789653405..0000000000 --- a/docs/sponsors/nlnet.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/indexer/indexer_tests/jni/Application.mk b/indexer/indexer_tests/jni/Application.mk deleted file mode 120000 index 43a1529450..0000000000 --- a/indexer/indexer_tests/jni/Application.mk +++ /dev/null @@ -1 +0,0 @@ -../../../android/UnitTests/jni/Application.mk \ No newline at end of file diff --git a/iphone/Chart/Chart.xcodeproj/project.pbxproj b/iphone/Chart/Chart.xcodeproj/project.pbxproj index 1913b52e49..4c164811dc 100644 --- a/iphone/Chart/Chart.xcodeproj/project.pbxproj +++ b/iphone/Chart/Chart.xcodeproj/project.pbxproj @@ -351,6 +351,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.mapswithme.Chart; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; @@ -372,6 +373,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.mapswithme.Chart; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; diff --git a/iphone/CoreApi/CoreApi.xcodeproj/project.pbxproj b/iphone/CoreApi/CoreApi.xcodeproj/project.pbxproj index 41aa070566..adcfdcd321 100644 --- a/iphone/CoreApi/CoreApi.xcodeproj/project.pbxproj +++ b/iphone/CoreApi/CoreApi.xcodeproj/project.pbxproj @@ -637,6 +637,7 @@ "@loader_path/Frameworks", ); MACH_O_TYPE = staticlib; + MARKETING_VERSION = 1.0; MODULEMAP_FILE = CoreApi.modulemap; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -660,6 +661,7 @@ "@loader_path/Frameworks", ); MACH_O_TYPE = staticlib; + MARKETING_VERSION = 1.0; MODULEMAP_FILE = CoreApi.modulemap; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/iphone/DatePicker/DatePicker.xcodeproj/project.pbxproj b/iphone/DatePicker/DatePicker.xcodeproj/project.pbxproj index cdb8945cdc..ea40d86808 100644 --- a/iphone/DatePicker/DatePicker.xcodeproj/project.pbxproj +++ b/iphone/DatePicker/DatePicker.xcodeproj/project.pbxproj @@ -251,6 +251,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.mapswithme.DatePicker; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; @@ -271,6 +272,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.mapswithme.DatePicker; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; diff --git a/iphone/Maps/Bookmarks/BookmarksList/BookmarksListViewController.swift b/iphone/Maps/Bookmarks/BookmarksList/BookmarksListViewController.swift index 53c323eb24..e93bfb1796 100644 --- a/iphone/Maps/Bookmarks/BookmarksList/BookmarksListViewController.swift +++ b/iphone/Maps/Bookmarks/BookmarksList/BookmarksListViewController.swift @@ -224,10 +224,12 @@ extension BookmarksListViewController: IBookmarksListView { actionSheet.addAction(action) } actionSheet.addAction(UIAlertAction(title: L("cancel"), style: .cancel, handler: nil)) - let barButtonItem = switch source { - case .sort: sortToolbarItem - case .more: moreToolbarItem - } + let barButtonItem: UIBarButtonItem = { + switch source { + case .sort: return sortToolbarItem + case .more: return moreToolbarItem + } + }() actionSheet.popoverPresentationController?.barButtonItem = barButtonItem present(actionSheet, animated: true) } diff --git a/iphone/Maps/Bookmarks/BookmarksList/BookmarksListViewController.xib b/iphone/Maps/Bookmarks/BookmarksList/BookmarksListViewController.xib index ed2639b95d..be66c95e3d 100644 --- a/iphone/Maps/Bookmarks/BookmarksList/BookmarksListViewController.xib +++ b/iphone/Maps/Bookmarks/BookmarksList/BookmarksListViewController.xib @@ -1,14 +1,14 @@ - + - + - + @@ -23,7 +23,7 @@ - + diff --git a/iphone/Maps/Classes/BackButtonWithText.m b/iphone/Maps/Classes/BackButtonWithText.m new file mode 100644 index 0000000000..ba1218fb72 --- /dev/null +++ b/iphone/Maps/Classes/BackButtonWithText.m @@ -0,0 +1,51 @@ +#import + +@interface BackButtonWithText : UIView + +@property (nonatomic, strong) UIButton *backButton; +@property (nonatomic, strong) UILabel *backLabel; + +- (instancetype)initWithFrame:(CGRect)frame; +- (void)setBackButtonAction:(SEL)action target:(id)target; + +@end + +@implementation BackButtonWithText + +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + if (self) { + [self setupBackButton]; + [self setupBackLabel]; + } + return self; +} + +- (void)setupBackButton { + self.backButton = [UIButton buttonWithType:UIButtonTypeCustom]; + [self.backButton setImage:[UIImage imageNamed:@"back_arrow"] forState:UIControlStateNormal]; + self.backButton.frame = CGRectMake(0, 0, 44, 44); + [self addSubview:self.backButton]; +} + +- (void)setupBackLabel { + self.backLabel = [[UILabel alloc] initWithFrame:CGRectMake(44, 0, 100, 44)]; + self.backLabel.text = @"Back"; + self.backLabel.textColor = [UIColor blackColor]; + self.backLabel.userInteractionEnabled = YES; + + UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(labelTapped:)]; + [self.backLabel addGestureRecognizer:tapGesture]; + + [self addSubview:self.backLabel]; +} + +- (void)setBackButtonAction:(SEL)action target:(id)target { + [self.backButton addTarget:target action:action forControlEvents:UIControlEventTouchUpInside]; +} + +- (void)labelTapped:(UITapGestureRecognizer *)gesture { + [self.backButton sendActionsForControlEvents:UIControlEventTouchUpInside]; +} + +@end diff --git a/iphone/Maps/Classes/BackButtonWithText/BackButtonWithText.h b/iphone/Maps/Classes/BackButtonWithText/BackButtonWithText.h new file mode 100644 index 0000000000..a8616f5ee7 --- /dev/null +++ b/iphone/Maps/Classes/BackButtonWithText/BackButtonWithText.h @@ -0,0 +1,11 @@ +#import + +@interface BackButtonWithText : UIView + +@property (nonatomic, strong) UIButton *backButton; +@property (nonatomic, strong) UILabel *backLabel; + +- (instancetype)initWithFrame:(CGRect)frame; +- (void)setBackButtonAction:(SEL)action target:(id)target; + +@end diff --git a/iphone/Maps/Classes/BackButtonWithText/BackButtonWithText.m b/iphone/Maps/Classes/BackButtonWithText/BackButtonWithText.m new file mode 100644 index 0000000000..ba1218fb72 --- /dev/null +++ b/iphone/Maps/Classes/BackButtonWithText/BackButtonWithText.m @@ -0,0 +1,51 @@ +#import + +@interface BackButtonWithText : UIView + +@property (nonatomic, strong) UIButton *backButton; +@property (nonatomic, strong) UILabel *backLabel; + +- (instancetype)initWithFrame:(CGRect)frame; +- (void)setBackButtonAction:(SEL)action target:(id)target; + +@end + +@implementation BackButtonWithText + +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + if (self) { + [self setupBackButton]; + [self setupBackLabel]; + } + return self; +} + +- (void)setupBackButton { + self.backButton = [UIButton buttonWithType:UIButtonTypeCustom]; + [self.backButton setImage:[UIImage imageNamed:@"back_arrow"] forState:UIControlStateNormal]; + self.backButton.frame = CGRectMake(0, 0, 44, 44); + [self addSubview:self.backButton]; +} + +- (void)setupBackLabel { + self.backLabel = [[UILabel alloc] initWithFrame:CGRectMake(44, 0, 100, 44)]; + self.backLabel.text = @"Back"; + self.backLabel.textColor = [UIColor blackColor]; + self.backLabel.userInteractionEnabled = YES; + + UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(labelTapped:)]; + [self.backLabel addGestureRecognizer:tapGesture]; + + [self addSubview:self.backLabel]; +} + +- (void)setBackButtonAction:(SEL)action target:(id)target { + [self.backButton addTarget:target action:action forControlEvents:UIControlEventTouchUpInside]; +} + +- (void)labelTapped:(UITapGestureRecognizer *)gesture { + [self.backButton sendActionsForControlEvents:UIControlEventTouchUpInside]; +} + +@end diff --git a/iphone/Maps/Classes/CustomViews/MapViewControls/MWMMapViewControlsManager.mm b/iphone/Maps/Classes/CustomViews/MapViewControls/MWMMapViewControlsManager.mm index cb5147d275..65a0001ba0 100644 --- a/iphone/Maps/Classes/CustomViews/MapViewControls/MWMMapViewControlsManager.mm +++ b/iphone/Maps/Classes/CustomViews/MapViewControls/MWMMapViewControlsManager.mm @@ -315,11 +315,12 @@ NSString *const kMapToCategorySelectorSegue = @"MapToCategorySelectorSegue"; } - (void)setMenuState:(MWMBottomMenuState)menuState { + // we don't need BottomTabBarViewController, so we hide it _menuState = menuState; MapViewController * ownerController = _ownerController; switch (_menuState) { case MWMBottomMenuStateActive: - _tabBarController.isHidden = NO; + _tabBarController.isHidden = YES; if (_menuController == nil) { _menuController = [BottomMenuBuilder buildMenuWithMapViewController:ownerController controlsManager:self @@ -328,7 +329,7 @@ NSString *const kMapToCategorySelectorSegue = @"MapToCategorySelectorSegue"; } break; case MWMBottomMenuStateLayers: - _tabBarController.isHidden = NO; + _tabBarController.isHidden = YES; if (_menuController == nil) { _menuController = [BottomMenuBuilder buildLayersWithMapViewController:ownerController controlsManager:self @@ -337,7 +338,7 @@ NSString *const kMapToCategorySelectorSegue = @"MapToCategorySelectorSegue"; } break; case MWMBottomMenuStateInactive: - _tabBarController.isHidden = NO; + _tabBarController.isHidden = YES; if (_menuController != nil) { [_menuController dismissViewControllerAnimated:YES completion:nil]; _menuController = nil; diff --git a/iphone/Maps/Classes/CustomViews/MapViewControls/TrafficButton/MWMTrafficButtonViewController.mm b/iphone/Maps/Classes/CustomViews/MapViewControls/TrafficButton/MWMTrafficButtonViewController.mm index 007a7c9949..b230923551 100644 --- a/iphone/Maps/Classes/CustomViews/MapViewControls/TrafficButton/MWMTrafficButtonViewController.mm +++ b/iphone/Maps/Classes/CustomViews/MapViewControls/TrafficButton/MWMTrafficButtonViewController.mm @@ -153,24 +153,8 @@ NSArray *imagesWithName(NSString *name) { - (void)applyTheme { MWMButton *btn = static_cast(self.view); - UIImageView *iv = btn.imageView; - - // Traffic state machine: https://confluence.mail.ru/pages/viewpage.action?pageId=103680959 - [iv stopAnimating]; - if ([MWMMapOverlayManager trafficEnabled]) { - [self handleTrafficState:[MWMMapOverlayManager trafficState]]; - } else if ([MWMMapOverlayManager transitEnabled]) { - btn.imageName = @"btn_subway_on"; - if ([MWMMapOverlayManager transitState] == MWMMapOverlayTransitStateNoData) - [[MWMToast toastWithText:L(@"subway_data_unavailable")] show]; - } else if ([MWMMapOverlayManager isoLinesEnabled]) { - btn.imageName = @"btn_isoMap_on"; - [self handleIsolinesState:[MWMMapOverlayManager isolinesState]]; - } else if ([MWMMapOverlayManager outdoorEnabled]) { - btn.imageName = @"btn_isoMap_on"; - } else { - btn.imageName = @"btn_layers"; - } + // we don't need that + btn.hidden = true; } - (IBAction)buttonTouchUpInside { diff --git a/iphone/Maps/Classes/CustomViews/NavigationDashboard/MWMNavigationDashboardManager.mm b/iphone/Maps/Classes/CustomViews/NavigationDashboard/MWMNavigationDashboardManager.mm index 7ad7002306..4135dc289c 100644 --- a/iphone/Maps/Classes/CustomViews/NavigationDashboard/MWMNavigationDashboardManager.mm +++ b/iphone/Maps/Classes/CustomViews/NavigationDashboard/MWMNavigationDashboardManager.mm @@ -286,9 +286,10 @@ NSString *const kNavigationControlViewXibName = @"NavigationControlView"; } _state = state; [[MapViewController sharedController] updateStatusBarStyle]; + // we don't need BottomTabBarViewController, so we hide it // Restore bottom buttons only if they were not already hidden by tapping anywhere on an empty map. - if (!MWMMapViewControlsManager.manager.hidden) - BottomTabBarViewController.controller.isHidden = state != MWMNavigationDashboardStateHidden; +// if (!MWMMapViewControlsManager.manager.hidden) +// BottomTabBarViewController.controller.isHidden = state != MWMNavigationDashboardStateHidden; } @synthesize routePreview = _routePreview; diff --git a/iphone/Maps/Classes/CustomViews/NavigationDashboard/Views/MWMNavigationInfoView.xib b/iphone/Maps/Classes/CustomViews/NavigationDashboard/Views/MWMNavigationInfoView.xib index ded600ce1e..665854b7de 100644 --- a/iphone/Maps/Classes/CustomViews/NavigationDashboard/Views/MWMNavigationInfoView.xib +++ b/iphone/Maps/Classes/CustomViews/NavigationDashboard/Views/MWMNavigationInfoView.xib @@ -1,9 +1,9 @@ - + - + @@ -97,17 +97,17 @@ - @@ -371,25 +356,20 @@ - - - - - diff --git a/iphone/Maps/Classes/CustomViews/NavigationDashboard/Views/NavigationControlView.xib b/iphone/Maps/Classes/CustomViews/NavigationDashboard/Views/NavigationControlView.xib index b9537a088d..09e301273a 100644 --- a/iphone/Maps/Classes/CustomViews/NavigationDashboard/Views/NavigationControlView.xib +++ b/iphone/Maps/Classes/CustomViews/NavigationDashboard/Views/NavigationControlView.xib @@ -1,9 +1,9 @@ - - + + - + @@ -18,7 +18,7 @@ - + @@ -46,7 +46,7 @@ - + @@ -123,7 +123,7 @@ - diff --git a/iphone/Maps/Classes/MapViewController.h b/iphone/Maps/Classes/MapViewController.h index 3657a6e340..a560abe03d 100644 --- a/iphone/Maps/Classes/MapViewController.h +++ b/iphone/Maps/Classes/MapViewController.h @@ -34,6 +34,7 @@ - (void)setPlacePageTopBound:(CGFloat)bound duration:(double)duration; + (void)setViewport:(double)lat lon:(double)lon zoomLevel:(int)zoomlevel; ++ (void)setViewportToDushanbe; - (void)initialize; - (void)enableCarPlayRepresentation; diff --git a/iphone/Maps/Classes/MapViewController.mm b/iphone/Maps/Classes/MapViewController.mm index 5743e89b33..a41f8c0c4e 100644 --- a/iphone/Maps/Classes/MapViewController.mm +++ b/iphone/Maps/Classes/MapViewController.mm @@ -14,6 +14,9 @@ #import "MWMPlacePageProtocol.h" #import "MapsAppDelegate.h" #import "SwiftBridge.h" +#import "MWMRoutePoint+CPP.h" + +#import "BackButtonWithText.h" #import #import @@ -309,6 +312,8 @@ NSString *const kPP2BookmarkEditingSegue = @"PP2BookmarkEditing"; - (void)viewDidLoad { [super viewDidLoad]; + + [self showButtonToTourismMain]; self.title = L(@"map"); @@ -367,6 +372,9 @@ NSString *const kPP2BookmarkEditingSegue = @"PP2BookmarkEditing"; // Otherwise PP container view is nil, or there is no animation/selection of the point. if (DeepLinkHandler.shared.isLaunchedByDeeplink) (void)[DeepLinkHandler.shared handleDeepLinkAndReset]; + + [self handleNavigationOnMapResume]; + [self createRoute]; } - (void)viewDidLayoutSubviews { @@ -536,7 +544,20 @@ NSString *const kPP2BookmarkEditingSegue = @"PP2BookmarkEditing"; #pragma mark - MWMFrameworkDrapeObserver - (void)processViewportCountryEvent:(CountryId const &)countryId { - [self.downloadDialog processViewportCountryEvent:countryId]; + if (countryId == "Tajikistan") { + [self.downloadDialog processViewportCountryEvent:countryId]; + } else { + auto &f = GetFramework(); + + ms::LatLon viewportCenterLocation = mercator::ToLatLon(f.GetViewportCenter()); + BOOL isInBounds = isLocationInBounds(viewportCenterLocation, + ms::LatLon(41.196740, 66.949922), + ms::LatLon(36.483415, 75.400353)); + if (!isInBounds) { + [MapViewController setViewportToDushanbe]; + [[MWMToast toastWithText:L(@"plz_dont_go_out_of_tjk")] show]; + } + } } #pragma mark - Authorization @@ -740,4 +761,96 @@ NSString *const kPP2BookmarkEditingSegue = @"PP2BookmarkEditing"; } } +#pragma mark - Functions for Tourism purposes +- (void) createRoute { + TourismUserPreferences *prefs = [TourismUserPreferences shared]; + if (!prefs.isLocationEmpty) { + PlaceLocation *location = [prefs getLocation]; + + m2::PointD pointD = mercator::FromLatLon(ms::LatLon(location.lat, location.lon)); + auto point = [[MWMRoutePoint alloc] initWithPoint:pointD + title:location.name + subtitle:@"" + type:MWMRoutePointType::MWMRoutePointTypeFinish + intermediateIndex:0]; + + [MWMRouter buildToPoint:point bestRouter:NO]; + + // we clear location so next time, when we get back to map it doesn't create route again + [prefs clearLocation]; + } +} + +BOOL isLocationInBounds(ms::LatLon location, ms::LatLon topLeft, ms::LatLon bottomRight) { + return (location.m_lat <= topLeft.m_lat && + location.m_lat >= bottomRight.m_lat && + location.m_lon >= topLeft.m_lon && + location.m_lon <= bottomRight.m_lon); +} + ++ (void)setViewportToDushanbe { + [self setViewport: 38.5598 lon: 68.7870 zoomLevel: 10]; +} + +- (void)showButtonToTourismMain { + UIButton *homeButton = [UIButton buttonWithType:UIButtonTypeSystem]; + + [homeButton setTitle:NSLocalizedString(@"home", nil) forState:UIControlStateNormal]; + [homeButton setTitleColor:[UIColor blackColor] forState:UIControlStateNormal]; + homeButton.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.5]; + homeButton.layer.cornerRadius = 10.0; + homeButton.clipsToBounds = YES; + + homeButton.translatesAutoresizingMaskIntoConstraints = NO; + + [homeButton addTarget:self action:@selector(goToTourismMain) forControlEvents:UIControlEventTouchUpInside]; + + [self.controlsView addSubview:homeButton]; + + UIImage *homeIcon = [UIImage systemImageNamed:@"house.fill"]; + homeButton.tintColor = [UIColor blackColor]; + [homeButton setImage:homeIcon forState:UIControlStateNormal]; + + homeButton.semanticContentAttribute = UISemanticContentAttributeForceLeftToRight; + homeButton.imageEdgeInsets = UIEdgeInsetsMake(0, -4, 0, 0); + homeButton.contentEdgeInsets = UIEdgeInsetsMake(12, 8, 12, 8); + + [NSLayoutConstraint activateConstraints:@[ + [homeButton.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-16], + [homeButton.topAnchor constraintEqualToAnchor:self.view.topAnchor constant:120], + [homeButton.heightAnchor constraintEqualToConstant:50] + ]]; + +} + +- (void)handleNavigationOnMapResume { + /* + The thing is that we present those screens modally, + so we need some way to understand when we should go anywhere. + For example, in auth screen when user is signed in we dismiss it + and want mapViewController to know that is should go to TourismMain + unfortunately couldn't find any other maintainable way + */ + + TourismUserPreferences *prefs = [TourismUserPreferences shared]; + BOOL shouldGoToTourismMain = [prefs getShouldGoToTourismMain]; + BOOL shouldGoToAuth = [prefs getShouldGoToAuth]; + + if(shouldGoToTourismMain) { + [prefs setShouldGoToTourismMainWithValue:NO]; + [self goToTourismMain]; + } + if(shouldGoToAuth) { + [prefs setShouldGoToAuthWithValue:NO]; + [self goToAuth]; + } +} + +- (void)goToAuth { + [[MapViewController sharedController]performSegueWithIdentifier:@"Map2Auth" sender:nil]; +} + +- (void)goToTourismMain { + [[MapViewController sharedController]performSegueWithIdentifier:@"Map2TourismMain" sender:nil]; +} @end diff --git a/iphone/Maps/Classes/MapsAppDelegate.mm b/iphone/Maps/Classes/MapsAppDelegate.mm index 344d3916e5..ff3bfa8f17 100644 --- a/iphone/Maps/Classes/MapsAppDelegate.mm +++ b/iphone/Maps/Classes/MapsAppDelegate.mm @@ -31,6 +31,8 @@ #include "base/assert.hpp" +//#include "storage/storage.hpp" + #include "private.h" // If you have a "missing header error" here, then please run configure.sh script in the root repo // folder. @@ -107,6 +109,8 @@ using namespace osm_auth_ios; - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { NSLog(@"application:didFinishLaunchingWithOptions: %@", launchOptions); + [self setTajikistanMap]; + [HttpThreadImpl setDownloadIndicatorProtocol:self]; InitLocalizedStrings(); @@ -126,6 +130,12 @@ using namespace osm_auth_ios; [[DeepLinkHandler shared] applicationDidFinishLaunching:launchOptions]; // application:openUrl:options is called later for deep links if YES is returned. + + dispatch_async(dispatch_get_main_queue(), ^{ + [self handleNavigationForAppStartup]; + [self moveToDushanbeIfNotInTjk]; + }); + return YES; } @@ -209,8 +219,8 @@ using namespace osm_auth_ios; - (void)applicationDidBecomeActive:(UIApplication *)application { LOG(LINFO, ("applicationDidBecomeActive - begin")); - auto & f = GetFramework(); + f.EnterForeground(); [self.mapViewController onGetFocus:YES]; f.SetRenderingEnabled(); @@ -228,6 +238,52 @@ using namespace osm_auth_ios; LOG(LINFO, ("applicationDidBecomeActive - end")); } + +// MARK: Functions for Tourism purposes +- (void) handleNavigationForAppStartup { + auto & f = GetFramework(); + + // We don't want map to be our default entry point, that's why we go to places list or auth if there's no token) + if(f.IsCountryLoadedByName("Tajikistan")) { + TourismUserPreferences *prefs = [TourismUserPreferences shared]; + if ([prefs getToken] == nil) { // go to Auth (note: token is cleared when user signs out) + [[MapViewController sharedController]performSegueWithIdentifier:@"Map2Auth" sender:nil]; + } else { // go to TourismMain (Home) + [[MapViewController sharedController]performSegueWithIdentifier:@"Map2TourismMain" sender:nil]; + } + } +} + +- (void) moveToDushanbeIfNotInTjk { + auto & f = GetFramework(); + ms::LatLon viewportCenterLocation = mercator::ToLatLon(f.GetViewportCenter()); + BOOL isInBounds = isLocationInBounds1(viewportCenterLocation, + ms::LatLon(41.196740, 66.949922), + ms::LatLon(36.483415, 75.400353)); + if (!isInBounds) [MapViewController setViewportToDushanbe]; +} + +- (void) setTajikistanMap { + BOOL fileExists = [FileManagerHelper fileExistsInDocumentsWithSubdirectory:@"240429" + fileName:@"Tajikistan" + fileExtension:@"mwm"]; + + if (!fileExists) { + [FileManagerHelper copyProjectFileToDocumentsWithFileName:@"Tajikistan" + fileExtension:@"mwm" + toSubdirectory:@"240429"]; + + GetFramework().LoadMapsSync(); + } +} + +BOOL isLocationInBounds1(ms::LatLon location, ms::LatLon topLeft, ms::LatLon bottomRight) { + return (location.m_lat <= topLeft.m_lat && + location.m_lat >= bottomRight.m_lat && + location.m_lon >= topLeft.m_lon && + location.m_lon <= bottomRight.m_lon); +} + // TODO: Drape enabling and iCloud sync are skipped during the test run due to the app crashing in teardown. This is a temporary solution. Drape should be properly disabled instead of merely skipping the enabling process. + (BOOL)isTestsEnvironment { NSProcessInfo * processInfo = [NSProcessInfo processInfo]; diff --git a/iphone/Maps/Classes/Widgets/MWMMapDownloadDialog.mm b/iphone/Maps/Classes/Widgets/MWMMapDownloadDialog.mm index 7900f6ce9c..66ea458e25 100644 --- a/iphone/Maps/Classes/Widgets/MWMMapDownloadDialog.mm +++ b/iphone/Maps/Classes/Widgets/MWMMapDownloadDialog.mm @@ -80,7 +80,7 @@ using namespace storage; self.parentNode.text = @(nodeAttrs.m_topmostParentInfo[0].m_localName.c_str()); self.parentNode.textColor = [UIColor blackSecondaryText]; } - self.node.text = @(nodeAttrs.m_nodeLocalName.c_str()); + self.node.text = L(@"wait_tjk_map_downloading"); self.node.textColor = [UIColor blackPrimaryText]; self.nodeSize.hidden = NO; self.nodeSize.textColor = [UIColor blackSecondaryText]; @@ -144,6 +144,10 @@ using namespace storage; // Center dialog in the parent view. [self.centerXAnchor constraintEqualToAnchor:controller.view.centerXAnchor].active = YES; [self.centerYAnchor constraintEqualToAnchor:controller.view.centerYAnchor].active = YES; + [self.topAnchor constraintEqualToAnchor:controller.view.topAnchor].active = YES; + [self.bottomAnchor constraintEqualToAnchor:controller.view.bottomAnchor].active = YES; + [self.leftAnchor constraintEqualToAnchor:controller.view.leftAnchor].active = YES; + [self.rightAnchor constraintEqualToAnchor:controller.view.rightAnchor].active = YES; } @@ -233,8 +237,13 @@ using namespace storage; - (void)processCountry:(NSString *)countryId downloadedBytes:(uint64_t)downloadedBytes totalBytes:(uint64_t)totalBytes { - if (self.superview && m_countryId == countryId.UTF8String) + if (self.superview && m_countryId == countryId.UTF8String) { [self showDownloading:(CGFloat)downloadedBytes / totalBytes]; + } + + if(downloadedBytes == totalBytes) { + [[MapViewController sharedController]performSegueWithIdentifier:@"Map2Auth" sender:nil]; + } } #pragma mark - MWMCircularProgressDelegate @@ -244,9 +253,10 @@ using namespace storage; [self showInQueue]; [[MWMStorage sharedStorage] retryDownloadNode:@(m_countryId.c_str())]; } else { - if (m_autoDownloadCountryId == m_countryId) - self.isAutoDownloadCancelled = YES; - [[MWMStorage sharedStorage] cancelDownloadNode:@(m_countryId.c_str())]; + // we're forcing the user to download Tajikistan map, so we remove cancel button +// if (m_autoDownloadCountryId == m_countryId) +// self.isAutoDownloadCancelled = YES; +// [[MWMStorage sharedStorage] cancelDownloadNode:@(m_countryId.c_str())]; } } diff --git a/iphone/Maps/Common/Common.swift b/iphone/Maps/Common/Common.swift index 7c04fa4907..9416cc72f1 100644 --- a/iphone/Maps/Common/Common.swift +++ b/iphone/Maps/Common/Common.swift @@ -24,8 +24,9 @@ func toString(_ cls: AnyClass) -> String { } func statusBarHeight() -> CGFloat { - let statusBarSize = UIApplication.shared.statusBarFrame.size - return min(statusBarSize.height, statusBarSize.width) + return UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first?.statusBarManager?.statusBarFrame.height ?? 0 } private let enableLoggingInRelease = true diff --git a/iphone/Maps/Core/Theme/Renderers/UINavigationBarRenderer.swift b/iphone/Maps/Core/Theme/Renderers/UINavigationBarRenderer.swift index 67fb830589..94c3894f7b 100644 --- a/iphone/Maps/Core/Theme/Renderers/UINavigationBarRenderer.swift +++ b/iphone/Maps/Core/Theme/Renderers/UINavigationBarRenderer.swift @@ -14,38 +14,7 @@ extension UINavigationBar { class UINavigationBarRenderer: UIViewRenderer { class func render(_ control: UINavigationBar, style: Style) { super.render(control, style: style) - if let barTintColor = style.barTintColor { - if #available(iOS 13.0, *) { - let appearance = UINavigationBarAppearance() - appearance.configureWithOpaqueBackground() - appearance.backgroundColor = barTintColor - control.standardAppearance = appearance - control.scrollEdgeAppearance = appearance - } else { - control.barTintColor = barTintColor - } - } - if let shadowImage = style.shadowImage { - if #available(iOS 13.0, *) { - control.standardAppearance.shadowImage = shadowImage - control.scrollEdgeAppearance!.shadowImage = shadowImage - } else { - control.shadowImage = shadowImage - } - } - - var attributes = [NSAttributedString.Key: Any]() - if let font = style.font { - attributes[NSAttributedString.Key.font] = font - } - if let fontColor = style.fontColor { - attributes[NSAttributedString.Key.foregroundColor] = fontColor - } - if #available(iOS 13.0, *) { - control.standardAppearance.titleTextAttributes = attributes - control.scrollEdgeAppearance!.titleTextAttributes = attributes - } else { - control.titleTextAttributes = attributes - } + // there were some codes here that were forcing to show navigation bar on other storyboards + control.isHidden = true } } diff --git a/iphone/Maps/Images.xcassets/AppIcon.appiconset/100.png b/iphone/Maps/Images.xcassets/AppIcon.appiconset/100.png deleted file mode 100644 index fe1e61be5e..0000000000 Binary files a/iphone/Maps/Images.xcassets/AppIcon.appiconset/100.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/AppIcon.appiconset/1024.png b/iphone/Maps/Images.xcassets/AppIcon.appiconset/1024.png index d32f5cb3c5..736069b30c 100644 Binary files a/iphone/Maps/Images.xcassets/AppIcon.appiconset/1024.png and b/iphone/Maps/Images.xcassets/AppIcon.appiconset/1024.png differ diff --git a/iphone/Maps/Images.xcassets/AppIcon.appiconset/114.png b/iphone/Maps/Images.xcassets/AppIcon.appiconset/114.png deleted file mode 100644 index 186e44006f..0000000000 Binary files a/iphone/Maps/Images.xcassets/AppIcon.appiconset/114.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/AppIcon.appiconset/120.png b/iphone/Maps/Images.xcassets/AppIcon.appiconset/120.png deleted file mode 100644 index b703bccc5a..0000000000 Binary files a/iphone/Maps/Images.xcassets/AppIcon.appiconset/120.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/AppIcon.appiconset/144.png b/iphone/Maps/Images.xcassets/AppIcon.appiconset/144.png deleted file mode 100644 index 0f848a8d52..0000000000 Binary files a/iphone/Maps/Images.xcassets/AppIcon.appiconset/144.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/AppIcon.appiconset/152.png b/iphone/Maps/Images.xcassets/AppIcon.appiconset/152.png deleted file mode 100644 index be9360e158..0000000000 Binary files a/iphone/Maps/Images.xcassets/AppIcon.appiconset/152.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/AppIcon.appiconset/167.png b/iphone/Maps/Images.xcassets/AppIcon.appiconset/167.png deleted file mode 100644 index a09436d571..0000000000 Binary files a/iphone/Maps/Images.xcassets/AppIcon.appiconset/167.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/AppIcon.appiconset/180.png b/iphone/Maps/Images.xcassets/AppIcon.appiconset/180.png deleted file mode 100644 index 91eab76448..0000000000 Binary files a/iphone/Maps/Images.xcassets/AppIcon.appiconset/180.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/AppIcon.appiconset/20.png b/iphone/Maps/Images.xcassets/AppIcon.appiconset/20.png deleted file mode 100644 index c7828389fd..0000000000 Binary files a/iphone/Maps/Images.xcassets/AppIcon.appiconset/20.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/AppIcon.appiconset/29.png b/iphone/Maps/Images.xcassets/AppIcon.appiconset/29.png deleted file mode 100644 index ad75246a1b..0000000000 Binary files a/iphone/Maps/Images.xcassets/AppIcon.appiconset/29.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/AppIcon.appiconset/40.png b/iphone/Maps/Images.xcassets/AppIcon.appiconset/40.png deleted file mode 100644 index 1bcd6ed242..0000000000 Binary files a/iphone/Maps/Images.xcassets/AppIcon.appiconset/40.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/AppIcon.appiconset/50.png b/iphone/Maps/Images.xcassets/AppIcon.appiconset/50.png deleted file mode 100644 index 848b0a8d40..0000000000 Binary files a/iphone/Maps/Images.xcassets/AppIcon.appiconset/50.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/AppIcon.appiconset/57.png b/iphone/Maps/Images.xcassets/AppIcon.appiconset/57.png deleted file mode 100644 index 39fd8c7053..0000000000 Binary files a/iphone/Maps/Images.xcassets/AppIcon.appiconset/57.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/AppIcon.appiconset/58.png b/iphone/Maps/Images.xcassets/AppIcon.appiconset/58.png deleted file mode 100644 index 5a0cb45f14..0000000000 Binary files a/iphone/Maps/Images.xcassets/AppIcon.appiconset/58.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/AppIcon.appiconset/60.png b/iphone/Maps/Images.xcassets/AppIcon.appiconset/60.png deleted file mode 100644 index 04c34d1fdd..0000000000 Binary files a/iphone/Maps/Images.xcassets/AppIcon.appiconset/60.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/AppIcon.appiconset/72.png b/iphone/Maps/Images.xcassets/AppIcon.appiconset/72.png deleted file mode 100644 index c3183a824c..0000000000 Binary files a/iphone/Maps/Images.xcassets/AppIcon.appiconset/72.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/AppIcon.appiconset/76.png b/iphone/Maps/Images.xcassets/AppIcon.appiconset/76.png deleted file mode 100644 index 9fa41ac0a2..0000000000 Binary files a/iphone/Maps/Images.xcassets/AppIcon.appiconset/76.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/AppIcon.appiconset/80.png b/iphone/Maps/Images.xcassets/AppIcon.appiconset/80.png deleted file mode 100644 index ab2bd7f8f1..0000000000 Binary files a/iphone/Maps/Images.xcassets/AppIcon.appiconset/80.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/AppIcon.appiconset/87.png b/iphone/Maps/Images.xcassets/AppIcon.appiconset/87.png deleted file mode 100644 index f01bfa42f7..0000000000 Binary files a/iphone/Maps/Images.xcassets/AppIcon.appiconset/87.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/AppIcon.appiconset/Contents.json b/iphone/Maps/Images.xcassets/AppIcon.appiconset/Contents.json index 65b74d7ef1..cff1680b35 100644 --- a/iphone/Maps/Images.xcassets/AppIcon.appiconset/Contents.json +++ b/iphone/Maps/Images.xcassets/AppIcon.appiconset/Contents.json @@ -1 +1,14 @@ -{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"72x72","expected-size":"72","filename":"72.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"76x76","expected-size":"152","filename":"152.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"50x50","expected-size":"100","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"76x76","expected-size":"76","filename":"76.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"50x50","expected-size":"50","filename":"50.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"72x72","expected-size":"144","filename":"144.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"40x40","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"83.5x83.5","expected-size":"167","filename":"167.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"20x20","expected-size":"20","filename":"20.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"}]} \ No newline at end of file +{ + "images" : [ + { + "filename" : "1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iphone/Maps/Images.xcassets/AppIconDebug.appiconset/1024.png b/iphone/Maps/Images.xcassets/AppIconDebug.appiconset/1024.png deleted file mode 100644 index 09777dda6c..0000000000 Binary files a/iphone/Maps/Images.xcassets/AppIconDebug.appiconset/1024.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/AppIconDebug.appiconset/Contents.json b/iphone/Maps/Images.xcassets/AppIconDebug.appiconset/Contents.json index cff1680b35..efb128b76a 100644 --- a/iphone/Maps/Images.xcassets/AppIconDebug.appiconset/Contents.json +++ b/iphone/Maps/Images.xcassets/AppIconDebug.appiconset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "1024.png", + "filename" : "logo 1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/iphone/Maps/Images.xcassets/AppIconDebug.appiconset/logo 1024.png b/iphone/Maps/Images.xcassets/AppIconDebug.appiconset/logo 1024.png new file mode 100644 index 0000000000..1ec46af3e5 Binary files /dev/null and b/iphone/Maps/Images.xcassets/AppIconDebug.appiconset/logo 1024.png differ diff --git a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/1024x768-1.png b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/1024x768-1.png deleted file mode 100644 index 88e840ab3f..0000000000 Binary files a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/1024x768-1.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/1024x768.png b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/1024x768.png deleted file mode 100644 index 88e840ab3f..0000000000 Binary files a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/1024x768.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/1242x2208.png b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/1242x2208.png deleted file mode 100644 index 5427a83a54..0000000000 Binary files a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/1242x2208.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/1536x2048-1.png b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/1536x2048-1.png deleted file mode 100644 index 053d120dfd..0000000000 Binary files a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/1536x2048-1.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/1536x2048.png b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/1536x2048.png deleted file mode 100644 index 053d120dfd..0000000000 Binary files a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/1536x2048.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/2048x1536-1.png b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/2048x1536-1.png deleted file mode 100644 index ce818a7482..0000000000 Binary files a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/2048x1536-1.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/2048x1536.png b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/2048x1536.png deleted file mode 100644 index ce818a7482..0000000000 Binary files a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/2048x1536.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/2208x1242.png b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/2208x1242.png deleted file mode 100644 index 1ddf301d09..0000000000 Binary files a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/2208x1242.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/320x480.png b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/320x480.png deleted file mode 100644 index dab3af9e16..0000000000 Binary files a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/320x480.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/640x1136-1.png b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/640x1136-1.png deleted file mode 100644 index bb126fd662..0000000000 Binary files a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/640x1136-1.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/640x1136.png b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/640x1136.png deleted file mode 100644 index bb126fd662..0000000000 Binary files a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/640x1136.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/640x960-1.png b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/640x960-1.png deleted file mode 100644 index 8e11bdfa40..0000000000 Binary files a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/640x960-1.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/640x960.png b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/640x960.png deleted file mode 100644 index 8e11bdfa40..0000000000 Binary files a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/640x960.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/750x1334.png b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/750x1334.png deleted file mode 100644 index 523af45654..0000000000 Binary files a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/750x1334.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/768x1024-1.png b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/768x1024-1.png deleted file mode 100644 index c43dd4adf1..0000000000 Binary files a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/768x1024-1.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/768x1024.png b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/768x1024.png deleted file mode 100644 index c43dd4adf1..0000000000 Binary files a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/768x1024.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/Contents.json b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/Contents.json index 26e3cae6c3..d7167a5dd8 100644 --- a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/Contents.json +++ b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/Contents.json @@ -1,134 +1,59 @@ { "images" : [ + { + "extent" : "full-screen", + "filename" : "New Project.png", + "idiom" : "iphone", + "minimum-system-version" : "12.0", + "orientation" : "portrait", + "scale" : "3x", + "subtype" : "2688h" + }, + { + "extent" : "full-screen", + "filename" : "New Project (1).png", + "idiom" : "iphone", + "minimum-system-version" : "12.0", + "orientation" : "portrait", + "scale" : "2x", + "subtype" : "1792h" + }, + { + "extent" : "full-screen", + "filename" : "New Project (2).png", + "idiom" : "iphone", + "minimum-system-version" : "11.0", + "orientation" : "portrait", + "scale" : "3x", + "subtype" : "2436h" + }, { "extent" : "full-screen", "idiom" : "iphone", - "subtype" : "736h", - "filename" : "1242x2208.png", "minimum-system-version" : "8.0", "orientation" : "portrait", - "scale" : "3x" + "scale" : "3x", + "subtype" : "736h" }, { "extent" : "full-screen", "idiom" : "iphone", - "subtype" : "736h", - "filename" : "2208x1242.png", "minimum-system-version" : "8.0", "orientation" : "landscape", - "scale" : "3x" + "scale" : "3x", + "subtype" : "736h" }, { "extent" : "full-screen", "idiom" : "iphone", - "subtype" : "667h", - "filename" : "750x1334.png", "minimum-system-version" : "8.0", "orientation" : "portrait", - "scale" : "2x" - }, - { - "orientation" : "portrait", - "idiom" : "iphone", - "extent" : "full-screen", - "minimum-system-version" : "7.0", - "filename" : "640x960-1.png", - "scale" : "2x" - }, - { - "extent" : "full-screen", - "idiom" : "iphone", - "subtype" : "retina4", - "filename" : "640x1136.png", - "minimum-system-version" : "7.0", - "orientation" : "portrait", - "scale" : "2x" - }, - { - "orientation" : "portrait", - "idiom" : "ipad", - "extent" : "full-screen", - "minimum-system-version" : "7.0", - "filename" : "768x1024-1.png", - "scale" : "1x" - }, - { - "orientation" : "landscape", - "idiom" : "ipad", - "extent" : "full-screen", - "minimum-system-version" : "7.0", - "filename" : "1024x768.png", - "scale" : "1x" - }, - { - "orientation" : "portrait", - "idiom" : "ipad", - "extent" : "full-screen", - "minimum-system-version" : "7.0", - "filename" : "1536x2048-1.png", - "scale" : "2x" - }, - { - "orientation" : "landscape", - "idiom" : "ipad", - "extent" : "full-screen", - "minimum-system-version" : "7.0", - "filename" : "2048x1536.png", - "scale" : "2x" - }, - { - "orientation" : "portrait", - "idiom" : "iphone", - "extent" : "full-screen", - "filename" : "320x480.png", - "scale" : "1x" - }, - { - "orientation" : "portrait", - "idiom" : "iphone", - "extent" : "full-screen", - "filename" : "640x960.png", - "scale" : "2x" - }, - { - "orientation" : "portrait", - "idiom" : "iphone", - "extent" : "full-screen", - "filename" : "640x1136-1.png", - "subtype" : "retina4", - "scale" : "2x" - }, - { - "orientation" : "portrait", - "idiom" : "ipad", - "extent" : "full-screen", - "filename" : "768x1024.png", - "scale" : "1x" - }, - { - "orientation" : "landscape", - "idiom" : "ipad", - "extent" : "full-screen", - "filename" : "1024x768-1.png", - "scale" : "1x" - }, - { - "orientation" : "portrait", - "idiom" : "ipad", - "extent" : "full-screen", - "filename" : "1536x2048.png", - "scale" : "2x" - }, - { - "orientation" : "landscape", - "idiom" : "ipad", - "extent" : "full-screen", - "filename" : "2048x1536-1.png", - "scale" : "2x" + "scale" : "2x", + "subtype" : "667h" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/New Project (1).png b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/New Project (1).png new file mode 100644 index 0000000000..5304c50cc5 Binary files /dev/null and b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/New Project (1).png differ diff --git a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/New Project (2).png b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/New Project (2).png new file mode 100644 index 0000000000..d71bf99c8a Binary files /dev/null and b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/New Project (2).png differ diff --git a/iphone/Maps/Images.xcassets/LaunchImage.launchimage/New Project.png b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/New Project.png new file mode 100644 index 0000000000..2fc718ef6d Binary files /dev/null and b/iphone/Maps/Images.xcassets/LaunchImage.launchimage/New Project.png differ diff --git a/iphone/Maps/Images.xcassets/Settings/imgLogo.imageset/Contents.json b/iphone/Maps/Images.xcassets/Settings/imgLogo.imageset/Contents.json index 97804fb11e..4e12055ad8 100644 --- a/iphone/Maps/Images.xcassets/Settings/imgLogo.imageset/Contents.json +++ b/iphone/Maps/Images.xcassets/Settings/imgLogo.imageset/Contents.json @@ -1,23 +1,23 @@ { "images" : [ { + "filename" : "logo.png", "idiom" : "universal", - "filename" : "imgLogo.png", "scale" : "1x" }, { + "filename" : "logo 1.png", "idiom" : "universal", - "filename" : "imgLogo@2x.png", "scale" : "2x" }, { + "filename" : "logo 2.png", "idiom" : "universal", - "filename" : "imgLogo@3x.png", "scale" : "3x" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/iphone/Maps/Images.xcassets/Settings/imgLogo.imageset/imgLogo.png b/iphone/Maps/Images.xcassets/Settings/imgLogo.imageset/imgLogo.png deleted file mode 100644 index fcbd5a435c..0000000000 Binary files a/iphone/Maps/Images.xcassets/Settings/imgLogo.imageset/imgLogo.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/Settings/imgLogo.imageset/imgLogo@2x.png b/iphone/Maps/Images.xcassets/Settings/imgLogo.imageset/imgLogo@2x.png deleted file mode 100644 index 58cc04111c..0000000000 Binary files a/iphone/Maps/Images.xcassets/Settings/imgLogo.imageset/imgLogo@2x.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/Settings/imgLogo.imageset/imgLogo@3x.png b/iphone/Maps/Images.xcassets/Settings/imgLogo.imageset/imgLogo@3x.png deleted file mode 100644 index 72d26cddd8..0000000000 Binary files a/iphone/Maps/Images.xcassets/Settings/imgLogo.imageset/imgLogo@3x.png and /dev/null differ diff --git a/iphone/Maps/Images.xcassets/Settings/imgLogo.imageset/logo 1.png b/iphone/Maps/Images.xcassets/Settings/imgLogo.imageset/logo 1.png new file mode 100644 index 0000000000..6c30ed64c3 Binary files /dev/null and b/iphone/Maps/Images.xcassets/Settings/imgLogo.imageset/logo 1.png differ diff --git a/iphone/Maps/Images.xcassets/Settings/imgLogo.imageset/logo 2.png b/iphone/Maps/Images.xcassets/Settings/imgLogo.imageset/logo 2.png new file mode 100644 index 0000000000..6c30ed64c3 Binary files /dev/null and b/iphone/Maps/Images.xcassets/Settings/imgLogo.imageset/logo 2.png differ diff --git a/iphone/Maps/Images.xcassets/Settings/imgLogo.imageset/logo.png b/iphone/Maps/Images.xcassets/Settings/imgLogo.imageset/logo.png new file mode 100644 index 0000000000..6c30ed64c3 Binary files /dev/null and b/iphone/Maps/Images.xcassets/Settings/imgLogo.imageset/logo.png differ diff --git a/iphone/Maps/Images.xcassets/SplashScreen.imageset/Contents.json b/iphone/Maps/Images.xcassets/SplashScreen.imageset/Contents.json new file mode 100644 index 0000000000..bdefa4af19 --- /dev/null +++ b/iphone/Maps/Images.xcassets/SplashScreen.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "SplashScreen.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iphone/Maps/Images.xcassets/SplashScreen.imageset/SplashScreen.png b/iphone/Maps/Images.xcassets/SplashScreen.imageset/SplashScreen.png new file mode 100644 index 0000000000..c06c4cf5e8 Binary files /dev/null and b/iphone/Maps/Images.xcassets/SplashScreen.imageset/SplashScreen.png differ diff --git a/iphone/Maps/Images.xcassets/logo.imageset/Contents.json b/iphone/Maps/Images.xcassets/logo.imageset/Contents.json index ad42a58df2..7c58c478f6 100644 --- a/iphone/Maps/Images.xcassets/logo.imageset/Contents.json +++ b/iphone/Maps/Images.xcassets/logo.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "logo.svg", + "filename" : "logo.png", "idiom" : "universal" } ], diff --git a/iphone/Maps/Images.xcassets/logo.imageset/logo.png b/iphone/Maps/Images.xcassets/logo.imageset/logo.png new file mode 100644 index 0000000000..6c30ed64c3 Binary files /dev/null and b/iphone/Maps/Images.xcassets/logo.imageset/logo.png differ diff --git a/iphone/Maps/Images.xcassets/logo.imageset/logo.svg b/iphone/Maps/Images.xcassets/logo.imageset/logo.svg deleted file mode 100644 index 14f52eba26..0000000000 --- a/iphone/Maps/Images.xcassets/logo.imageset/logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/iphone/Maps/LocalizedStrings/en-GB.lproj/Localizable.strings b/iphone/Maps/LocalizedStrings/en-GB.lproj/Localizable.strings index 262ec6cfad..97e8ba7dbf 100644 --- a/iphone/Maps/LocalizedStrings/en-GB.lproj/Localizable.strings +++ b/iphone/Maps/LocalizedStrings/en-GB.lproj/Localizable.strings @@ -3937,3 +3937,181 @@ "type.shop.auction" = "Auction"; "type.shop.collector" = "Collectables"; + + +// MARK: Tourism + +"organization_name" = "Committee for Tourism Development under the Government of the Republic of Tajikistan "; + +"developed_by_label" = "Developed by Rebus LLC"; + +"smth_went_wrong" = "Error"; + +"error" = "Error"; + +"server_error" = "Server error, try later"; + +"cache_error" = "Cache error, try to clean cache"; + +"wait_tjk_map_downloading" = "Please wait, the map of Tajikistan is loading"; + +"welcome_to_tjk" = "Welcome to Tajikistan"; + +"sign_in" = "Log in"; + +"sign_up" = "Registration"; + +"sign_in_title" = "Entry"; + +"sign_up_title" = "Registration"; + +"signed_in_successfully" = "Successfully signed in"; + +"username" = "Login"; + +"full_name" = "Full name"; + +"country" = "Country"; + +"tourism_email" = "Email"; + +"tourism_password" = "Password"; + +"confirm_password" = "Repeat the password"; + +"tourism_forgot_password" = "Forgot password?"; + +"send_email_for_password_reset" = "Send your email, so you receive a link for password reset"; + +"we_sent_you_password_reset_email" = "We sent you email for password reset"; + +"home" = "Home"; + +"favorites" = "Favorites"; + +"account" = "Account"; + +"tourism_profile" = "Profile"; + +"change" = "Edit"; + +"found" = "Found it"; + +"popular_in_tjk" = "Popular in Tajikistan"; + +"popular_in" = "Popular in"; + +"description_tourism" = "Description"; + +"gallery" = "Photogallery"; + +"reviews" = "Feedbacks"; + +"compose_review" = "Leave feedback"; + +"see_all_reviews" = "All reviews"; + +"more" = "Unfold"; + +"less" = "Show less"; + +"review_title" = "Feedback"; + +"tap_to_rate" = "Click to rate:"; + +"text" = "Text"; + +"upload_photo" = "Upload photo"; + +"images_number_warning" = "You can send no more than 10 images"; + +"send" = "Send"; + +"profile_tourism" = "Profile"; + +"usd" = "USD"; + +"eur" = "EUR"; + +"rub" = "RUB"; + +"personal_data" = "Personal information"; + +"language" = "Language"; + +"current_language" = "English"; + +"dark_theme" = "Dark theme"; + +"light_theme" = "Light theme"; + +"sign_out" = "Exit"; + +"sign_out_title" = "Exit"; + +"sign_out_warning" = "Are you sure you want to get out?"; + +"update_data" = "Edit data"; + +"phone" = "Phone number"; + +"chose_language" = "Select a language"; + +"retry" = "Try again"; + +"no_connection" = "Couldn't reach the server, please check connection"; + +"no_image" = "No image"; + +"tjk" = "Tajikistan"; + +"clear_search_field" = "Clear search field"; + +"top30" = "Top 30 places"; + +"sights" = "Sights"; + +"restaurants" = "Restaurants"; + +"hotels_tourism" = "Hotels"; + +"add_to_favorites" = "Add to favorites"; + +"show_route" = "Show route"; + +"passwords_not_same" = "Passwords are not the same"; + +"wrong_email_format" = "Wrong email format"; + +"please_fill_all_fields" = "Please fill all of the fields"; + +"saved" = "Saved"; + +"great_success" = "Great success😄"; + +"review_deleted" = "Review was successfully deleted"; + +"delete_review" = "Delete review"; + +"deletion_warning" = "Are you sure you wanna delete this?"; + +"deletionPlanned" = "Deleting…"; + +"plz_wait_dowloading" = "Please, wait, data being downloaded"; + +"download_failed" = "Download failed"; + +"no_content" = "Empty"; + +"empty_list" = "Empty"; + +"back" = "Back"; + +"review_will_be_published_when_online" = "Review will be published when you are online"; + +"review_was_published" = "Review was successfully published"; + +"failed_to_publish_review" = "Failed to publish review\n"; + +"plz_dont_go_out_of_tjk" = "Please, don't go out of Tajikistan, it's Tajikistan app"; + diff --git a/iphone/Maps/LocalizedStrings/en.lproj/Localizable.strings b/iphone/Maps/LocalizedStrings/en.lproj/Localizable.strings index d92f0ccf16..49e5990b78 100644 --- a/iphone/Maps/LocalizedStrings/en.lproj/Localizable.strings +++ b/iphone/Maps/LocalizedStrings/en.lproj/Localizable.strings @@ -3937,3 +3937,181 @@ "type.shop.auction" = "Auction"; "type.shop.collector" = "Collectables"; + + +// MARK: Tourism + +"organization_name" = "Committee for Tourism Development under the Government of the Republic of Tajikistan "; + +"developed_by_label" = "Developed by Rebus LLC"; + +"smth_went_wrong" = "Error"; + +"error" = "Error"; + +"server_error" = "Server error, try later"; + +"cache_error" = "Cache error, try to clean cache"; + +"wait_tjk_map_downloading" = "Please wait, the map of Tajikistan is loading"; + +"welcome_to_tjk" = "Welcome to Tajikistan"; + +"sign_in" = "Log in"; + +"sign_up" = "Registration"; + +"sign_in_title" = "Entry"; + +"sign_up_title" = "Registration"; + +"signed_in_successfully" = "Successfully signed in"; + +"username" = "Login"; + +"full_name" = "Full name"; + +"country" = "Country"; + +"tourism_email" = "Email"; + +"tourism_password" = "Password"; + +"confirm_password" = "Repeat the password"; + +"tourism_forgot_password" = "Forgot password?"; + +"send_email_for_password_reset" = "Send your email, so you receive a link for password reset"; + +"we_sent_you_password_reset_email" = "We sent you email for password reset"; + +"home" = "Home"; + +"favorites" = "Favorites"; + +"account" = "Account"; + +"tourism_profile" = "Profile"; + +"change" = "Edit"; + +"found" = "Found it"; + +"popular_in_tjk" = "Popular in Tajikistan"; + +"popular_in" = "Popular in"; + +"description_tourism" = "Description"; + +"gallery" = "Photogallery"; + +"reviews" = "Feedbacks"; + +"compose_review" = "Leave feedback"; + +"see_all_reviews" = "All reviews"; + +"more" = "Unfold"; + +"less" = "Show less"; + +"review_title" = "Feedback"; + +"tap_to_rate" = "Click to rate:"; + +"text" = "Text"; + +"upload_photo" = "Upload photo"; + +"images_number_warning" = "You can send no more than 10 images"; + +"send" = "Send"; + +"profile_tourism" = "Profile"; + +"usd" = "USD"; + +"eur" = "EUR"; + +"rub" = "RUB"; + +"personal_data" = "Personal information"; + +"language" = "Language"; + +"current_language" = "English"; + +"dark_theme" = "Dark theme"; + +"light_theme" = "Light theme"; + +"sign_out" = "Exit"; + +"sign_out_title" = "Exit"; + +"sign_out_warning" = "Are you sure you want to get out?"; + +"update_data" = "Edit data"; + +"phone" = "Phone number"; + +"chose_language" = "Select a language"; + +"retry" = "Try again"; + +"no_connection" = "Couldn't reach the server, please check connection"; + +"no_image" = "No image"; + +"tjk" = "Tajikistan"; + +"clear_search_field" = "Clear search field"; + +"top30" = "Top 30 places"; + +"sights" = "Sights"; + +"restaurants" = "Restaurants"; + +"hotels_tourism" = "Hotels"; + +"add_to_favorites" = "Add to favorites"; + +"show_route" = "Show route"; + +"passwords_not_same" = "Passwords are not the same"; + +"wrong_email_format" = "Wrong email format"; + +"please_fill_all_fields" = "Please fill all of the fields"; + +"saved" = "Saved"; + +"great_success" = "Great success😄"; + +"review_deleted" = "Review was successfully deleted"; + +"delete_review" = "Delete review"; + +"deletion_warning" = "Are you sure you wanna delete this?"; + +"deletionPlanned" = "Deleting…"; + +"plz_wait_dowloading" = "Please, wait, data being downloaded"; + +"download_failed" = "Download failed"; + +"no_content" = "Empty"; + +"empty_list" = "Empty"; + +"back" = "Back"; + +"review_will_be_published_when_online" = "Review will be published when you are online"; + +"review_was_published" = "Review was successfully published"; + +"failed_to_publish_review" = "Failed to publish review\n"; + +"plz_dont_go_out_of_tjk" = "Please, don't go out of Tajikistan, it's Tajikistan app"; + diff --git a/iphone/Maps/LocalizedStrings/ru.lproj/Localizable.strings b/iphone/Maps/LocalizedStrings/ru.lproj/Localizable.strings index 0e12f55ab9..417120b3f5 100644 --- a/iphone/Maps/LocalizedStrings/ru.lproj/Localizable.strings +++ b/iphone/Maps/LocalizedStrings/ru.lproj/Localizable.strings @@ -3937,3 +3937,180 @@ "type.shop.auction" = "Аукцион"; "type.shop.collector" = "Коллекции"; + + +// MARK: Tourism + +"organization_name" = "Комитет по развитию туризма при Правительстве Республики Таджикистан "; + +"developed_by_label" = "Developed by Rebus LLC"; + +"smth_went_wrong" = "Упс, что-то пошло не так"; + +"server_error" = "Ошибка сервера, попробуйте позже"; + +"cache_error" = "Ошибка кеша, попробуйте очистить кеш"; + +"error" = "Ошибка"; + +"wait_tjk_map_downloading" = "Пожалуйста, подождите, идет загрузка карты Таджикистана"; + +"welcome_to_tjk" = "Добро пожаловать в Таджикистан"; + +"sign_in" = "Войти"; + +"sign_up" = "Регистрация"; + +"sign_in_title" = "Вход"; + +"sign_up_title" = "Регистрация"; + +"signed_in_successfully" = "Successfully signed in"; + +"username" = "Логин"; + +"full_name" = "Ф.И.О"; + +"country" = "Страна"; + +"tourism_email" = "Email"; + +"tourism_password" = "Пароль"; + +"confirm_password" = "Повторите пароль"; + +"tourism_forgot_password" = "Забыли пароль?"; + +"send_email_for_password_reset" = "Отправьте свой email, чтобы мы отправили вам ссылку для восстановления пароля"; + +"we_sent_you_password_reset_email" = "Мы отправили вам письмо для восстановления пароля"; + +"home" = "Главная"; + +"favorites" = "Избранное"; + +"account" = "Аккаунт"; + +"tourism_profile" = "Профиль"; + +"change" = "Изменить"; + +"found" = "Найдено"; + +"popular_in_tjk" = "Популярное в Таджикистане"; + +"popular_in" = "Популярное в"; + +"description_tourism" = "Описание"; + +"gallery" = "Фотогаллерея"; + +"reviews" = "Отзывы"; + +"compose_review" = "Оставить отзыв"; + +"see_all_reviews" = "Все отзывы"; + +"more" = "Развернуть"; + +"less" = "Свернуть"; + +"review_title" = "Отзыв"; + +"tap_to_rate" = "Нажмите, чтобы оценить:"; + +"text" = "Текст"; + +"upload_photo" = "Загрузить фото"; + +"images_number_warning" = "Можно отправить не более 10 изображений"; + +"send" = "Отправить"; + +"profile_tourism" = "Профиль"; + +"usd" = "USD"; + +"eur" = "EUR"; + +"rub" = "RUB"; + +"personal_data" = "Персональные данные"; + +"language" = "Язык"; + +"current_language" = "Русский"; + +"dark_theme" = "Темная тема"; + +"light_theme" = "Светлая тема"; + +"sign_out" = "Выход"; + +"sign_out_title" = "Выход"; + +"sign_out_warning" = "Вы уверенны что хотите выйти?"; + +"update_data" = "Изменить данные"; + +"phone" = "Номер телефона"; + +"chose_language" = "Выберите язык"; + +"retry" = "Попробовать заново"; + +"no_connection" = "Не удается соединиться с сервером, проверьте интернет подключение"; + +"no_image" = "Нет фото"; + +"tjk" = "Таджикистан"; + +"clear_search_field" = "Очистить поле поиска"; + +"top30" = "Топ-30 мест"; + +"sights" = "Достопримечательности"; + +"restaurants" = "Рестораны"; + +"hotels_tourism" = "Отели"; + +"add_to_favorites" = "Добавить в избранное"; + +"show_route" = "Посмотреть маршрут"; + +"passwords_not_same" = "Пароли не схожи"; + +"wrong_email_format" = "Неправильный формат имейла"; + +"please_fill_all_fields" = "Пожалуйста, заполните все данные"; + +"saved" = "Сохранено"; + +"great_success" = "Мне нраится😄"; + +"review_deleted" = "Отзыв успешно удален"; + +"delete_review" = "Удалить отзыв"; + +"deletion_warning" = "Вы уверены что хотите удалить это?"; + +"deletionPlanned" = "В процессе удаления"; + +"plz_wait_dowloading" = "Пожалуйста подождите данные скачиваются"; + +"download_failed" = "Загрузка данных провалена"; + +"no_content" = "Пусто"; + +"empty_list" = "Пусто"; + +"back" = "Назад"; + +"review_will_be_published_when_online" = "Отзыв будет публикован когда будете онлайн"; + +"review_was_published" = "Отзыв был успешно опубликован"; + +"failed_to_publish_review" = "Не удалось публиковать отзыв"; + +"plz_dont_go_out_of_tjk" = "Пожалуйста, не выходите за рамки Таджикистана, вы должны быть в Таджикистане"; diff --git a/iphone/Maps/Maps.xcodeproj/project.pbxproj b/iphone/Maps/Maps.xcodeproj/project.pbxproj index 1490a78f7e..f3f1c4a85f 100644 --- a/iphone/Maps/Maps.xcodeproj/project.pbxproj +++ b/iphone/Maps/Maps.xcodeproj/project.pbxproj @@ -179,6 +179,18 @@ 34F742321E0834F400AC1FD6 /* UIViewController+Navigation.m in Sources */ = {isa = PBXBuildFile; fileRef = 34F742301E0834F400AC1FD6 /* UIViewController+Navigation.m */; }; 34FE5A6F1F18F30F00BCA729 /* TrafficButtonArea.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34FE5A6D1F18F30F00BCA729 /* TrafficButtonArea.swift */; }; 3D15ACEE2155117000F725D5 /* MWMObjectsCategorySelectorDataSource.mm in Sources */ = {isa = PBXBuildFile; fileRef = 3D15ACED2155117000F725D5 /* MWMObjectsCategorySelectorDataSource.mm */; }; + 3D2D79BA2C7C508E0062BC3D /* SingleEntityCoreDataController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D2D79B92C7C508E0062BC3D /* SingleEntityCoreDataController.swift */; }; + 3D2D79BC2C7C5E300062BC3D /* ProfileRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D2D79BB2C7C5E300062BC3D /* ProfileRepositoryImpl.swift */; }; + 3D2D79C32C7C80E60062BC3D /* PersonalDataPersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D2D79C22C7C80E60062BC3D /* PersonalDataPersistenceController.swift */; }; + 3D2D79CC2C7C8C350062BC3D /* ProfileService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D2D79CB2C7C8C350062BC3D /* ProfileService.swift */; }; + 3D2D79D32C7CF4F70062BC3D /* PersonalDataViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D2D79D22C7CF4F70062BC3D /* PersonalDataViewController.swift */; }; + 3D2D79D52C7CF6970062BC3D /* Spacers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D2D79D42C7CF6970062BC3D /* Spacers.swift */; }; + 3D2D79D72C7D0AC00062BC3D /* AppTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D2D79D62C7D0ABF0062BC3D /* AppTextField.swift */; }; + 3D2D79D92C7D15190062BC3D /* PrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D2D79D82C7D15190062BC3D /* PrimaryButton.swift */; }; + 3D2D79DB2C7D15410062BC3D /* SecondaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D2D79DA2C7D15410062BC3D /* SecondaryButton.swift */; }; + 3D2D79DD2C7DE34B0062BC3D /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D2D79DC2C7DE34B0062BC3D /* ImagePicker.swift */; }; + 3D585BF42C760850005DF71F /* UIScreenExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D585BF32C760850005DF71F /* UIScreenExtensions.swift */; }; + 3DA3FC992C75ED2A0065E4D6 /* changeTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DA3FC982C75ED2A0065E4D6 /* changeTheme.swift */; }; 3DBD7BE42425015C00ED9FE8 /* ParntersStyleSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DBD7BE32425015C00ED9FE8 /* ParntersStyleSheet.swift */; }; 3DEE1AEB21F72CD300054A91 /* MWMPowerManagmentViewController.mm in Sources */ = {isa = PBXBuildFile; fileRef = 3DEE1AEA21F72CD300054A91 /* MWMPowerManagmentViewController.mm */; }; 408645FC21495EB1000A4A1D /* categories_cuisines.txt in Resources */ = {isa = PBXBuildFile; fileRef = 408645FB21495EB1000A4A1D /* categories_cuisines.txt */; }; @@ -261,6 +273,109 @@ 47F86D0120C93D8D00FEE291 /* TabViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47F86D0020C93D8D00FEE291 /* TabViewController.swift */; }; 4A300ED51C6DCFD400140018 /* countries-strings in Resources */ = {isa = PBXBuildFile; fileRef = 4A300ED31C6DCFD400140018 /* countries-strings */; }; 4B4153B52BF9695500EE4B02 /* MWMTextToSpeechTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 4B4153B42BF9695500EE4B02 /* MWMTextToSpeechTests.mm */; }; + 524634C62C53BC3100FDCABA /* Auth.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 524634C22C53BB3A00FDCABA /* Auth.storyboard */; }; + 524634CD2C57232400FDCABA /* TourismMain.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 524634CC2C57232400FDCABA /* TourismMain.storyboard */; }; + 52522F2E2C6C9E070015709C /* UserPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52522F2D2C6C9E060015709C /* UserPreferences.swift */; }; + 52522F312C6CC87C0015709C /* TabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52522F302C6CC87B0015709C /* TabBarController.swift */; }; + 52522F332C6DC7A40015709C /* HomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52522F322C6DC7A40015709C /* HomeViewController.swift */; }; + 52522F392C6DD9DA0015709C /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52522F382C6DD9DA0015709C /* ProfileViewModel.swift */; }; + 52522F3B2C6DDA750015709C /* ThemeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52522F3A2C6DDA750015709C /* ThemeViewModel.swift */; }; + 52522F3E2C6DDF190015709C /* PersonalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52522F3D2C6DDF190015709C /* PersonalData.swift */; }; + 52522F402C6DDF290015709C /* CurrencyRates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52522F3F2C6DDF290015709C /* CurrencyRates.swift */; }; + 52522F462C6DFE060015709C /* AppTopBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52522F452C6DFE060015709C /* AppTopBar.swift */; }; + 52522F482C6DFE460015709C /* AppBackButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52522F472C6DFE460015709C /* AppBackButton.swift */; }; + 52522F4A2C6DFE580015709C /* BackButtonWithText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52522F492C6DFE580015709C /* BackButtonWithText.swift */; }; + 52522F4C2C6E10FD0015709C /* LoadImg.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52522F4B2C6E10FD0015709C /* LoadImg.swift */; }; + 5260D3CE2C64F60200C673B4 /* APIEndpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5260D3CD2C64F60200C673B4 /* APIEndpoints.swift */; }; + 5260D3D12C64F7F100C673B4 /* SignInRequestDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5260D3D02C64F7F100C673B4 /* SignInRequestDTO.swift */; }; + 5260D3D82C64F8BC00C673B4 /* AuthResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5260D3D72C64F8BC00C673B4 /* AuthResponseDTO.swift */; }; + 5260D3DA2C661FF000C673B4 /* ResourceError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5260D3D92C661FF000C673B4 /* ResourceError.swift */; }; + 5260D3DE2C66237700C673B4 /* AuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5260D3DD2C66237700C673B4 /* AuthService.swift */; }; + 5260D3E02C6624B900C673B4 /* AuthRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5260D3DF2C6624B900C673B4 /* AuthRepositoryImpl.swift */; }; + 5260D3E32C66289900C673B4 /* SignInRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5260D3E22C66289900C673B4 /* SignInRequest.swift */; }; + 5260D3E52C66290500C673B4 /* AuthResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5260D3E42C66290500C673B4 /* AuthResponse.swift */; }; + 5260D3E82C66439400C673B4 /* AuthRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5260D3E72C66439400C673B4 /* AuthRepository.swift */; }; + 527D5E752C60A1F800736A85 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 527D5E742C60A1F800736A85 /* Images.xcassets */; }; + 527D5E782C60D94B00736A85 /* AppButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 527D5E772C60D94B00736A85 /* AppButton.swift */; }; + 527D5E7B2C60E05D00736A85 /* LanguageUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 527D5E7A2C60E05D00736A85 /* LanguageUtils.swift */; }; + 527D5E7F2C60E69C00736A85 /* Layouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 527D5E7E2C60E69C00736A85 /* Layouting.swift */; }; + 527D5E822C60EFEE00736A85 /* UIViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 527D5E812C60EFEE00736A85 /* UIViewExtensions.swift */; }; + 528D72A12C5BBBF700D53210 /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 528D72A02C5BBBF700D53210 /* Colors.xcassets */; }; + 529A5F192C85BFF0004FE4A1 /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F182C85BFF0004FE4A1 /* ToastView.swift */; }; + 529A5F1E2C86DDE5004FE4A1 /* PlaceDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F1D2C86DDE5004FE4A1 /* PlaceDTO.swift */; }; + 529A5F202C86DE14004FE4A1 /* CoordinatesDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F1F2C86DE14004FE4A1 /* CoordinatesDTO.swift */; }; + 529A5F222C86DE50004FE4A1 /* Reviews DTOs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F212C86DE50004FE4A1 /* Reviews DTOs.swift */; }; + 529A5F282C86DEC5004FE4A1 /* UserDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F272C86DEC5004FE4A1 /* UserDTO.swift */; }; + 529A5F2B2C86DF2D004FE4A1 /* PlaceShort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F2A2C86DF2D004FE4A1 /* PlaceShort.swift */; }; + 529A5F2D2C86DF3B004FE4A1 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F2C2C86DF3B004FE4A1 /* User.swift */; }; + 529A5F312C86DF61004FE4A1 /* Review Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F302C86DF61004FE4A1 /* Review Models.swift */; }; + 529A5F332C86DF6F004FE4A1 /* PlaceFull.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F322C86DF6F004FE4A1 /* PlaceFull.swift */; }; + 529A5F352C86DF99004FE4A1 /* PlaceLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F342C86DF99004FE4A1 /* PlaceLocation.swift */; }; + 529A5F372C86E02E004FE4A1 /* AllDataDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F362C86E02E004FE4A1 /* AllDataDTO.swift */; }; + 529A5F392C86E048004FE4A1 /* CategoryDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F382C86E048004FE4A1 /* CategoryDTO.swift */; }; + 529A5F3B2C86E065004FE4A1 /* FavoritesDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F3A2C86E065004FE4A1 /* FavoritesDTO.swift */; }; + 529A5F3D2C86E08E004FE4A1 /* FavoritesIdsDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F3C2C86E08E004FE4A1 /* FavoritesIdsDTO.swift */; }; + 529A5F3F2C86E09B004FE4A1 /* HashDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F3E2C86E09B004FE4A1 /* HashDTO.swift */; }; + 529A5F422C86E108004FE4A1 /* Category.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F412C86E108004FE4A1 /* Category.swift */; }; + 529A5F442C86E118004FE4A1 /* PlaceCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F432C86E118004FE4A1 /* PlaceCategory.swift */; }; + 529A5F5E2C86E37A004FE4A1 /* PlacesItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F5D2C86E37A004FE4A1 /* PlacesItem.swift */; }; + 529A5F632C86E39A004FE4A1 /* HorizontalSingleChoice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F612C86E39A004FE4A1 /* HorizontalSingleChoice.swift */; }; + 529A5F642C86E39A004FE4A1 /* AppSearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F622C86E39A004FE4A1 /* AppSearchBar.swift */; }; + 529A5F682C8707CD004FE4A1 /* CategoriesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F672C8707CD004FE4A1 /* CategoriesViewController.swift */; }; + 529A5F6A2C8707F9004FE4A1 /* FavoritesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F692C8707F9004FE4A1 /* FavoritesViewController.swift */; }; + 529A5F6C2C870D45004FE4A1 /* HorizontalPlaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F6B2C870D45004FE4A1 /* HorizontalPlaces.swift */; }; + 529A5F6E2C870FAF004FE4A1 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F6D2C870FAF004FE4A1 /* HomeViewModel.swift */; }; + 529A5F702C8720A8004FE4A1 /* CategoriesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529A5F6F2C8720A8004FE4A1 /* CategoriesViewModel.swift */; }; + 52A48ADF2C882FE40081E522 /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A48ADE2C882FE40081E522 /* SearchViewController.swift */; }; + 52A48AE12C882FEE0081E522 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A48AE02C882FEE0081E522 /* SearchViewModel.swift */; }; + 52A48AE32C887BA00081E522 /* PlacesRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A48AE22C887BA00081E522 /* PlacesRepositoryImpl.swift */; }; + 52A48AE52C887EA70081E522 /* PlacesRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A48AE42C887EA70081E522 /* PlacesRepository.swift */; }; + 52A48AE72C8882A90081E522 /* ReviewsRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A48AE62C8882A90081E522 /* ReviewsRepository.swift */; }; + 52A48AE92C888AD90081E522 /* ReviewsRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A48AE82C888AD90081E522 /* ReviewsRepositoryImpl.swift */; }; + 52A48AEB2C888B2C0081E522 /* PlacesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A48AEA2C888B2C0081E522 /* PlacesService.swift */; }; + 52A48AED2C888B370081E522 /* ReviewsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A48AEC2C888B370081E522 /* ReviewsService.swift */; }; + 52B573EC2C61E1C10047FAC9 /* SignInViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52B573EB2C61E1C10047FAC9 /* SignInViewController.swift */; }; + 52B573F02C61E4110047FAC9 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52B573EF2C61E4110047FAC9 /* Constants.swift */; }; + 52B573F22C61E8980047FAC9 /* SignUpViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52B573F12C61E8980047FAC9 /* SignUpViewController.swift */; }; + 52B573F52C61F11E0047FAC9 /* AuhtTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52B573F42C61F11E0047FAC9 /* AuhtTextField.swift */; }; + 52B573F72C61F4D00047FAC9 /* PasswordTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52B573F62C61F4D00047FAC9 /* PasswordTextField.swift */; }; + 52B573F92C6223CE0047FAC9 /* AuthBackButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52B573F82C6223CE0047FAC9 /* AuthBackButton.swift */; }; + 52B573FE2C624A520047FAC9 /* CountryPickerUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52B573FD2C624A520047FAC9 /* CountryPickerUtils.swift */; }; + 52CD2D852C6F093B00CCC439 /* CurrencyRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52CD2D842C6F093A00CCC439 /* CurrencyRepository.swift */; }; + 52CD2D892C6F0AF200CCC439 /* ProfileRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52CD2D882C6F0AF200CCC439 /* ProfileRepository.swift */; }; + 52D588A92C5CD56200AB96B3 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52D588A82C5CD56200AB96B3 /* Color.swift */; }; + 52D588BA2C5CE2E800AB96B3 /* Font.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52D588B92C5CE2E800AB96B3 /* Font.swift */; }; + 52D588C52C5CEAF900AB96B3 /* Gilroy-Thin.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 52D588BB2C5CEAF800AB96B3 /* Gilroy-Thin.ttf */; }; + 52D588C62C5CEAF900AB96B3 /* Gilroy-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 52D588BC2C5CEAF800AB96B3 /* Gilroy-Bold.ttf */; }; + 52D588C72C5CEAF900AB96B3 /* Gilroy-Heavy.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 52D588BD2C5CEAF800AB96B3 /* Gilroy-Heavy.ttf */; }; + 52D588C82C5CEAF900AB96B3 /* Gilroy-UltraLight.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 52D588BE2C5CEAF800AB96B3 /* Gilroy-UltraLight.ttf */; }; + 52D588C92C5CEAF900AB96B3 /* Gilroy-Medium.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 52D588BF2C5CEAF800AB96B3 /* Gilroy-Medium.ttf */; }; + 52D588CA2C5CEAF900AB96B3 /* Gilroy-Black.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 52D588C02C5CEAF800AB96B3 /* Gilroy-Black.ttf */; }; + 52D588CB2C5CEAF900AB96B3 /* Gilroy-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 52D588C12C5CEAF800AB96B3 /* Gilroy-Regular.ttf */; }; + 52D588CC2C5CEAF900AB96B3 /* Gilroy-SemiBold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 52D588C22C5CEAF800AB96B3 /* Gilroy-SemiBold.ttf */; }; + 52D588CD2C5CEAF900AB96B3 /* Gilroy-Light.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 52D588C32C5CEAF800AB96B3 /* Gilroy-Light.ttf */; }; + 52D588CE2C5CEAF900AB96B3 /* Gilroy-ExtraBold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 52D588C42C5CEAF900AB96B3 /* Gilroy-ExtraBold.ttf */; }; + 52E2D3A42C59F9CE00A8843A /* WelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52E2D3A32C59F9CE00A8843A /* WelcomeViewController.swift */; }; + 52E95F022C6B32E500A3FE2E /* ErrorResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52E95F012C6B32E500A3FE2E /* ErrorResponse.swift */; }; + 52E95F042C6B71B900A3FE2E /* NetworkHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52E95F032C6B71B900A3FE2E /* NetworkHelper.swift */; }; + 52E95F072C6B7E2400A3FE2E /* SignUpRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52E95F062C6B7E2400A3FE2E /* SignUpRequest.swift */; }; + 52E95F0B2C6B8CC800A3FE2E /* UIViewControllerExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52E95F0A2C6B8CC800A3FE2E /* UIViewControllerExtensions.swift */; }; + 52E95F0D2C6C797B00A3FE2E /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52E95F0C2C6C797B00A3FE2E /* ProfileViewController.swift */; }; + 52ECA80F2C8A0D5F00F213B3 /* DescriptionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52ECA80E2C8A0D5F00F213B3 /* DescriptionScreen.swift */; }; + 52ECA8132C8A0D7A00F213B3 /* ReviewsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52ECA8122C8A0D7A00F213B3 /* ReviewsScreen.swift */; }; + 52ECA8152C8A0D9E00F213B3 /* GalleryScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52ECA8142C8A0D9E00F213B3 /* GalleryScreen.swift */; }; + 52ECA8182C8A255900F213B3 /* PlaceTabsBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52ECA8172C8A255900F213B3 /* PlaceTabsBar.swift */; }; + 52ECA81A2C8A25D800F213B3 /* PlaceTopBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52ECA8192C8A25D800F213B3 /* PlaceTopBar.swift */; }; + 52ED919D2C71F639000EE25B /* SimpleResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52ED919C2C71F639000EE25B /* SimpleResponse.swift */; }; + 52ED919F2C71F718000EE25B /* SignUpRequestDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52ED919E2C71F718000EE25B /* SignUpRequestDTO.swift */; }; + 52ED91A52C72C50F000EE25B /* CurrencyRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52ED91A42C72C50F000EE25B /* CurrencyRepositoryImpl.swift */; }; + 52ED91A72C72C58A000EE25B /* CurrencyPersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52ED91A62C72C58A000EE25B /* CurrencyPersistenceController.swift */; }; + 52ED91A92C73020A000EE25B /* CurrencyService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52ED91A82C73020A000EE25B /* CurrencyService.swift */; }; + 52ED91AB2C7302A7000EE25B /* CurrencyRatesDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52ED91AA2C7302A7000EE25B /* CurrencyRatesDTO.swift */; }; + 52ED91B02C73030D000EE25B /* PersonalDataDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52ED91AF2C73030D000EE25B /* PersonalDataDTO.swift */; }; + 52ED91B32C73211F000EE25B /* EntitiesMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52ED91B22C73211F000EE25B /* EntitiesMapping.swift */; }; + 52EF1B622C8989F1003046A4 /* PlaceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52EF1B612C8989F1003046A4 /* PlaceViewController.swift */; }; + 52EF1B662C8989F9003046A4 /* PlaceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52EF1B652C8989F9003046A4 /* PlaceViewModel.swift */; }; 6741A9421BF340DE002C974C /* sound-strings in Resources */ = {isa = PBXBuildFile; fileRef = 5605022E1B6211E100169CAD /* sound-strings */; }; 6741A9451BF340DE002C974C /* classificator.txt in Resources */ = {isa = PBXBuildFile; fileRef = EE026F0511D6AC0D00645242 /* classificator.txt */; }; 6741A9491BF340DE002C974C /* countries.txt in Resources */ = {isa = PBXBuildFile; fileRef = FA46DA2B12D4166E00968C36 /* countries.txt */; }; @@ -466,6 +581,42 @@ CDCA27842245090900167D87 /* ListenerContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCA27832245090900167D87 /* ListenerContainer.swift */; }; CDCA278622451F5000167D87 /* RouteInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCA278522451F5000167D87 /* RouteInfo.swift */; }; CDCA278E2248F34C00167D87 /* MWMRoutingManager.mm in Sources */ = {isa = PBXBuildFile; fileRef = CDCA278B2248F34C00167D87 /* MWMRoutingManager.mm */; }; + CE2D27F82CA2C49F00094565 /* BackButtonWithText.m in Sources */ = {isa = PBXBuildFile; fileRef = CE2D27F72CA2C49F00094565 /* BackButtonWithText.m */; }; + CE60A8C52CAD15C20055F49C /* FullscreenImageViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE60A8C42CAD15C20055F49C /* FullscreenImageViewer.swift */; }; + CE64501D2C93F8350075A59B /* ReviewsPersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE64501C2C93F8350075A59B /* ReviewsPersistenceController.swift */; }; + CE6450242C9772310075A59B /* DownloadProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6450232C9772310075A59B /* DownloadProgress.swift */; }; + CE6450282C99572F0075A59B /* ImageStoreUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6450272C99572F0075A59B /* ImageStoreUtils.swift */; }; + CE8982032CB9588E00FC2D2E /* EmailBodyDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8982022CB9588E00FC2D2E /* EmailBodyDto.swift */; }; + CE8982052CBCD46300FC2D2E /* ForgotPasswordViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8982042CBCD46300FC2D2E /* ForgotPasswordViewController.swift */; }; + CEA45BC42C9AE01000ABE6B2 /* DataSyncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEA45BC32C9AE01000ABE6B2 /* DataSyncer.swift */; }; + CED0E00E2C8ACBCA008C61CA /* SDWebImageSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = CED0E00D2C8ACBCA008C61CA /* SDWebImageSwiftUI */; }; + CED0E0112C8ACBE1008C61CA /* CountryPickerView in Frameworks */ = {isa = PBXBuildFile; productRef = CED0E0102C8ACBE1008C61CA /* CountryPickerView */; }; + CED0E0172C8ACF0D008C61CA /* RoundedCornerShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E0162C8ACF0D008C61CA /* RoundedCornerShape.swift */; }; + CED0E0192C8AD57C008C61CA /* EmptyUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E0182C8AD57C008C61CA /* EmptyUI.swift */; }; + CED0E01B2C8B048C008C61CA /* AllPicsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E01A2C8B048C008C61CA /* AllPicsScreen.swift */; }; + CED0E0222C8B22CD008C61CA /* ReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E0212C8B22CD008C61CA /* ReviewView.swift */; }; + CED0E0242C8C6DF9008C61CA /* RatingBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E0232C8C6DF9008C61CA /* RatingBarView.swift */; }; + CED0E0262C8C85BD008C61CA /* PostReviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E0252C8C85BD008C61CA /* PostReviewViewModel.swift */; }; + CED0E0282C8C85C9008C61CA /* PostReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E0272C8C85C9008C61CA /* PostReviewView.swift */; }; + CED0E02A2C8C88B9008C61CA /* FlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E0292C8C88B9008C61CA /* FlowLayout.swift */; }; + CED0E02C2C8F6BFF008C61CA /* MultiImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E02B2C8F6BFF008C61CA /* MultiImagePicker.swift */; }; + CED0E0312C900BB2008C61CA /* AllReviewsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E0302C900BB2008C61CA /* AllReviewsScreen.swift */; }; + CED0E0332C900D4C008C61CA /* ReviewsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E0322C900D4C008C61CA /* ReviewsViewModel.swift */; }; + CED0E0352C902527008C61CA /* LanguageDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E0342C902527008C61CA /* LanguageDTO.swift */; }; + CED0E0372C902532008C61CA /* ThemeDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E0362C902532008C61CA /* ThemeDTO.swift */; }; + CED0E0392C904868008C61CA /* NavigationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E0382C904868008C61CA /* NavigationUtils.swift */; }; + CED0E03B2C904A06008C61CA /* FavoritesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E03A2C904A06008C61CA /* FavoritesViewModel.swift */; }; + CED0E0422C9077D3008C61CA /* HashesPersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E0412C9077D3008C61CA /* HashesPersistenceController.swift */; }; + CED0E0452C918ED4008C61CA /* Hash.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E0442C918ED4008C61CA /* Hash.swift */; }; + CED0E0472C919F44008C61CA /* PlacesPersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E0462C919F44008C61CA /* PlacesPersistenceController.swift */; }; + CED0E04A2C91A2A9008C61CA /* DBUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E0492C91A2A9008C61CA /* DBUtils.swift */; }; + CED0E04C2C91A6A3008C61CA /* CoordinatesEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E04B2C91A6A3008C61CA /* CoordinatesEntity.swift */; }; + CED0E04E2C91A702008C61CA /* UserEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E04D2C91A702008C61CA /* UserEntity.swift */; }; + E11202462D744BFA001B3B24 /* TourismDB.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = E11202442D744BFA001B3B24 /* TourismDB.xcdatamodeld */; }; + E112024A2D7454F3001B3B24 /* CoreDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11202492D7454EF001B3B24 /* CoreDataManager.swift */; }; + E153EE3D2D80B3830065233D /* FileManagerUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E153EE3A2D80B3780065233D /* FileManagerUtils.swift */; }; + E153EE3F2D80C5AC0065233D /* FileManagerBridge.mm in Sources */ = {isa = PBXBuildFile; fileRef = E153EE3E2D80C59E0065233D /* FileManagerBridge.mm */; }; + E1E3F8482D7ACCB7002E7BDD /* Tajikistan.mwm in Resources */ = {isa = PBXBuildFile; fileRef = E1E3F8472D7ACCB7002E7BDD /* Tajikistan.mwm */; }; ED0B1C312BC2951F00FB8EDD /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = ED0B1C302BC2951F00FB8EDD /* PrivacyInfo.xcprivacy */; }; ED1080A72B791CFE0023F27E /* SocialMediaCollectionViewHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED1080A62B791CFE0023F27E /* SocialMediaCollectionViewHeader.swift */; }; ED1263AB2B6F99F900AD99F3 /* UIView+AddSeparator.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED1263AA2B6F99F900AD99F3 /* UIView+AddSeparator.swift */; }; @@ -1067,6 +1218,18 @@ 34FE5A6D1F18F30F00BCA729 /* TrafficButtonArea.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrafficButtonArea.swift; sourceTree = ""; }; 3D15ACED2155117000F725D5 /* MWMObjectsCategorySelectorDataSource.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = MWMObjectsCategorySelectorDataSource.mm; sourceTree = ""; }; 3D15ACEF2155118800F725D5 /* MWMObjectsCategorySelectorDataSource.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MWMObjectsCategorySelectorDataSource.h; sourceTree = ""; }; + 3D2D79B92C7C508E0062BC3D /* SingleEntityCoreDataController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleEntityCoreDataController.swift; sourceTree = ""; }; + 3D2D79BB2C7C5E300062BC3D /* ProfileRepositoryImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRepositoryImpl.swift; sourceTree = ""; }; + 3D2D79C22C7C80E60062BC3D /* PersonalDataPersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalDataPersistenceController.swift; sourceTree = ""; }; + 3D2D79CB2C7C8C350062BC3D /* ProfileService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileService.swift; sourceTree = ""; }; + 3D2D79D22C7CF4F70062BC3D /* PersonalDataViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalDataViewController.swift; sourceTree = ""; }; + 3D2D79D42C7CF6970062BC3D /* Spacers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Spacers.swift; sourceTree = ""; }; + 3D2D79D62C7D0ABF0062BC3D /* AppTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTextField.swift; sourceTree = ""; }; + 3D2D79D82C7D15190062BC3D /* PrimaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryButton.swift; sourceTree = ""; }; + 3D2D79DA2C7D15410062BC3D /* SecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryButton.swift; sourceTree = ""; }; + 3D2D79DC2C7DE34B0062BC3D /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; }; + 3D585BF32C760850005DF71F /* UIScreenExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIScreenExtensions.swift; sourceTree = ""; }; + 3DA3FC982C75ED2A0065E4D6 /* changeTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = changeTheme.swift; sourceTree = ""; }; 3DBD7BE32425015C00ED9FE8 /* ParntersStyleSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParntersStyleSheet.swift; sourceTree = ""; }; 3DEE1AE921F72CD300054A91 /* MWMPowerManagmentViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MWMPowerManagmentViewController.h; sourceTree = ""; }; 3DEE1AEA21F72CD300054A91 /* MWMPowerManagmentViewController.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = MWMPowerManagmentViewController.mm; sourceTree = ""; }; @@ -1181,8 +1344,111 @@ 4A7D89C31B2EBF3B00AC843E /* resources-xhdpi_dark */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "resources-xhdpi_dark"; path = "../../data/resources-xhdpi_dark"; sourceTree = ""; }; 4A7D89C41B2EBF3B00AC843E /* resources-xxhdpi_dark */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "resources-xxhdpi_dark"; path = "../../data/resources-xxhdpi_dark"; sourceTree = ""; }; 4B4153B42BF9695500EE4B02 /* MWMTextToSpeechTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = MWMTextToSpeechTests.mm; sourceTree = ""; }; + 524634C22C53BB3A00FDCABA /* Auth.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Auth.storyboard; sourceTree = ""; }; + 524634CC2C57232400FDCABA /* TourismMain.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = TourismMain.storyboard; sourceTree = ""; }; + 52522F2D2C6C9E060015709C /* UserPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPreferences.swift; sourceTree = ""; }; + 52522F302C6CC87B0015709C /* TabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarController.swift; sourceTree = ""; }; + 52522F322C6DC7A40015709C /* HomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HomeViewController.swift; path = "../New Group/HomeViewController.swift"; sourceTree = ""; }; + 52522F382C6DD9DA0015709C /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; + 52522F3A2C6DDA750015709C /* ThemeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeViewModel.swift; sourceTree = ""; }; + 52522F3D2C6DDF190015709C /* PersonalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalData.swift; sourceTree = ""; }; + 52522F3F2C6DDF290015709C /* CurrencyRates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyRates.swift; sourceTree = ""; }; + 52522F452C6DFE060015709C /* AppTopBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTopBar.swift; sourceTree = ""; }; + 52522F472C6DFE460015709C /* AppBackButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppBackButton.swift; sourceTree = ""; }; + 52522F492C6DFE580015709C /* BackButtonWithText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackButtonWithText.swift; sourceTree = ""; }; + 52522F4B2C6E10FD0015709C /* LoadImg.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadImg.swift; sourceTree = ""; }; + 5260D3CD2C64F60200C673B4 /* APIEndpoints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIEndpoints.swift; sourceTree = ""; }; + 5260D3D02C64F7F100C673B4 /* SignInRequestDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInRequestDTO.swift; sourceTree = ""; }; + 5260D3D72C64F8BC00C673B4 /* AuthResponseDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthResponseDTO.swift; sourceTree = ""; }; + 5260D3D92C661FF000C673B4 /* ResourceError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceError.swift; sourceTree = ""; }; + 5260D3DD2C66237700C673B4 /* AuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthService.swift; sourceTree = ""; }; + 5260D3DF2C6624B900C673B4 /* AuthRepositoryImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthRepositoryImpl.swift; sourceTree = ""; }; + 5260D3E22C66289900C673B4 /* SignInRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInRequest.swift; sourceTree = ""; }; + 5260D3E42C66290500C673B4 /* AuthResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthResponse.swift; sourceTree = ""; }; + 5260D3E72C66439400C673B4 /* AuthRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthRepository.swift; sourceTree = ""; }; + 527D5E742C60A1F800736A85 /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; }; + 527D5E772C60D94B00736A85 /* AppButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppButton.swift; path = "../New Group/AppButton.swift"; sourceTree = ""; }; + 527D5E7A2C60E05D00736A85 /* LanguageUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageUtils.swift; sourceTree = ""; }; + 527D5E7E2C60E69C00736A85 /* Layouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Layouting.swift; sourceTree = ""; }; + 527D5E812C60EFEE00736A85 /* UIViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewExtensions.swift; sourceTree = ""; }; + 528D72A02C5BBBF700D53210 /* Colors.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Colors.xcassets; sourceTree = ""; }; + 529A5F182C85BFF0004FE4A1 /* ToastView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; }; + 529A5F1D2C86DDE5004FE4A1 /* PlaceDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceDTO.swift; sourceTree = ""; }; + 529A5F1F2C86DE14004FE4A1 /* CoordinatesDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoordinatesDTO.swift; sourceTree = ""; }; + 529A5F212C86DE50004FE4A1 /* Reviews DTOs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Reviews DTOs.swift"; sourceTree = ""; }; + 529A5F272C86DEC5004FE4A1 /* UserDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDTO.swift; sourceTree = ""; }; + 529A5F2A2C86DF2D004FE4A1 /* PlaceShort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceShort.swift; sourceTree = ""; }; + 529A5F2C2C86DF3B004FE4A1 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; + 529A5F302C86DF61004FE4A1 /* Review Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Review Models.swift"; sourceTree = ""; }; + 529A5F322C86DF6F004FE4A1 /* PlaceFull.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceFull.swift; sourceTree = ""; }; + 529A5F342C86DF99004FE4A1 /* PlaceLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceLocation.swift; sourceTree = ""; }; + 529A5F362C86E02E004FE4A1 /* AllDataDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDataDTO.swift; sourceTree = ""; }; + 529A5F382C86E048004FE4A1 /* CategoryDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDTO.swift; sourceTree = ""; }; + 529A5F3A2C86E065004FE4A1 /* FavoritesDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesDTO.swift; sourceTree = ""; }; + 529A5F3C2C86E08E004FE4A1 /* FavoritesIdsDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesIdsDTO.swift; sourceTree = ""; }; + 529A5F3E2C86E09B004FE4A1 /* HashDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashDTO.swift; sourceTree = ""; }; + 529A5F412C86E108004FE4A1 /* Category.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Category.swift; sourceTree = ""; }; + 529A5F432C86E118004FE4A1 /* PlaceCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceCategory.swift; sourceTree = ""; }; + 529A5F5D2C86E37A004FE4A1 /* PlacesItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlacesItem.swift; sourceTree = ""; }; + 529A5F612C86E39A004FE4A1 /* HorizontalSingleChoice.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HorizontalSingleChoice.swift; sourceTree = ""; }; + 529A5F622C86E39A004FE4A1 /* AppSearchBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppSearchBar.swift; sourceTree = ""; }; + 529A5F672C8707CD004FE4A1 /* CategoriesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoriesViewController.swift; sourceTree = ""; }; + 529A5F692C8707F9004FE4A1 /* FavoritesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewController.swift; sourceTree = ""; }; + 529A5F6B2C870D45004FE4A1 /* HorizontalPlaces.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HorizontalPlaces.swift; sourceTree = ""; }; + 529A5F6D2C870FAF004FE4A1 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; + 529A5F6F2C8720A8004FE4A1 /* CategoriesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoriesViewModel.swift; sourceTree = ""; }; + 52A48ADE2C882FE40081E522 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = ""; }; + 52A48AE02C882FEE0081E522 /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = ""; }; + 52A48AE22C887BA00081E522 /* PlacesRepositoryImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlacesRepositoryImpl.swift; sourceTree = ""; }; + 52A48AE42C887EA70081E522 /* PlacesRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlacesRepository.swift; sourceTree = ""; }; + 52A48AE62C8882A90081E522 /* ReviewsRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewsRepository.swift; sourceTree = ""; }; + 52A48AE82C888AD90081E522 /* ReviewsRepositoryImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewsRepositoryImpl.swift; sourceTree = ""; }; + 52A48AEA2C888B2C0081E522 /* PlacesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlacesService.swift; sourceTree = ""; }; + 52A48AEC2C888B370081E522 /* ReviewsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewsService.swift; sourceTree = ""; }; + 52B573EB2C61E1C10047FAC9 /* SignInViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInViewController.swift; sourceTree = ""; }; + 52B573EF2C61E4110047FAC9 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + 52B573F12C61E8980047FAC9 /* SignUpViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpViewController.swift; sourceTree = ""; }; + 52B573F42C61F11E0047FAC9 /* AuhtTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuhtTextField.swift; sourceTree = ""; }; + 52B573F62C61F4D00047FAC9 /* PasswordTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordTextField.swift; sourceTree = ""; }; + 52B573F82C6223CE0047FAC9 /* AuthBackButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthBackButton.swift; sourceTree = ""; }; + 52B573FD2C624A520047FAC9 /* CountryPickerUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountryPickerUtils.swift; sourceTree = ""; }; + 52CD2D842C6F093A00CCC439 /* CurrencyRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyRepository.swift; sourceTree = ""; }; + 52CD2D882C6F0AF200CCC439 /* ProfileRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRepository.swift; sourceTree = ""; }; + 52D588A82C5CD56200AB96B3 /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; + 52D588B92C5CE2E800AB96B3 /* Font.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Font.swift; sourceTree = ""; }; + 52D588BB2C5CEAF800AB96B3 /* Gilroy-Thin.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Gilroy-Thin.ttf"; sourceTree = ""; }; + 52D588BC2C5CEAF800AB96B3 /* Gilroy-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Gilroy-Bold.ttf"; sourceTree = ""; }; + 52D588BD2C5CEAF800AB96B3 /* Gilroy-Heavy.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Gilroy-Heavy.ttf"; sourceTree = ""; }; + 52D588BE2C5CEAF800AB96B3 /* Gilroy-UltraLight.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Gilroy-UltraLight.ttf"; sourceTree = ""; }; + 52D588BF2C5CEAF800AB96B3 /* Gilroy-Medium.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Gilroy-Medium.ttf"; sourceTree = ""; }; + 52D588C02C5CEAF800AB96B3 /* Gilroy-Black.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Gilroy-Black.ttf"; sourceTree = ""; }; + 52D588C12C5CEAF800AB96B3 /* Gilroy-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Gilroy-Regular.ttf"; sourceTree = ""; }; + 52D588C22C5CEAF800AB96B3 /* Gilroy-SemiBold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Gilroy-SemiBold.ttf"; sourceTree = ""; }; + 52D588C32C5CEAF800AB96B3 /* Gilroy-Light.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Gilroy-Light.ttf"; sourceTree = ""; }; + 52D588C42C5CEAF900AB96B3 /* Gilroy-ExtraBold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Gilroy-ExtraBold.ttf"; sourceTree = ""; }; + 52E2D3A32C59F9CE00A8843A /* WelcomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewController.swift; sourceTree = ""; }; + 52E95F012C6B32E500A3FE2E /* ErrorResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorResponse.swift; sourceTree = ""; }; + 52E95F032C6B71B900A3FE2E /* NetworkHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkHelper.swift; sourceTree = ""; }; + 52E95F062C6B7E2400A3FE2E /* SignUpRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpRequest.swift; sourceTree = ""; }; + 52E95F0A2C6B8CC800A3FE2E /* UIViewControllerExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewControllerExtensions.swift; sourceTree = ""; }; + 52E95F0C2C6C797B00A3FE2E /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; + 52ECA80E2C8A0D5F00F213B3 /* DescriptionScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DescriptionScreen.swift; sourceTree = ""; }; + 52ECA8122C8A0D7A00F213B3 /* ReviewsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewsScreen.swift; sourceTree = ""; }; + 52ECA8142C8A0D9E00F213B3 /* GalleryScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryScreen.swift; sourceTree = ""; }; + 52ECA8172C8A255900F213B3 /* PlaceTabsBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlaceTabsBar.swift; path = Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Gallery/PlaceTabsBar.swift; sourceTree = SOURCE_ROOT; }; + 52ECA8192C8A25D800F213B3 /* PlaceTopBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceTopBar.swift; sourceTree = ""; }; + 52ED919C2C71F639000EE25B /* SimpleResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleResponse.swift; sourceTree = ""; }; + 52ED919E2C71F718000EE25B /* SignUpRequestDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpRequestDTO.swift; sourceTree = ""; }; + 52ED91A42C72C50F000EE25B /* CurrencyRepositoryImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyRepositoryImpl.swift; sourceTree = ""; }; + 52ED91A62C72C58A000EE25B /* CurrencyPersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyPersistenceController.swift; sourceTree = ""; }; + 52ED91A82C73020A000EE25B /* CurrencyService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyService.swift; sourceTree = ""; }; + 52ED91AA2C7302A7000EE25B /* CurrencyRatesDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyRatesDTO.swift; sourceTree = ""; }; + 52ED91AF2C73030D000EE25B /* PersonalDataDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalDataDTO.swift; sourceTree = ""; }; + 52ED91B22C73211F000EE25B /* EntitiesMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitiesMapping.swift; sourceTree = ""; }; + 52EF1B612C8989F1003046A4 /* PlaceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceViewController.swift; sourceTree = ""; }; + 52EF1B652C8989F9003046A4 /* PlaceViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceViewModel.swift; sourceTree = ""; }; 5605022E1B6211E100169CAD /* sound-strings */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "sound-strings"; path = "../../data/sound-strings"; sourceTree = ""; }; - 6741AA5D1BF340DE002C974C /* Organic Maps (Debug).app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Organic Maps (Debug).app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 6741AA5D1BF340DE002C974C /* Tourism (Debug).app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Tourism (Debug).app"; sourceTree = BUILT_PRODUCTS_DIR; }; 6B15907026623AE500944BBA /* 00_NotoSansThai-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "00_NotoSansThai-Regular.ttf"; path = "../../data/00_NotoSansThai-Regular.ttf"; sourceTree = ""; }; 6B679E88266BFD090074AE2A /* 00_NotoNaskhArabic-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "00_NotoNaskhArabic-Regular.ttf"; path = "../../data/00_NotoNaskhArabic-Regular.ttf"; sourceTree = ""; }; 6B9978341C89A316003B8AA0 /* editor.config */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = editor.config; path = ../../data/editor.config; sourceTree = ""; }; @@ -1371,6 +1637,42 @@ CDCA278C2248F34C00167D87 /* MWMRouterResultCode.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MWMRouterResultCode.h; sourceTree = ""; }; CDCA278F2248F3B800167D87 /* MWMLocationModeListener.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MWMLocationModeListener.h; sourceTree = ""; }; CDE0F3AD225B8D45008BA5C3 /* MWMSpeedCameraManagerMode.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MWMSpeedCameraManagerMode.h; sourceTree = ""; }; + CE2D27F72CA2C49F00094565 /* BackButtonWithText.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BackButtonWithText.m; sourceTree = ""; }; + CE2D27FB2CA2C64700094565 /* BackButtonWithText.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BackButtonWithText.h; sourceTree = ""; }; + CE60A8C42CAD15C20055F49C /* FullscreenImageViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullscreenImageViewer.swift; sourceTree = ""; }; + CE64501C2C93F8350075A59B /* ReviewsPersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewsPersistenceController.swift; sourceTree = ""; }; + CE6450232C9772310075A59B /* DownloadProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadProgress.swift; sourceTree = ""; }; + CE6450272C99572F0075A59B /* ImageStoreUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageStoreUtils.swift; sourceTree = ""; }; + CE8982022CB9588E00FC2D2E /* EmailBodyDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailBodyDto.swift; sourceTree = ""; }; + CE8982042CBCD46300FC2D2E /* ForgotPasswordViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForgotPasswordViewController.swift; sourceTree = ""; }; + CEA45BC32C9AE01000ABE6B2 /* DataSyncer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSyncer.swift; sourceTree = ""; }; + CED0E0162C8ACF0D008C61CA /* RoundedCornerShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedCornerShape.swift; sourceTree = ""; }; + CED0E0182C8AD57C008C61CA /* EmptyUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyUI.swift; sourceTree = ""; }; + CED0E01A2C8B048C008C61CA /* AllPicsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllPicsScreen.swift; sourceTree = ""; }; + CED0E0212C8B22CD008C61CA /* ReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewView.swift; sourceTree = ""; }; + CED0E0232C8C6DF9008C61CA /* RatingBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingBarView.swift; sourceTree = ""; }; + CED0E0252C8C85BD008C61CA /* PostReviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostReviewViewModel.swift; sourceTree = ""; }; + CED0E0272C8C85C9008C61CA /* PostReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostReviewView.swift; sourceTree = ""; }; + CED0E0292C8C88B9008C61CA /* FlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowLayout.swift; sourceTree = ""; }; + CED0E02B2C8F6BFF008C61CA /* MultiImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiImagePicker.swift; sourceTree = ""; }; + CED0E0302C900BB2008C61CA /* AllReviewsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllReviewsScreen.swift; sourceTree = ""; }; + CED0E0322C900D4C008C61CA /* ReviewsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewsViewModel.swift; sourceTree = ""; }; + CED0E0342C902527008C61CA /* LanguageDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageDTO.swift; sourceTree = ""; }; + CED0E0362C902532008C61CA /* ThemeDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeDTO.swift; sourceTree = ""; }; + CED0E0382C904868008C61CA /* NavigationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationUtils.swift; sourceTree = ""; }; + CED0E03A2C904A06008C61CA /* FavoritesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewModel.swift; sourceTree = ""; }; + CED0E0412C9077D3008C61CA /* HashesPersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashesPersistenceController.swift; sourceTree = ""; }; + CED0E0442C918ED4008C61CA /* Hash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hash.swift; sourceTree = ""; }; + CED0E0462C919F44008C61CA /* PlacesPersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlacesPersistenceController.swift; sourceTree = ""; }; + CED0E0492C91A2A9008C61CA /* DBUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBUtils.swift; sourceTree = ""; }; + CED0E04B2C91A6A3008C61CA /* CoordinatesEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoordinatesEntity.swift; sourceTree = ""; }; + CED0E04D2C91A702008C61CA /* UserEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserEntity.swift; sourceTree = ""; }; + E11202452D744BFA001B3B24 /* TourismDB.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = TourismDB.xcdatamodel; sourceTree = ""; }; + E11202492D7454EF001B3B24 /* CoreDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataManager.swift; sourceTree = ""; }; + E153EE3A2D80B3780065233D /* FileManagerUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManagerUtils.swift; sourceTree = ""; }; + E153EE3E2D80C59E0065233D /* FileManagerBridge.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = FileManagerBridge.mm; sourceTree = ""; }; + E153EE402D80C5BE0065233D /* FileManagerBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FileManagerBridge.h; sourceTree = ""; }; + E1E3F8472D7ACCB7002E7BDD /* Tajikistan.mwm */ = {isa = PBXFileReference; lastKnownFileType = file; path = Tajikistan.mwm; sourceTree = ""; }; ED097E762BB80C320006ED01 /* OMapsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OMapsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; ED0B1C302BC2951F00FB8EDD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; ED1080A62B791CFE0023F27E /* SocialMediaCollectionViewHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialMediaCollectionViewHeader.swift; sourceTree = ""; }; @@ -1740,8 +2042,10 @@ FA853BA926BC3B8A0026D455 /* libbase.a in Frameworks */, FA853BAB26BC3B8A0026D455 /* libcoding.a in Frameworks */, FA853BAD26BC3B8A0026D455 /* libdrape.a in Frameworks */, + CED0E00E2C8ACBCA008C61CA /* SDWebImageSwiftUI in Frameworks */, FA853BAF26BC3B8A0026D455 /* libdrape_frontend.a in Frameworks */, FA853BB126BC3B8A0026D455 /* libeditor.a in Frameworks */, + CED0E0112C8ACBE1008C61CA /* CountryPickerView in Frameworks */, FA853BB326BC3B8A0026D455 /* libgeometry.a in Frameworks */, FA853BB526BC3B8A0026D455 /* libicu.a in Frameworks */, FA853BB726BC3B8A0026D455 /* libindexer.a in Frameworks */, @@ -1799,6 +2103,7 @@ F607C18B1C047FCA00B53A87 /* Segue */, 340837101B7243B500B5C185 /* Share */, F6588E291B15C25C00EE1E58 /* TextView */, + CE2D27FA2CA2C61500094565 /* BackButtonWithText */, FA8E808825F412E2002A1434 /* FirstSession.mm */, FA8E808A25F41337002A1434 /* FirstSession.h */, ); @@ -1808,7 +2113,7 @@ 19C28FACFE9D520D11CA2CBB /* Products */ = { isa = PBXGroup; children = ( - 6741AA5D1BF340DE002C974C /* Organic Maps (Debug).app */, + 6741AA5D1BF340DE002C974C /* Tourism (Debug).app */, ED097E762BB80C320006ED01 /* OMapsTests.xctest */, ); name = Products; @@ -1817,6 +2122,7 @@ 29B97314FDCFA39411CA2CEA /* Maps */ = { isa = PBXGroup; children = ( + 5236A3692C52588000E3A7AD /* Tourism */, FA456C4026BDCC8E00B83C20 /* shaders.xcodeproj */, FA36B8011540388B004560CC /* Bookmarks */, 3454D7981E07F045004AF2AD /* Categories */, @@ -2539,6 +2845,24 @@ path = Widgets; sourceTree = ""; }; + 3D2D79B82C7C4FC30062BC3D /* ControllerTemplates */ = { + isa = PBXGroup; + children = ( + 3D2D79B92C7C508E0062BC3D /* SingleEntityCoreDataController.swift */, + ); + path = ControllerTemplates; + sourceTree = ""; + }; + 3D585BF72C768BED005DF71F /* Buttons */ = { + isa = PBXGroup; + children = ( + 527D5E772C60D94B00736A85 /* AppButton.swift */, + 3D2D79D82C7D15190062BC3D /* PrimaryButton.swift */, + 3D2D79DA2C7D15410062BC3D /* SecondaryButton.swift */, + ); + path = Buttons; + sourceTree = ""; + }; 447DB4B72BA7826D000DF4C2 /* ReauthAlert */ = { isa = PBXGroup; children = ( @@ -2677,6 +3001,524 @@ path = CarPlay; sourceTree = ""; }; + 5236A3692C52588000E3A7AD /* Tourism */ = { + isa = PBXGroup; + children = ( + 5260D3C72C64F52C00C673B4 /* Resources */, + 5260D3C52C64B85B00C673B4 /* Domain */, + 5260D3C42C64B84600C673B4 /* Data */, + 527D5E792C60E04900736A85 /* Utils */, + 5236A36C2C5258AC00E3A7AD /* Presentation */, + 52B573EF2C61E4110047FAC9 /* Constants.swift */, + ); + path = Tourism; + sourceTree = ""; + }; + 5236A36C2C5258AC00E3A7AD /* Presentation */ = { + isa = PBXGroup; + children = ( + 5260D3E92C6967AF00C673B4 /* Extensions */, + 527D5E7D2C60E68200736A85 /* Utils */, + 527D5E762C60D92900736A85 /* Components */, + 528D729F2C5BB7D100D53210 /* Theme */, + 52B189982C53B9FD00B5B6F9 /* Auth */, + 52B189972C53B9E900B5B6F9 /* Home */, + ); + path = Presentation; + sourceTree = ""; + }; + 52522F342C6DD9480015709C /* Profile */ = { + isa = PBXGroup; + children = ( + 52522F382C6DD9DA0015709C /* ProfileViewModel.swift */, + 52E95F0C2C6C797B00A3FE2E /* ProfileViewController.swift */, + 3D2D79D22C7CF4F70062BC3D /* PersonalDataViewController.swift */, + ); + path = Profile; + sourceTree = ""; + }; + 52522F352C6DD9860015709C /* Home */ = { + isa = PBXGroup; + children = ( + 52522F322C6DC7A40015709C /* HomeViewController.swift */, + 529A5F6B2C870D45004FE4A1 /* HorizontalPlaces.swift */, + 529A5F6D2C870FAF004FE4A1 /* HomeViewModel.swift */, + ); + path = Home; + sourceTree = ""; + }; + 52522F3C2C6DDF040015709C /* Profile */ = { + isa = PBXGroup; + children = ( + 52522F3D2C6DDF190015709C /* PersonalData.swift */, + 52522F3F2C6DDF290015709C /* CurrencyRates.swift */, + ); + path = Profile; + sourceTree = ""; + }; + 52522F442C6DFD220015709C /* Nav */ = { + isa = PBXGroup; + children = ( + 52522F452C6DFE060015709C /* AppTopBar.swift */, + 52522F472C6DFE460015709C /* AppBackButton.swift */, + 52522F492C6DFE580015709C /* BackButtonWithText.swift */, + ); + path = Nav; + sourceTree = ""; + }; + 5260D3C42C64B84600C673B4 /* Data */ = { + isa = PBXGroup; + children = ( + 5260D3D92C661FF000C673B4 /* ResourceError.swift */, + 5260D3DB2C66205700C673B4 /* Repositories */, + 5260D3CA2C64F59700C673B4 /* Prefs */, + 5260D3C92C64F58A00C673B4 /* Db */, + 5260D3C82C64F58300C673B4 /* Network */, + ); + path = Data; + sourceTree = ""; + }; + 5260D3C52C64B85B00C673B4 /* Domain */ = { + isa = PBXGroup; + children = ( + 5260D3E62C66437B00C673B4 /* Repositories */, + 5260D3C62C64B87D00C673B4 /* Models */, + ); + path = Domain; + sourceTree = ""; + }; + 5260D3C62C64B87D00C673B4 /* Models */ = { + isa = PBXGroup; + children = ( + 529A5F402C86E0F9004FE4A1 /* Category */, + 529A5F292C86DF21004FE4A1 /* Details */, + 52522F3C2C6DDF040015709C /* Profile */, + 52E95EFE2C6B32D900A3FE2E /* Responses */, + 5260D3E12C66287D00C673B4 /* Auth */, + 52ED919C2C71F639000EE25B /* SimpleResponse.swift */, + 529A5F342C86DF99004FE4A1 /* PlaceLocation.swift */, + CE6450232C9772310075A59B /* DownloadProgress.swift */, + ); + path = Models; + sourceTree = ""; + }; + 5260D3C72C64F52C00C673B4 /* Resources */ = { + isa = PBXGroup; + children = ( + E1E3F8472D7ACCB7002E7BDD /* Tajikistan.mwm */, + 52D588B62C5CE10200AB96B3 /* Fonts */, + 527D5E742C60A1F800736A85 /* Images.xcassets */, + ); + path = Resources; + sourceTree = ""; + }; + 5260D3C82C64F58300C673B4 /* Network */ = { + isa = PBXGroup; + children = ( + 52E95F052C6B797E00A3FE2E /* Utils */, + 5260D3DC2C66236500C673B4 /* Services */, + 5260D3CF2C64F7E200C673B4 /* DTO */, + 5260D3CD2C64F60200C673B4 /* APIEndpoints.swift */, + ); + path = Network; + sourceTree = ""; + }; + 5260D3C92C64F58A00C673B4 /* Db */ = { + isa = PBXGroup; + children = ( + E11202492D7454EF001B3B24 /* CoreDataManager.swift */, + CED0E0492C91A2A9008C61CA /* DBUtils.swift */, + CED0E0402C9077B7008C61CA /* PersistenceControllers */, + 3D2D79B82C7C4FC30062BC3D /* ControllerTemplates */, + 52ED91A02C72007C000EE25B /* DataModels */, + 52ED91B22C73211F000EE25B /* EntitiesMapping.swift */, + ); + path = Db; + sourceTree = ""; + }; + 5260D3CA2C64F59700C673B4 /* Prefs */ = { + isa = PBXGroup; + children = ( + 52522F2D2C6C9E060015709C /* UserPreferences.swift */, + ); + path = Prefs; + sourceTree = ""; + }; + 5260D3CF2C64F7E200C673B4 /* DTO */ = { + isa = PBXGroup; + children = ( + 5260D3D52C64F87500C673B4 /* Details */, + 5260D3D42C64F87200C673B4 /* Profile */, + 5260D3D22C64F84700C673B4 /* Auth */, + 529A5F362C86E02E004FE4A1 /* AllDataDTO.swift */, + 529A5F382C86E048004FE4A1 /* CategoryDTO.swift */, + 529A5F3A2C86E065004FE4A1 /* FavoritesDTO.swift */, + 529A5F3C2C86E08E004FE4A1 /* FavoritesIdsDTO.swift */, + ); + path = DTO; + sourceTree = ""; + }; + 5260D3D22C64F84700C673B4 /* Auth */ = { + isa = PBXGroup; + children = ( + 5260D3D02C64F7F100C673B4 /* SignInRequestDTO.swift */, + 5260D3D72C64F8BC00C673B4 /* AuthResponseDTO.swift */, + 52ED919E2C71F718000EE25B /* SignUpRequestDTO.swift */, + 529A5F3E2C86E09B004FE4A1 /* HashDTO.swift */, + CE8982022CB9588E00FC2D2E /* EmailBodyDto.swift */, + ); + path = Auth; + sourceTree = ""; + }; + 5260D3D42C64F87200C673B4 /* Profile */ = { + isa = PBXGroup; + children = ( + 52ED91AA2C7302A7000EE25B /* CurrencyRatesDTO.swift */, + 52ED91AF2C73030D000EE25B /* PersonalDataDTO.swift */, + CED0E0342C902527008C61CA /* LanguageDTO.swift */, + CED0E0362C902532008C61CA /* ThemeDTO.swift */, + ); + path = Profile; + sourceTree = ""; + }; + 5260D3D52C64F87500C673B4 /* Details */ = { + isa = PBXGroup; + children = ( + 529A5F1D2C86DDE5004FE4A1 /* PlaceDTO.swift */, + 529A5F212C86DE50004FE4A1 /* Reviews DTOs.swift */, + 529A5F272C86DEC5004FE4A1 /* UserDTO.swift */, + 529A5F1F2C86DE14004FE4A1 /* CoordinatesDTO.swift */, + ); + path = Details; + sourceTree = ""; + }; + 5260D3DB2C66205700C673B4 /* Repositories */ = { + isa = PBXGroup; + children = ( + 5260D3DF2C6624B900C673B4 /* AuthRepositoryImpl.swift */, + 52ED91A42C72C50F000EE25B /* CurrencyRepositoryImpl.swift */, + 3D2D79BB2C7C5E300062BC3D /* ProfileRepositoryImpl.swift */, + 52A48AE22C887BA00081E522 /* PlacesRepositoryImpl.swift */, + 52A48AE82C888AD90081E522 /* ReviewsRepositoryImpl.swift */, + ); + path = Repositories; + sourceTree = ""; + }; + 5260D3DC2C66236500C673B4 /* Services */ = { + isa = PBXGroup; + children = ( + 5260D3DD2C66237700C673B4 /* AuthService.swift */, + 52ED91A82C73020A000EE25B /* CurrencyService.swift */, + 3D2D79CB2C7C8C350062BC3D /* ProfileService.swift */, + 52A48AEA2C888B2C0081E522 /* PlacesService.swift */, + 52A48AEC2C888B370081E522 /* ReviewsService.swift */, + ); + path = Services; + sourceTree = ""; + }; + 5260D3E12C66287D00C673B4 /* Auth */ = { + isa = PBXGroup; + children = ( + 5260D3E22C66289900C673B4 /* SignInRequest.swift */, + 5260D3E42C66290500C673B4 /* AuthResponse.swift */, + 52E95F062C6B7E2400A3FE2E /* SignUpRequest.swift */, + ); + path = Auth; + sourceTree = ""; + }; + 5260D3E62C66437B00C673B4 /* Repositories */ = { + isa = PBXGroup; + children = ( + 5260D3E72C66439400C673B4 /* AuthRepository.swift */, + 52CD2D842C6F093A00CCC439 /* CurrencyRepository.swift */, + 52CD2D882C6F0AF200CCC439 /* ProfileRepository.swift */, + 52A48AE42C887EA70081E522 /* PlacesRepository.swift */, + 52A48AE62C8882A90081E522 /* ReviewsRepository.swift */, + ); + path = Repositories; + sourceTree = ""; + }; + 5260D3E92C6967AF00C673B4 /* Extensions */ = { + isa = PBXGroup; + children = ( + 527D5E812C60EFEE00736A85 /* UIViewExtensions.swift */, + 52E95F0A2C6B8CC800A3FE2E /* UIViewControllerExtensions.swift */, + 3D585BF32C760850005DF71F /* UIScreenExtensions.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 527D5E762C60D92900736A85 /* Components */ = { + isa = PBXGroup; + children = ( + 529A5F5C2C86E37A004FE4A1 /* Special */, + 529A5F172C85BF99004FE4A1 /* ToastView */, + 3D585BF72C768BED005DF71F /* Buttons */, + 52522F442C6DFD220015709C /* Nav */, + 52B573F32C61F10B0047FAC9 /* TextFields */, + 52B573F82C6223CE0047FAC9 /* AuthBackButton.swift */, + 52B573FD2C624A520047FAC9 /* CountryPickerUtils.swift */, + 52522F4B2C6E10FD0015709C /* LoadImg.swift */, + 3D2D79D42C7CF6970062BC3D /* Spacers.swift */, + 3D2D79DC2C7DE34B0062BC3D /* ImagePicker.swift */, + CED0E02B2C8F6BFF008C61CA /* MultiImagePicker.swift */, + 529A5F622C86E39A004FE4A1 /* AppSearchBar.swift */, + 529A5F612C86E39A004FE4A1 /* HorizontalSingleChoice.swift */, + CED0E0182C8AD57C008C61CA /* EmptyUI.swift */, + CED0E0292C8C88B9008C61CA /* FlowLayout.swift */, + ); + path = Components; + sourceTree = ""; + }; + 527D5E792C60E04900736A85 /* Utils */ = { + isa = PBXGroup; + children = ( + E153EE402D80C5BE0065233D /* FileManagerBridge.h */, + E153EE3E2D80C59E0065233D /* FileManagerBridge.mm */, + E153EE3A2D80B3780065233D /* FileManagerUtils.swift */, + 527D5E7A2C60E05D00736A85 /* LanguageUtils.swift */, + CE6450272C99572F0075A59B /* ImageStoreUtils.swift */, + ); + path = Utils; + sourceTree = ""; + }; + 527D5E7D2C60E68200736A85 /* Utils */ = { + isa = PBXGroup; + children = ( + 527D5E7E2C60E69C00736A85 /* Layouting.swift */, + 3DA3FC982C75ED2A0065E4D6 /* changeTheme.swift */, + CED0E0162C8ACF0D008C61CA /* RoundedCornerShape.swift */, + CED0E0382C904868008C61CA /* NavigationUtils.swift */, + ); + path = Utils; + sourceTree = ""; + }; + 528D729F2C5BB7D100D53210 /* Theme */ = { + isa = PBXGroup; + children = ( + 528D72A02C5BBBF700D53210 /* Colors.xcassets */, + 52D588A82C5CD56200AB96B3 /* Color.swift */, + 52D588B92C5CE2E800AB96B3 /* Font.swift */, + ); + path = Theme; + sourceTree = ""; + }; + 529A5F172C85BF99004FE4A1 /* ToastView */ = { + isa = PBXGroup; + children = ( + 529A5F182C85BFF0004FE4A1 /* ToastView.swift */, + ); + path = ToastView; + sourceTree = ""; + }; + 529A5F292C86DF21004FE4A1 /* Details */ = { + isa = PBXGroup; + children = ( + 529A5F2A2C86DF2D004FE4A1 /* PlaceShort.swift */, + 529A5F2C2C86DF3B004FE4A1 /* User.swift */, + 529A5F302C86DF61004FE4A1 /* Review Models.swift */, + 529A5F322C86DF6F004FE4A1 /* PlaceFull.swift */, + CED0E0442C918ED4008C61CA /* Hash.swift */, + ); + path = Details; + sourceTree = ""; + }; + 529A5F402C86E0F9004FE4A1 /* Category */ = { + isa = PBXGroup; + children = ( + 529A5F412C86E108004FE4A1 /* Category.swift */, + 529A5F432C86E118004FE4A1 /* PlaceCategory.swift */, + ); + path = Category; + sourceTree = ""; + }; + 529A5F5C2C86E37A004FE4A1 /* Special */ = { + isa = PBXGroup; + children = ( + 529A5F5D2C86E37A004FE4A1 /* PlacesItem.swift */, + CED0E0232C8C6DF9008C61CA /* RatingBarView.swift */, + ); + path = Special; + sourceTree = ""; + }; + 529A5F652C8706C7004FE4A1 /* Categories */ = { + isa = PBXGroup; + children = ( + 529A5F672C8707CD004FE4A1 /* CategoriesViewController.swift */, + 529A5F6F2C8720A8004FE4A1 /* CategoriesViewModel.swift */, + ); + path = Categories; + sourceTree = ""; + }; + 529A5F662C8706D1004FE4A1 /* Favorites */ = { + isa = PBXGroup; + children = ( + 529A5F692C8707F9004FE4A1 /* FavoritesViewController.swift */, + CED0E03A2C904A06008C61CA /* FavoritesViewModel.swift */, + ); + path = Favorites; + sourceTree = ""; + }; + 52A48ADB2C882FD70081E522 /* Search */ = { + isa = PBXGroup; + children = ( + 52A48ADE2C882FE40081E522 /* SearchViewController.swift */, + 52A48AE02C882FEE0081E522 /* SearchViewModel.swift */, + ); + path = Search; + sourceTree = ""; + }; + 52A48AEE2C8988CC0081E522 /* PlaceDetails */ = { + isa = PBXGroup; + children = ( + 52ECA8162C8A253D00F213B3 /* Components */, + 52A48AF02C8989630081E522 /* Description */, + 52A48AF12C89896B0081E522 /* Gallery */, + 52A48AF22C8989780081E522 /* Reviews */, + 52EF1B612C8989F1003046A4 /* PlaceViewController.swift */, + 52EF1B652C8989F9003046A4 /* PlaceViewModel.swift */, + CED0E01A2C8B048C008C61CA /* AllPicsScreen.swift */, + CE60A8C42CAD15C20055F49C /* FullscreenImageViewer.swift */, + ); + name = PlaceDetails; + path = Profile/PlaceDetails; + sourceTree = ""; + }; + 52A48AF02C8989630081E522 /* Description */ = { + isa = PBXGroup; + children = ( + 52ECA80E2C8A0D5F00F213B3 /* DescriptionScreen.swift */, + ); + path = Description; + sourceTree = ""; + }; + 52A48AF12C89896B0081E522 /* Gallery */ = { + isa = PBXGroup; + children = ( + 52ECA8142C8A0D9E00F213B3 /* GalleryScreen.swift */, + ); + path = Gallery; + sourceTree = ""; + }; + 52A48AF22C8989780081E522 /* Reviews */ = { + isa = PBXGroup; + children = ( + CED0E0202C8B22BD008C61CA /* Components */, + 52ECA8122C8A0D7A00F213B3 /* ReviewsScreen.swift */, + CED0E0252C8C85BD008C61CA /* PostReviewViewModel.swift */, + CED0E0302C900BB2008C61CA /* AllReviewsScreen.swift */, + CED0E0322C900D4C008C61CA /* ReviewsViewModel.swift */, + ); + path = Reviews; + sourceTree = ""; + }; + 52B189972C53B9E900B5B6F9 /* Home */ = { + isa = PBXGroup; + children = ( + 52522F302C6CC87B0015709C /* TabBarController.swift */, + 52E2D39B2C58E72900A8843A /* Screens */, + 524634CC2C57232400FDCABA /* TourismMain.storyboard */, + 52522F3A2C6DDA750015709C /* ThemeViewModel.swift */, + CEA45BC32C9AE01000ABE6B2 /* DataSyncer.swift */, + ); + path = Home; + sourceTree = ""; + }; + 52B189982C53B9FD00B5B6F9 /* Auth */ = { + isa = PBXGroup; + children = ( + 52E2D39E2C58E78700A8843A /* Screens */, + 524634C22C53BB3A00FDCABA /* Auth.storyboard */, + ); + path = Auth; + sourceTree = ""; + }; + 52B573F32C61F10B0047FAC9 /* TextFields */ = { + isa = PBXGroup; + children = ( + 3D2D79D62C7D0ABF0062BC3D /* AppTextField.swift */, + 52B573F42C61F11E0047FAC9 /* AuhtTextField.swift */, + 52B573F62C61F4D00047FAC9 /* PasswordTextField.swift */, + ); + path = TextFields; + sourceTree = ""; + }; + 52D588B62C5CE10200AB96B3 /* Fonts */ = { + isa = PBXGroup; + children = ( + 52D588C02C5CEAF800AB96B3 /* Gilroy-Black.ttf */, + 52D588BC2C5CEAF800AB96B3 /* Gilroy-Bold.ttf */, + 52D588C42C5CEAF900AB96B3 /* Gilroy-ExtraBold.ttf */, + 52D588BD2C5CEAF800AB96B3 /* Gilroy-Heavy.ttf */, + 52D588C32C5CEAF800AB96B3 /* Gilroy-Light.ttf */, + 52D588BF2C5CEAF800AB96B3 /* Gilroy-Medium.ttf */, + 52D588C12C5CEAF800AB96B3 /* Gilroy-Regular.ttf */, + 52D588C22C5CEAF800AB96B3 /* Gilroy-SemiBold.ttf */, + 52D588BB2C5CEAF800AB96B3 /* Gilroy-Thin.ttf */, + 52D588BE2C5CEAF800AB96B3 /* Gilroy-UltraLight.ttf */, + ); + path = Fonts; + sourceTree = ""; + }; + 52E2D39B2C58E72900A8843A /* Screens */ = { + isa = PBXGroup; + children = ( + 52522F352C6DD9860015709C /* Home */, + 52A48ADB2C882FD70081E522 /* Search */, + 529A5F652C8706C7004FE4A1 /* Categories */, + 529A5F662C8706D1004FE4A1 /* Favorites */, + 52522F342C6DD9480015709C /* Profile */, + 52A48AEE2C8988CC0081E522 /* PlaceDetails */, + ); + path = Screens; + sourceTree = ""; + }; + 52E2D39E2C58E78700A8843A /* Screens */ = { + isa = PBXGroup; + children = ( + 52E2D3A32C59F9CE00A8843A /* WelcomeViewController.swift */, + 52B573EB2C61E1C10047FAC9 /* SignInViewController.swift */, + 52B573F12C61E8980047FAC9 /* SignUpViewController.swift */, + CE8982042CBCD46300FC2D2E /* ForgotPasswordViewController.swift */, + ); + path = Screens; + sourceTree = ""; + }; + 52E95EFE2C6B32D900A3FE2E /* Responses */ = { + isa = PBXGroup; + children = ( + 52E95F012C6B32E500A3FE2E /* ErrorResponse.swift */, + ); + path = Responses; + sourceTree = ""; + }; + 52E95F052C6B797E00A3FE2E /* Utils */ = { + isa = PBXGroup; + children = ( + 52E95F032C6B71B900A3FE2E /* NetworkHelper.swift */, + ); + path = Utils; + sourceTree = ""; + }; + 52ECA8162C8A253D00F213B3 /* Components */ = { + isa = PBXGroup; + children = ( + 52ECA8172C8A255900F213B3 /* PlaceTabsBar.swift */, + 52ECA8192C8A25D800F213B3 /* PlaceTopBar.swift */, + ); + path = Components; + sourceTree = ""; + }; + 52ED91A02C72007C000EE25B /* DataModels */ = { + isa = PBXGroup; + children = ( + CED0E04B2C91A6A3008C61CA /* CoordinatesEntity.swift */, + CED0E04D2C91A702008C61CA /* UserEntity.swift */, + E11202442D744BFA001B3B24 /* TourismDB.xcdatamodeld */, + ); + path = DataModels; + sourceTree = ""; + }; 97B4E9271851DAB300BEC5D7 /* Custom Views */ = { isa = PBXGroup; children = ( @@ -3040,6 +3882,36 @@ path = Location; sourceTree = ""; }; + CE2D27FA2CA2C61500094565 /* BackButtonWithText */ = { + isa = PBXGroup; + children = ( + CE2D27F72CA2C49F00094565 /* BackButtonWithText.m */, + CE2D27FB2CA2C64700094565 /* BackButtonWithText.h */, + ); + path = BackButtonWithText; + sourceTree = ""; + }; + CED0E0202C8B22BD008C61CA /* Components */ = { + isa = PBXGroup; + children = ( + CED0E0272C8C85C9008C61CA /* PostReviewView.swift */, + CED0E0212C8B22CD008C61CA /* ReviewView.swift */, + ); + path = Components; + sourceTree = ""; + }; + CED0E0402C9077B7008C61CA /* PersistenceControllers */ = { + isa = PBXGroup; + children = ( + 52ED91A62C72C58A000EE25B /* CurrencyPersistenceController.swift */, + 3D2D79C22C7C80E60062BC3D /* PersonalDataPersistenceController.swift */, + CED0E0412C9077D3008C61CA /* HashesPersistenceController.swift */, + CED0E0462C919F44008C61CA /* PlacesPersistenceController.swift */, + CE64501C2C93F8350075A59B /* ReviewsPersistenceController.swift */, + ); + path = PersistenceControllers; + sourceTree = ""; + }; ED1ADA312BC6B19E0029209F /* Tests */ = { isa = PBXGroup; children = ( @@ -3829,8 +4701,12 @@ FA456C4D26BDCC9400B83C20 /* PBXTargetDependency */, ); name = OMaps; + packageProductDependencies = ( + CED0E00D2C8ACBCA008C61CA /* SDWebImageSwiftUI */, + CED0E0102C8ACBE1008C61CA /* CountryPickerView */, + ); productName = Maps; - productReference = 6741AA5D1BF340DE002C974C /* Organic Maps (Debug).app */; + productReference = 6741AA5D1BF340DE002C974C /* Tourism (Debug).app */; productType = "com.apple.product-type.application"; }; ED097E752BB80C320006ED01 /* OMapsTests */ = { @@ -3925,6 +4801,10 @@ hi, ); mainGroup = 29B97314FDCFA39411CA2CEA /* Maps */; + packageReferences = ( + CED0E00C2C8ACBCA008C61CA /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */, + CED0E00F2C8ACBE1008C61CA /* XCRemoteSwiftPackageReference "CountryPickerView" */, + ); productRefGroup = 19C28FACFE9D520D11CA2CBB /* Products */; projectDirPath = ""; projectReferences = ( @@ -3970,6 +4850,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 524634C62C53BC3100FDCABA /* Auth.storyboard in Resources */, 47AEF8402231249E00D20538 /* categories_brands.txt in Resources */, F6C3A1B221AC22810060EEC8 /* Alert 5.m4a in Resources */, 6741A9531BF340DE002C974C /* 01_dejavusans.ttf in Resources */, @@ -3981,6 +4862,7 @@ 6741A9591BF340DE002C974C /* 05_khmeros.ttf in Resources */, 34AB66801FC5AA330078E451 /* MWMiPhoneRoutePreview.xib in Resources */, 6741A95B1BF340DE002C974C /* 06_code2000.ttf in Resources */, + 52D588CA2C5CEAF900AB96B3 /* Gilroy-Black.ttf in Resources */, 471A7BC22481D44B00A0D4C1 /* BookmarkTitleCell.xib in Resources */, 6741A99F1BF340DE002C974C /* 07_roboto_medium.ttf in Resources */, FA637ED329A500BE00D8921A /* drules_proto_outdoors_light.txt in Resources */, @@ -3990,11 +4872,13 @@ 47CA68D22500435E00671019 /* BookmarksListViewController.xib in Resources */, 34AB66141FC5AA320078E451 /* MWMNavigationInfoView.xib in Resources */, 340E1EEC1E2F614400CE49BF /* Authorization.storyboard in Resources */, + 524634CD2C57232400FDCABA /* TourismMain.storyboard in Resources */, 6741A95C1BF340DE002C974C /* categories.txt in Resources */, 6741A9451BF340DE002C974C /* classificator.txt in Resources */, 676507611C10559B00830BB3 /* colors.txt in Resources */, 6741A9711BF340DE002C974C /* copyright.html in Resources */, 4A300ED51C6DCFD400140018 /* countries-strings in Resources */, + 52D588CB2C5CEAF900AB96B3 /* Gilroy-Regular.ttf in Resources */, 6741A9491BF340DE002C974C /* countries.txt in Resources */, 6741A9871BF340DE002C974C /* drules_proto_default_light.bin in Resources */, 6741A97E1BF340DE002C974C /* drules_proto_default_dark.bin in Resources */, @@ -4009,12 +4893,14 @@ 34F73F9F1E082FF800AC1FD6 /* Localizable.strings in Resources */, 340E1EF21E2F614400CE49BF /* Main.storyboard in Resources */, F6E2FE521E097BA00083EBEC /* MWMActionBarButton.xib in Resources */, + 52D588CC2C5CEAF900AB96B3 /* Gilroy-SemiBold.ttf in Resources */, EDBD68072B625724005DD151 /* LocationServicesDisabledAlert.xib in Resources */, 993DF0CA23F6BD0600AC231A /* ElevationDetailsViewController.xib in Resources */, F623DA6F1C9C2E62006A3436 /* MWMAddPlaceNavigationBar.xib in Resources */, 6741A9991BF340DE002C974C /* MWMAlertViewController.xib in Resources */, 4501B1942077C35A001B9173 /* resources-xxxhdpi_light in Resources */, 3467CEB7202C6FA900D3C670 /* BMCNotificationsCell.xib in Resources */, + 52D588C62C5CEAF900AB96B3 /* Gilroy-Bold.ttf in Resources */, 4761BE2B252D3DB900EE2DE4 /* SubgroupCell.xib in Resources */, 99F9A0E72462CA1700AE21E0 /* DownloadAllView.xib in Resources */, 349D1AD51E2E325B004A2006 /* BottomMenuItemCell.xib in Resources */, @@ -4033,6 +4919,7 @@ 34D3B0331E389D05004100F9 /* MWMEditorCategoryCell.xib in Resources */, F6E2FDCB1E097BA00083EBEC /* MWMEditorNotesFooter.xib in Resources */, 34D3B0391E389D05004100F9 /* MWMEditorSelectTableViewCell.xib in Resources */, + 52D588CD2C5CEAF900AB96B3 /* Gilroy-Light.ttf in Resources */, 34AB663B1FC5AA330078E451 /* RouteManagerViewController.xib in Resources */, 34D3B03F1E389D05004100F9 /* MWMEditorSwitchTableViewCell.xib in Resources */, 467F341B2BCA928B00CDC7DE /* MWMEditorSegmentedTableViewCell.xib in Resources */, @@ -4040,8 +4927,10 @@ ED0B1C312BC2951F00FB8EDD /* PrivacyInfo.xcprivacy in Resources */, F64D9CA31C899C760063FA30 /* MWMEditorViralAlert.xib in Resources */, 6741A96D1BF340DE002C974C /* MWMLocationAlert.xib in Resources */, + E1E3F8482D7ACCB7002E7BDD /* Tajikistan.mwm in Resources */, 3406FA191C6E0D8F00E9FAD2 /* MWMMapDownloadDialog.xib in Resources */, A630D1EA207CA95900976DEA /* Localizable.stringsdict in Resources */, + 52D588C92C5CEAF900AB96B3 /* Gilroy-Medium.ttf in Resources */, F6E2FD591E097BA00083EBEC /* MWMMapDownloaderButtonTableViewCell.xib in Resources */, F62607FF207B83C400176C5A /* MWMSpinnerAlert.xib in Resources */, F6E2FD621E097BA00083EBEC /* MWMMapDownloaderLargeCountryTableViewCell.xib in Resources */, @@ -4082,6 +4971,7 @@ F6E2FF061E097BA00083EBEC /* SearchHistoryClearCell.xib in Resources */, BB7626B61E85599C0031D71C /* icudt73l.dat in Resources */, 34AB665C1FC5AA330078E451 /* TransportTransitIntermediatePoint.xib in Resources */, + 52D588CE2C5CEAF900AB96B3 /* Gilroy-ExtraBold.ttf in Resources */, F6E2FF151E097BA00083EBEC /* SearchHistoryQueryCell.xib in Resources */, F6E2FEE81E097BA00083EBEC /* MWMSearchNoResults.xib in Resources */, 34AB660E1FC5AA320078E451 /* NavigationControlView.xib in Resources */, @@ -4096,6 +4986,7 @@ F6D67CDE2062BBA60032FD38 /* MWMBCCreateCategoryAlert.xib in Resources */, 3490D2E31CE9DD2500D0B838 /* MWMSideButtonsView.xib in Resources */, F6E2FE2E1E097BA00083EBEC /* MWMStreetEditorEditTableViewCell.xib in Resources */, + 52D588C72C5CEAF900AB96B3 /* Gilroy-Heavy.ttf in Resources */, 3463BA691DE81DB90082417F /* MWMTrafficButtonViewController.xib in Resources */, F623DA6C1C9C2731006A3436 /* opening_hours_how_to_edit.html in Resources */, 447DB4C42BA78665000DF4C2 /* MWMOsmReauthAlert.xib in Resources */, @@ -4107,12 +4998,14 @@ 6741A9741BF340DE002C974C /* resources-6plus_dark in Resources */, 677A2DE21C0DD50900635A00 /* resources-default in Resources */, F607C1881C032A8800B53A87 /* resources-hdpi_light in Resources */, + 527D5E752C60A1F800736A85 /* Images.xcassets in Resources */, F607C18A1C032A8800B53A87 /* resources-hdpi_dark in Resources */, FA637ED229A500BE00D8921A /* drules_proto_outdoors_light.bin in Resources */, 6741A97F1BF340DE002C974C /* resources-mdpi_light in Resources */, 6741A9901BF340DE002C974C /* resources-mdpi_dark in Resources */, 6741A9981BF340DE002C974C /* resources-xhdpi_light in Resources */, 6741A9611BF340DE002C974C /* resources-xhdpi_dark in Resources */, + 52D588C52C5CEAF900AB96B3 /* Gilroy-Thin.ttf in Resources */, 6741A94D1BF340DE002C974C /* resources-xxhdpi_light in Resources */, 3404F49E2028A2430090E401 /* BMCActionsCell.xib in Resources */, 6741A9551BF340DE002C974C /* resources-xxhdpi_dark in Resources */, @@ -4121,6 +5014,8 @@ 44360A112A7D35440016F412 /* TransportRuler.xib in Resources */, 6741A9421BF340DE002C974C /* sound-strings in Resources */, F69018BD1E9F7CB600B3C10B /* MWMAutoupdateController.xib in Resources */, + 52D588C82C5CEAF900AB96B3 /* Gilroy-UltraLight.ttf in Resources */, + 528D72A12C5BBBF700D53210 /* Colors.xcassets in Resources */, 6741A97D1BF340DE002C974C /* synonyms.txt in Resources */, CDB4D4D7222D248900104869 /* CarPlayStoryboard.storyboard in Resources */, 4707E4B42372FF480017DF6E /* PlacePage.storyboard in Resources */, @@ -4153,25 +5048,35 @@ 6741A9A31BF340DE002C974C /* main.mm in Sources */, 34D3B04F1E38A20C004100F9 /* Bundle+Init.swift in Sources */, 34AB666E1FC5AA330078E451 /* TransportTransitStepsCollectionView.swift in Sources */, + 3D2D79D52C7CF6970062BC3D /* Spacers.swift in Sources */, 993DF11E23F6BDB100AC231A /* UITextViewRenderer.swift in Sources */, F6E2FF5A1E097BA00083EBEC /* MWMNightModeController.m in Sources */, 471A7BB8247FE3C300A0D4C1 /* URL+Query.swift in Sources */, 47F86D0120C93D8D00FEE291 /* TabViewController.swift in Sources */, + 52E95F022C6B32E500A3FE2E /* ErrorResponse.swift in Sources */, + CE6450282C99572F0075A59B /* ImageStoreUtils.swift in Sources */, 99536113235DB86C008B218F /* InsetsLabel.swift in Sources */, + 52ECA8182C8A255900F213B3 /* PlaceTabsBar.swift in Sources */, 6741A9A51BF340DE002C974C /* MWMShareActivityItem.mm in Sources */, 994F790723E85C5900660E75 /* DifficultyView.swift in Sources */, F6E2FF5D1E097BA00083EBEC /* MWMRecentTrackSettingsController.mm in Sources */, 34AB66651FC5AA330078E451 /* TransportTransitTrain.swift in Sources */, FA85D44E279B738F00B858E9 /* CopyableLabel.swift in Sources */, + CED0E0262C8C85BD008C61CA /* PostReviewViewModel.swift in Sources */, 993DF11C23F6BDB100AC231A /* UITableViewHeaderFooterViewRenderer.swift in Sources */, 99A906E623F6F7030005872B /* OpeningHoursViewController.swift in Sources */, 343064411E9FDC7300DC7665 /* SearchIndex.swift in Sources */, CDCA273F2238087700167D87 /* MWMCarPlaySearchService.mm in Sources */, + 529A5F392C86E048004FE4A1 /* CategoryDTO.swift in Sources */, + 529A5F1E2C86DDE5004FE4A1 /* PlaceDTO.swift in Sources */, + CE64501D2C93F8350075A59B /* ReviewsPersistenceController.swift in Sources */, 348B926D1FF3B5E100379009 /* UIView+Animation.swift in Sources */, F6E2FDE91E097BA00083EBEC /* MWMObjectsCategorySelectorController.mm in Sources */, 34AB665F1FC5AA330078E451 /* TransportTransitIntermediatePoint.swift in Sources */, 34B846A82029E8110081ECCD /* BMCDefaultViewModel.swift in Sources */, 993DF12123F6BDB100AC231A /* UIViewControllerRenderer.swift in Sources */, + 529A5F192C85BFF0004FE4A1 /* ToastView.swift in Sources */, + CED0E0422C9077D3008C61CA /* HashesPersistenceController.swift in Sources */, 34D3AFF61E37A36A004100F9 /* UICollectionView+Cells.swift in Sources */, 4767CDA420AAF66B00BD8166 /* NSAttributedString+HTML.swift in Sources */, 6741A9A91BF340DE002C974C /* MWMDefaultAlert.mm in Sources */, @@ -4180,9 +5085,11 @@ 340708781F2B5D6C00029ECC /* DimBackground.swift in Sources */, 3490D2DF1CE9DD2500D0B838 /* MWMSideButtons.mm in Sources */, 99AAEA76244DA7BF0039D110 /* BottomMenuTransitioning.swift in Sources */, + 52522F482C6DFE460015709C /* AppBackButton.swift in Sources */, F6E2FDF81E097BA00083EBEC /* MWMOpeningHoursAllDayTableViewCell.mm in Sources */, 340B33C61F3AEFDB00A8C1B4 /* MWMRouter+RouteManager.mm in Sources */, F6E2FE191E097BA00083EBEC /* MWMOpeningHoursTimeSpanTableViewCell.mm in Sources */, + E153EE3D2D80B3830065233D /* FileManagerUtils.swift in Sources */, 4757D6212535BB6E0062364F /* BookmarksListInterfaces.swift in Sources */, F6E2FDEC1E097BA00083EBEC /* MWMOpeningHoursAddClosedTableViewCell.mm in Sources */, 99C964292428C0F700E41723 /* PlacePageHeaderPresenter.swift in Sources */, @@ -4192,6 +5099,7 @@ 34F742321E0834F400AC1FD6 /* UIViewController+Navigation.m in Sources */, 340475811E081B3300C92850 /* iosOGLContextFactory.mm in Sources */, 99F3EB0623F418A200C713F8 /* PlacePagePresenter.swift in Sources */, + 52E95F072C6B7E2400A3FE2E /* SignUpRequest.swift in Sources */, AA1C7E3E269A2DD600BAADF2 /* EditTrackViewController.swift in Sources */, 34AB66561FC5AA330078E451 /* TransportTransitPedestrian.swift in Sources */, 993DF0C823F6BD0600AC231A /* ElevationDetailsViewController.swift in Sources */, @@ -4201,27 +5109,40 @@ 47D48BF52432A7CA00FEFB1F /* ChartViewRenderer.swift in Sources */, CDCA273A2237FCFE00167D87 /* SearchTemplateBuilder.swift in Sources */, 993DF10B23F6BDB100AC231A /* CheckmarkRenderer.swift in Sources */, + CED0E02C2C8F6BFF008C61CA /* MultiImagePicker.swift in Sources */, F6E2FED01E097BA00083EBEC /* MWMSearchFilterViewController.mm in Sources */, 34D3B01B1E389D05004100F9 /* MWMButtonCell.m in Sources */, 337F98B421D3C9F200C8AC27 /* SearchHistoryViewController.swift in Sources */, 3404F49D2028A2430090E401 /* BMCActionsCell.swift in Sources */, F6E2FD8F1E097BA00083EBEC /* MWMNoMapsViewController.mm in Sources */, + 529A5F442C86E118004FE4A1 /* PlaceCategory.swift in Sources */, + 52522F332C6DC7A40015709C /* HomeViewController.swift in Sources */, 993DF12C23F6BDB100AC231A /* Theme.swift in Sources */, 47CA68D8250044C500671019 /* BookmarksListRouter.swift in Sources */, + 52ED91AB2C7302A7000EE25B /* CurrencyRatesDTO.swift in Sources */, + CED0E0472C919F44008C61CA /* PlacesPersistenceController.swift in Sources */, 34D3B0421E389D05004100F9 /* MWMEditorTextTableViewCell.m in Sources */, 99F3EB1223F418C900C713F8 /* PlacePageInteractor.swift in Sources */, + 52522F3B2C6DDA750015709C /* ThemeViewModel.swift in Sources */, 340708651F2905A500029ECC /* NavigationInfoArea.swift in Sources */, 993DF0CC23F6BD0600AC231A /* ElevationDetailsPresenter.swift in Sources */, 34AB666B1FC5AA330078E451 /* TransportTransitCell.swift in Sources */, 47E8163323B17734008FD836 /* MWMStorage+UI.m in Sources */, + 3D2D79BA2C7C508E0062BC3D /* SingleEntityCoreDataController.swift in Sources */, 993DF11123F6BDB100AC231A /* UILabelRenderer.swift in Sources */, + E112024A2D7454F3001B3B24 /* CoreDataManager.swift in Sources */, + 52A48AE12C882FEE0081E522 /* SearchViewModel.swift in Sources */, 34AB66471FC5AA330078E451 /* RouteManagerTableView.swift in Sources */, + 52D588BA2C5CE2E800AB96B3 /* Font.swift in Sources */, 9989273C2449E60200260CE2 /* BottomMenuInteractor.swift in Sources */, 47DF72B922520CE20004AB10 /* MWMRoutingOptions.mm in Sources */, 999FC12023ABA9AD00B0E6F9 /* SearchStyleSheet.swift in Sources */, 3D15ACEE2155117000F725D5 /* MWMObjectsCategorySelectorDataSource.mm in Sources */, + 52EF1B662C8989F9003046A4 /* PlaceViewModel.swift in Sources */, + CED0E0242C8C6DF9008C61CA /* RatingBarView.swift in Sources */, 9977E6A32480F9BF0073780C /* BottomMenuLayerButtonRenderer.swift in Sources */, 3454D7D11E07F045004AF2AD /* UIImage+RGBAData.m in Sources */, + 52B573EC2C61E1C10047FAC9 /* SignInViewController.swift in Sources */, 6741A9B71BF340DE002C974C /* EAGLView.mm in Sources */, 6741A9B81BF340DE002C974C /* MapViewController.mm in Sources */, 34AB662C1FC5AA330078E451 /* RouteManagerViewModel.swift in Sources */, @@ -4236,6 +5157,7 @@ F6E2FF2D1E097BA00083EBEC /* MWMSearchCell.mm in Sources */, 3454D7C51E07F045004AF2AD /* UIButton+Orientation.m in Sources */, 34AB66831FC5AA330078E451 /* NavigationAddPointToastView.swift in Sources */, + 52E95F0B2C6B8CC800A3FE2E /* UIViewControllerExtensions.swift in Sources */, F6E2FE4C1E097BA00083EBEC /* MWMPlacePageManager.mm in Sources */, 3404757E1E081B3300C92850 /* iosOGLContext.mm in Sources */, 993F5513237C622700545511 /* DeepLinkHandler.swift in Sources */, @@ -4244,15 +5166,20 @@ CDCA278E2248F34C00167D87 /* MWMRoutingManager.mm in Sources */, 34D3AFF21E37945B004100F9 /* UITableView+Cells.swift in Sources */, 34D3B0301E389D05004100F9 /* MWMEditorCategoryCell.m in Sources */, + 52B573F72C61F4D00047FAC9 /* PasswordTextField.swift in Sources */, F653CE191C71F62700A453F1 /* MWMAddPlaceNavigationBar.mm in Sources */, + CED0E0332C900D4C008C61CA /* ReviewsViewModel.swift in Sources */, 340475621E081A4600C92850 /* MWMNetworkPolicy+UI.m in Sources */, F6E2FEE51E097BA00083EBEC /* MWMSearchNoResults.m in Sources */, 3DBD7BE42425015C00ED9FE8 /* ParntersStyleSheet.swift in Sources */, F6E2FF631E097BA00083EBEC /* MWMTTSLanguageViewController.mm in Sources */, + 52522F462C6DFE060015709C /* AppTopBar.swift in Sources */, + 52E2D3A42C59F9CE00A8843A /* WelcomeViewController.swift in Sources */, 4715273524907F8200E91BBA /* BookmarkColorViewController.swift in Sources */, 47E3C7292111E614008B3B27 /* FadeInAnimatedTransitioning.swift in Sources */, ED79A5D42BDF8D6100952D1F /* MetadataItem.swift in Sources */, 34AB667D1FC5AA330078E451 /* MWMRoutePreview.mm in Sources */, + 529A5F3F2C86E09B004FE4A1 /* HashDTO.swift in Sources */, 993DF11B23F6BDB100AC231A /* UIViewRenderer.swift in Sources */, 99C964302428C27A00E41723 /* PlacePageHeaderView.swift in Sources */, 9989273A2449E60200260CE2 /* BottomMenuViewController.swift in Sources */, @@ -4262,7 +5189,13 @@ 993DF0B523F6B2EF00AC231A /* PlacePageElevationLayout.swift in Sources */, 44360A0D2A7D34990016F412 /* TransportRuler.swift in Sources */, CD6E8677226774C700D1EDF7 /* CPConstants.swift in Sources */, + 52EF1B622C8989F1003046A4 /* PlaceViewController.swift in Sources */, + 529A5F642C86E39A004FE4A1 /* AppSearchBar.swift in Sources */, 99A906DE23F6F7030005872B /* PlacePageBookmarkViewController.swift in Sources */, + 52B573F92C6223CE0047FAC9 /* AuthBackButton.swift in Sources */, + 52522F4A2C6DFE580015709C /* BackButtonWithText.swift in Sources */, + 52ECA80F2C8A0D5F00F213B3 /* DescriptionScreen.swift in Sources */, + 529A5F2B2C86DF2D004FE4A1 /* PlaceShort.swift in Sources */, F6791B141C43DF0B007A8A6E /* MWMStartButton.m in Sources */, 9977E6A12480E1EE0073780C /* BottomMenuLayerButton.swift in Sources */, 471527372491C20500E91BBA /* SelectBookmarkGroupViewController.swift in Sources */, @@ -4271,6 +5204,8 @@ 34AC8FD11EFC02C000E7F910 /* MWMRoutePoint.mm in Sources */, CDB4D5012231412900104869 /* ListTemplateBuilder.swift in Sources */, 99A906F323FA95AB0005872B /* PlacePageStyleSheet.swift in Sources */, + 529A5F6C2C870D45004FE4A1 /* HorizontalPlaces.swift in Sources */, + 52522F2E2C6C9E070015709C /* UserPreferences.swift in Sources */, 6741A9CF1BF340DE002C974C /* MWMLocationAlert.m in Sources */, F6E2FDA11E097BA00083EBEC /* MWMEditorAdditionalNamesTableViewController.mm in Sources */, 4767CDA620AB1F6200BD8166 /* LeftAlignedIconButton.swift in Sources */, @@ -4282,6 +5217,7 @@ 993DF11023F6BDB100AC231A /* MWMButtonRenderer.swift in Sources */, 3463BA671DE81DB90082417F /* MWMTrafficButtonViewController.mm in Sources */, ED79A5D52BDF8D6100952D1F /* SynchronizationError.swift in Sources */, + 5260D3DA2C661FF000C673B4 /* ResourceError.swift in Sources */, 993DF10323F6BDB100AC231A /* MainTheme.swift in Sources */, 34AB66051FC5AA320078E451 /* MWMNavigationDashboardManager+Entity.mm in Sources */, 993DF12A23F6BDB100AC231A /* Style.swift in Sources */, @@ -4293,17 +5229,20 @@ 470F0B7D238842EA006AEC94 /* ExpandableLabel.swift in Sources */, B33D21AF20DAF9F000BAD749 /* Toast.swift in Sources */, 6741A9D41BF340DE002C974C /* MWMAlertViewController.mm in Sources */, + 52B573F52C61F11E0047FAC9 /* AuhtTextField.swift in Sources */, 34D3B0181E389D05004100F9 /* EditorAdditionalNamePlaceholderTableViewCell.swift in Sources */, 993DF12223F6BDB100AC231A /* UINavigationItemRenderer.swift in Sources */, 993DF12B23F6BDB100AC231A /* StyleManager.swift in Sources */, 470E1674252AD7F2002D201A /* BookmarksListInfoViewController.swift in Sources */, 47B9065521C7FA400079C85E /* NSString+MD5.m in Sources */, CDB4D5022231412900104869 /* SettingsTemplateBuilder.swift in Sources */, + 529A5F682C8707CD004FE4A1 /* CategoriesViewController.swift in Sources */, 993DF10A23F6BDB100AC231A /* UISwitchRenderer.swift in Sources */, 99C9642C2428C0F700E41723 /* PlacePageHeaderBuilder.swift in Sources */, 99C9642B2428C0F700E41723 /* PlacePageHeaderViewController.swift in Sources */, F6FE3C391CC50FFD00A73196 /* MWMPlaceDoesntExistAlert.m in Sources */, F6E2FDFE1E097BA00083EBEC /* MWMOpeningHoursClosedSpanTableViewCell.mm in Sources */, + CE8982032CB9588E00FC2D2E /* EmailBodyDto.swift in Sources */, 34B846A12029DCC10081ECCD /* BMCCategoriesHeader.swift in Sources */, 99A614D523C8911A00D8D8D0 /* AuthStyleSheet.swift in Sources */, 99A906F123FA946E0005872B /* DifficultyViewRenderer.swift in Sources */, @@ -4313,33 +5252,52 @@ F6E2FF481E097BA00083EBEC /* SettingsTableViewSelectableCell.swift in Sources */, ED63CEB92BDF8F9D006155C4 /* SettingsTableViewiCloudSwitchCell.swift in Sources */, 47CA68D4250043C000671019 /* BookmarksListPresenter.swift in Sources */, + 529A5F2D2C86DF3B004FE4A1 /* User.swift in Sources */, + 3D2D79D72C7D0AC00062BC3D /* AppTextField.swift in Sources */, + 527D5E7F2C60E69C00736A85 /* Layouting.swift in Sources */, F6E2FF451E097BA00083EBEC /* SettingsTableViewLinkCell.swift in Sources */, 34C9BD0A1C6DBCDA000DC38D /* MWMNavigationController.m in Sources */, ED1080A72B791CFE0023F27E /* SocialMediaCollectionViewHeader.swift in Sources */, F6E2FE311E097BA00083EBEC /* MWMStreetEditorViewController.mm in Sources */, + 529A5F632C86E39A004FE4A1 /* HorizontalSingleChoice.swift in Sources */, F6E2FE281E097BA00083EBEC /* MWMOpeningHoursSection.mm in Sources */, + 529A5F372C86E02E004FE4A1 /* AllDataDTO.swift in Sources */, + 52B573FE2C624A520047FAC9 /* CountryPickerUtils.swift in Sources */, 3406FA161C6E0C3300E9FAD2 /* MWMMapDownloadDialog.mm in Sources */, 340416481E7BF28E00E2B6D6 /* UIView+Snapshot.swift in Sources */, F6E2FE251E097BA00083EBEC /* MWMOpeningHoursModel.mm in Sources */, 99AAEA74244DA5ED0039D110 /* BottomMenuPresentationController.swift in Sources */, 99514BB823E82B450085D3A7 /* ElevationProfilePresenter.swift in Sources */, 34C9BD031C6DB693000DC38D /* MWMTableViewController.m in Sources */, + 52E95F0D2C6C797B00A3FE2E /* ProfileViewController.swift in Sources */, + CE2D27F82CA2C49F00094565 /* BackButtonWithText.m in Sources */, + E11202462D744BFA001B3B24 /* TourismDB.xcdatamodeld in Sources */, F6E2FD8C1E097BA00083EBEC /* MWMNoMapsView.m in Sources */, + 529A5F312C86DF61004FE4A1 /* Review Models.swift in Sources */, 34D3B0361E389D05004100F9 /* MWMEditorSelectTableViewCell.m in Sources */, 990128562449A82500C72B10 /* BottomTabBarView.swift in Sources */, + 529A5F422C86E108004FE4A1 /* Category.swift in Sources */, F6E2FD711E097BA00083EBEC /* MWMMapDownloaderTableViewCell.m in Sources */, + CED0E0352C902527008C61CA /* LanguageDTO.swift in Sources */, F6E2FE4F1E097BA00083EBEC /* MWMActionBarButton.m in Sources */, 47F86CFF20C936FC00FEE291 /* TabView.swift in Sources */, + 5260D3CE2C64F60200C673B4 /* APIEndpoints.swift in Sources */, 34AB66741FC5AA330078E451 /* BaseRoutePreviewStatus.swift in Sources */, 8C4FB9C72BEFEFF400D44877 /* CarPlayWindowScaleAdjuster.swift in Sources */, 349D1CE41E3F836900A878FD /* UIViewController+Hierarchy.swift in Sources */, + 52B573F02C61E4110047FAC9 /* Constants.swift in Sources */, 8CB13C3B2BF1276A004288F2 /* CarplayPlaceholderView.swift in Sources */, + 529A5F202C86DE14004FE4A1 /* CoordinatesDTO.swift in Sources */, CDB4D4E1222D70DF00104869 /* CarPlayMapViewController.swift in Sources */, 471AB98923AA8A3500F56D49 /* IDownloaderDataSource.swift in Sources */, + 52ED91B02C73030D000EE25B /* PersonalDataDTO.swift in Sources */, EDE243E72B6D55610057369B /* InfoView.swift in Sources */, F692F3831EA0FAF5001E82EB /* MWMAutoupdateController.mm in Sources */, + CED0E01B2C8B048C008C61CA /* AllPicsScreen.swift in Sources */, 34BF0CC71C31304A00D097EB /* MWMAuthorizationCommon.mm in Sources */, + 527D5E822C60EFEE00736A85 /* UIViewExtensions.swift in Sources */, 34AB664D1FC5AA330078E451 /* RouteManagerFooterView.swift in Sources */, + 3D2D79D32C7CF4F70062BC3D /* PersonalDataViewController.swift in Sources */, 6741A9E01BF340DE002C974C /* MWMDownloaderDialogHeader.mm in Sources */, CDCA2748223FD24600167D87 /* MWMCarPlaySearchResultObject.mm in Sources */, 475ED78824C7D0F30063ADC7 /* ValueStepperView.swift in Sources */, @@ -4347,12 +5305,16 @@ 99AAEA78244DA9810039D110 /* BottomMenuTransitioningManager.swift in Sources */, 4761BE2A252D3DB900EE2DE4 /* SubgroupCell.swift in Sources */, 1DFA2F6A20D3B57400FB2C66 /* UIColor+PartnerColor.m in Sources */, + 529A5F6E2C870FAF004FE4A1 /* HomeViewModel.swift in Sources */, 9989273B2449E60200260CE2 /* BottomMenuBuilder.swift in Sources */, 993DF10F23F6BDB100AC231A /* UIActivityIndicatorRenderer.swift in Sources */, + 52A48AE32C887BA00081E522 /* PlacesRepositoryImpl.swift in Sources */, 99A614E423CDD1D900D8D8D0 /* UIButton+RuntimeAttributes.m in Sources */, + 52CD2D852C6F093B00CCC439 /* CurrencyRepository.swift in Sources */, 343E75981E5B1EE20041226A /* MWMCollectionViewController.m in Sources */, 34E776141F14B17F003040B3 /* AvailableArea.swift in Sources */, 34AB66081FC5AA320078E451 /* MWMNavigationDashboardManager.mm in Sources */, + CED0E03B2C904A06008C61CA /* FavoritesViewModel.swift in Sources */, 3404F490202898CC0090E401 /* BMCModels.swift in Sources */, F6E2FD561E097BA00083EBEC /* MWMMapDownloaderButtonTableViewCell.m in Sources */, 9901284F244732DB00C72B10 /* BottomTabBarPresenter.swift in Sources */, @@ -4360,8 +5322,12 @@ 34AB66171FC5AA320078E451 /* MWMiPhoneRoutePreview.m in Sources */, 99A906EA23F6F7030005872B /* PlacePageInfoViewController.swift in Sources */, 993DF11723F6BDB100AC231A /* UINavigationBarRenderer.swift in Sources */, + 52522F312C6CC87C0015709C /* TabBarController.swift in Sources */, 6741A9E71BF340DE002C974C /* MWMCircularProgressView.m in Sources */, 34AC8FDB1EFC07FE00E7F910 /* UILabel+NumberOfVisibleLines.swift in Sources */, + 52ED91A52C72C50F000EE25B /* CurrencyRepositoryImpl.swift in Sources */, + 52A48AE92C888AD90081E522 /* ReviewsRepositoryImpl.swift in Sources */, + 52ECA8132C8A0D7A00F213B3 /* ReviewsScreen.swift in Sources */, ED79A5AD2BD7BA0F00952D1F /* UIApplication+LoadingOverlay.swift in Sources */, 9959C75C24599CCD008FD4FD /* DirectionView.swift in Sources */, 47CA68D62500448D00671019 /* BookmarksListInteractor.swift in Sources */, @@ -4369,16 +5335,25 @@ CDCA2745223FCFD200167D87 /* SearchResultInfo.swift in Sources */, 349A13831DEC138C00C7DB60 /* MWMMobileInternetAlert.m in Sources */, 6741A9EC1BF340DE002C974C /* MWMCircularProgress.m in Sources */, + 3D585BF42C760850005DF71F /* UIScreenExtensions.swift in Sources */, 993DF11923F6BDB100AC231A /* UITextFieldRenderer.swift in Sources */, + 5260D3E82C66439400C673B4 /* AuthRepository.swift in Sources */, 342CC5F21C2D7730005F3FE5 /* MWMAuthorizationLoginViewController.mm in Sources */, + CED0E0282C8C85C9008C61CA /* PostReviewView.swift in Sources */, + 529A5F5E2C86E37A004FE4A1 /* PlacesItem.swift in Sources */, 340475591E081A4600C92850 /* WebViewController.m in Sources */, 3404F4992028A20D0090E401 /* BMCCategoryCell.swift in Sources */, F62607FD207B790300176C5A /* SpinnerAlert.swift in Sources */, 3444DFD21F17620C00E73099 /* MWMMapWidgetsHelper.mm in Sources */, 3472B5E1200F86C800DC6CD5 /* MWMEditorHelper.mm in Sources */, + 3D2D79CC2C7C8C350062BC3D /* ProfileService.swift in Sources */, 99F3EB1123F418C900C713F8 /* PlacePageBuilder.swift in Sources */, + 52522F402C6DDF290015709C /* CurrencyRates.swift in Sources */, 4735008A23A83CF700661A95 /* DownloadedMapsDataSource.swift in Sources */, + CED0E02A2C8C88B9008C61CA /* FlowLayout.swift in Sources */, + 3D2D79D92C7D15190062BC3D /* PrimaryButton.swift in Sources */, CD9AD96F2281DF3600EC174A /* CategoryInfo.swift in Sources */, + 3D2D79BC2C7C5E300062BC3D /* ProfileRepositoryImpl.swift in Sources */, 3DEE1AEB21F72CD300054A91 /* MWMPowerManagmentViewController.mm in Sources */, 34AB66771FC5AA330078E451 /* TransportRoutePreviewStatus.swift in Sources */, 34D3AFEA1E378AF1004100F9 /* UINib+Init.swift in Sources */, @@ -4389,18 +5364,22 @@ 34AB662F1FC5AA330078E451 /* RouteManagerPresentationController.swift in Sources */, 993F5508237C622700545511 /* DeepLinkRouteStrategyAdapter.mm in Sources */, FA85D43D27958BF500B858E9 /* FaqController.swift in Sources */, + 52ED919F2C71F718000EE25B /* SignUpRequestDTO.swift in Sources */, 99A906ED23F6F7030005872B /* PlacePagePreviewViewController.swift in Sources */, 993DF10223F6BDB100AC231A /* Colors.swift in Sources */, 34AB66201FC5AA330078E451 /* RouteStartButton.swift in Sources */, AC79C8922A65AB9500594C24 /* UIColor+hexString.swift in Sources */, 99DEF9D723E420F6006BFD21 /* ElevationProfileDescriptionCell.swift in Sources */, + CED0E04A2C91A2A9008C61CA /* DBUtils.swift in Sources */, 993DF11623F6BDB100AC231A /* UIWindowRenderer.swift in Sources */, F660DEE51EAF4F59004DC056 /* MWMLocationManager+SpeedAndAltitude.swift in Sources */, F6E2FDF21E097BA00083EBEC /* MWMOpeningHoursAddScheduleTableViewCell.mm in Sources */, 3304306D21D4EAFB00317CA3 /* SearchCategoryCell.swift in Sources */, + 529A5F222C86DE50004FE4A1 /* Reviews DTOs.swift in Sources */, ED79A5AB2BD7AA9C00952D1F /* LoadingOverlayViewController.swift in Sources */, 34AB66111FC5AA320078E451 /* NavigationTurnsView.swift in Sources */, 475ED78624C7C7300063ADC7 /* ValueStepperViewRenderer.swift in Sources */, + 52D588A92C5CD56200AB96B3 /* Color.swift in Sources */, 3490D2E11CE9DD2500D0B838 /* MWMSideButtonsView.mm in Sources */, 47F4F21523A6F06F0022FD56 /* AvailableMapsDataSource.swift in Sources */, 99012852244732DB00C72B10 /* BottomTabBarBuilder.swift in Sources */, @@ -4409,15 +5388,19 @@ 47B9065221C7FA400079C85E /* MWMWebImage.m in Sources */, 47A13CAD24BE9AA500027D4F /* DatePickerViewRenderer.swift in Sources */, F6E2FE7C1E097BA00083EBEC /* MWMPlacePageOpeningHoursCell.mm in Sources */, + 3D2D79DD2C7DE34B0062BC3D /* ImagePicker.swift in Sources */, 340E1EFB1E2F614400CE49BF /* Storyboard.swift in Sources */, 34E776331F15FAC2003040B3 /* MWMPlacePageManagerHelper.mm in Sources */, + 52ED919D2C71F639000EE25B /* SimpleResponse.swift in Sources */, 462452E92BD052C0004C85E1 /* MWMEditorSegmentedTableViewCell.mm in Sources */, + 529A5F332C86DF6F004FE4A1 /* PlaceFull.swift in Sources */, 993DF12D23F6BDB100AC231A /* GlobalStyleSheet.swift in Sources */, F6E2FF361E097BA00083EBEC /* MWMSearchSuggestionCell.mm in Sources */, 3472B5CF200F4A2B00DC6CD5 /* BackgroundFetchTask.swift in Sources */, 477219052243E79500E5B227 /* DrivingOptionsViewController.swift in Sources */, CDB4D4E4222E8FF600104869 /* CarPlayService.swift in Sources */, F6E2FF3C1E097BA00083EBEC /* MWMSearchTableView.m in Sources */, + 52ED91A72C72C58A000EE25B /* CurrencyPersistenceController.swift in Sources */, F6E2FF661E097BA00083EBEC /* MWMTTSSettingsViewController.mm in Sources */, 3454D7C21E07F045004AF2AD /* NSString+Categories.m in Sources */, 34E7761F1F14DB48003040B3 /* PlacePageArea.swift in Sources */, @@ -4433,14 +5416,21 @@ 99A906E123F6F7030005872B /* PlacePageButtonsViewController.swift in Sources */, 998927382449E60200260CE2 /* BottomMenuPresenter.swift in Sources */, F6E2FE821E097BA00083EBEC /* MWMPlacePageOpeningHoursDayView.m in Sources */, + 52A48AED2C888B370081E522 /* ReviewsService.swift in Sources */, F6E2FD6B1E097BA00083EBEC /* MWMMapDownloaderSubplaceTableViewCell.m in Sources */, CDCA27842245090900167D87 /* ListenerContainer.swift in Sources */, + 52ED91A92C73020A000EE25B /* CurrencyService.swift in Sources */, + 5260D3E52C66290500C673B4 /* AuthResponse.swift in Sources */, 47E3C7252111E41B008B3B27 /* DimmedModalPresentationController.swift in Sources */, + 52B573F22C61E8980047FAC9 /* SignUpViewController.swift in Sources */, + CE6450242C9772310075A59B /* DownloadProgress.swift in Sources */, 3472B5CB200F43EF00DC6CD5 /* BackgroundFetchScheduler.swift in Sources */, 34FE5A6F1F18F30F00BCA729 /* TrafficButtonArea.swift in Sources */, 993DF10D23F6BDB100AC231A /* UIPageControlRenderer.swift in Sources */, + CED0E04C2C91A6A3008C61CA /* CoordinatesEntity.swift in Sources */, FA8E808925F412E2002A1434 /* FirstSession.mm in Sources */, F6E2FF691E097BA00083EBEC /* MWMUnitsController.mm in Sources */, + 52ED91B32C73211F000EE25B /* EntitiesMapping.swift in Sources */, 6741AA031BF340DE002C974C /* MWMActivityViewController.mm in Sources */, CDCA27382237F1BD00167D87 /* BookmarkInfo.swift in Sources */, 993DF11A23F6BDB100AC231A /* UIBarButtonItemRenderer.swift in Sources */, @@ -4453,65 +5443,88 @@ 993DF12023F6BDB100AC231A /* TabViewRenderer.swift in Sources */, F6E2FE131E097BA00083EBEC /* MWMOpeningHoursTimeSelectorTableViewCell.mm in Sources */, F626D52F1C3E83F800C17D15 /* MWMTableViewCell.m in Sources */, + 52A48AE72C8882A90081E522 /* ReviewsRepository.swift in Sources */, 34AB66591FC5AA330078E451 /* TransportTransitFlowLayout.swift in Sources */, EDBD680B2B62572E005DD151 /* LocationServicesDisabledAlert.swift in Sources */, + CED0E0312C900BB2008C61CA /* AllReviewsScreen.swift in Sources */, 3486B5191E27AD3B0069C126 /* MWMFrameworkListener.mm in Sources */, 3404756B1E081A4600C92850 /* MWMSearch+CoreSpotlight.mm in Sources */, CD9AD96C2281B56900EC174A /* CPViewPortState.swift in Sources */, + CE8982052CBCD46300FC2D2E /* ForgotPasswordViewController.swift in Sources */, EDE243DD2B6D2E640057369B /* AboutController.swift in Sources */, 3404755C1E081A4600C92850 /* MWMLocationManager.mm in Sources */, 3454D7BC1E07F045004AF2AD /* CLLocation+Mercator.mm in Sources */, 47E3C7272111E5A8008B3B27 /* AlertPresentationController.swift in Sources */, CDCA27812243F59800167D87 /* CarPlayRouter.swift in Sources */, + 52ECA81A2C8A25D800F213B3 /* PlaceTopBar.swift in Sources */, 34F5E0D41E3F254800B1C415 /* UIView+Hierarchy.swift in Sources */, 6741AA0B1BF340DE002C974C /* MWMMapViewControlsManager.mm in Sources */, F6E2FED91E097BA00083EBEC /* MWMSearchContentView.m in Sources */, EDFDFB4C2B722C9C0013A44C /* InfoTableViewCell.swift in Sources */, + E153EE3F2D80C5AC0065233D /* FileManagerBridge.mm in Sources */, + 5260D3DE2C66237700C673B4 /* AuthService.swift in Sources */, 47CA68F8250F8AB700671019 /* BookmarksListSectionHeader.swift in Sources */, + CED0E0172C8ACF0D008C61CA /* RoundedCornerShape.swift in Sources */, F6BD1D211CA412920047B8E8 /* MWMOsmAuthAlert.mm in Sources */, 47CF2E6323BA0DD500D11C30 /* CopyLabel.swift in Sources */, 47CA68D12500435E00671019 /* BookmarksListViewController.swift in Sources */, 34AB66321FC5AA330078E451 /* RouteManagerHeaderView.swift in Sources */, 347040301EA6470700038379 /* BorderedButton.swift in Sources */, F6E2FF4B1E097BA00083EBEC /* SettingsTableViewSwitchCell.swift in Sources */, + 529A5F702C8720A8004FE4A1 /* CategoriesViewModel.swift in Sources */, 993DF12623F6BDB100AC231A /* SwizzleStyle.m in Sources */, + 527D5E782C60D94B00736A85 /* AppButton.swift in Sources */, 993DF10E23F6BDB100AC231A /* UIButtonRenderer.swift in Sources */, 99514BBB23E82B450085D3A7 /* ElevationProfileBuilder.swift in Sources */, 34AB66381FC5AA330078E451 /* RouteManagerCell.swift in Sources */, ED1263AB2B6F99F900AD99F3 /* UIView+AddSeparator.swift in Sources */, CD4A1F132305872700F2A6B6 /* PromoBookingPresentationController.swift in Sources */, + 52A48AEB2C888B2C0081E522 /* PlacesService.swift in Sources */, 3472B5D3200F501500DC6CD5 /* BackgroundFetchTaskFrameworkType.swift in Sources */, 47E460AD240D737D00385B45 /* OpeinigHoursLocalization.swift in Sources */, 99F9A0E52462CA0E00AE21E0 /* DownloadAllView.swift in Sources */, F6E2FF301E097BA00083EBEC /* MWMSearchCommonCell.mm in Sources */, 337F98B821D3D67E00C8AC27 /* SearchHistoryQueryCell.swift in Sources */, 34AB66621FC5AA330078E451 /* TransportTransitSeparator.swift in Sources */, + 52A48AE52C887EA70081E522 /* PlacesRepository.swift in Sources */, CDCA2743223F8D1E00167D87 /* ListItemInfo.swift in Sources */, 993DF11F23F6BDB100AC231A /* UITableViewCellRenderer.swift in Sources */, 4767CDA820AB401000BD8166 /* LinkTextView.swift in Sources */, 34763EE71F2F392300F4D2D3 /* MWMTextToSpeech.mm in Sources */, 998927402449ECC200260CE2 /* BottomMenuItemCell.swift in Sources */, F6E2FEE21E097BA00083EBEC /* MWMSearchManager.mm in Sources */, + 52ECA8152C8A0D9E00F213B3 /* GalleryScreen.swift in Sources */, F6E2FE221E097BA00083EBEC /* MWMOpeningHoursEditorViewController.mm in Sources */, ED79A5D72BDF8D6100952D1F /* SynchronizationStateManager.swift in Sources */, 999FC12B23ABB4B800B0E6F9 /* FontStyleSheet.swift in Sources */, + CEA45BC42C9AE01000ABE6B2 /* DataSyncer.swift in Sources */, 47CA68DA2500469400671019 /* BookmarksListBuilder.swift in Sources */, 34D3AFE21E376F7E004100F9 /* UITableView+Updates.swift in Sources */, 3404164C1E7BF42E00E2B6D6 /* UIView+Coordinates.swift in Sources */, 99F3EB0323F4178200C713F8 /* PlacePageCommonLayout.swift in Sources */, 99C6532223F2F506004322F3 /* IPlacePageLayout.swift in Sources */, + 5260D3D82C64F8BC00C673B4 /* AuthResponseDTO.swift in Sources */, 99F8B4C623B644A6009FF0B4 /* MapStyleSheet.swift in Sources */, + 52CD2D892C6F0AF200CCC439 /* ProfileRepository.swift in Sources */, + 529A5F3D2C86E08E004FE4A1 /* FavoritesIdsDTO.swift in Sources */, 99012851244732DB00C72B10 /* BottomTabBarViewController.swift in Sources */, + CED0E0222C8B22CD008C61CA /* ReviewView.swift in Sources */, + 52522F3E2C6DDF190015709C /* PersonalData.swift in Sources */, F63AF5061EA6162400A1DB98 /* FilterTypeCell.swift in Sources */, 993DF10623F6BDB100AC231A /* UIColor+rgba.swift in Sources */, + 52E95F042C6B71B900A3FE2E /* NetworkHelper.swift in Sources */, EDC3573B2B7B5029001AE9CA /* CALayer+SetCorner.swift in Sources */, 47E3C7332111F4D8008B3B27 /* CoverVerticalDismissalAnimator.swift in Sources */, 471AB99423ABA3BD00F56D49 /* SearchMapsDataSource.swift in Sources */, 47CA68F1250B54AF00671019 /* BookmarksListCell.swift in Sources */, 471A7BC02481C82500A0D4C1 /* BookmarkTitleCell.swift in Sources */, + 52522F392C6DD9DA0015709C /* ProfileViewModel.swift in Sources */, 47F67D1521CAB21B0069754E /* MWMImageCoder.m in Sources */, + 529A5F352C86DF99004FE4A1 /* PlaceLocation.swift in Sources */, + CED0E0392C904868008C61CA /* NavigationUtils.swift in Sources */, 34AB66861FC5AA330078E451 /* MWMNavigationInfoView.mm in Sources */, 34C9BD051C6DB693000DC38D /* MWMViewController.m in Sources */, + 5260D3E02C6624B900C673B4 /* AuthRepositoryImpl.swift in Sources */, F6E2FDA41E097BA00083EBEC /* MWMCuisineEditorViewController.mm in Sources */, 3454D7CB1E07F045004AF2AD /* UIColor+MapsMeColor.m in Sources */, 34B127EA1FBDD410008713D9 /* MWMRouterTransitStepInfo.mm in Sources */, @@ -4532,6 +5545,7 @@ 3454D7BF1E07F045004AF2AD /* DateComponentsFormatter+ETA.swift in Sources */, 991FCA2423B11E61009AD684 /* BookmarksStyleSheet.swift in Sources */, 993DF12823F6BDB100AC231A /* IStyleSheet.swift in Sources */, + 5260D3E32C66289900C673B4 /* SignInRequest.swift in Sources */, 6741AA1C1BF340DE002C974C /* MWMRoutingDisclaimerAlert.m in Sources */, 34D3B0481E389D05004100F9 /* MWMNoteCell.m in Sources */, CD9AD967228067F500EC174A /* MapInfo.swift in Sources */, @@ -4541,7 +5555,10 @@ 993DF0C923F6BD0600AC231A /* ElevationDetailsBuilder.swift in Sources */, 674A7E301C0DB10B003D48E1 /* MWMMapWidgets.mm in Sources */, 34AB66291FC5AA330078E451 /* RouteManagerViewController.swift in Sources */, + 5260D3D12C64F7F100C673B4 /* SignInRequestDTO.swift in Sources */, + 3DA3FC992C75ED2A0065E4D6 /* changeTheme.swift in Sources */, 3404754D1E081A4600C92850 /* MWMKeyboard.m in Sources */, + 3D2D79DB2C7D15410062BC3D /* SecondaryButton.swift in Sources */, EDE243E52B6D3F400057369B /* OSMView.swift in Sources */, 993DF10C23F6BDB100AC231A /* MWMTableViewCellRenderer.swift in Sources */, 3457C4261F680F1900028233 /* String+BoundingRect.swift in Sources */, @@ -4551,32 +5568,42 @@ F6D67CDC2062B9C00032FD38 /* BCCreateCategoryAlert.swift in Sources */, F6E2FF601E097BA00083EBEC /* MWMSettingsViewController.mm in Sources */, F6E2FE2B1E097BA00083EBEC /* MWMStreetEditorEditTableViewCell.m in Sources */, + 3D2D79C32C7C80E60062BC3D /* PersonalDataPersistenceController.swift in Sources */, 34AB66891FC5AA330078E451 /* NavigationControlView.swift in Sources */, + 52A48ADF2C882FE40081E522 /* SearchViewController.swift in Sources */, 479EE94A2292FB03009DEBA6 /* ActivityIndicator.swift in Sources */, ED3EAC202B03C88100220A4A /* BottomTabBarButton.swift in Sources */, EDFDFB612B74E2500013A44C /* DonationView.swift in Sources */, 47B9065321C7FA400079C85E /* MWMImageCache.m in Sources */, F6FEA82E1C58F108007223CC /* MWMButton.m in Sources */, 34B924431DC8A29C0008D971 /* MWMMailViewController.m in Sources */, + CE60A8C52CAD15C20055F49C /* FullscreenImageViewer.swift in Sources */, 340475651E081A4600C92850 /* MWMRouter.mm in Sources */, 47E3C72F2111F472008B3B27 /* CoverVerticalModalTransitioning.swift in Sources */, + 52522F4C2C6E10FD0015709C /* LoadImg.swift in Sources */, + 527D5E7B2C60E05D00736A85 /* LanguageUtils.swift in Sources */, 34E776101F14B165003040B3 /* VisibleArea.swift in Sources */, 995F1613244F0AA50060631D /* BottomMenuLayersCell.swift in Sources */, 993DF10723F6BDB100AC231A /* UIColor+image.swift in Sources */, 3454D7D71E07F045004AF2AD /* UIKitCategories.m in Sources */, + 529A5F3B2C86E065004FE4A1 /* FavoritesDTO.swift in Sources */, 34AB39C21D2BD8310021857D /* MWMStopButton.m in Sources */, 3488B01A1E9D0B230068AFD8 /* UIColor+Modifications.swift in Sources */, 6741AA281BF340DE002C974C /* MWMAlert.mm in Sources */, F6E2FF571E097BA00083EBEC /* MWMMobileInternetViewController.m in Sources */, 993DF11323F6BDB100AC231A /* UITableViewRenderer.swift in Sources */, 34AB66261FC5AA330078E451 /* RouteManagerDimView.swift in Sources */, + CED0E0452C918ED4008C61CA /* Hash.swift in Sources */, EDFDFB4A2B722A310013A44C /* SocialMediaCollectionViewCell.swift in Sources */, + 529A5F282C86DEC5004FE4A1 /* UserDTO.swift in Sources */, 6741AA2B1BF340DE002C974C /* CircleView.m in Sources */, 4788739220EE326500F6826B /* VerticallyAlignedButton.swift in Sources */, EDFDFB462B7139490013A44C /* AboutInfo.swift in Sources */, 3444DFDE1F18A5AF00E73099 /* SideButtonsArea.swift in Sources */, + CED0E04E2C91A702008C61CA /* UserEntity.swift in Sources */, CDCA278622451F5000167D87 /* RouteInfo.swift in Sources */, 3467CEB6202C6FA900D3C670 /* BMCNotificationsCell.swift in Sources */, + 529A5F6A2C8707F9004FE4A1 /* FavoritesViewController.swift in Sources */, 337F98B221D3BAE600C8AC27 /* SearchCategoriesViewController.swift in Sources */, F6E2FE0A1E097BA00083EBEC /* MWMOpeningHoursDeleteScheduleTableViewCell.mm in Sources */, 3454D7DA1E07F045004AF2AD /* UILabel+RuntimeAttributes.m in Sources */, @@ -4584,6 +5611,8 @@ 3454D7E31E07F045004AF2AD /* UITextView+RuntimeAttributes.m in Sources */, F6A2184A1CA3F26800BE2CC6 /* MWMEditorViralActivityItem.mm in Sources */, 993DF10923F6BDB100AC231A /* IFonts.swift in Sources */, + CED0E0372C902532008C61CA /* ThemeDTO.swift in Sources */, + CED0E0192C8AD57C008C61CA /* EmptyUI.swift in Sources */, 47699A0821F08E37009E6585 /* NSDate+TimeDistance.m in Sources */, 34845DB31E165E24003D55B9 /* SearchNoResultsViewController.swift in Sources */, 47E3C72B2111E62A008B3B27 /* FadeOutAnimatedTransitioning.swift in Sources */, @@ -4780,17 +5809,28 @@ CODE_SIGN_ENTITLEMENTS = "OMaps-Debug.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; - DEVELOPMENT_TEAM = 9Z6432XD7L; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_TEAM = BDJSH66HZT; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + INFOPLIST_KEY_CFBundleDisplayName = Tourism; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.travel"; + INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Determining your location is necessary for navigation and for saving your recently travelled track."; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UIStatusBarHidden = NO; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2022.11.17; + MARKETING_VERSION = 1.0; OTHER_SWIFT_FLAGS = "$(inherited)"; - PRODUCT_BUNDLE_IDENTIFIER = app.organicmaps.debug; + PRODUCT_BUNDLE_IDENTIFIER = tj.rebus.tourism; PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; }; name = Debug; }; @@ -4799,20 +5839,31 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = "OMaps-Release.entitlements"; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = 9Z6432XD7L; + DEVELOPMENT_TEAM = BDJSH66HZT; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + INFOPLIST_KEY_CFBundleDisplayName = Tourism; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.travel"; + INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Determining your location is necessary for navigation and for saving your recently travelled track."; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UIStatusBarHidden = NO; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2022.11.17; + MARKETING_VERSION = 1.0; OTHER_SWIFT_FLAGS = "$(inherited)"; - PRODUCT_BUNDLE_IDENTIFIER = app.organicmaps; - PROVISIONING_PROFILE_SPECIFIER = "CarPlay Release"; + PRODUCT_BUNDLE_IDENTIFIER = tj.rebus.tourism; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; }; name = Release; }; @@ -4825,6 +5876,8 @@ DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 9Z6432XD7L; "DEVELOPMENT_TEAM[sdk=macosx*]" = 9Z6432XD7L; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 11.0; PRODUCT_BUNDLE_IDENTIFIER = app.organicmaps.debug.tests; PROVISIONING_PROFILE_SPECIFIER = ""; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Organic Maps (Debug).app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Organic Maps (Debug)"; @@ -4839,6 +5892,8 @@ CODE_SIGN_STYLE = Manual; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 9Z6432XD7L; "DEVELOPMENT_TEAM[sdk=macosx*]" = 9Z6432XD7L; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 11.0; PRODUCT_BUNDLE_IDENTIFIER = app.organicmaps.release.tests; PROVISIONING_PROFILE_SPECIFIER = ""; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Organic Maps (Debug).app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Organic Maps (Debug)"; @@ -4875,7 +5930,7 @@ ); OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_IDENTIFIER}"; - PRODUCT_NAME = "Organic Maps (Debug)"; + PRODUCT_NAME = "Tourism (Debug)"; SDKROOT = iphoneos; SKIP_INSTALL = NO; SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; @@ -4916,7 +5971,7 @@ ); OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_IDENTIFIER}"; - PRODUCT_NAME = "Organic Maps"; + PRODUCT_NAME = "Tourism Map Tajikistan"; SDKROOT = iphoneos; SKIP_INSTALL = NO; SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; @@ -4958,6 +6013,51 @@ defaultConfigurationName = Debug; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + CED0E00C2C8ACBCA008C61CA /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SDWebImage/SDWebImageSwiftUI.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.1.2; + }; + }; + CED0E00F2C8ACBE1008C61CA /* XCRemoteSwiftPackageReference "CountryPickerView" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/kizitonwose/CountryPickerView.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.3.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + CED0E00D2C8ACBCA008C61CA /* SDWebImageSwiftUI */ = { + isa = XCSwiftPackageProductDependency; + package = CED0E00C2C8ACBCA008C61CA /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */; + productName = SDWebImageSwiftUI; + }; + CED0E0102C8ACBE1008C61CA /* CountryPickerView */ = { + isa = XCSwiftPackageProductDependency; + package = CED0E00F2C8ACBE1008C61CA /* XCRemoteSwiftPackageReference "CountryPickerView" */; + productName = CountryPickerView; + }; +/* End XCSwiftPackageProductDependency section */ + +/* Begin XCVersionGroup section */ + E11202442D744BFA001B3B24 /* TourismDB.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + E11202452D744BFA001B3B24 /* TourismDB.xcdatamodel */, + ); + currentVersion = E11202452D744BFA001B3B24 /* TourismDB.xcdatamodel */; + path = TourismDB.xcdatamodeld; + sourceTree = ""; + versionGroupType = wrapper.xcdatamodel; + }; +/* End XCVersionGroup section */ }; rootObject = 29B97313FDCFA39411CA2CEA /* Project object */; } diff --git a/iphone/Maps/Maps.xcodeproj/xcshareddata/xcschemes/OMaps.xcscheme b/iphone/Maps/Maps.xcodeproj/xcshareddata/xcschemes/OMaps.xcscheme index c89f1bcc2c..9ed7b0f798 100644 --- a/iphone/Maps/Maps.xcodeproj/xcshareddata/xcschemes/OMaps.xcscheme +++ b/iphone/Maps/Maps.xcodeproj/xcshareddata/xcschemes/OMaps.xcscheme @@ -15,7 +15,7 @@ @@ -70,7 +70,7 @@ @@ -87,7 +87,7 @@ diff --git a/iphone/Maps/OMaps-Debug.entitlements b/iphone/Maps/OMaps-Debug.entitlements index 06b83f7169..46df05b120 100644 --- a/iphone/Maps/OMaps-Debug.entitlements +++ b/iphone/Maps/OMaps-Debug.entitlements @@ -8,11 +8,9 @@ applinks:omaps.app - com.apple.developer.carplay-maps - com.apple.developer.icloud-container-identifiers - iCloud.app.organicmaps.debug + iCloud.app.tourism.debug com.apple.developer.icloud-services @@ -21,7 +19,7 @@ com.apple.developer.ubiquity-container-identifiers - iCloud.app.organicmaps.debug + iCloud.app.tourism.debug com.apple.developer.ubiquity-kvstore-identifier $(TeamIdentifierPrefix)$(CFBundleIdentifier) diff --git a/iphone/Maps/OMaps-Release.entitlements b/iphone/Maps/OMaps-Release.entitlements index 9e52a78a0b..abba4ad5c8 100644 --- a/iphone/Maps/OMaps-Release.entitlements +++ b/iphone/Maps/OMaps-Release.entitlements @@ -8,11 +8,9 @@ applinks:omaps.app - com.apple.developer.carplay-maps - com.apple.developer.icloud-container-identifiers - iCloud.app.organicmaps + iCloud.app.tourism com.apple.developer.icloud-services @@ -21,7 +19,7 @@ com.apple.developer.ubiquity-container-identifiers - iCloud.app.organicmaps + iCloud.app.tourism com.apple.developer.ubiquity-kvstore-identifier $(TeamIdentifierPrefix)$(CFBundleIdentifier) diff --git a/iphone/Maps/OMaps.plist b/iphone/Maps/OMaps.plist index 3659f28325..828000f79a 100644 --- a/iphone/Maps/OMaps.plist +++ b/iphone/Maps/OMaps.plist @@ -2,10 +2,6 @@ - CFBundleDevelopmentRegion - en - CFBundleDisplayName - ${PRODUCT_NAME} CFBundleDocumentTypes @@ -55,18 +51,6 @@ - CFBundleExecutable - ${EXECUTABLE_NAME} - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - ${PRODUCT_NAME} - CFBundlePackageType - APPL - CFBundleShortVersionString - $(MARKETING_VERSION) CFBundleSignature ???? CFBundleURLTypes @@ -85,18 +69,12 @@ - CFBundleVersion - $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes mailto - LSRequiresIPhoneOS - - LSSupportsOpeningDocumentsInPlace - NSAppTransportSecurity NSAllowsArbitraryLoads @@ -121,13 +99,49 @@ NSThirdPartyExceptionRequiresForwardSecrecy + tourismmap.tj + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + + + NSUbiquitousContainers + + iCloud.app.organicmaps + + NSUbiquitousContainerIsDocumentScopePublic + + NSUbiquitousContainerName + OrganicMaps + NSUbiquitousContainerSupportedFolderLevels + ANY + + iCloud.app.organicmaps.debug + + NSUbiquitousContainerIsDocumentScopePublic + + NSUbiquitousContainerName + OrganicMapsDEBUG + NSUbiquitousContainerSupportedFolderLevels + ANY - NSLocationWhenInUseUsageDescription - Determining your location is necessary for navigation and for saving your recently travelled track. UIAppFonts FredokaOne-Regular.ttf + Gilroy-Black.ttf + Gilroy-Heavy.ttf + Gilroy-ExtraBold + Gilroy-Bold.ttf + Gilroy-SemiBold.ttf + Gilroy-Medium.ttf + Gilroy-Regular.ttf + Gilroy-Light.ttf + Gilroy-Thin.ttf + Gilroy-UltraLight.ttf UIApplicationShortcutItems @@ -164,21 +178,8 @@ UIFileSharingEnabled - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main UIPrerenderedIcon - UIStatusBarHidden - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - UTImportedTypeDeclarations @@ -252,26 +253,5 @@ - NSUbiquitousContainers - - iCloud.app.organicmaps - - NSUbiquitousContainerIsDocumentScopePublic - - NSUbiquitousContainerSupportedFolderLevels - ANY - NSUbiquitousContainerName - OrganicMaps - - iCloud.app.organicmaps.debug - - NSUbiquitousContainerIsDocumentScopePublic - - NSUbiquitousContainerSupportedFolderLevels - ANY - NSUbiquitousContainerName - OrganicMapsDEBUG - - diff --git a/iphone/Maps/Tourism/Constants.swift b/iphone/Maps/Tourism/Constants.swift new file mode 100644 index 0000000000..8a11522b89 --- /dev/null +++ b/iphone/Maps/Tourism/Constants.swift @@ -0,0 +1,83 @@ +struct Constants { + // MARK: - Image Loading URLs + static let imageUrlExample = "https://img.freepik.com/free-photo/young-woman-hiker-taking-photo-with-smartphone-on-mountains-peak-in-winter_335224-427.jpg?w=2000" + static let thumbnailUrlExample = "https://render.fineartamerica.com/images/images-profile-flow/400/images-medium-large-5/awesome-solitude-bess-hamiti.jpg" + static let logoUrlExample = "https://brandeps.com/logo-download/O/OSCE-logo-vector-01.svg" + static let anotherImageExample = "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSiceDsFSiLmW2Jl-XP3m5UXRdyLRKBQTlPGQ&s" + + static let reviewExample = Review( + id: 1, + placeId: 1, + rating: 5, + user: User(id: 1, name: "John Doe", pfpUrl: Constants.imageUrlExample, countryCodeName: "US"), + date: "2024-09-01", + comment: "Amazing place! The views are incredible and the atmosphere is so calming.The views are incredible and the atmosphere is so calming.The views are incredible and the atmosphere is so calming.The views are incredible and the atmosphere is so calming.The views are incredible and the atmosphere is so calming.The views are incredible and the atmosphere is so calming.", + picsUrls: [ + Constants.imageUrlExample, + Constants.thumbnailUrlExample, + Constants.imageUrlExample, + Constants.thumbnailUrlExample, + Constants.imageUrlExample, + Constants.thumbnailUrlExample + ] + ) + + // MARK: - Data + static let categories: [String: String] = [ + "sights": NSLocalizedString("sights", comment: ""), + "restaurants": NSLocalizedString("restaurants", comment: ""), + "hotels_tourism": NSLocalizedString("hotels_tourism", comment: "") + ] + + static let placeExample = PlaceFull( + id: 1, + name: "Beautiful Place", + rating: 4.5, + excerpt: "

Ресторан отличается от других подобных объектов bla bla bla.

", + description: """ +

Ресторан отличается от других подобных объектов уникальным дизайном и новым способом подачи различных блюд. Красивое оформление холла здания, использование экологически чистых материалов и приятная музыка отражают теплый климат Бразилии.

Объект построен по франчайзинговому проекту, то есть на основе подряда, с использованием известного иностранного бренда и его технологий. Управлением рестораном и приготовлением различных блюд занимается гражданин Бразилии, который будет обслуживать клиентов в сотрудничестве с таджикскими поварами и официантами.

+ """, + placeLocation: PlaceLocation( + name: "Mountain Retreat", + lat: 38.550288, + lon: 68.729752 + ), + cover: Constants.imageUrlExample, + pics: [ + Constants.imageUrlExample, + Constants.thumbnailUrlExample, + Constants.imageUrlExample, + Constants.imageUrlExample, + Constants.anotherImageExample + ], + reviews: [ + Review( + id: 1, + placeId: 1, + rating: 5, + user: User(id: 1, name: "John Doe", pfpUrl: Constants.imageUrlExample, countryCodeName: "us"), + date: "2024-09-01", + comment: "Amazing place! The views are incredible and the atmosphere is so calming.", + picsUrls: [ + Constants.imageUrlExample, + Constants.thumbnailUrlExample + ] + ), + Review( + id: 2, + placeId: 1, + rating: 4, + user: User(id: 2, name: "Jane Smith", pfpUrl: Constants.imageUrlExample, countryCodeName: "tj"), + date: "2024-08-20", + comment: "Great place for a weekend getaway. A bit crowded but worth the visit.", + picsUrls: [ + Constants.imageUrlExample + ] + ) + ], + isFavorite: false + ) +} + +let BASE_URL_WITHOUT_API = "https://tourismmap.tj/" +let BASE_URL = "https://tourismmap.tj/api/" diff --git a/iphone/Maps/Tourism/Data/Db/ControllerTemplates/SingleEntityCoreDataController.swift b/iphone/Maps/Tourism/Data/Db/ControllerTemplates/SingleEntityCoreDataController.swift new file mode 100644 index 0000000000..06e2c9488b --- /dev/null +++ b/iphone/Maps/Tourism/Data/Db/ControllerTemplates/SingleEntityCoreDataController.swift @@ -0,0 +1,76 @@ +import CoreData +import Combine + +class SingleEntityCoreDataController: NSObject, NSFetchedResultsControllerDelegate { + + private var fetchedResultsController: NSFetchedResultsController? + var entitySubject = PassthroughSubject() + + private let viewContext = CoreDataManager.shared.viewContext + private let backgroundContext = CoreDataManager.shared.backgroundContext + +// init(modelName: String) { +// container = NSPersistentContainer(name: modelName) +// super.init() +// container.loadPersistentStores { (description, error) in +// if let error = error { +// fatalError("Failed to load Core Data stack: \(error)") +// } +// } +// container.viewContext.automaticallyMergesChangesFromParent = true +// } + +// var context: NSManagedObjectContext { +// return container.viewContext +// } + + func observeEntity(fetchRequest: NSFetchRequest, sortDescriptor: NSSortDescriptor) { + fetchRequest.sortDescriptors = [sortDescriptor] + + fetchedResultsController = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: viewContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + fetchedResultsController?.delegate = self + + do { + try fetchedResultsController?.performFetch() + if let fetchedEntity = fetchedResultsController?.fetchedObjects?.first { + entitySubject.send(fetchedEntity) + } else { + entitySubject.send(nil) + } + } catch { + entitySubject.send(completion: .failure(ResourceError.cacheError)) + } + } + + func updateEntity(updateBlock: @escaping (Entity) -> Void, fetchRequest: NSFetchRequest) -> AnyPublisher { + Future { promise in + do { + let entityToUpdate = try self.viewContext.fetch(fetchRequest).first ?? Entity(context: self.viewContext) + updateBlock(entityToUpdate) + try self.viewContext.save() + + promise(.success(())) + } catch { + promise(.failure(ResourceError.cacheError)) + } + } + .eraseToAnyPublisher() + } + + + // NSFetchedResultsControllerDelegate + func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + guard let fetchedObjects = controller.fetchedObjects as? [Entity], + let updatedEntity = fetchedObjects.first else { + entitySubject.send(completion: .failure(ResourceError.cacheError)) + return + } + entitySubject.send(updatedEntity) + } +} diff --git a/iphone/Maps/Tourism/Data/Db/CoreDataManager.swift b/iphone/Maps/Tourism/Data/Db/CoreDataManager.swift new file mode 100644 index 0000000000..354d5397a3 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Db/CoreDataManager.swift @@ -0,0 +1,54 @@ +import CoreData + +final class CoreDataManager: NSObject { + + static let shared = CoreDataManager() + + + let persistentContainer: NSPersistentContainer + + var viewContext: NSManagedObjectContext { + return persistentContainer.viewContext + } + + var backgroundContext: NSManagedObjectContext { + return persistentContainer.newBackgroundContext() + } + + + private override init() { + persistentContainer = NSPersistentContainer(name: "TourismDB") + persistentContainer.loadPersistentStores { (storeDescription, error) in + if let error = error as NSError? { + fatalError("Unresolved error \(error), \(error.userInfo)") + } + } + + persistentContainer.viewContext.automaticallyMergesChangesFromParent = true + + } + +// lazy var persistentContainer: NSPersistentContainer = { +// let container = NSPersistentContainer(name: "TourismDB") +// container.loadPersistentStores { (storeDescription, error) in +// if let error = error as NSError? { +// fatalError("Unresolved error \(error), \(error.userInfo)") +// } +// } +// return container +// }() + + + + func saveContext() { + let context = viewContext + if context.hasChanges { + do { + try context.save() + } catch { + let nserror = error as NSError + fatalError("Unresolved error \(nserror), \(nserror.userInfo)") + } + } + } +} diff --git a/iphone/Maps/Tourism/Data/Db/DBUtils.swift b/iphone/Maps/Tourism/Data/Db/DBUtils.swift new file mode 100644 index 0000000000..71f93492d0 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Db/DBUtils.swift @@ -0,0 +1,30 @@ +class DBUtils { + static func encodeToJsonString(_ body: T) -> String? { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = .withoutEscapingSlashes + let encoded = try encoder.encode(body) + return convertDataToString(encoded) + } catch { + print(error) + return nil + } + } + + private static func convertDataToString(_ data: Data) -> String? { + return String(data: data, encoding: .utf8) + } + + static func decodeFromJsonString(_ jsonString: String, to type: T.Type) -> T? { + guard let data = jsonString.data(using: .utf8) else { + return nil + } + do { + let decoder = JSONDecoder() + return try decoder.decode(T.self, from: data) + } catch { + print(error) + return nil + } + } +} diff --git a/iphone/Maps/Tourism/Data/Db/DataModels/CoordinatesEntity.swift b/iphone/Maps/Tourism/Data/Db/DataModels/CoordinatesEntity.swift new file mode 100644 index 0000000000..73c13dcfc9 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Db/DataModels/CoordinatesEntity.swift @@ -0,0 +1,4 @@ +struct CoordinatesEntity: Codable { + let latitude: Double + let longitude: Double +} diff --git a/iphone/Maps/Tourism/Data/Db/DataModels/TourismDB.xcdatamodeld/TourismDB.xcdatamodel/contents b/iphone/Maps/Tourism/Data/Db/DataModels/TourismDB.xcdatamodeld/TourismDB.xcdatamodel/contents new file mode 100644 index 0000000000..6ef343cfaf --- /dev/null +++ b/iphone/Maps/Tourism/Data/Db/DataModels/TourismDB.xcdatamodeld/TourismDB.xcdatamodel/contents @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/iphone/Maps/Tourism/Data/Db/DataModels/UserEntity.swift b/iphone/Maps/Tourism/Data/Db/DataModels/UserEntity.swift new file mode 100644 index 0000000000..05f01b715c --- /dev/null +++ b/iphone/Maps/Tourism/Data/Db/DataModels/UserEntity.swift @@ -0,0 +1,6 @@ +struct UserEntity: Codable { + let userId: Int64 + let fullName: String + let avatar: String + let country: String +} diff --git a/iphone/Maps/Tourism/Data/Db/EntitiesMapping.swift b/iphone/Maps/Tourism/Data/Db/EntitiesMapping.swift new file mode 100644 index 0000000000..6b69edd2db --- /dev/null +++ b/iphone/Maps/Tourism/Data/Db/EntitiesMapping.swift @@ -0,0 +1,118 @@ +extension CurrencyRatesEntity { + func toCurrencyRates() -> CurrencyRates { + return CurrencyRates(usd: usd, eur: eur, rub: rub) + } +} + +extension PersonalDataEntity { + func toPersonalData() -> PersonalData { + return PersonalData( + id: self.id, + fullName: self.fullName ?? "", + country: self.country ?? "", + pfpUrl: self.pfpUrl, + email: self.email ?? "", + language: self.language, + theme: self.theme + ) + } +} + +extension PlaceEntity { + func toPlaceShort() -> PlaceShort { + return PlaceShort( + id: self.id, + name: self.name ?? "", + cover: self.cover, + rating: self.rating, + excerpt: self.excerpt, + isFavorite: self.isFavorite + ) + } + + func toPlaceFull() -> PlaceFull { + let placeLocation = + DBUtils.decodeFromJsonString(self.coordinatesJson ?? "", to: CoordinatesEntity.self)?.toPlaceLocation(name: self.name ?? "") + let pics = DBUtils.decodeFromJsonString(self.galleryJson ?? "", to: [String].self) ?? [] + + return PlaceFull( + id: self.id, + name: self.name ?? "", + rating: self.rating, + excerpt: self.excerpt ?? "", + description: self.descr ?? "", + placeLocation: placeLocation, + cover: self.cover ?? "", + pics: pics, + // we have different table for reviews + reviews: nil, + isFavorite: self.isFavorite + ) + } +} + +extension [PlaceEntity] { + func toFullPlaces() -> [PlaceFull] { + return self.map { placeEntity in + placeEntity.toPlaceFull() + } + } + + func toShortPlaces() -> [PlaceShort] { + return self.map { placeEntity in + placeEntity.toPlaceShort() + } + } +} + +extension CoordinatesEntity { + func toPlaceLocation(name: String) -> PlaceLocation { + return PlaceLocation(name: name, lat: self.latitude, lon: self.longitude) + } +} + +extension UserEntity { + func toUser() -> User { + return User( + id: self.userId, + name: self.fullName, + pfpUrl: self.avatar, + countryCodeName: self.country + ) + } +} + +extension ReviewEntity { + func toReview() -> Review { + + let user = DBUtils.decodeFromJsonString(self.userJson ?? "", to: UserEntity.self)?.toUser() + let picsUrls = DBUtils.decodeFromJsonString(self.picsUrlsJson ?? "", to: [String].self) + + return Review( + id: self.id, + placeId: self.placeId, + rating: Int(self.rating), + user: user, + date: self.date, + comment: self.comment, + picsUrls: picsUrls ?? [], + deletionPlanned: self.deletionPlanned + ) + } +} + +extension ReviewPlannedToPostEntity { + func toReviewToPostDTO() -> ReviewToPostDTO { + var images = [String]() + if let imagesJson = self.imagesJson { + images = DBUtils.decodeFromJsonString(imagesJson, to: [String].self) ?? [] + } + + return ReviewToPostDTO( + placeId: self.placeId, + comment: self.comment ?? "", + rating: Int(self.rating), + images: images.map { URL(string: $0)! } + ) + } +} diff --git a/iphone/Maps/Tourism/Data/Db/PersistenceControllers/CurrencyPersistenceController.swift b/iphone/Maps/Tourism/Data/Db/PersistenceControllers/CurrencyPersistenceController.swift new file mode 100644 index 0000000000..257c7e66ee --- /dev/null +++ b/iphone/Maps/Tourism/Data/Db/PersistenceControllers/CurrencyPersistenceController.swift @@ -0,0 +1,30 @@ +import CoreData +import Combine + +class CurrencyPersistenceController { + static let shared = CurrencyPersistenceController() + private let coreDataController: SingleEntityCoreDataController + + private init() { + coreDataController = SingleEntityCoreDataController() + } + + var currencyRatesSubject: PassthroughSubject { + return coreDataController.entitySubject + } + + func observeCurrencyRates() { + let fetchRequest: NSFetchRequest = CurrencyRatesEntity.fetchRequest() + let sortDescriptor = NSSortDescriptor(key: "id", ascending: true) + coreDataController.observeEntity(fetchRequest: fetchRequest, sortDescriptor: sortDescriptor) + } + + func updateCurrencyRates(entity: CurrencyRates) -> AnyPublisher { + let fetchRequest: NSFetchRequest = CurrencyRatesEntity.fetchRequest() + return coreDataController.updateEntity(updateBlock: { entityToUpdate in + entityToUpdate.usd = entity.usd + entityToUpdate.eur = entity.eur + entityToUpdate.rub = entity.rub + }, fetchRequest: fetchRequest) + } +} diff --git a/iphone/Maps/Tourism/Data/Db/PersistenceControllers/HashesPersistenceController.swift b/iphone/Maps/Tourism/Data/Db/PersistenceControllers/HashesPersistenceController.swift new file mode 100644 index 0000000000..fe574ae2c7 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Db/PersistenceControllers/HashesPersistenceController.swift @@ -0,0 +1,82 @@ +import Foundation +import CoreData + +class HashesPersistenceController { + static let shared = HashesPersistenceController() + + private let viewContext = CoreDataManager.shared.viewContext + + // MARK: - CRUD Operations + func insertHashes(hashes: [Hash]) { + + let backgroundContext = CoreDataManager.shared.backgroundContext + + backgroundContext.perform { + do { + for hash in hashes { + let newHash = HashEntity(context: backgroundContext) + newHash.categoryId = hash.categoryId + newHash.value = hash.value + } + try backgroundContext.save() + } catch { + print("Failed to save context: \(error)") + } + } + + } + + func getHash(categoryId: Int64) -> Hash? { + let fetchRequest: NSFetchRequest = HashEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "categoryId == %lld", categoryId) + fetchRequest.fetchLimit = 1 + + do { + let result = try viewContext.fetch(fetchRequest).first + if let result = result { + return Hash(categoryId: result.categoryId, value: result.value!) + } else { + return nil + } + } catch { + print("Failed to fetch hash: \(error)") + return nil + } + } + + func getHashes() -> [Hash] { + let fetchRequest: NSFetchRequest = HashEntity.fetchRequest() + + do { + let result = try viewContext.fetch(fetchRequest) + let hashes = result.map { hashEntity in + Hash(categoryId: hashEntity.categoryId, value: hashEntity.value!) + } + return hashes + } catch { + print("Failed to fetch hashes: \(error)") + return [] + } + } + + func deleteHash(hash: Hash) { + + let backgroundContext = CoreDataManager.shared.backgroundContext + + backgroundContext.perform { + let fetchRequest: NSFetchRequest = HashEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "categoryId == %lld", hash.categoryId) + + do { + if let hash = try backgroundContext.fetch(fetchRequest).first { + backgroundContext.delete(hash) + try backgroundContext.save() + } + } catch { + print(error) + print("Failed to delete review: \(error)") + } + } + } + +} diff --git a/iphone/Maps/Tourism/Data/Db/PersistenceControllers/PersonalDataPersistenceController.swift b/iphone/Maps/Tourism/Data/Db/PersistenceControllers/PersonalDataPersistenceController.swift new file mode 100644 index 0000000000..d238923729 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Db/PersistenceControllers/PersonalDataPersistenceController.swift @@ -0,0 +1,34 @@ +import CoreData +import Combine + +class PersonalDataPersistenceController { + static let shared = PersonalDataPersistenceController() + private let coreDataController: SingleEntityCoreDataController + + private init() { + coreDataController = SingleEntityCoreDataController() + } + + var personalDataSubject: PassthroughSubject { + return coreDataController.entitySubject + } + + func observePersonalData() { + let fetchRequest: NSFetchRequest = PersonalDataEntity.fetchRequest() + let sortDescriptor = NSSortDescriptor(key: "id", ascending: true) + coreDataController.observeEntity(fetchRequest: fetchRequest, sortDescriptor: sortDescriptor) + } + + func updatePersonalData(personalData: PersonalData) -> AnyPublisher { + let fetchRequest: NSFetchRequest = PersonalDataEntity.fetchRequest() + return coreDataController.updateEntity(updateBlock: { entityToUpdate in + entityToUpdate.id = personalData.id + entityToUpdate.fullName = personalData.fullName + entityToUpdate.country = personalData.country + entityToUpdate.pfpUrl = personalData.pfpUrl + entityToUpdate.email = personalData.email + entityToUpdate.language = personalData.language + entityToUpdate.theme = personalData.theme + }, fetchRequest: fetchRequest) + } +} diff --git a/iphone/Maps/Tourism/Data/Db/PersistenceControllers/PlacesPersistenceController.swift b/iphone/Maps/Tourism/Data/Db/PersistenceControllers/PlacesPersistenceController.swift new file mode 100644 index 0000000000..9bd52145e5 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Db/PersistenceControllers/PlacesPersistenceController.swift @@ -0,0 +1,388 @@ +import CoreData +import Combine + +class PlacesPersistenceController: NSObject, NSFetchedResultsControllerDelegate { + static let shared = PlacesPersistenceController() + + private let viewContext = CoreDataManager.shared.viewContext + + private var searchFetchedResultsController: NSFetchedResultsController? + private var placesByCatFetchedResultsController: NSFetchedResultsController? + private var topSightsFetchedResultsController: NSFetchedResultsController? + private var topRestaurantsFetchedResultsController: NSFetchedResultsController? + private var singlePlaceFetchedResultsController: NSFetchedResultsController? + private var favoritePlacesFetchedResultsController: NSFetchedResultsController? + + let searchSubject = PassthroughSubject<[PlaceShort], ResourceError>() + let placesByCatSubject = PassthroughSubject<[PlaceShort], ResourceError>() + let topSightsSubject = PassthroughSubject<[PlaceShort], ResourceError>() + let topRestaurantsSubject = PassthroughSubject<[PlaceShort], ResourceError>() + let singlePlaceSubject = PassthroughSubject() + let favoritePlacesSubject = PassthroughSubject<[PlaceShort], ResourceError>() + + +// init(inMemory: Bool = false) { +// container = NSPersistentContainer(name: "Place") +// if inMemory { +// container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") +// } +// super.init() +// container.loadPersistentStores { (storeDescription, error) in +// if let error = error as NSError? { +// fatalError("Unresolved error \(error), \(error.userInfo)") +// } +// } +// container.viewContext.automaticallyMergesChangesFromParent = true +// } + + // MARK: Places + func insertPlaces(_ places: [PlaceFull], categoryId: Int64) { + + let backgroundContext = CoreDataManager.shared.backgroundContext + + backgroundContext.perform { [weak self] in + do { + for place in places { + let newPlace = PlaceEntity(context: backgroundContext) + newPlace.id = place.id + self?.updatePlace(newPlace, with: place, categoryId: categoryId) + } + try backgroundContext.save() + } catch { + print("Failed to save context: \(error)") + } + } + + } + + private func updatePlace(_ placeEntity: PlaceEntity, with place: PlaceFull, categoryId: Int64) { + placeEntity.categoryId = categoryId + placeEntity.name = place.name + placeEntity.excerpt = place.excerpt + placeEntity.descr = place.description + placeEntity.cover = place.cover + placeEntity.galleryJson = DBUtils.encodeToJsonString(place.pics) + placeEntity.coordinatesJson = DBUtils.encodeToJsonString(place.placeLocation?.toCoordinatesEntity()) + placeEntity.rating = place.rating + placeEntity.isFavorite = place.isFavorite + } + + func deleteAllPlaces() { + + let backgroundContext = CoreDataManager.shared.backgroundContext + + backgroundContext.perform { + let fetchRequest: NSFetchRequest = PlaceEntity.fetchRequest() + let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) + + // Configure the request to return the IDs of the deleted objects + deleteRequest.resultType = .resultTypeObjectIDs + + do { + let result = try backgroundContext.execute(deleteRequest) as? NSBatchDeleteResult + let changes: [AnyHashable: Any] = [ + NSDeletedObjectsKey: result?.result as? [NSManagedObjectID] ?? [] + ] + + NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [backgroundContext]) + try backgroundContext.save() + } catch { + print("Failed to delete all places: \(error)") + } + } + + } + + func deleteAllPlacesByCategory(categoryId: Int64) { + + let backgroundContext = CoreDataManager.shared.backgroundContext + + backgroundContext.perform { + let fetchRequest: NSFetchRequest = PlaceEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "categoryId == %d", categoryId) + + let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) + + // Configure the request to return the IDs of the deleted objects + deleteRequest.resultType = .resultTypeObjectIDs + + do { + let result = try backgroundContext.execute(deleteRequest) as? NSBatchDeleteResult + let changes: [AnyHashable: Any] = [ + NSDeletedObjectsKey: result?.result as? [NSManagedObjectID] ?? [] + ] + + NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [backgroundContext]) + try backgroundContext.save() + } catch { + print("Failed to delete places by category \(categoryId): \(error)") + } + } + + } + + // MARK: Observe places + func observeSearch(query: String) { + let fetchRequest: NSFetchRequest = PlaceEntity.fetchRequest() + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "id", ascending: true)] + if !query.isEmpty { + fetchRequest.predicate = NSPredicate(format: "name CONTAINS[cd] %@", query) + } + + searchFetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: viewContext, sectionNameKeyPath: nil, cacheName: nil) + + searchFetchedResultsController?.delegate = self + + do { + try searchFetchedResultsController!.performFetch() + if let results = searchFetchedResultsController!.fetchedObjects { + searchSubject.send(results.toShortPlaces()) + } + } catch { + searchSubject.send(completion: .failure(.cacheError)) + } + } + + func observePlacesByCategoryId(categoryId: Int64) { + let fetchRequest: NSFetchRequest = PlaceEntity.fetchRequest() + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "id", ascending: true)] + fetchRequest.predicate = NSPredicate(format: "categoryId == %d", categoryId) + + placesByCatFetchedResultsController = NSFetchedResultsController( + fetchRequest: fetchRequest, managedObjectContext: viewContext, sectionNameKeyPath: nil, cacheName: nil + ) + + placesByCatFetchedResultsController?.delegate = self + + do { + try placesByCatFetchedResultsController!.performFetch() + if let results = placesByCatFetchedResultsController!.fetchedObjects { + placesByCatSubject.send(results.toShortPlaces()) + } + } catch { + placesByCatSubject.send(completion: .failure(.cacheError)) + } + } + + func observeTopSights() { + let fetchRequest: NSFetchRequest = PlaceEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "categoryId == %lld", PlaceCategory.sights.id) + fetchRequest.sortDescriptors = [ + NSSortDescriptor(key: "rating", ascending: false), + NSSortDescriptor(key: "name", ascending: true) + ] + fetchRequest.fetchLimit = 15 + + topSightsFetchedResultsController = NSFetchedResultsController( + fetchRequest: fetchRequest, managedObjectContext: viewContext, sectionNameKeyPath: nil, cacheName: nil + ) + + topSightsFetchedResultsController?.delegate = self + + do { + try topSightsFetchedResultsController!.performFetch() + if let results = topSightsFetchedResultsController!.fetchedObjects { + topSightsSubject.send(results.toShortPlaces()) + } + } catch { + topSightsSubject.send(completion: .failure(.cacheError)) + } + } + + func observeTopRestaurants() { + let fetchRequest: NSFetchRequest = PlaceEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "categoryId == %lld", PlaceCategory.restaurants.id) + fetchRequest.sortDescriptors = [ + NSSortDescriptor(key: "rating", ascending: false), + NSSortDescriptor(key: "name", ascending: true) + ] + fetchRequest.fetchLimit = 15 + + topRestaurantsFetchedResultsController = NSFetchedResultsController( + fetchRequest: fetchRequest, managedObjectContext: viewContext, sectionNameKeyPath: nil, cacheName: nil + ) + + topRestaurantsFetchedResultsController?.delegate = self + + do { + try topRestaurantsFetchedResultsController!.performFetch() + if let results = topRestaurantsFetchedResultsController!.fetchedObjects { + topRestaurantsSubject.send(results.toShortPlaces()) + } + } catch { + topRestaurantsSubject.send(completion: .failure(.cacheError)) + } + } + + func observePlaceById(placeId: Int64) { + let fetchRequest: NSFetchRequest = PlaceEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id == %lld", placeId) + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "id", ascending: true)] + fetchRequest.fetchLimit = 1 + + singlePlaceFetchedResultsController = NSFetchedResultsController( + fetchRequest: fetchRequest, managedObjectContext: viewContext, sectionNameKeyPath: nil, cacheName: nil + ) + + singlePlaceFetchedResultsController?.delegate = self + + do { + try singlePlaceFetchedResultsController!.performFetch() + if let results = singlePlaceFetchedResultsController!.fetchedObjects { + if let place = results.first { + singlePlaceSubject.send(place.toPlaceFull()) + } + } + } catch { + singlePlaceSubject.send(completion: .failure(.cacheError)) + } + } + + // MARK: Favorites + func observeFavoritePlaces(query: String = "") { + let fetchRequest: NSFetchRequest = PlaceEntity.fetchRequest() + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "id", ascending: true)] + var predicates = [ + NSPredicate(format: "isFavorite == YES"), + ] + if !query.isEmpty { + predicates.append(NSPredicate(format: "name CONTAINS[cd] %@", query)) + } + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + + favoritePlacesFetchedResultsController = NSFetchedResultsController( + fetchRequest: fetchRequest, managedObjectContext: viewContext, sectionNameKeyPath: nil, cacheName: nil + ) + + favoritePlacesFetchedResultsController?.delegate = self + + do { + try favoritePlacesFetchedResultsController!.performFetch() + if let results = favoritePlacesFetchedResultsController!.fetchedObjects { + favoritePlacesSubject.send(results.toShortPlaces()) + } + } catch { + favoritePlacesSubject.send(completion: .failure(.cacheError)) + } + } + + func getFavoritePlaces(query: String = "") -> [PlaceEntity] { + let fetchRequest: NSFetchRequest = PlaceEntity.fetchRequest() + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] + var predicates = [ + NSPredicate(format: "isFavorite == YES"), + ] + if !query.isEmpty { + predicates.append(NSPredicate(format: "name CONTAINS[cd] %@", query)) + } + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + + do { + return try viewContext.fetch(fetchRequest) + } catch { + print("Failed to fetch favorite places: \(error)") + return [] + } + } + + func setFavorite(placeId: Int64, isFavorite: Bool) { + + let backgroundContext = CoreDataManager.shared.backgroundContext + + backgroundContext.perform { + let fetchRequest: NSFetchRequest = PlaceEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id == %lld", placeId) + + do { + if let place = try backgroundContext.fetch(fetchRequest).first { + place.isFavorite = isFavorite + try backgroundContext.save() + } + } catch { + print("Failed to set favorite status: \(error)") + } + } + + } + + func addFavoritingRecordForSync(placeId: Int64, isFavorite: Bool) { + + let backgroundContext = CoreDataManager.shared.backgroundContext + + backgroundContext.perform { + let favoriteSyncEntity = FavoriteSyncEntity(context: backgroundContext) + favoriteSyncEntity.placeId = placeId + favoriteSyncEntity.isFavorite = isFavorite + + do { + try backgroundContext.save() + } catch { + print("Failed to add favorite sync: \(error)") + } + } + + } + + func removeFavoritingRecordsForSync(placeIds: [Int64]) { + + let backgroundContext = CoreDataManager.shared.backgroundContext + + backgroundContext.perform { + let fetchRequest: NSFetchRequest = FavoriteSyncEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "placeId IN %@", placeIds) + + let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) + deleteRequest.resultType = .resultTypeObjectIDs + + do { + let result = try backgroundContext.execute(deleteRequest) as? NSBatchDeleteResult + let changes: [AnyHashable: Any] = [ + NSDeletedObjectsKey: result?.result as? [NSManagedObjectID] ?? [] + ] + + NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [backgroundContext]) + try backgroundContext.save() + } catch { + print("Failed to remove favoriting records for sync: \(error)") + } + } + + } + + func getFavoritingRecordsForSync() -> [FavoriteSyncEntity] { + let fetchRequest: NSFetchRequest = FavoriteSyncEntity.fetchRequest() + + do { + return try viewContext.fetch(fetchRequest) + } catch { + print("Failed to fetch favorite sync data: \(error)") + return [] + } + } + + // MARK: - NSFetchedResultsControllerDelegate + func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + guard let fetchedObjects = controller.fetchedObjects as? [PlaceEntity] else { + return + } + + switch controller { + case searchFetchedResultsController: + searchSubject.send(fetchedObjects.toShortPlaces()) + case placesByCatFetchedResultsController: + placesByCatSubject.send(fetchedObjects.toShortPlaces()) + case topSightsFetchedResultsController: + topSightsSubject.send(fetchedObjects.toShortPlaces()) + case topRestaurantsFetchedResultsController: + topRestaurantsSubject.send(fetchedObjects.toShortPlaces()) + case singlePlaceFetchedResultsController: + if let place = fetchedObjects.first { + singlePlaceSubject.send(place.toPlaceFull()) + } + case favoritePlacesFetchedResultsController: + favoritePlacesSubject.send(fetchedObjects.toShortPlaces()) + default: + break + } + } +} diff --git a/iphone/Maps/Tourism/Data/Db/PersistenceControllers/ReviewsPersistenceController.swift b/iphone/Maps/Tourism/Data/Db/PersistenceControllers/ReviewsPersistenceController.swift new file mode 100644 index 0000000000..b052285529 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Db/PersistenceControllers/ReviewsPersistenceController.swift @@ -0,0 +1,319 @@ +import Foundation +import CoreData +import Combine + +class ReviewsPersistenceController: NSObject, NSFetchedResultsControllerDelegate { + static let shared = ReviewsPersistenceController() + + private let viewContext = CoreDataManager.shared.viewContext + + private var reviewsForPlaceFetchedResultsController: NSFetchedResultsController? + private var reviewsPlannedToPostFetchedResultsController: NSFetchedResultsController? + + let reviewsForPlaceSubject = PassthroughSubject<[Review], ResourceError>() + let reviewsPlannedToPostSubject = PassthroughSubject<[ReviewPlannedToPostEntity], ResourceError>() + +// override init() { +// container = NSPersistentContainer(name: "Place") +// super.init() +// container.loadPersistentStores { (storeDescription, error) in +// if let error = error as NSError? { +// fatalError("Unresolved error \(error), \(error.userInfo)") +// } +// } +// container.viewContext.automaticallyMergesChangesFromParent = true +// } + + // MARK: - Review Operations + func insertReviews(_ reviews: [Review]) { + let backgroundContext = CoreDataManager.shared.backgroundContext + + backgroundContext.perform { + do { + for review in reviews { + let newReview = ReviewEntity(context: backgroundContext) + newReview.id = review.id + self.updateReviewEntity(newReview, with: review) + } + try backgroundContext.save() + } catch { + print(error) + print("Failed to insert/update reviews: \(error)") + } + } + + + } + + private func updateReviewEntity(_ entity: ReviewEntity, with review: Review) { + entity.placeId = review.placeId + entity.rating = Int16(review.rating) + entity.userJson = DBUtils.encodeToJsonString(review.user?.toUserEntity()) + entity.date = review.date + entity.comment = review.comment + entity.picsUrlsJson = DBUtils.encodeToJsonString(review.picsUrls) + entity.deletionPlanned = review.deletionPlanned + } + + func deleteReview(id: Int64) { + + let backgroundContext = CoreDataManager.shared.backgroundContext + + backgroundContext.perform { + let fetchRequest: NSFetchRequest = ReviewEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id == %lld", id) + + do { + let reviews = try backgroundContext.fetch(fetchRequest) + for review in reviews { + backgroundContext.delete(review) + } + try backgroundContext.save() + } catch { + print(error) + print("Failed to delete review: \(error)") + } + } + + } + + func deleteReviews(ids: [Int64]) { + + let backgroundContext = CoreDataManager.shared.backgroundContext + + backgroundContext.perform { + let fetchRequest: NSFetchRequest = ReviewEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id IN %@", ids) + + do { + let reviews = try backgroundContext.fetch(fetchRequest) + for review in reviews { + backgroundContext.delete(review) + } + try backgroundContext.save() + } catch { + print(error) + print("Failed to delete reviews: \(error)") + } + } + + } + + func deleteAllReviews() { + + let backgroundContext = CoreDataManager.shared.backgroundContext + + backgroundContext.perform { + let fetchRequest: NSFetchRequest = ReviewEntity.fetchRequest() + let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) + deleteRequest.resultType = .resultTypeObjectIDs + + do { + let result = try backgroundContext.execute(deleteRequest) as? NSBatchDeleteResult + let changes: [AnyHashable: Any] = [ + NSDeletedObjectsKey: result?.result as? [NSManagedObjectID] ?? [] + ] + + NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [backgroundContext]) + try backgroundContext.save() + } catch { + print(error) + print("Failed to delete all places: \(error)") + } + } + + } + + func deleteAllPlaceReviews(placeId: Int64) { + + let backgroundContext = CoreDataManager.shared.backgroundContext + + backgroundContext.perform { + let fetchRequest: NSFetchRequest = ReviewEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "placeId == %lld", placeId) + let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) + deleteRequest.resultType = .resultTypeObjectIDs + + do { + let result = try backgroundContext.execute(deleteRequest) as? NSBatchDeleteResult + let changes: [AnyHashable: Any] = [ + NSDeletedObjectsKey: result?.result as? [NSManagedObjectID] ?? [] + ] + + NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [backgroundContext]) + try backgroundContext.save() + } catch { + print(error) + print("Failed to delete place reviews: \(error)") + } + } + + + } + + func observeReviewsForPlace(placeId: Int64) { + + let fetchRequest: NSFetchRequest = ReviewEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "placeId == %lld", placeId) + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)] + + reviewsForPlaceFetchedResultsController = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: viewContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + reviewsForPlaceFetchedResultsController?.delegate = self + + do { + try reviewsForPlaceFetchedResultsController?.performFetch() + if let results = reviewsForPlaceFetchedResultsController?.fetchedObjects { + reviewsForPlaceSubject.send(results.map({ reviews in + reviews.toReview() + })) + } + } catch { + print(error) + reviewsForPlaceSubject.send(completion: .failure(ResourceError.cacheError)) + } + } + + func markReviewForDeletion(id: Int64, deletionPlanned: Bool = true) { + let fetchRequest: NSFetchRequest = ReviewEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id == %lld", id) + + do { + let reviews = try viewContext.fetch(fetchRequest) + if let review = reviews.first { + review.deletionPlanned = deletionPlanned + try viewContext.save() + } + } catch { + print(error) + print("Failed to mark review for deletion: \(error)") + } + } + + func getReviewsPlannedForDeletion() -> [ReviewEntity] { + let fetchRequest: NSFetchRequest = ReviewEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "deletionPlanned == YES") + + do { + return try viewContext.fetch(fetchRequest) + } catch { + print(error) + print("Failed to fetch reviews planned for deletion: \(error)") + return [] + } + } + + // // MARK: - Planned Review Operations + + func insertReviewPlannedToPost(_ review: ReviewToPost) { + + let backgroundContext = CoreDataManager.shared.backgroundContext + + backgroundContext.perform { + let newReview = ReviewPlannedToPostEntity(context: backgroundContext) + newReview.placeId = review.placeId + newReview.comment = review.comment + newReview.rating = Int32(review.rating) + let imagesJson = DBUtils.encodeToJsonString(review.images) + newReview.imagesJson = imagesJson + + do { + try backgroundContext.save() + } catch { + print(error) + print("Failed to insert planned review: \(error)") + } + } + + + } + + func deleteReviewPlannedToPost(placeId: Int64) { + + let backgroundContext = CoreDataManager.shared.backgroundContext + + backgroundContext.perform { + let fetchRequest: NSFetchRequest = ReviewPlannedToPostEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "placeId == %lld", placeId) + + let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) + deleteRequest.resultType = .resultTypeObjectIDs + + do { + let result = try backgroundContext.execute(deleteRequest) as? NSBatchDeleteResult + let changes: [AnyHashable: Any] = [ + NSDeletedObjectsKey: result?.result as? [NSManagedObjectID] ?? [] + ] + + NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [backgroundContext]) + try backgroundContext.save() + } catch { + print(error) + print("Failed to delete planned review: \(error)") + } + } + + } + + func getReviewsPlannedToPost() -> [ReviewPlannedToPostEntity] { + let fetchRequest: NSFetchRequest = ReviewPlannedToPostEntity.fetchRequest() + + do { + return try viewContext.fetch(fetchRequest) + } catch { + print(error) + print("Failed to fetch planned reviews: \(error)") + return [] + } + } + + // we only use it to limit the user from reviewing when he already made review + // for a place +// func observeReviewsPlannedToPost(placeId: Int64) { +// let fetchRequest: NSFetchRequest = ReviewPlannedToPostEntity.fetchRequest() +// fetchRequest.sortDescriptors = [NSSortDescriptor(key: "placeId", ascending: true)] +// fetchRequest.predicate = NSPredicate(format: "placeId == %lld", placeId) +// +// reviewsPlannedToPostFetchedResultsController = NSFetchedResultsController( +// fetchRequest: fetchRequest, +// managedObjectContext: container.viewContext, +// sectionNameKeyPath: nil, +// cacheName: nil +// ) +// +// reviewsPlannedToPostFetchedResultsController?.delegate = self +// +// do { +// try reviewsPlannedToPostFetchedResultsController?.performFetch() +// if let results = reviewsPlannedToPostFetchedResultsController?.fetchedObjects { +// reviewsPlannedToPostSubject.send(results) +// } +// } catch { +// print(error) +// reviewsPlannedToPostSubject.send(completion: .failure(ResourceError.cacheError)) +// } +// } + + // MARK: - NSFetchedResultsControllerDelegate + func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + guard let fetchedObjects = controller.fetchedObjects else { + return + } + + switch controller { + case reviewsForPlaceFetchedResultsController: + let reviewsEntities = fetchedObjects as! [ReviewEntity] + let reviews = reviewsEntities.map { reviewEntity in reviewEntity.toReview() } + reviewsForPlaceSubject.send(reviews) + case reviewsPlannedToPostFetchedResultsController: + reviewsPlannedToPostSubject.send(fetchedObjects as! [ReviewPlannedToPostEntity]) + default: + break + } + } +} diff --git a/iphone/Maps/Tourism/Data/Db/PersistenceControllers/Testers/PlacePersistenceControllerTesterBro.swift b/iphone/Maps/Tourism/Data/Db/PersistenceControllers/Testers/PlacePersistenceControllerTesterBro.swift new file mode 100644 index 0000000000..7b5e44b98d --- /dev/null +++ b/iphone/Maps/Tourism/Data/Db/PersistenceControllers/Testers/PlacePersistenceControllerTesterBro.swift @@ -0,0 +1,192 @@ +//import Combine +// +//class PlacePersistenceControllerTesterBro { +// private static var cancellables = Set() +// private static let persistenceController = PlacesPersistenceController.shared +// private static let searchQuery = "place" +// +// static func testAllPlaces() { +// testSearchOperation() +// testPlacesByCatFetchOperation() +// testTopPlacesFetchOperation() +// testSinglePlaceFetchOperation() +// testFavoritePlacesFetchOperation() +// testCRUDOperations() +// } +// +// private static func testCRUDOperations() { +// print("Testing CRUD Operations...") +// +// // Example PlaceFull object +// let place = PlaceFull( +// id: 1, +// name: "Test Place", +// rating: 5, +// excerpt: "A great place", +// description: "Detailed description", +// placeLocation: nil, +// cover: Constants.imageUrlExample, +// pics: [Constants.imageUrlExample, Constants.imageUrlExample, Constants.anotherImageExample], +// reviews: nil, +// isFavorite: true +// ) +// +// let place2 = PlaceFull( +// id: 2, +// name: "Test Place 2222", +// rating: 4.9, +// excerpt: "A great place", +// description: "Detailed description", +// placeLocation: nil, +// cover: Constants.imageUrlExample, +// pics: [Constants.imageUrlExample, Constants.imageUrlExample, Constants.anotherImageExample], +// reviews: nil, +// isFavorite: true +// ) +// +// let place3 = PlaceFull( +// id: 3, +// name: "Test Place 3", +// rating: 5, +// excerpt: "A great place", +// description: "Detailed description", +// placeLocation: nil, +// cover: Constants.imageUrlExample, +// pics: [Constants.imageUrlExample, Constants.imageUrlExample, Constants.anotherImageExample], +// reviews: nil, +// isFavorite: false +// ) +// +// var place4 = PlaceFull( +// id: 4, +// name: "Test Place 4", +// rating: 4, +// excerpt: "A great place", +// description: "Detailed description", +// placeLocation: nil, +// cover: Constants.imageUrlExample, +// pics: [Constants.imageUrlExample, Constants.imageUrlExample, Constants.anotherImageExample], +// reviews: nil, +// isFavorite: false +// ) +// +// // Insert or update place +// DispatchQueue.main.asyncAfter(deadline: .now() + 1) { +// self.persistenceController.putPlaces([place], categoryId: 1) +// print("Inserted/Updated places with ID: \(place.id)") +// } +// DispatchQueue.main.asyncAfter(deadline: .now() + 3) { +// self.persistenceController.putPlaces([place2, place3], categoryId: 2) +// print("Inserted/Updated places with ID: \(place2.id), \(place3.id)") +// } +// DispatchQueue.main.asyncAfter(deadline: .now() + 5) { +// self.persistenceController.putPlaces([place4], categoryId: 3) +// print("Inserted/Updated places with ID: \(place4.id)") +// } +// DispatchQueue.main.asyncAfter(deadline: .now() + 9) { +// place4.isFavorite = !place4.isFavorite +// self.persistenceController.putPlaces([place4], categoryId: 3) +// print("Inserted/Updated places with ID: \(place4.id)") +// } +// // Delete all +// DispatchQueue.main.asyncAfter(deadline: .now() + 0) { +// self.persistenceController.deleteAllPlaces() +// print("Deleted all places") +// } +// // Delete places by category (assuming `categoryId` is available) +// DispatchQueue.main.asyncAfter(deadline: .now() + 7) { +// self.persistenceController.deleteAllPlacesByCategory(categoryId: 3) +// print("Deleted places with category ID: 2") +// } +// } +// +// private static func testSearchOperation() { +// print("Testing Search Operation...") +// persistenceController.searchSubject +// .sink(receiveCompletion: { completion in +// if case .failure(let error) = completion { +// print("Search failed with error: \(error)") +// } +// }, receiveValue: { places in +// print("Search Results:") +// places.forEach { place in +// print("ID: \(place.id), Name: \(place.name ?? ""), Excerpt: \(place.excerpt ?? "No excerpt")") +// } +// }) +// .store(in: &cancellables) +// +// persistenceController.observeSearch(query: searchQuery) +// } +// +// private static func testPlacesByCatFetchOperation() { +// print("Testing PlacesByCat Operation...") +// persistenceController.placesByCatSubject +// .sink(receiveCompletion: { completion in +// if case .failure(let error) = completion { +// print("PlacesByCat failed with error: \(error)") +// } +// }, receiveValue: { places in +// print("PlacesByCat Results:") +// places.forEach { place in +// print("ID: \(place.id), Name: \(place.name ?? ""), Excerpt: \(place.excerpt ?? "No excerpt")") +// } +// }) +// .store(in: &cancellables) +// +// persistenceController.observePlacesByCategoryId(categoryId: 3) +// } +// +// private static func testTopPlacesFetchOperation() { +// print("Testing TopPlaces Operation...") +// persistenceController.topPlacesSubject +// .sink(receiveCompletion: { completion in +// if case .failure(let error) = completion { +// print("TopPlaces failed with error: \(error)") +// } +// }, receiveValue: { places in +// print("TopPlaces Results:") +// places.forEach { place in +// print("ID: \(place.id), Name: \(place.name ?? ""), Excerpt: \(place.excerpt ?? "No excerpt")") +// } +// }) +// .store(in: &cancellables) +// +// persistenceController.observeTopPlacesByCategoryId(categoryId: 2) +// } +// +// private static func testSinglePlaceFetchOperation() { +// print("Testing SinglePlace Operation...") +// persistenceController.singlePlaceSubject +// .sink(receiveCompletion: { completion in +// if case .failure(let error) = completion { +// print("SinglePlace failed with error: \(error)") +// } +// }, receiveValue: { place in +// print("SinglePlace Results:") +// if let place = place { +// print("ID: \(place.id), Name: \(place.name ?? ""), Excerpt: \(place.excerpt ?? "No excerpt")") +// } +// }) +// .store(in: &cancellables) +// +// persistenceController.observePlaceById(placeId: 1) +// } +// +// private static func testFavoritePlacesFetchOperation() { +// print("Testing FavoritePlaces Operation...") +// persistenceController.favoritePlacesSubject +// .sink(receiveCompletion: { completion in +// if case .failure(let error) = completion { +// print("FavoritePlaces failed with error: \(error)") +// } +// }, receiveValue: { places in +// print("FavoritePlaces Results:") +// places.forEach { place in +// print("ID: \(place.id), Name: \(place.name ?? ""), Excerpt: \(place.excerpt ?? "No excerpt")") +// } +// }) +// .store(in: &cancellables) +// +// persistenceController.observeFavoritePlaces() +// } +//} diff --git a/iphone/Maps/Tourism/Data/Db/PersistenceControllers/Testers/ReviewsPersistenceControllerTesterBro.swift b/iphone/Maps/Tourism/Data/Db/PersistenceControllers/Testers/ReviewsPersistenceControllerTesterBro.swift new file mode 100644 index 0000000000..39e3c67afb --- /dev/null +++ b/iphone/Maps/Tourism/Data/Db/PersistenceControllers/Testers/ReviewsPersistenceControllerTesterBro.swift @@ -0,0 +1,144 @@ +import Combine +import Foundation + +class ReviewsPersistenceControllerTesterBro { + private static var cancellables = Set() + private static let persistenceController = ReviewsPersistenceController.shared + + static func testAllReviews() { + testCRUDOperations() + testObserveReviewsForPlace() + testMarkReviewForDeletion() + testGetReviewsPlannedForDeletion() + } + + private static func testCRUDOperations() { + print("Testing CRUD Operations...") + + // Example Review objects + let review1 = Review( + id: 1, + placeId: 1, + rating: 5, + user: User(id: 1, name: "John Doe", pfpUrl: Constants.imageUrlExample, countryCodeName: "us"), + date: "01-01-2000", + comment: "Great place!", + picsUrls: [Constants.imageUrlExample,Constants.imageUrlExample,Constants.anotherImageExample], + deletionPlanned: false + ) + + let review2 = Review( + id: 2, + placeId: 1, + rating: 4, + user: User(id: 1, name: "John Doe", pfpUrl: Constants.anotherImageExample, countryCodeName: "us"), + date: "01-01-2000", + comment: "Nice atmosphere", + picsUrls: [Constants.imageUrlExample], + deletionPlanned: false + ) + + let review3 = Review( + id: 3, + placeId: 1, + rating: 4, + user: User(id: 1, name: "John Doe", pfpUrl: Constants.anotherImageExample, countryCodeName: "us"), + date: "01-01-2000", + comment: "Nice atmosphere", + picsUrls: [Constants.imageUrlExample], + deletionPlanned: false + ) + + // Insert reviews + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.persistenceController.putReview(review1) + print("Inserted review with ID: \(review1.id)") + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.persistenceController.putReviews([review1, review2]) + print("Inserted reviews with IDs: \(review1.id), \(review2.id)") + } + + // Delete review + DispatchQueue.main.asyncAfter(deadline: .now() + 4) { + self.persistenceController.deleteReview(id: review1.id) + print("Deleted review with ID: \(review1.id)") + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + self.persistenceController.putReviews([review1, review2]) + print("Inserted reviews with IDs: \(review1.id), \(review2.id)") + } + + // Delete multiple reviews + DispatchQueue.main.asyncAfter(deadline: .now() + 6) { + self.persistenceController.deleteReviews(ids: [review1.id, review2.id]) + print("Deleted reviews with IDs: \(review1.id), \(review2.id)") + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 7) { + self.persistenceController.putReviews([review1, review2, review3]) + print("Inserted reviews with IDs: \(review1.id), \(review2.id), \(review3.id)") + } + + // Delete all reviews for a place + DispatchQueue.main.asyncAfter(deadline: .now() + 8) { + self.persistenceController.deleteAllPlaceReviews(placeId: 1) + print("Deleted all reviews for place with ID: 1") + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 9) { + self.persistenceController.putReviews([review1, review2, review3]) + print("Inserted reviews with IDs: \(review1.id), \(review2.id), \(review3.id)") + } + + // Delete all reviews + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { + self.persistenceController.deleteAllReviews() + print("Deleted all reviews") + } + } + + private static func testObserveReviewsForPlace() { + print("Testing Observe Reviews for Place...") + persistenceController.reviewsForPlaceSubject + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + print("Observe reviews failed with error: \(error)") + } + }, receiveValue: { reviews in + print("Reviews for Place Results:") + reviews.forEach { review in + print("ID: \(review.id), PlaceID: \(review.placeId), Rating: \(review.rating), Comment: \(review.comment ?? "No comment"), deletionPlanned: \(review.deletionPlanned)") + } + }) + .store(in: &cancellables) + + persistenceController.observeReviewsForPlace(placeId: 1) + } + + private static func testMarkReviewForDeletion() { + print("Testing Mark Review for Deletion...") + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.persistenceController.markReviewForDeletion(id: 1, deletionPlanned: true) + print("Marked review with ID 1 for deletion") + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.persistenceController.markReviewForDeletion(id: 1, deletionPlanned: false) + print("Unmarked review with ID 1 for deletion") + } + } + + private static func testGetReviewsPlannedForDeletion() { + print("Testing Get Reviews Planned for Deletion...") + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + let reviewsPlannedForDeletion = self.persistenceController.getReviewsPlannedForDeletion() + print("Reviews planned for deletion:") + reviewsPlannedForDeletion.forEach { review in + print("ID: \(review.id), PlaceID: \(review.placeId), Rating: \(review.rating), Comment: \(review.comment ?? "No comment"), deletionPlanned: \(review.deletionPlanned)") + } + } + } +} diff --git a/iphone/Maps/Tourism/Data/Network/APIEndpoints.swift b/iphone/Maps/Tourism/Data/Network/APIEndpoints.swift new file mode 100644 index 0000000000..e62a5a93fe --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/APIEndpoints.swift @@ -0,0 +1,36 @@ +import Foundation + +struct APIEndpoints { + // MARK: - Auth + static let signInUrl = "\(BASE_URL)login" + static let signUpUrl = "\(BASE_URL)register" + static let signOutUrl = "\(BASE_URL)logout" + static let forgotPassword = "\(BASE_URL)forgot-password" + + // MARK: - Profile + static let getUserUrl = "\(BASE_URL)user" + static let updateProfileUrl = "\(BASE_URL)profile" + static let updateLanguageUrl = "\(BASE_URL)profile/lang" + static let updateThemeUrl = "\(BASE_URL)profile/theme" + + // MARK: - Places + static func getPlacesByCategoryUrl(id: Int64, hash: String) -> String { + return "\(BASE_URL)marks/\(id)?hash=\(hash)" + } + static let getAllPlacesUrl = "\(BASE_URL)marks/all" + + // MARK: - Favorites + static let getFavoritesUrl = "\(BASE_URL)favourite-marks" + static let addFavoritesUrl = "\(BASE_URL)favourite-marks" + static let removeFromFavoritesUrl = "\(BASE_URL)favourite-marks" + + // MARK: - Reviews + static func getReviewsByPlaceIdUrl(id: Int64) -> String { + return "\(BASE_URL)feedbacks/\(id)" + } + static let postReviewUrl = "\(BASE_URL)feedbacks" + static let deleteReviewsUrl = "\(BASE_URL)feedbacks" + + // MARK: - Currency + static let currencyUrl = "\(BASE_URL)currency" +} diff --git a/iphone/Maps/Tourism/Data/Network/DTO/AllDataDTO.swift b/iphone/Maps/Tourism/Data/Network/DTO/AllDataDTO.swift new file mode 100644 index 0000000000..25b669a3b0 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/DTO/AllDataDTO.swift @@ -0,0 +1,10 @@ +import Foundation + +struct AllDataDTO: Codable { + let attractions: [PlaceDTO] + let restaurants: [PlaceDTO] + let accommodations: [PlaceDTO] + let attractionsHash: String + let restaurantsHash: String + let accommodationsHash: String +} diff --git a/iphone/Maps/Tourism/Data/Network/DTO/Auth/AuthResponseDTO.swift b/iphone/Maps/Tourism/Data/Network/DTO/Auth/AuthResponseDTO.swift new file mode 100644 index 0000000000..2bd6e4accc --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/DTO/Auth/AuthResponseDTO.swift @@ -0,0 +1,3 @@ +struct AuthResponseDTO: Codable { + let token: String +} diff --git a/iphone/Maps/Tourism/Data/Network/DTO/Auth/EmailBodyDto.swift b/iphone/Maps/Tourism/Data/Network/DTO/Auth/EmailBodyDto.swift new file mode 100644 index 0000000000..0dd3a0363b --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/DTO/Auth/EmailBodyDto.swift @@ -0,0 +1,5 @@ +import Foundation + +struct EmailBodyDto: Codable { + let email: String +} diff --git a/iphone/Maps/Tourism/Data/Network/DTO/Auth/HashDTO.swift b/iphone/Maps/Tourism/Data/Network/DTO/Auth/HashDTO.swift new file mode 100644 index 0000000000..9a156e5fd6 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/DTO/Auth/HashDTO.swift @@ -0,0 +1,5 @@ +import Foundation + +struct HashDTO: Codable { + let hash: String +} diff --git a/iphone/Maps/Tourism/Data/Network/DTO/Auth/SignInRequestDTO.swift b/iphone/Maps/Tourism/Data/Network/DTO/Auth/SignInRequestDTO.swift new file mode 100644 index 0000000000..e336ad6169 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/DTO/Auth/SignInRequestDTO.swift @@ -0,0 +1,11 @@ +struct SignInRequestDTO: Codable { + let email: String + let password: String +} + +extension SignInRequestDTO { + init(from domainModel: SignInRequest) { + self.email = domainModel.email + self.password = domainModel.password + } +} diff --git a/iphone/Maps/Tourism/Data/Network/DTO/Auth/SignUpRequestDTO.swift b/iphone/Maps/Tourism/Data/Network/DTO/Auth/SignUpRequestDTO.swift new file mode 100644 index 0000000000..47579a2750 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/DTO/Auth/SignUpRequestDTO.swift @@ -0,0 +1,17 @@ +struct SignUpRequestDTO: Codable { + let fullName: String + let email: String + let password: String + let passwordConfirmation: String + let country: String +} + +extension SignUpRequestDTO { + init(from domainModel: SignUpRequest) { + self.fullName = domainModel.fullName + self.email = domainModel.email + self.password = domainModel.password + self.passwordConfirmation = domainModel.passwordConfirmation + self.country = domainModel.country + } +} diff --git a/iphone/Maps/Tourism/Data/Network/DTO/CategoryDTO.swift b/iphone/Maps/Tourism/Data/Network/DTO/CategoryDTO.swift new file mode 100644 index 0000000000..40147bc59c --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/DTO/CategoryDTO.swift @@ -0,0 +1,6 @@ +import Foundation + +struct CategoryDTO: Codable { + let data: [PlaceDTO] + let hash: String +} diff --git a/iphone/Maps/Tourism/Data/Network/DTO/Details/CoordinatesDTO.swift b/iphone/Maps/Tourism/Data/Network/DTO/Details/CoordinatesDTO.swift new file mode 100644 index 0000000000..0e71f63965 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/DTO/Details/CoordinatesDTO.swift @@ -0,0 +1,14 @@ +import Foundation + +struct CoordinatesDTO: Codable { + let latitude: String? + let longitude: String? + + func toPlaceLocation(name: String) -> PlaceLocation? { + guard let latitude = latitude, let longitude = longitude, + let lat = Double(latitude), let lon = Double(longitude) else { + return nil + } + return PlaceLocation(name: name, lat: lat, lon: lon) + } +} diff --git a/iphone/Maps/Tourism/Data/Network/DTO/Details/PlaceDTO.swift b/iphone/Maps/Tourism/Data/Network/DTO/Details/PlaceDTO.swift new file mode 100644 index 0000000000..782ae3fbce --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/DTO/Details/PlaceDTO.swift @@ -0,0 +1,61 @@ +import Foundation + +struct PlaceDTO: Codable { + let id: Int64 + let name: String + let coordinates: CoordinatesDTO? + let cover: String + let feedbacks: [ReviewDTO]? + let gallery: [String] + let rating: Double + let shortDescription: String + let longDescription: String + + enum CodingKeys: String, CodingKey { + case id, name, coordinates, cover, feedbacks, gallery, rating, shortDescription, longDescription + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(Int64.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + coordinates = try container.decodeIfPresent(CoordinatesDTO.self, forKey: .coordinates) + cover = try container.decode(String.self, forKey: .cover) + feedbacks = try container.decodeIfPresent([ReviewDTO].self, forKey: .feedbacks) + gallery = try container.decode([String].self, forKey: .gallery) + rating = try container.decode(FlexibleDouble.self, forKey: .rating).value + shortDescription = try container.decode(String.self, forKey: .shortDescription) + longDescription = try container.decode(String.self, forKey: .longDescription) + } + + func toPlaceFull(isFavorite: Bool) -> PlaceFull { + return PlaceFull( + id: id, + name: name, + rating: rating, + excerpt: shortDescription, + description: longDescription, + placeLocation: coordinates?.toPlaceLocation(name: name), + cover: cover, + pics: gallery, + reviews: feedbacks?.map { $0.toReview() } ?? [], + isFavorite: isFavorite + ) + } +} + +struct FlexibleDouble: Codable { + let value: Double + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let doubleValue = try? container.decode(Double.self) { + value = doubleValue + } else if let stringValue = try? container.decode(String.self), + let doubleValue = Double(stringValue) { + value = doubleValue + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unable to decode double value") + } + } +} diff --git a/iphone/Maps/Tourism/Data/Network/DTO/Details/Reviews DTOs.swift b/iphone/Maps/Tourism/Data/Network/DTO/Details/Reviews DTOs.swift new file mode 100644 index 0000000000..63c48aec47 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/DTO/Details/Reviews DTOs.swift @@ -0,0 +1,41 @@ +import Foundation + +struct ReviewDTO: Codable { + let id: Int64 + let markId: Int64 + let images: [String] + let message: String? + let points: Int + let createdAt: String + let user: UserDTO + + func toReview() -> Review { + return Review( + id: id, + placeId: markId, + rating: points, + user: user.toUser(), + date: createdAt, + comment: message, + picsUrls: images + ) + } +} + + +struct ReviewIdsDTO: Codable { + let feedbacks: [Int64] +} + + +struct ReviewsDTO: Codable { + let data: [ReviewDTO] +} + + +struct ReviewToPostDTO: Codable { + let placeId: Int64 + let comment: String + let rating: Int + let images: [URL] +} diff --git a/iphone/Maps/Tourism/Data/Network/DTO/Details/UserDTO.swift b/iphone/Maps/Tourism/Data/Network/DTO/Details/UserDTO.swift new file mode 100644 index 0000000000..fd46145f72 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/DTO/Details/UserDTO.swift @@ -0,0 +1,17 @@ +import Foundation + +struct UserDTO: Codable { + let id: Int64 + let avatar: String + let country: String + let fullName: String + + func toUser() -> User { + return User( + id: id, + name: fullName, + pfpUrl: avatar, + countryCodeName: country + ) + } +} diff --git a/iphone/Maps/Tourism/Data/Network/DTO/FavoritesDTO.swift b/iphone/Maps/Tourism/Data/Network/DTO/FavoritesDTO.swift new file mode 100644 index 0000000000..2f2f08b9c3 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/DTO/FavoritesDTO.swift @@ -0,0 +1,6 @@ +import Foundation + +struct FavoritesDTO: Codable { + let data: [PlaceDTO] +} + diff --git a/iphone/Maps/Tourism/Data/Network/DTO/FavoritesIdsDTO.swift b/iphone/Maps/Tourism/Data/Network/DTO/FavoritesIdsDTO.swift new file mode 100644 index 0000000000..76d2285522 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/DTO/FavoritesIdsDTO.swift @@ -0,0 +1,5 @@ +import Foundation + +struct FavoritesIdsDTO: Codable { + let marks: [Int64] +} diff --git a/iphone/Maps/Tourism/Data/Network/DTO/Profile/CurrencyRatesDTO.swift b/iphone/Maps/Tourism/Data/Network/DTO/Profile/CurrencyRatesDTO.swift new file mode 100644 index 0000000000..38cbd8c948 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/DTO/Profile/CurrencyRatesDTO.swift @@ -0,0 +1,26 @@ +import Foundation + +enum CurrencyConversionError: Error { + case invalidData +} + +struct CurrencyRatesDTO: Codable { + let data: CurrencyDataDTO + + struct CurrencyDataDTO: Codable { + let usd: String + let eur: String + let rub: String + } +} + +extension CurrencyRatesDTO { + func toCurrencyRates() throws -> CurrencyRates { + guard let usd = Double(data.usd), + let eur = Double(data.eur), + let rub = Double(data.rub) else { + throw CurrencyConversionError.invalidData + } + return CurrencyRates(usd: usd, eur: eur, rub: rub) + } +} diff --git a/iphone/Maps/Tourism/Data/Network/DTO/Profile/LanguageDTO.swift b/iphone/Maps/Tourism/Data/Network/DTO/Profile/LanguageDTO.swift new file mode 100644 index 0000000000..6f9679c141 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/DTO/Profile/LanguageDTO.swift @@ -0,0 +1,3 @@ +struct LanguageDTO : Codable { + let language: String +} diff --git a/iphone/Maps/Tourism/Data/Network/DTO/Profile/PersonalDataDTO.swift b/iphone/Maps/Tourism/Data/Network/DTO/Profile/PersonalDataDTO.swift new file mode 100644 index 0000000000..13b4517e93 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/DTO/Profile/PersonalDataDTO.swift @@ -0,0 +1,27 @@ +import Foundation + +struct PersonalDataDTO: Codable { + let data: DataDTO + + struct DataDTO: Codable { + let id: Int64 + let fullName: String + let country: String + let avatar: String? + let email: String + let language: String? + let theme: String? + } + + func toPersonalData() -> PersonalData { + return PersonalData( + id: data.id, + fullName: data.fullName, + country: data.country, + pfpUrl: data.avatar, + email: data.email, + language: data.language, + theme: data.theme + ) + } +} diff --git a/iphone/Maps/Tourism/Data/Network/DTO/Profile/ThemeDTO.swift b/iphone/Maps/Tourism/Data/Network/DTO/Profile/ThemeDTO.swift new file mode 100644 index 0000000000..865554e4ce --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/DTO/Profile/ThemeDTO.swift @@ -0,0 +1,3 @@ +struct ThemeDTO : Codable { + let theme: String +} diff --git a/iphone/Maps/Tourism/Data/Network/HttpMethod.swift b/iphone/Maps/Tourism/Data/Network/HttpMethod.swift new file mode 100644 index 0000000000..a60053dca1 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/HttpMethod.swift @@ -0,0 +1,8 @@ +import Foundation + +enum HTTPMethod: String { + case get = "GET" + case post = "POST" + case put = "PUT" + case delete = "DELETE" +} diff --git a/iphone/Maps/Tourism/Data/Network/Services/AuthService.swift b/iphone/Maps/Tourism/Data/Network/Services/AuthService.swift new file mode 100644 index 0000000000..6aac6547b6 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/Services/AuthService.swift @@ -0,0 +1,28 @@ +import Combine +import Foundation + +protocol AuthService { + func signIn(body: SignInRequestDTO) -> AnyPublisher + func signUp(body: SignUpRequestDTO) -> AnyPublisher + func signOut() -> AnyPublisher + func sendEmailForPasswordReset(email: String) -> AnyPublisher +} + +class AuthServiceImpl: AuthService { + + func signIn(body: SignInRequestDTO) -> AnyPublisher { + return CombineNetworkHelper.post(path: APIEndpoints.signInUrl, body: body) + } + + func signUp(body: SignUpRequestDTO) -> AnyPublisher { + return CombineNetworkHelper.post(path: APIEndpoints.signUpUrl, body: body) + } + + func signOut() -> AnyPublisher { + return CombineNetworkHelper.postWithoutBody(path: APIEndpoints.signOutUrl) + } + + func sendEmailForPasswordReset(email: String) -> AnyPublisher { + return CombineNetworkHelper.post(path: APIEndpoints.forgotPassword, body: EmailBodyDto(email: email)) + } +} diff --git a/iphone/Maps/Tourism/Data/Network/Services/CurrencyService.swift b/iphone/Maps/Tourism/Data/Network/Services/CurrencyService.swift new file mode 100644 index 0000000000..e2258cb237 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/Services/CurrencyService.swift @@ -0,0 +1,13 @@ +import Combine +import Foundation + +protocol CurrencyService { + func getCurrencyRates() -> AnyPublisher +} + +class CurrencyServiceImpl: CurrencyService { + + func getCurrencyRates() -> AnyPublisher { + return CombineNetworkHelper.get(path: APIEndpoints.currencyUrl) + } +} diff --git a/iphone/Maps/Tourism/Data/Network/Services/PlacesService.swift b/iphone/Maps/Tourism/Data/Network/Services/PlacesService.swift new file mode 100644 index 0000000000..75ab3d8c29 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/Services/PlacesService.swift @@ -0,0 +1,32 @@ +import Combine + +protocol PlacesService { + func getPlacesByCategory(id: Int64, hash: String) async throws -> CategoryDTO + func getAllPlaces() async throws -> AllDataDTO + func getFavorites() async throws -> FavoritesDTO + func addFavorites(ids: FavoritesIdsDTO) async throws -> SimpleResponse + func removeFromFavorites(ids: FavoritesIdsDTO) async throws -> SimpleResponse +} + +class PlacesServiceImpl : PlacesService { + + func getPlacesByCategory(id: Int64, hash: String) async throws -> CategoryDTO { + return try await AppNetworkHelper.get(path: APIEndpoints.getPlacesByCategoryUrl(id: id, hash: hash)) + } + + func getAllPlaces() async throws -> AllDataDTO { + return try await AppNetworkHelper.get(path: APIEndpoints.getAllPlacesUrl) + } + + func getFavorites() async throws -> FavoritesDTO { + return try await AppNetworkHelper.get(path: APIEndpoints.getFavoritesUrl) + } + + func addFavorites(ids: FavoritesIdsDTO) async throws -> SimpleResponse { + return try await AppNetworkHelper.post(path: APIEndpoints.addFavoritesUrl, body: ids) + } + + func removeFromFavorites(ids: FavoritesIdsDTO) async throws -> SimpleResponse { + return try await AppNetworkHelper.delete(path: APIEndpoints.removeFromFavoritesUrl, body: ids) + } +} diff --git a/iphone/Maps/Tourism/Data/Network/Services/ProfileService.swift b/iphone/Maps/Tourism/Data/Network/Services/ProfileService.swift new file mode 100644 index 0000000000..2202cf541e --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/Services/ProfileService.swift @@ -0,0 +1,140 @@ +import Combine +import Foundation +import UIKit + +protocol ProfileService { + func getPersonalData() -> AnyPublisher + + func updateProfile( + fullName: String, + country: String, + email: String?, + pfpUrl: UIImage? + ) -> AnyPublisher + + func updateLanguage(code: String) + + func updateTheme(code: String) +} + +class ProfileServiceImpl: ProfileService { + let userPreferences: UserPreferences + + init(userPreferences: UserPreferences) { + self.userPreferences = userPreferences + } + + func getPersonalData() -> AnyPublisher { + return CombineNetworkHelper.get(path: APIEndpoints.getUserUrl) + } + + func updateProfile( + fullName: String, + country: String, + email: String?, + pfpUrl: UIImage? + ) -> AnyPublisher { + var parameters = [ + [ + "key": "full_name", + "value": fullName, + "type": "text" + ], + [ + "key": "country", + "value": country, + "type": "text" + ], + [ + "key": "_method", + "value": "PUT", + "type": "text" + ]] as [[String: Any]] + + if let newEmail = email { + parameters.append([ + "key": "email", + "value": newEmail, + "type": "text"]) + } + + let theme = userPreferences.getTheme() + parameters.append([ + "key": "theme", + "value": theme?.code ?? "light", + "type": "text"]) + + let language = userPreferences.getLanguage() + parameters.append([ + "key": "language", + "value": language?.code ?? "ru", + "type": "text"]) + + let boundary = "Boundary-\(UUID().uuidString)" + var body = Data() + + for param in parameters { + let paramName = param["key"] as! String + body += Data("--\(boundary)\r\n".utf8) + body += Data("Content-Disposition: form-data; name=\"\(paramName)\"\r\n\r\n".utf8) + body += Data("\(param["value"] as! String)\r\n".utf8) + } + + // Add image file data if it exists + if let image = pfpUrl, let imageData = image.jpegData(compressionQuality: 0.01) { + body += Data("--\(boundary)\r\n".utf8) + body += Data("Content-Disposition: form-data; name=\"avatar\"; filename=\"avatar.jpg\"\r\n".utf8) + body += Data("Content-Type: image/jpeg\r\n\r\n".utf8) + body += imageData + body += Data("\r\n".utf8) + } + + body += Data("--\(boundary)--\r\n".utf8) + + var request = URLRequest(url: URL(string: APIEndpoints.updateProfileUrl)!, timeoutInterval: Double.infinity) + request.addValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + if let token = userPreferences.getToken() { + request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + request.httpMethod = "POST" + request.httpBody = body + + return URLSession.shared.dataTaskPublisher(for: request) + .tryMap { data, response in + try AppNetworkHelper.handleResponse(data: data, response: response) + } + .mapError { error in + AppNetworkHelper.handleMappingError(error) + } + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + func updateLanguage(code: String) { + Task { + do { + let _: SimpleResponse = try await AppNetworkHelper.put( + path: APIEndpoints.updateLanguageUrl, + body: LanguageDTO(language: code) + ) + } catch { + print("Failed to update language on server") + } + } + } + + func updateTheme(code: String) { + Task { + do { + let _: SimpleResponse = try await AppNetworkHelper.put( + path: APIEndpoints.updateThemeUrl, + body: ThemeDTO(theme: code) + ) + } catch { + print("Failed to update theme on server") + } + } + } +} diff --git a/iphone/Maps/Tourism/Data/Network/Services/ReviewsService.swift b/iphone/Maps/Tourism/Data/Network/Services/ReviewsService.swift new file mode 100644 index 0000000000..8b92e2d112 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/Services/ReviewsService.swift @@ -0,0 +1,92 @@ +import Combine + +protocol ReviewsService { + func getReviewsByPlaceId(id: Int64) async throws -> ReviewsDTO + func postReview(review: ReviewToPostDTO) async throws -> SimpleResponse + func deleteReview(reviews: ReviewIdsDTO) async throws -> SimpleResponse +} + +class ReviewsServiceImpl : ReviewsService { + let userPreferences: UserPreferences + + init(userPreferences: UserPreferences) { + self.userPreferences = userPreferences + } + + func getReviewsByPlaceId(id: Int64) async throws -> ReviewsDTO { + return try await AppNetworkHelper.get(path: APIEndpoints.getReviewsByPlaceIdUrl(id: id)) + } + + func postReview(review: ReviewToPostDTO) async throws -> SimpleResponse { + guard let url = URL(string: APIEndpoints.postReviewUrl) else { + throw ResourceError.other(message: "Invalid URL") + } + + let boundary = "Boundary-\(UUID().uuidString)" + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let token = userPreferences.getToken() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + let parameters: [[String: Any]] = [ + ["key": "message", "value": review.comment, "type": "text"], + ["key": "mark_id", "value": "\(review.placeId)", "type": "text"], + ["key": "points", "value": "\(review.rating)", "type": "text"] + ] + review.images.map { ["key": "images[]", "src": $0.path, "type": "file"] } + + let body = try createBody(with: parameters, boundary: boundary) + request.httpBody = body + + let (data, response) = try await URLSession.shared.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + if !(200...299).contains(httpResponse.statusCode) { + throw ResourceError.other(message: "Response not successful") + } + } + + let decoder = JSONDecoder() + return try decoder.decode(SimpleResponse.self, from: data) + } + + private func createBody(with parameters: [[String: Any]], boundary: String) throws -> Data { + var body = Data() + + for param in parameters { + if param["disabled"] != nil { continue } + + let paramName = param["key"] as! String + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition:form-data; name=\"\(paramName)\"".data(using: .utf8)!) + + if let contentType = param["contentType"] as? String { + body.append("\r\nContent-Type: \(contentType)".data(using: .utf8)!) + } + + let paramType = param["type"] as! String + if paramType == "text" { + let paramValue = param["value"] as! String + body.append("\r\n\r\n\(paramValue)\r\n".data(using: .utf8)!) + } else { + let paramSrc = param["src"] as! String + let fileURL = URL(fileURLWithPath: paramSrc) + let fileName = fileURL.lastPathComponent + let data = try Data(contentsOf: fileURL) + body.append("; filename=\"\(fileName)\"\r\n".data(using: .utf8)!) + body.append("Content-Type: \"content-type header\"\r\n\r\n".data(using: .utf8)!) + body.append(data) + body.append("\r\n".data(using: .utf8)!) + } + } + + body.append("--\(boundary)--\r\n".data(using: .utf8)!) + return body + } + + func deleteReview(reviews: ReviewIdsDTO) async throws -> SimpleResponse { + return try await AppNetworkHelper.delete(path: APIEndpoints.deleteReviewsUrl, body: reviews) + } +} diff --git a/iphone/Maps/Tourism/Data/Network/Utils/NetworkHelper.swift b/iphone/Maps/Tourism/Data/Network/Utils/NetworkHelper.swift new file mode 100644 index 0000000000..445ca739fa --- /dev/null +++ b/iphone/Maps/Tourism/Data/Network/Utils/NetworkHelper.swift @@ -0,0 +1,258 @@ +import Foundation +import Combine + +class CombineNetworkHelper { + // MARK: - HTTP requests + static func get(path: String, headers: [String: String] = [:], decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher { + guard let url = URL(string: path) else { + print("Invalid url") + return Fail(error: ResourceError.other(message: "Invalid url")).eraseToAnyPublisher() + } + + return performRequest(url: url, method: "GET", headers: headers, decoder: decoder) + } + + static func post(path: String, body: U, headers: [String: String] = [:], decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher { + guard let url = URL(string: path) else { + print("Invalid url") + return Fail(error: ResourceError.other(message: "Invalid url")).eraseToAnyPublisher() + } + + do { + let jsonData = try AppNetworkHelper.encodeRequestBody(body) + return performRequest(url: url, method: "POST", body: jsonData, headers: headers, decoder: decoder) + } catch { + print(error) + return Fail(error: ResourceError.other(message: "Encoding error: \(error)")).eraseToAnyPublisher() + } + } + + static func postWithoutBody(path: String, headers: [String: String] = [:], decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher { + guard let url = URL(string: path) else { + print("Invalid url") + return Fail(error: ResourceError.other(message: "Invalid url")).eraseToAnyPublisher() + } + + return performRequest(url: url, method: "POST", headers: headers, decoder: decoder) + } + + // MARK: - Lower level code + static func performRequest(url: URL, + method: String, + body: Data? = nil, + headers: [String: String] = [:], + decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher { + let request = AppNetworkHelper.createRequest(url: url, method: method, headers: headers, body: body) + + return URLSession.shared.dataTaskPublisher(for: request) + .tryMap { data, response in + try AppNetworkHelper.handleResponse(data: data, response: response, decoder: decoder) + } + .mapError { error in + AppNetworkHelper.handleMappingError(error) + } + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + +} + +class AppNetworkHelper { + // MARK: - HTTP requests + static func get( + path: String, + headers: [String: String] = [:], + decoder: JSONDecoder = JSONDecoder() + ) async throws -> T { + guard let url = URL(string: path) else { + throw ResourceError.other(message: "Invalid URL") + } + + return try await performRequest( + url: url, + method: "GET", + headers: headers, + decoder: decoder + ) + } + + + static func post( + path: String, + body: U, + headers: [String: String] = [:], + decoder: JSONDecoder = JSONDecoder() + ) async throws -> T { + guard let url = URL(string: path) else { + throw ResourceError.other(message: "Invalid URL") + } + + do { + let jsonData = try AppNetworkHelper.encodeRequestBody(body) + return try await performRequest( + url: url, + method: "POST", + body: jsonData, + headers: headers, + decoder: decoder + ) + } catch { + print(error) + throw ResourceError.other(message: "Encoding error") + } + } + + static func postWithoutBody( + path: String, + headers: [String: String] = [:], + decoder: JSONDecoder = JSONDecoder() + ) async throws -> T { + guard let url = URL(string: path) else { + throw ResourceError.other(message: "Invalid URL") + } + + return try await performRequest( + url: url, + method: "POST", + headers: headers, + decoder: decoder + ) + } + + static func put( + path: String, + body: U, + headers: [String: String] = [:], + decoder: JSONDecoder = JSONDecoder() + ) async throws -> T { + guard let url = URL(string: path) else { + throw ResourceError.other(message: "Invalid URL") + } + + do { + let jsonData = try AppNetworkHelper.encodeRequestBody(body) + return try await performRequest( + url: url, + method: "PUT", + body: jsonData, + headers: headers, + decoder: decoder + ) + } catch { + print(error) + throw ResourceError.other(message: "Encoding error") + } + } + + static func delete( + path: String, + body: U, + headers: [String: String] = [:], + decoder: JSONDecoder = JSONDecoder() + ) async throws -> T { + guard let url = URL(string: path) else { + throw ResourceError.other(message: "Invalid URL") + } + + do { + let jsonData = try AppNetworkHelper.encodeRequestBody(body) + return try await performRequest( + url: url, + method: "DELETE", + body: jsonData, + headers: headers, + decoder: decoder + ) + } catch let error as NSError { + print(error) + throw ResourceError.other(message: "Encoding error") + } + } + + static func performRequest( + url: URL, + method: String, + body: Data? = nil, + headers: [String: String] = [:], + decoder: JSONDecoder + ) async throws -> T { + let loggingInfo = "url: \(url), \nwith method: \(method)" + print("Performing request on\n\(loggingInfo)") + let request = createRequest(url: url, method: method, headers: headers, body: body) + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + // Handle response and decode data + do { + let decodedData: T = try handleResponse(data: data, response: response, decoder: decoder) + print("handling response \n\(loggingInfo)") + return decodedData + } catch { + debugPrint(error) + throw ResourceError.other(message: "Failed to handle response: \(error.localizedDescription) on \n\(loggingInfo)") + } + } catch { + throw handleMappingError(error) + } + } + + // MARK: - Lower level code + static func createRequest(url: URL, method: String, headers: [String: String] = [:], body: Data? = nil) -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = method + request.addValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let token = UserPreferences.shared.getToken() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + headers.forEach { key, value in + request.addValue(value, forHTTPHeaderField: key) + } + + request.httpBody = body + return request + } + + static func handleResponse(data: Data, response: URLResponse, decoder: JSONDecoder = JSONDecoder()) throws -> T { + guard let httpResponse = response as? HTTPURLResponse else { + throw ResourceError.other(message: "Network request error") + } + + print("Status Code: \(httpResponse.statusCode)") + + switch httpResponse.statusCode { + case 200...299: + return try decodeResponse(data: data, as: T.self) + case 401: + throw ResourceError.unauthed + case 422: + let decodedResponse = try decodeResponse(data: data, as: ErrorResponse.self) + throw ResourceError.errorToUser(message: decodedResponse.message) + case 500...599: + throw ResourceError.serverError(message: "Server Error: \(httpResponse.statusCode)") + default: + throw ResourceError.other(message: "Unknown error") + } + } + + static func encodeRequestBody(_ body: T) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = .withoutEscapingSlashes + encoder.keyEncodingStrategy = .convertToSnakeCase + return try encoder.encode(body) + } + + static func decodeResponse(data: Data, as type: T.Type = T.self) throws -> T { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(type, from: data) + } + + static func handleMappingError(_ error: Error) -> ResourceError { + print("Mapping error: \(error)") + return error as? ResourceError ?? ResourceError.other(message: "\(error)") + } + +} diff --git a/iphone/Maps/Tourism/Data/Prefs/UserPreferences.swift b/iphone/Maps/Tourism/Data/Prefs/UserPreferences.swift new file mode 100644 index 0000000000..1312460f0e --- /dev/null +++ b/iphone/Maps/Tourism/Data/Prefs/UserPreferences.swift @@ -0,0 +1,107 @@ +import Foundation + +@objc(TourismUserPreferences) +class UserPreferences: NSObject { + @objc static let shared = UserPreferences() + + private override init() {} + + private let userDefaults = UserDefaults.standard + + struct Language { + let code: String + let name: String + } + + struct Theme { + let code: String + let name: String + } + + var languages: [Language] = [ + Language(code: "ru", name: "Русский"), + Language(code: "en", name: "English") + ] + + var themes: [Theme] = [ + Theme(code: "dark", name: L("dark_theme")), + Theme(code: "light", name: L("light_theme")) + ] + + func getLanguage() -> Language? { + guard let languageCode = userDefaults.string(forKey: "language") else { return nil } + return languages.first { $0.code == languageCode } + } + + func setLanguage(value: String) { + userDefaults.set(value, forKey: "language") + } + + func getTheme() -> Theme? { + guard let themeCode = userDefaults.string(forKey: "theme") else { return nil } + return themes.first { $0.code == themeCode } + } + + func setTheme(value: String?) { + userDefaults.set(value, forKey: "theme") + } + + @objc func getToken() -> String? { + return userDefaults.string(forKey: "token") + } + + @objc func setToken(value: String?) { + userDefaults.set(value, forKey: "token") + } + + func getUserId() -> String? { + return userDefaults.string(forKey: "user_id") + } + + func setUserId(value: String?) { + userDefaults.set(value, forKey: "user_id") + } + + @objc func getLocation() -> PlaceLocation? { + let name = userDefaults.string(forKey: "name") + let lat = userDefaults.double(forKey: "lat") + let lon = userDefaults.double(forKey: "lon") + return PlaceLocation(name: name ?? "", lat: lat, lon: lon) + } + + @objc func setLocation(value: PlaceLocation) { + userDefaults.set(value.name, forKey: "name") + userDefaults.set(value.lat, forKey: "lat") + userDefaults.set(value.lon, forKey: "lon") + } + + @objc func clearLocation() { + userDefaults.removeObject(forKey: "name") + userDefaults.removeObject(forKey: "lat") + userDefaults.removeObject(forKey: "lon") + } + + @objc func isLocationEmpty() -> Bool { + let location = getLocation() + if let location { + return location.lat == 0.0 && location.lon == 0.0 + } + return true + } + + @objc func getShouldGoToTourismMain() -> Bool { + userDefaults.bool(forKey: "should_go_to_tourism_main") + } + + @objc func setShouldGoToTourismMain(value: Bool) { + userDefaults.set(value, forKey: "should_go_to_tourism_main") + } + + @objc func getShouldGoToAuth() -> Bool { + userDefaults.bool(forKey: "should_go_to_auth") + } + + @objc func setShouldGoToAuth(value: Bool) { + userDefaults.set(value, forKey: "should_go_to_auth") + } +} diff --git a/iphone/Maps/Tourism/Data/Repositories/AuthRepositoryImpl.swift b/iphone/Maps/Tourism/Data/Repositories/AuthRepositoryImpl.swift new file mode 100644 index 0000000000..431678b050 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Repositories/AuthRepositoryImpl.swift @@ -0,0 +1,31 @@ +import Combine + +class AuthRepositoryImpl: AuthRepository { + private let authService: AuthService + + init(authService: AuthService) { + self.authService = authService + } + + func signIn(body: SignInRequest) -> AnyPublisher { + return authService.signIn(body: SignInRequestDTO.init(from: body)) + .map { dto in AuthResponse.init(from: dto) } + .eraseToAnyPublisher() + } + + func signUp(body: SignUpRequest) -> AnyPublisher { + return authService.signUp(body: SignUpRequestDTO.init(from: body)) + .map { dto in AuthResponse.init(from: dto) } + .eraseToAnyPublisher() + } + + func signOut() -> AnyPublisher { + return authService.signOut() + .eraseToAnyPublisher() + } + + func sendEmailForPasswordReset(email: String) -> AnyPublisher { + return authService.sendEmailForPasswordReset(email: email) + .eraseToAnyPublisher() + } +} diff --git a/iphone/Maps/Tourism/Data/Repositories/CurrencyRepositoryImpl.swift b/iphone/Maps/Tourism/Data/Repositories/CurrencyRepositoryImpl.swift new file mode 100644 index 0000000000..6b133ed238 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Repositories/CurrencyRepositoryImpl.swift @@ -0,0 +1,62 @@ +import Combine +import Foundation + +class CurrencyRepositoryImpl: CurrencyRepository { + private let currencyService: CurrencyService + private let persistenceController: CurrencyPersistenceController + + let currencyPassThroughSubject = PassthroughSubject() + + private var cancellables = Set() + + init(currencyService: CurrencyService, currencyPersistenceController: CurrencyPersistenceController) { + self.currencyService = currencyService + self.persistenceController = currencyPersistenceController + } + + func getCurrency() { + // Local persistence subscription + persistenceController.currencyRatesSubject + .compactMap { $0?.toCurrencyRates() } + .sink { completion in + if case let .failure(error) = completion { + print(error.localizedDescription) + self.currencyPassThroughSubject.send(completion: .failure(error)) + } + } receiveValue: { currencyRates in + self.currencyPassThroughSubject.send(currencyRates) + } + .store(in: &cancellables) // Store the cancellable + + persistenceController.observeCurrencyRates() + + // Remote service subscription + currencyService.getCurrencyRates() + .flatMap { [weak self] remoteRates -> AnyPublisher in + guard let self = self else { + print("CurrencyRepositoryImpl/getCurrency/ self was null") + return Fail(error: ResourceError.other(message: "")).eraseToAnyPublisher() + } + + // Update the local database with the fetched remote rates + do { + let newCurrencyRates = try remoteRates.toCurrencyRates() + return self.persistenceController.updateCurrencyRates(entity: newCurrencyRates) + .map { newCurrencyRates } + .eraseToAnyPublisher() + } catch { + print("CurrencyRepositoryImpl/getCurrency/ failed to convert dto to domain model") + return Fail(error: ResourceError.other(message: "")).eraseToAnyPublisher() + } + } + .sink { completion in + if case let .failure(error) = completion { + print(error.localizedDescription) + self.currencyPassThroughSubject.send(completion: .failure(error)) + } + } receiveValue: { currencyRates in + // yes, nothing, we observe anyway + } + .store(in: &cancellables) // Store the cancellable + } +} diff --git a/iphone/Maps/Tourism/Data/Repositories/PlacesRepositoryImpl.swift b/iphone/Maps/Tourism/Data/Repositories/PlacesRepositoryImpl.swift new file mode 100644 index 0000000000..02564e0af7 --- /dev/null +++ b/iphone/Maps/Tourism/Data/Repositories/PlacesRepositoryImpl.swift @@ -0,0 +1,236 @@ +import Combine + +class PlacesRepositoryImpl: PlacesRepository { + let placesService: PlacesService + let placesPersistenceController: PlacesPersistenceController + let reviewsPersistenceController: ReviewsPersistenceController + let hashesPersistenceController: HashesPersistenceController + + var downloadProgress: PassthroughSubject + var searchResource: PassthroughSubject<[PlaceShort], ResourceError> + var placesByCategoryResource: PassthroughSubject<[PlaceShort], ResourceError> + var topSightsResource: PassthroughSubject<[PlaceShort], ResourceError> + var topRestaurantsResource: PassthroughSubject<[PlaceShort], ResourceError> + var placeResource: PassthroughSubject + var favoritesResource: PassthroughSubject<[PlaceShort], ResourceError> + + + init( + placesService: PlacesService, + placesPersistenceController: PlacesPersistenceController, + reviewsPersistenceController: ReviewsPersistenceController, + hashesPersistenceController: HashesPersistenceController + ) { + self.placesService = placesService + self.placesPersistenceController = placesPersistenceController + self.hashesPersistenceController = hashesPersistenceController + self.reviewsPersistenceController = reviewsPersistenceController + + downloadProgress = PassthroughSubject() + downloadProgress.send(DownloadProgress.idle) + searchResource = placesPersistenceController.searchSubject + placesByCategoryResource = placesPersistenceController.placesByCatSubject + topSightsResource = placesPersistenceController.topSightsSubject + topRestaurantsResource = placesPersistenceController.topRestaurantsSubject + placeResource = placesPersistenceController.singlePlaceSubject + favoritesResource = placesPersistenceController.favoritePlacesSubject + } + + func downloadAllData() async throws { + do { + let hashes = hashesPersistenceController.getHashes() + + if(hashes.isEmpty) { + downloadProgress.send(DownloadProgress.loading) + + // download all data + let allData = try await placesService.getAllPlaces() + let favoritesDto = try await placesService.getFavorites() + + // patch data + let favorites = favoritesDto.data.map { placeDto in + placeDto.toPlaceFull(isFavorite: true) + } + + var reviews: [Review] = [] + + func toPlaceFull(placeDto: PlaceDTO) -> PlaceFull { + var placeFull = placeDto.toPlaceFull(isFavorite: false) + + placeFull.isFavorite = favorites.contains { $0.id == placeFull.id } + + if let placeReviews = placeFull.reviews { + reviews.append(contentsOf: placeReviews) + } + + return placeFull + } + let sights = allData.attractions.map { placeDto in + toPlaceFull(placeDto: placeDto) + } + let restaurants = allData.restaurants.map { placeDto in + toPlaceFull(placeDto: placeDto) + } + let hotels = allData.accommodations.map { placeDto in + toPlaceFull(placeDto: placeDto) + } + + // update places + placesPersistenceController.deleteAllPlaces() + placesPersistenceController.insertPlaces(sights, categoryId: PlaceCategory.sights.id) + placesPersistenceController.insertPlaces(restaurants, categoryId: PlaceCategory.restaurants.id) + placesPersistenceController.insertPlaces(hotels, categoryId: PlaceCategory.hotels.id) + + // update reviews + reviewsPersistenceController.deleteAllReviews() + reviewsPersistenceController.insertReviews(reviews) + + // update favorites + favorites.forEach { favorite in + placesPersistenceController.setFavorite(placeId: favorite.id, isFavorite: favorite.isFavorite) + } + + // update hashes + hashesPersistenceController.insertHashes(hashes: [ + Hash(categoryId: PlaceCategory.sights.id, value: allData.attractionsHash), + Hash(categoryId: PlaceCategory.restaurants.id, value: allData.restaurantsHash), + Hash(categoryId: PlaceCategory.hotels.id, value: allData.accommodationsHash) + ]) + + // return response + downloadProgress.send(DownloadProgress.success) + } else { + downloadProgress.send(DownloadProgress.success) + } + } catch let error as ResourceError { + downloadProgress.send(completion: .failure(error)) + } + } + + func observeSearch(query: String) { + placesPersistenceController.observeSearch(query: query) + } + + func observePlacesByCategoryAndUpdate(categoryId: Int64) { + placesPersistenceController.observePlacesByCategoryId(categoryId: categoryId) + Task { + try await getPlacesByCategoryFromApiIfThereIsChange(categoryId) + } + } + + func observeTopSightsAndUpdate() { + placesPersistenceController.observeTopSights() + Task { + try await getPlacesByCategoryFromApiIfThereIsChange(PlaceCategory.sights.id) + } + } + + func observeTopRestaurantsAndUpdate() { + placesPersistenceController.observeTopRestaurants() + Task { + try await getPlacesByCategoryFromApiIfThereIsChange(PlaceCategory.restaurants.id) + } + } + + private func getPlacesByCategoryFromApiIfThereIsChange( + _ categoryId: Int64 + ) async throws -> Void { + let hash = hashesPersistenceController.getHash(categoryId: categoryId) + + let favorites = placesPersistenceController.getFavoritePlaces() + + let resource = + try await placesService.getPlacesByCategory(id: categoryId, hash: hash?.value ?? "") + + let places = resource.data + if (hash != nil && !places.isEmpty) { + // update places + placesPersistenceController.deleteAllPlacesByCategory(categoryId: categoryId) + + let places = places.map { placeDto in + var placeFull = placeDto.toPlaceFull(isFavorite: false) + placeFull.isFavorite = favorites.contains { $0.id == placeFull.id } + return placeFull + } + + placesPersistenceController.insertPlaces(places, categoryId: categoryId) + + // update reviews + places.forEach { place in + reviewsPersistenceController.deleteAllPlaceReviews(placeId: place.id) + reviewsPersistenceController.insertReviews(place.reviews ?? []) + } + + // update hash + hashesPersistenceController.deleteHash(hash: hash!) + hashesPersistenceController.insertHashes(hashes: [ + Hash(categoryId: hash!.categoryId, value: resource.hash) + ]) + } + } + + func observePlaceById(_ id: Int64) { + placesPersistenceController.observePlaceById(placeId: id) + } + + func observeFavorites(query: String) { + placesPersistenceController.observeFavoritePlaces(query: query) + } + + func setFavorite(placeId: Int64, isFavorite: Bool) { + placesPersistenceController.setFavorite(placeId: placeId, isFavorite: isFavorite) + + placesPersistenceController.addFavoritingRecordForSync( + placeId: placeId, + isFavorite: isFavorite + ) + + Task { + let favoritesIdsDto = FavoritesIdsDTO(marks: [placeId]) + + do { + if(isFavorite) { + let _ = try await placesService.addFavorites(ids: favoritesIdsDto) + } else { + let _ = try await placesService.removeFromFavorites(ids: favoritesIdsDto) + } + + placesPersistenceController.removeFavoritingRecordsForSync(placeIds: [placeId]) + } catch { + print("Failed to setFavorite") + print(error) + } + } + } + + func syncFavorites() { + let syncData = placesPersistenceController.getFavoritingRecordsForSync() + + let favoritesToAdd = syncData.filter { $0.isFavorite }.map { $0.placeId } + let favoritesToRemove = syncData.filter { !$0.isFavorite }.map { $0.placeId } + + if !favoritesToAdd.isEmpty { + Task { + do { + _ = + try await placesService.addFavorites(ids: FavoritesIdsDTO(marks: favoritesToAdd)) + placesPersistenceController.removeFavoritingRecordsForSync(placeIds: favoritesToAdd) + } catch { + print(error) + } + } + } + + if !favoritesToRemove.isEmpty { + Task { + do { + _ = + try await placesService.removeFromFavorites(ids: FavoritesIdsDTO(marks: favoritesToRemove)) + placesPersistenceController.removeFavoritingRecordsForSync(placeIds: favoritesToRemove) + } catch { + print(error) + } + } + } + } +} diff --git a/iphone/Maps/Tourism/Data/Repositories/ProfileRepositoryImpl.swift b/iphone/Maps/Tourism/Data/Repositories/ProfileRepositoryImpl.swift new file mode 100644 index 0000000000..52112cc2fe --- /dev/null +++ b/iphone/Maps/Tourism/Data/Repositories/ProfileRepositoryImpl.swift @@ -0,0 +1,92 @@ +import Foundation +import Combine + +class ProfileRepositoryImpl: ProfileRepository { + private let profileService: ProfileService + private let persistenceController: PersonalDataPersistenceController + private let userPreferences = UserPreferences.shared + + let personalDataPassThroughSubject = PassthroughSubject() + + private var cancellables = Set() + + init(profileService: ProfileService, personalDataPersistenceController: PersonalDataPersistenceController) { + self.profileService = profileService + self.persistenceController = personalDataPersistenceController + } + + func getPersonalData() { + // Local persistence subscription + persistenceController.personalDataSubject + .compactMap { $0?.toPersonalData() } + .sink { completion in + if case let .failure(error) = completion { + print(error.localizedDescription) + self.personalDataPassThroughSubject.send(completion: .failure(error)) + } + } receiveValue: { personalData in + self.personalDataPassThroughSubject.send(personalData) + } + .store(in: &cancellables) + + persistenceController.observePersonalData() + + // Remote service subscription + profileService.getPersonalData() + .flatMap { [weak self] remotePersonalData -> AnyPublisher in + guard let self = self else { + print("ProfileRepositoryImpl/getPersonalData/ self was null") + return Fail(error: ResourceError.other(message: "")).eraseToAnyPublisher() + } + + let newPersonalData = remotePersonalData.toPersonalData() + + userPreferences.setUserId(value: String(newPersonalData.id)) + + return self.persistenceController.updatePersonalData(personalData: newPersonalData) + .map { newPersonalData } + .eraseToAnyPublisher() + } + .sink { completion in + if case let .failure(error) = completion { + print(error.localizedDescription) + self.personalDataPassThroughSubject.send(completion: .failure(error)) + } + } receiveValue: { personalData in + // Yes, nothing, we observe anyway + } + .store(in: &cancellables) + } + + func updateProfile( + fullName: String, + country: String, + email: String?, + pfpUrl: UIImage? + ) -> AnyPublisher { + return profileService.updateProfile( + fullName: fullName, + country: country, + email: email, + pfpUrl: pfpUrl + ) + .flatMap{ dto in + let personalData = dto.toPersonalData() + + return self.persistenceController.updatePersonalData(personalData: personalData) + .map { personalData } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + func updateLanguage(code: String) { + userPreferences.setLanguage(value: code) + profileService.updateLanguage(code: code) + } + + func updateTheme(code: String) { + userPreferences.setTheme(value: code) + profileService.updateTheme(code: code) + } +} diff --git a/iphone/Maps/Tourism/Data/Repositories/ReviewsRepositoryImpl.swift b/iphone/Maps/Tourism/Data/Repositories/ReviewsRepositoryImpl.swift new file mode 100644 index 0000000000..fb099c40da --- /dev/null +++ b/iphone/Maps/Tourism/Data/Repositories/ReviewsRepositoryImpl.swift @@ -0,0 +1,128 @@ +import Combine + +class ReviewsRepositoryImpl : ReviewsRepository { + private var cancellables = Set() + + var reviewsPersistenceController: ReviewsPersistenceController + var reviewsService: ReviewsService + + var reviewsResource: PassthroughSubject<[Review], ResourceError> + var isThereReviewPlannedToPublishResource = PassthroughSubject() + + init( + reviewsPersistenceController: ReviewsPersistenceController, + reviewsService: ReviewsService + ) { + self.reviewsPersistenceController = reviewsPersistenceController + self.reviewsService = reviewsService + + self.reviewsResource = reviewsPersistenceController.reviewsForPlaceSubject + reviewsPersistenceController.reviewsPlannedToPostSubject.sink { _ in } receiveValue: { + reviews in self.isThereReviewPlannedToPublishResource.send(reviews.isEmpty) + } + .store(in: &cancellables) + } + + func observeReviewsForPlace(id: Int64) { + reviewsPersistenceController.observeReviewsForPlace(placeId: id) + + Task { + let reviewsDTO = try await reviewsService.getReviewsByPlaceId(id: id) + let reviews = reviewsDTO.data.map { reviewDto in reviewDto.toReview() } + + self.reviewsPersistenceController.deleteAllPlaceReviews(placeId: id) + self.reviewsPersistenceController.insertReviews(reviews) + } + } + + func checkIfThereIsReviewPlannedToPublish(for placeId: Int64) { + reviewsPersistenceController.observeReviewsForPlace(placeId: placeId) + } + + func postReview(review: ReviewToPost) -> AnyPublisher { + return Future { promise in + Task { + if Reachability.isConnectedToNetwork() { + do { + let response = try await self.reviewsService.postReview(review: review.toReviewToPostDTO()) + self.updateReviewsForDb(id: review.placeId) + promise(.success(SimpleResponse(message: response.message))) + } catch let error as ResourceError { + print(error) + promise(.failure(error)) + } + } else { + // images files already were saved in viewmodel, so no need to save them here + self.reviewsPersistenceController.insertReviewPlannedToPost(review) + promise(.failure(ResourceError.errorToUser(message: L("review_will_be_published_when_online")))) + } + } + } + .eraseToAnyPublisher() + } + + func deleteReview(id: Int64) -> AnyPublisher { + return Future { promise in + Task { + do { + let response = try await self.reviewsService.deleteReview(reviews: ReviewIdsDTO(feedbacks: [id])) + self.reviewsPersistenceController.deleteReview(id: id) + promise(.success(response)) + } catch let error as ResourceError { + self.reviewsPersistenceController.markReviewForDeletion(id: id, deletionPlanned: true) + promise(.failure(error)) + } + } + } + .eraseToAnyPublisher() + } + + func syncReviews() { + Task { + try await deleteReviewsPlannedForDeletion() + try await publishReviewsPlannedToPost() + } + } + + private func deleteReviewsPlannedForDeletion() async throws { + let reviews = reviewsPersistenceController.getReviewsPlannedForDeletion() + + if !reviews.isEmpty { + let reviewsIds = reviews.map(\.id) + _ = try await reviewsService.deleteReview(reviews: ReviewIdsDTO(feedbacks: reviewsIds)) + reviewsPersistenceController.deleteReviews(ids: reviewsIds) + } + } + + private func publishReviewsPlannedToPost() async throws { + let reviewsPlannedToPostEntities = reviewsPersistenceController.getReviewsPlannedToPost() + if !reviewsPlannedToPostEntities.isEmpty { + let reviewsDTO = reviewsPlannedToPostEntities.map {$0.toReviewToPostDTO()} + reviewsDTO.forEach { reviewDTO in + Task { + do { + _ = try await reviewsService.postReview(review: reviewDTO) + updateReviewsForDb(id: reviewDTO.placeId) + reviewsPersistenceController.deleteReviewPlannedToPost(placeId: reviewDTO.placeId) + try reviewDTO.images.forEach { URL in + try FileManager.default.removeItem(at: URL) + } + } catch { + print(error) + } + } + } + } + } + + private func updateReviewsForDb(id: Int64) { + Task { + let reviewsDTO = try await reviewsService.getReviewsByPlaceId(id: id) + if !reviewsDTO.data.isEmpty { + reviewsPersistenceController.deleteAllPlaceReviews(placeId: id) + let reviews = reviewsDTO.data.map{ $0.toReview() } + reviewsPersistenceController.insertReviews(reviews) + } + } + } +} diff --git a/iphone/Maps/Tourism/Data/ResourceError.swift b/iphone/Maps/Tourism/Data/ResourceError.swift new file mode 100644 index 0000000000..060d2df69a --- /dev/null +++ b/iphone/Maps/Tourism/Data/ResourceError.swift @@ -0,0 +1,27 @@ +import Foundation + +enum ResourceError: Error, Equatable { + case noConnection + case serverError(message: String) + case cacheError + case unauthed + case other(message: String) + case errorToUser(message: String) + + var errorDescription: String { + switch self { + case .noConnection: + return L("no_connection") + case .serverError: + return L("server_error") + case .cacheError: + return L("cache_error") + case .unauthed: + return L("unauthed_error") + case .other: + return L("smth_went_wrong") + case .errorToUser(let message): + return message + } + } +} diff --git a/iphone/Maps/Tourism/Domain/Models/Auth/AuthResponse.swift b/iphone/Maps/Tourism/Domain/Models/Auth/AuthResponse.swift new file mode 100644 index 0000000000..c988d8341f --- /dev/null +++ b/iphone/Maps/Tourism/Domain/Models/Auth/AuthResponse.swift @@ -0,0 +1,9 @@ +struct AuthResponse: Codable { + let token: String +} + +extension AuthResponse { + init(from dto: AuthResponseDTO) { + self.token = dto.token + } +} diff --git a/iphone/Maps/Tourism/Domain/Models/Auth/SignInRequest.swift b/iphone/Maps/Tourism/Domain/Models/Auth/SignInRequest.swift new file mode 100644 index 0000000000..05f1fa0d9d --- /dev/null +++ b/iphone/Maps/Tourism/Domain/Models/Auth/SignInRequest.swift @@ -0,0 +1,4 @@ +struct SignInRequest : Codable { + let email: String + let password: String +} diff --git a/iphone/Maps/Tourism/Domain/Models/Auth/SignUpRequest.swift b/iphone/Maps/Tourism/Domain/Models/Auth/SignUpRequest.swift new file mode 100644 index 0000000000..37759d46ca --- /dev/null +++ b/iphone/Maps/Tourism/Domain/Models/Auth/SignUpRequest.swift @@ -0,0 +1,7 @@ +struct SignUpRequest: Codable { + let fullName: String + let email: String + let password: String + let passwordConfirmation: String + let country: String +} diff --git a/iphone/Maps/Tourism/Domain/Models/Category/Category.swift b/iphone/Maps/Tourism/Domain/Models/Category/Category.swift new file mode 100644 index 0000000000..85bf6123b5 --- /dev/null +++ b/iphone/Maps/Tourism/Domain/Models/Category/Category.swift @@ -0,0 +1,6 @@ +import Foundation + +struct Category: Codable { + let value: String? + let label: String +} diff --git a/iphone/Maps/Tourism/Domain/Models/Category/PlaceCategory.swift b/iphone/Maps/Tourism/Domain/Models/Category/PlaceCategory.swift new file mode 100644 index 0000000000..5530176c3d --- /dev/null +++ b/iphone/Maps/Tourism/Domain/Models/Category/PlaceCategory.swift @@ -0,0 +1,22 @@ +import Foundation + +enum PlaceCategory: String, Codable { + case sights = "1" + case restaurants = "2" + case hotels = "3" + + var id: Int64 { + Int64(self.rawValue)! + } + + var serverName: String { + switch self { + case .sights: + return "attractions" + case .restaurants: + return "restaurants" + case .hotels: + return "accommodations" + } + } +} diff --git a/iphone/Maps/Tourism/Domain/Models/Currency/CurrencyRates.swift b/iphone/Maps/Tourism/Domain/Models/Currency/CurrencyRates.swift new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/iphone/Maps/Tourism/Domain/Models/Currency/CurrencyRates.swift @@ -0,0 +1 @@ + diff --git a/iphone/Maps/Tourism/Domain/Models/Details/Hash.swift b/iphone/Maps/Tourism/Domain/Models/Details/Hash.swift new file mode 100644 index 0000000000..7206e8483f --- /dev/null +++ b/iphone/Maps/Tourism/Domain/Models/Details/Hash.swift @@ -0,0 +1,4 @@ +struct Hash { + let categoryId: Int64 + let value: String +} diff --git a/iphone/Maps/Tourism/Domain/Models/Details/PlaceFull.swift b/iphone/Maps/Tourism/Domain/Models/Details/PlaceFull.swift new file mode 100644 index 0000000000..f792c0ba62 --- /dev/null +++ b/iphone/Maps/Tourism/Domain/Models/Details/PlaceFull.swift @@ -0,0 +1,25 @@ +import Foundation + +struct PlaceFull: Codable { + let id: Int64 + let name: String + let rating: Double + let excerpt: String + let description: String + let placeLocation: PlaceLocation? + let cover: String + let pics: [String] + let reviews: [Review]? + var isFavorite: Bool + + func toPlaceShort() -> PlaceShort { + return PlaceShort( + id: id, + name: name, + cover: cover, + rating: rating, + excerpt: excerpt, + isFavorite: isFavorite + ) + } +} diff --git a/iphone/Maps/Tourism/Domain/Models/Details/PlaceShort.swift b/iphone/Maps/Tourism/Domain/Models/Details/PlaceShort.swift new file mode 100644 index 0000000000..7654e2ba1b --- /dev/null +++ b/iphone/Maps/Tourism/Domain/Models/Details/PlaceShort.swift @@ -0,0 +1,10 @@ +import Foundation + +struct PlaceShort: Codable, Identifiable { + let id: Int64 + let name: String + let cover: String? + let rating: Double? + let excerpt: String? + var isFavorite: Bool +} diff --git a/iphone/Maps/Tourism/Domain/Models/Details/Review Models.swift b/iphone/Maps/Tourism/Domain/Models/Details/Review Models.swift new file mode 100644 index 0000000000..88b955b476 --- /dev/null +++ b/iphone/Maps/Tourism/Domain/Models/Details/Review Models.swift @@ -0,0 +1,24 @@ +import Foundation + +struct Review: Codable, Hashable { + let id: Int64 + let placeId: Int64 + let rating: Int + let user: User? + let date: String? + let comment: String? + let picsUrls: [String] + var deletionPlanned: Bool = false +} + + +struct ReviewToPost: Codable { + let placeId: Int64 + let comment: String + let rating: Int + let images: [URL] + + func toReviewToPostDTO() -> ReviewToPostDTO { + return ReviewToPostDTO(placeId: placeId, comment: comment, rating: rating, images: images) + } +} diff --git a/iphone/Maps/Tourism/Domain/Models/Details/User.swift b/iphone/Maps/Tourism/Domain/Models/Details/User.swift new file mode 100644 index 0000000000..dcd6557745 --- /dev/null +++ b/iphone/Maps/Tourism/Domain/Models/Details/User.swift @@ -0,0 +1,17 @@ +import Foundation + +struct User: Codable, Hashable { + let id: Int64 + let name: String + let pfpUrl: String? + let countryCodeName: String + + func toUserEntity() -> UserEntity { + return UserEntity( + userId: self.id, + fullName: self.name, + avatar: self.pfpUrl ?? "", + country: self.countryCodeName + ) + } +} diff --git a/iphone/Maps/Tourism/Domain/Models/DownloadProgress.swift b/iphone/Maps/Tourism/Domain/Models/DownloadProgress.swift new file mode 100644 index 0000000000..3676c2f098 --- /dev/null +++ b/iphone/Maps/Tourism/Domain/Models/DownloadProgress.swift @@ -0,0 +1,6 @@ +enum DownloadProgress: Equatable { + case idle + case loading + case success + case error +} diff --git a/iphone/Maps/Tourism/Domain/Models/PlaceLocation.swift b/iphone/Maps/Tourism/Domain/Models/PlaceLocation.swift new file mode 100644 index 0000000000..4bb8543bc0 --- /dev/null +++ b/iphone/Maps/Tourism/Domain/Models/PlaceLocation.swift @@ -0,0 +1,18 @@ +import Foundation + +@objc(PlaceLocation) +class PlaceLocation: NSObject, Codable { + @objc let name: String + @objc let lat: Double + @objc let lon: Double + + init(name: String, lat: Double, lon: Double) { + self.name = name + self.lat = lat + self.lon = lon + } + + func toCoordinatesEntity() -> CoordinatesEntity { + return CoordinatesEntity(latitude: lat, longitude: lon) + } +} diff --git a/iphone/Maps/Tourism/Domain/Models/Profile/CurrencyRates.swift b/iphone/Maps/Tourism/Domain/Models/Profile/CurrencyRates.swift new file mode 100644 index 0000000000..be38349817 --- /dev/null +++ b/iphone/Maps/Tourism/Domain/Models/Profile/CurrencyRates.swift @@ -0,0 +1,7 @@ +import Foundation + +struct CurrencyRates { + let usd: Double + let eur: Double + let rub: Double +} diff --git a/iphone/Maps/Tourism/Domain/Models/Profile/PersonalData.swift b/iphone/Maps/Tourism/Domain/Models/Profile/PersonalData.swift new file mode 100644 index 0000000000..8a3328c7c8 --- /dev/null +++ b/iphone/Maps/Tourism/Domain/Models/Profile/PersonalData.swift @@ -0,0 +1,11 @@ +import Foundation + +struct PersonalData: Identifiable, Codable { + let id: Int64 + let fullName: String + let country: String + let pfpUrl: String? + let email: String + let language: String? + let theme: String? +} diff --git a/iphone/Maps/Tourism/Domain/Models/Profile/PersonalDataToSend.swift b/iphone/Maps/Tourism/Domain/Models/Profile/PersonalDataToSend.swift new file mode 100644 index 0000000000..7cb81cd519 --- /dev/null +++ b/iphone/Maps/Tourism/Domain/Models/Profile/PersonalDataToSend.swift @@ -0,0 +1,5 @@ +struct PersonalDataToSend : Codable { + let fullName: String + let country: String + let email: String +} diff --git a/iphone/Maps/Tourism/Domain/Models/Responses/ErrorResponse.swift b/iphone/Maps/Tourism/Domain/Models/Responses/ErrorResponse.swift new file mode 100644 index 0000000000..f67a2a281b --- /dev/null +++ b/iphone/Maps/Tourism/Domain/Models/Responses/ErrorResponse.swift @@ -0,0 +1,3 @@ +struct ErrorResponse : Codable { + let message: String +} diff --git a/iphone/Maps/Tourism/Domain/Models/SimpleResponse.swift b/iphone/Maps/Tourism/Domain/Models/SimpleResponse.swift new file mode 100644 index 0000000000..2b53d95d76 --- /dev/null +++ b/iphone/Maps/Tourism/Domain/Models/SimpleResponse.swift @@ -0,0 +1,3 @@ +struct SimpleResponse : Codable { + let message: String +} diff --git a/iphone/Maps/Tourism/Domain/Repositories/AuthRepository.swift b/iphone/Maps/Tourism/Domain/Repositories/AuthRepository.swift new file mode 100644 index 0000000000..805bda8c30 --- /dev/null +++ b/iphone/Maps/Tourism/Domain/Repositories/AuthRepository.swift @@ -0,0 +1,9 @@ +import Foundation +import Combine + +protocol AuthRepository { + func signIn(body: SignInRequest) -> AnyPublisher + func signUp(body: SignUpRequest) -> AnyPublisher + func signOut() -> AnyPublisher + func sendEmailForPasswordReset(email: String) -> AnyPublisher +} diff --git a/iphone/Maps/Tourism/Domain/Repositories/CurrencyRepository.swift b/iphone/Maps/Tourism/Domain/Repositories/CurrencyRepository.swift new file mode 100644 index 0000000000..30954eac49 --- /dev/null +++ b/iphone/Maps/Tourism/Domain/Repositories/CurrencyRepository.swift @@ -0,0 +1,7 @@ +import Combine + +protocol CurrencyRepository { + var currencyPassThroughSubject: PassthroughSubject { get } + + func getCurrency() +} diff --git a/iphone/Maps/Tourism/Domain/Repositories/PlacesRepository.swift b/iphone/Maps/Tourism/Domain/Repositories/PlacesRepository.swift new file mode 100644 index 0000000000..915af87670 --- /dev/null +++ b/iphone/Maps/Tourism/Domain/Repositories/PlacesRepository.swift @@ -0,0 +1,28 @@ +import Combine + +protocol PlacesRepository { + var downloadProgress: PassthroughSubject { get } + func downloadAllData() async throws + + var searchResource: PassthroughSubject<[PlaceShort], ResourceError> { get } + func observeSearch(query: String) + + var placesByCategoryResource: PassthroughSubject<[PlaceShort], ResourceError> { get } + func observePlacesByCategoryAndUpdate(categoryId: Int64) + + var topSightsResource: PassthroughSubject<[PlaceShort], ResourceError> { get } + func observeTopSightsAndUpdate() + + var topRestaurantsResource: PassthroughSubject<[PlaceShort], ResourceError> { get } + func observeTopRestaurantsAndUpdate() + + var placeResource: PassthroughSubject { get } + func observePlaceById(_ id: Int64) + + var favoritesResource: PassthroughSubject<[PlaceShort], ResourceError> { get } + func observeFavorites(query: String) + + func setFavorite(placeId: Int64, isFavorite: Bool) + + func syncFavorites() +} diff --git a/iphone/Maps/Tourism/Domain/Repositories/ProfileRepository.swift b/iphone/Maps/Tourism/Domain/Repositories/ProfileRepository.swift new file mode 100644 index 0000000000..3230f9003c --- /dev/null +++ b/iphone/Maps/Tourism/Domain/Repositories/ProfileRepository.swift @@ -0,0 +1,18 @@ +import Foundation +import Combine + +protocol ProfileRepository { + var personalDataPassThroughSubject: PassthroughSubject { get } + func getPersonalData() + + func updateProfile( + fullName: String, + country: String, + email: String?, + pfpUrl: UIImage? + ) -> AnyPublisher + + func updateLanguage(code: String) + + func updateTheme(code: String) +} diff --git a/iphone/Maps/Tourism/Domain/Repositories/ReviewsRepository.swift b/iphone/Maps/Tourism/Domain/Repositories/ReviewsRepository.swift new file mode 100644 index 0000000000..45545ec1a9 --- /dev/null +++ b/iphone/Maps/Tourism/Domain/Repositories/ReviewsRepository.swift @@ -0,0 +1,15 @@ +import Combine + +protocol ReviewsRepository { + var reviewsResource: PassthroughSubject<[Review], ResourceError> { get } + func observeReviewsForPlace(id: Int64) + + var isThereReviewPlannedToPublishResource: PassthroughSubject { get } + func checkIfThereIsReviewPlannedToPublish(for placeId: Int64) + + func postReview(review: ReviewToPost) -> AnyPublisher + + func deleteReview(id: Int64) -> AnyPublisher + + func syncReviews() +} diff --git a/iphone/Maps/Tourism/Presentation/Auth/Auth.storyboard b/iphone/Maps/Tourism/Presentation/Auth/Auth.storyboard new file mode 100644 index 0000000000..79eae9ac2c --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Auth/Auth.storyboard @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iphone/Maps/Tourism/Presentation/Auth/Screens/ForgotPasswordViewController.swift b/iphone/Maps/Tourism/Presentation/Auth/Screens/ForgotPasswordViewController.swift new file mode 100644 index 0000000000..0e53b92e7b --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Auth/Screens/ForgotPasswordViewController.swift @@ -0,0 +1,179 @@ +import UIKit +import Combine + +class ForgotPasswordViewController: UIViewController { + + + private var cancellables = Set() + private var authRepository = AuthRepositoryImpl(authService: AuthServiceImpl()) + + private let backButton: BackButton = { + let backButton = BackButton() + backButton.translatesAutoresizingMaskIntoConstraints = false + return backButton + }() + + private let backgroundImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = UIImage(named: "splash_background") + imageView.contentMode = .scaleAspectFill + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private let containerView: UIView = { + let view = UIView() + view.backgroundColor = .clear + view.translatesAutoresizingMaskIntoConstraints = false + view.layer.cornerRadius = 16 + return view + }() + + private let blurView: UIVisualEffectView = { + let blurEffect = UIBlurEffect(style: .light) + let blurView = UIVisualEffectView(effect: blurEffect) + blurView.translatesAutoresizingMaskIntoConstraints = false + blurView.layer.cornerRadius = 16 + blurView.clipsToBounds = true + return blurView + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.text = L("send_email_for_password_reset") + UIKitFont.applyStyle(to: label, style: UIKitFont.h3) + label.textColor = .white + label.textAlignment = .center + label.numberOfLines = 2 + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let emailTextField: AuthTextField = { + let textField = AuthTextField() + textField.placeholder = L("tourism_email") + textField.keyboardType = .emailAddress + textField.autocapitalizationType = .none + textField.translatesAutoresizingMaskIntoConstraints = false + return textField + }() + + private lazy var sendButton: AppButton = { + let button = AppButton( + label: L("send"), + isPrimary: true, + icon: nil, + target: self, + action: #selector(sendButtonTapped) + ) + return button + }() + + private lazy var cancelButton: AppButton = { + let button = AppButton( + label: L("cancel"), + isPrimary: false, + icon: nil, + target: self, + action: #selector(cancelButtonTapped) + ) + return button + }() + + override func viewDidLoad() { + super.viewDidLoad() + setupViews() + } + + private func setupViews() { + view.addSubview(backgroundImageView) + view.addSubview(backButton) + view.addSubview(containerView) + + containerView.addSubview(blurView) + containerView.addSubview(titleLabel) + containerView.addSubview(emailTextField) + containerView.addSubview(sendButton) + containerView.addSubview(cancelButton) + + NSLayoutConstraint.activate([ + // Background Image + backgroundImageView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + backgroundImageView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + backgroundImageView.topAnchor.constraint(equalTo: view.topAnchor), + backgroundImageView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + // Back Button + backButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16), + backButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + + // Container View + containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -120), + + // Blur View + blurView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + blurView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + blurView.topAnchor.constraint(equalTo: containerView.topAnchor), + blurView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + + // Title Label + titleLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 32), + titleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 32), + titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -32), + + // Email Text Field + emailTextField.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 32), + emailTextField.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16), + emailTextField.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -16), + + // Send Button + sendButton.topAnchor.constraint(equalTo: emailTextField.bottomAnchor, constant: 32), + sendButton.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16), + sendButton.widthAnchor.constraint(equalTo: containerView.widthAnchor, multiplier: 0.43), + sendButton.heightAnchor.constraint(equalToConstant: 44), + + // Cancell Button + cancelButton.topAnchor.constraint(equalTo: emailTextField.bottomAnchor, constant: 32), + cancelButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -16), + cancelButton.widthAnchor.constraint(equalTo: containerView.widthAnchor, multiplier: 0.43), + cancelButton.heightAnchor.constraint(equalToConstant: 44), + + containerView.bottomAnchor.constraint(equalTo: sendButton.bottomAnchor, constant: 32) + ]) + + backButton.addTarget(self, action: #selector(backButtonTapped), for: .touchUpInside) + } + + // MARK: - buttons listeners + @objc private func backButtonTapped() { + self.navigationController?.popViewController(animated: false) + } + + @objc private func sendButtonTapped() { + sendButton.isLoading = true + authRepository.sendEmailForPasswordReset(email: emailTextField.text ?? "") + .sink(receiveCompletion: { [weak self] completion in + switch completion { + case .finished: + self?.showToast(message: L("we_sent_you_password_reset_email")) + self?.sendButton.isLoading = false + case .failure(let error): + self?.showError(message: error.errorDescription) + } + }, receiveValue: { _ in } + ) + .store(in: &cancellables) + } + + @objc private func cancelButtonTapped() { + self.navigationController?.popViewController(animated: false) + } + + // MARK: - other functions + private func showError(message: String) { + sendButton.isLoading = false + showAlert(title: L("error"), message: message) + } +} diff --git a/iphone/Maps/Tourism/Presentation/Auth/Screens/SignInViewController.swift b/iphone/Maps/Tourism/Presentation/Auth/Screens/SignInViewController.swift new file mode 100644 index 0000000000..d6b3407fd9 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Auth/Screens/SignInViewController.swift @@ -0,0 +1,220 @@ +import UIKit +import Combine + +class SignInViewController: UIViewController { + + + private var cancellables = Set() + private var authRepository = AuthRepositoryImpl(authService: AuthServiceImpl()) + + private let backButton: BackButton = { + let backButton = BackButton() + backButton.translatesAutoresizingMaskIntoConstraints = false + return backButton + }() + + private let backgroundImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = UIImage(named: "splash_background") + imageView.contentMode = .scaleAspectFill + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private let containerView: UIView = { + let view = UIView() + view.backgroundColor = .clear + view.translatesAutoresizingMaskIntoConstraints = false + view.layer.cornerRadius = 16 + return view + }() + + private let blurView: UIVisualEffectView = { + let blurEffect = UIBlurEffect(style: .light) + let blurView = UIVisualEffectView(effect: blurEffect) + blurView.translatesAutoresizingMaskIntoConstraints = false + blurView.layer.cornerRadius = 16 + blurView.clipsToBounds = true + return blurView + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.text = L("sign_in_title") + label.font = UIFont.systemFont(ofSize: 24, weight: .bold) + label.textColor = .white + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let emailTextField: AuthTextField = { + let textField = AuthTextField() + textField.placeholder = L("tourism_email") + textField.keyboardType = .emailAddress + textField.autocapitalizationType = .none + textField.translatesAutoresizingMaskIntoConstraints = false + return textField + }() + + private let passwordTextField: PasswordTextField = { + let textField = PasswordTextField() + textField.placeholder = L("tourism_password") + textField.translatesAutoresizingMaskIntoConstraints = false + return textField + }() + + private let signInButton: AppButton = { + let button = AppButton(label: L("sign_in"), isPrimary: true, target: self, action: #selector(signInTapped)) + return button + }() + + private let forgotPasswordButton: UIButton = { + let button = UIButton(type: .system) + button.setTitle(L("tourism_forgot_password"), for: .normal) + button.setTitleColor(.white, for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + private let developedByLabel: UILabel = { + let label = UILabel() + label.text = L("developed_by_label") + label.textColor = .white + UIKitFont.applyStyle(to: label, style: UIKitFont.h4) + applyWrapContent(label: label) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + override func viewDidLoad() { + super.viewDidLoad() + setupViews() + } + + private func setupViews() { + let gradientView = UIView(frame: CGRect(x: 0, y: view.height - 100, width: view.width, height: 100)) + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(developedByLabelTapped)) + + gradientView.addGestureRecognizer(tapGesture) + gradientView.isUserInteractionEnabled = true + + let gradient = CAGradientLayer() + gradient.frame = gradientView.bounds + gradient.colors = [UIColor.clear.cgColor, UIColor.black.cgColor] + gradientView.layer.insertSublayer(gradient, at: 0) + + + view.addSubview(backgroundImageView) + view.addSubview(backButton) + view.addSubview(containerView) + view.addSubview(gradientView) + view.addSubview(developedByLabel) + + containerView.addSubview(blurView) + containerView.addSubview(titleLabel) + containerView.addSubview(emailTextField) + containerView.addSubview(passwordTextField) + containerView.addSubview(signInButton) + containerView.addSubview(forgotPasswordButton) + + NSLayoutConstraint.activate([ + // Background Image + backgroundImageView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + backgroundImageView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + backgroundImageView.topAnchor.constraint(equalTo: view.topAnchor), + backgroundImageView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + // Back Button + backButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16), + backButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + + // Container View + containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -120), + + // Blur View + blurView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + blurView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + blurView.topAnchor.constraint(equalTo: containerView.topAnchor), + blurView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + + // Title Label + titleLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 32), + titleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 32), + titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -32), + + // Email Text Field + emailTextField.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 32), + emailTextField.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 32), + emailTextField.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -32), + + // Password Text Field + passwordTextField.topAnchor.constraint(equalTo: emailTextField.bottomAnchor, constant: 40), + passwordTextField.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 32), + passwordTextField.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -32), + + // Sign In Button + signInButton.topAnchor.constraint(equalTo: passwordTextField.bottomAnchor, constant: 48), + signInButton.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 32), + signInButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -32), + + // Forgot Password Button + forgotPasswordButton.topAnchor.constraint(equalTo: signInButton.bottomAnchor, constant: 20), + forgotPasswordButton.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 32), + forgotPasswordButton.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -32), + + developedByLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + developedByLabel.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -32) + ]) + + backButton.addTarget(self, action: #selector(backButtonTapped), for: .touchUpInside) + forgotPasswordButton.addTarget(self, action: #selector(forgotPasswordTapped), for: .touchUpInside) + } + + // MARK: - buttons listeners + @objc private func signInTapped() { + signInButton.isLoading = true + authRepository.signIn(body: SignInRequest(email: emailTextField.text ?? "", password: passwordTextField.text ?? "")) + .sink(receiveCompletion: { [weak self] completion in + switch completion { + case .finished: + self?.navigateToMain() + case .failure(let error): + self?.showError(message: error.errorDescription) + } + }, receiveValue: { response in + UserPreferences.shared.setToken(value: response.token) + } + ) + .store(in: &cancellables) + } + + @objc private func forgotPasswordTapped() { + self.navigationController?.pushViewController(ForgotPasswordViewController(), animated: false) + } + + @objc private func backButtonTapped() { + self.navigationController?.popViewController(animated: false) + } + + @objc func developedByLabelTapped() { + print("developedByLabelTapped") + if let url = URL(string: "https://rebus.tj") { + UIApplication.shared.open(url) + } + } + + // MARK: - other functions + private func showError(message: String) { + signInButton.isLoading = false + showAlert(title: L("error"), message: message) + } + + private func navigateToMain() { + signInButton.isLoading = false + self.dismiss(animated: true) + UserPreferences.shared.setShouldGoToTourismMain(value: true) + } +} diff --git a/iphone/Maps/Tourism/Presentation/Auth/Screens/SignUpViewController.swift b/iphone/Maps/Tourism/Presentation/Auth/Screens/SignUpViewController.swift new file mode 100644 index 0000000000..0eb18209d8 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Auth/Screens/SignUpViewController.swift @@ -0,0 +1,283 @@ +import UIKit +import Combine +import CountryPickerView + +class SignUpViewController: UIViewController { + private var cancellables = Set() + private var authRepository = AuthRepositoryImpl(authService: AuthServiceImpl()) + + private let backButton: BackButton = { + let backButton = BackButton() + backButton.translatesAutoresizingMaskIntoConstraints = false + return backButton + }() + + private let backgroundImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = UIImage(named: "splash_background") + imageView.contentMode = .scaleAspectFill + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private let containerView: UIView = { + let view = UIView() + view.backgroundColor = .clear + view.translatesAutoresizingMaskIntoConstraints = false + view.layer.cornerRadius = 16 + return view + }() + + private let blurView: UIVisualEffectView = { + let blurEffect = UIBlurEffect(style: .light) + let blurView = UIVisualEffectView(effect: blurEffect) + blurView.translatesAutoresizingMaskIntoConstraints = false + blurView.layer.cornerRadius = 16 + blurView.clipsToBounds = true + return blurView + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.text = L("sign_up_title") + label.font = UIFont.systemFont(ofSize: 24, weight: .bold) + label.textColor = .white + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let nameTextField: AuthTextField = { + let textField = AuthTextField() + textField.placeholder = L("full_name") + textField.keyboardType = .emailAddress + textField.autocapitalizationType = .none + textField.translatesAutoresizingMaskIntoConstraints = false + return textField + }() + + private let cpv: CountryPickerView = { + let cpv = getCountryPickerView() + cpv.textColor = .white + return cpv + }() + + private let underline: UIView = { + let underline = UIView() + underline.translatesAutoresizingMaskIntoConstraints = false + underline.backgroundColor = .white + return underline + }() + + private let emailTextField: AuthTextField = { + let textField = AuthTextField() + textField.placeholder = L("tourism_email") + textField.keyboardType = .emailAddress + textField.autocapitalizationType = .none + textField.translatesAutoresizingMaskIntoConstraints = false + return textField + }() + + private let passwordTextField: PasswordTextField = { + let textField = PasswordTextField() + textField.placeholder = L("tourism_password") + textField.translatesAutoresizingMaskIntoConstraints = false + return textField + }() + + private let confirmPasswordTextField: PasswordTextField = { + let confirmTextField = PasswordTextField() + confirmTextField.placeholder = L("confirm_password") + confirmTextField.translatesAutoresizingMaskIntoConstraints = false + return confirmTextField + }() + + private let signUpButton: AppButton = { + let button = AppButton(label: L("sign_up"), isPrimary: true, target: self, action: #selector(signUpClicked)) + return button + }() + + private let forgotPasswordButton: UIButton = { + let button = UIButton(type: .system) + button.setTitle(L("tourism_forgot_password"), for: .normal) + button.setTitleColor(.white, for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + private let developedByLabel: UILabel = { + let label = UILabel() + label.text = L("developed_by_label") + label.textColor = .white + UIKitFont.applyStyle(to: label, style: UIKitFont.h4) + applyWrapContent(label: label) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + override func viewDidLoad() { + super.viewDidLoad() + setupViews() + } + + private func setupViews() { + let gradientView = UIView(frame: CGRect(x: 0, y: view.height - 100, width: view.width, height: 100)) + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(developedByLabelTapped)) + + gradientView.addGestureRecognizer(tapGesture) + gradientView.isUserInteractionEnabled = true + + let gradient = CAGradientLayer() + gradient.frame = gradientView.bounds + gradient.colors = [UIColor.clear.cgColor, UIColor.black.cgColor] + gradientView.layer.insertSublayer(gradient, at: 0) + + view.addSubview(backgroundImageView) + view.addSubview(backButton) + view.addSubview(containerView) + view.addSubview(gradientView) + view.addSubview(developedByLabel) + + containerView.addSubview(blurView) + containerView.addSubview(titleLabel) + containerView.addSubview(nameTextField) + containerView.addSubview(cpv) + containerView.addSubview(underline) + containerView.addSubview(emailTextField) + containerView.addSubview(passwordTextField) + containerView.addSubview(confirmPasswordTextField) + containerView.addSubview(signUpButton) + containerView.addSubview(forgotPasswordButton) + + NSLayoutConstraint.activate([ + // Background Image + backgroundImageView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + backgroundImageView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + backgroundImageView.topAnchor.constraint(equalTo: view.topAnchor), + backgroundImageView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + // Back Button + backButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16), + backButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + + // Container View + containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -32), + + // Blur View + blurView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + blurView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + blurView.topAnchor.constraint(equalTo: containerView.topAnchor), + blurView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + + // Title Label + titleLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 32), + titleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 32), + titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -32), + + // Name Text Field + nameTextField.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 32), + nameTextField.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 32), + nameTextField.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -32), + + // Country Picker + cpv.topAnchor.constraint(equalTo: nameTextField.bottomAnchor, constant: 32), + cpv.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 32), + cpv.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -32), + cpv.heightAnchor.constraint(equalToConstant: 20), + + // Underline for Country Picker + underline.topAnchor.constraint(equalTo: cpv.bottomAnchor, constant: 8), + underline.leadingAnchor.constraint(equalTo: containerView.leadingAnchor,constant: 32), + underline.trailingAnchor.constraint(equalTo: containerView.trailingAnchor,constant: -32), + underline.heightAnchor.constraint(equalToConstant: 1), + + // Email Text Field + emailTextField.topAnchor.constraint(equalTo: cpv.bottomAnchor, constant: 32), + emailTextField.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 32), + emailTextField.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -32), + + // Password Text Field + passwordTextField.topAnchor.constraint(equalTo: emailTextField.bottomAnchor, constant: 40), + passwordTextField.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 32), + passwordTextField.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -32), + + // Confirm Password Text Field + confirmPasswordTextField.topAnchor.constraint(equalTo: passwordTextField.bottomAnchor, constant: 40), + confirmPasswordTextField.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 32), + confirmPasswordTextField.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -32), + + // Sign In Button + signUpButton.topAnchor.constraint(equalTo: confirmPasswordTextField.bottomAnchor, constant: 48), + signUpButton.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 32), + signUpButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -32), + + // Forgot Password Button + forgotPasswordButton.topAnchor.constraint(equalTo: signUpButton.bottomAnchor, constant: 20), + forgotPasswordButton.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 32), + forgotPasswordButton.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -32), + + developedByLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + developedByLabel.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -32) + ]) + + backButton.addTarget(self, action: #selector(backButtonTapped), for: .touchUpInside) + forgotPasswordButton.addTarget(self, action: #selector(forgotPasswordTapped), for: .touchUpInside) + } + + // MARK: - buttons listeners + @objc private func signUpClicked() { + signUpButton.isLoading = true + authRepository.signUp( + body: SignUpRequest( + fullName: nameTextField.text ?? "", + email: emailTextField.text ?? "", + password: passwordTextField.text ?? "", + passwordConfirmation: confirmPasswordTextField.text ?? "", + country: cpv.selectedCountry.code + ) + ) + .sink(receiveCompletion: { [weak self] completion in + switch completion { + case .finished: + self?.navigateToMain() + case .failure(let error): + self?.showError(message: error.errorDescription) + } + }, receiveValue: { response in + UserPreferences.shared.setToken(value: response.token) + } + ) + .store(in: &cancellables) + } + + @objc private func forgotPasswordTapped() { + if let url = URL(string: "\(BASE_URL)/forgot-password") { + UIApplication.shared.open(url) + } + } + + @objc private func backButtonTapped() { + self.navigationController?.popViewController(animated: false) + } + + @objc func developedByLabelTapped() { + print("developedByLabelTapped") + if let url = URL(string: "https://rebus.tj") { + UIApplication.shared.open(url) + } + } + + // MARK: - other functions + private func showError(message: String) { + signUpButton.isLoading = false + showAlert(title: L("error"), message: message) + } + + private func navigateToMain() { + signUpButton.isLoading = false + self.dismiss(animated: true) + UserPreferences.shared.setShouldGoToTourismMain(value: true) + } +} diff --git a/iphone/Maps/Tourism/Presentation/Auth/Screens/WelcomeViewController.swift b/iphone/Maps/Tourism/Presentation/Auth/Screens/WelcomeViewController.swift new file mode 100644 index 0000000000..2e08259e60 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Auth/Screens/WelcomeViewController.swift @@ -0,0 +1,177 @@ +import UIKit + +class WelcomeViewController: UIViewController { + + // MARK: - UI Components + + private let backgroundImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = UIImage(named: "splash_background") + imageView.contentMode = .scaleAspectFill + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private let languageLabel: UILabel = { + let label = UILabel() + label.text = L("current_language") + label.textColor = .white + UIKitFont.applyStyle(to: label, style: UIKitFont.h4) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let globeIcon: UIImageView = { + let imageView = UIImageView() + imageView.image = UIImage(named: "globe") + imageView.tintColor = .white + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private let welcomeLabel: UILabel = { + let label = UILabel() + label.text = L("welcome_to_tjk") + label.textColor = .white + UIKitFont.applyStyle(to: label, style: UIKitFont.h1) + applyWrapContent(label: label) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let signInButton: AppButton = { + let button = AppButton( + label: L("sign_in"), + isPrimary: true, + target: self, + action: #selector(signInClicked) + ) + return button + }() + + private let signUpButton: AppButton = { + let button = AppButton( + label: L("sign_up"), + isPrimary: true, + target: self, + action: #selector(signUpClicked) + ) + return button + }() + + private let copyrightLabel: UILabel = { + let label = UILabel() + label.text = "©" + label.textColor = .white + UIKitFont.applyStyle(to: label, style: UIKitFont.h2) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let organizationNameLabel: UILabel = { + let label = UILabel() + label.text = L("organization_name") + label.textColor = .white + UIKitFont.applyStyle(to: label, style: UIKitFont.h4) + applyWrapContent(label: label) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + isInternetAvailable() + } + + // MARK: - Setup + + private func setupUI() { + + let gradientView = UIView(frame: CGRect(x: 0, y: view.height - 100, width: view.width, height: 100)) + let gradient = CAGradientLayer() + gradient.frame = gradientView.bounds + gradient.colors = [UIColor.clear.cgColor, UIColor.black.cgColor] + gradientView.layer.insertSublayer(gradient, at: 0) + + view.addSubview(backgroundImageView) + view.addSubview(languageLabel) + view.addSubview(globeIcon) + view.addSubview(welcomeLabel) + view.addSubview(signInButton) + view.addSubview(signUpButton) + view.addSubview(gradientView) + view.addSubview(copyrightLabel) + view.addSubview(organizationNameLabel) + + let languageTapGesture = UITapGestureRecognizer(target: self, action: #selector(languageClicked)) + languageLabel.isUserInteractionEnabled = true + languageLabel.addGestureRecognizer(languageTapGesture) + + NSLayoutConstraint.activate([ + // Background Image + backgroundImageView.topAnchor.constraint(equalTo: view.topAnchor), + backgroundImageView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + backgroundImageView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + backgroundImageView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + // Language Selection Row + languageLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), + languageLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + + globeIcon.centerYAnchor.constraint(equalTo: languageLabel.centerYAnchor), + globeIcon.leadingAnchor.constraint(equalTo: languageLabel.trailingAnchor, constant: 8), + + // Welcome Text + welcomeLabel.bottomAnchor.constraint(equalTo: signInButton.topAnchor, constant: -24), + welcomeLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + welcomeLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + + // Sign In and Sign Up Buttons + signInButton.bottomAnchor.constraint(equalTo: copyrightLabel.topAnchor, constant: -24), + signInButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + signInButton.trailingAnchor.constraint(equalTo: view.centerXAnchor, constant: -8), + signInButton.heightAnchor.constraint(equalToConstant: 44), + + signUpButton.bottomAnchor.constraint(equalTo: copyrightLabel.topAnchor, constant: -24), + signUpButton.leadingAnchor.constraint(equalTo: view.centerXAnchor, constant: 8), + signUpButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + signUpButton.heightAnchor.constraint(equalToConstant: 44), + + // Copyright and Organization Name + copyrightLabel.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20), + copyrightLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + + organizationNameLabel.centerYAnchor.constraint(equalTo: copyrightLabel.centerYAnchor), + organizationNameLabel.leadingAnchor.constraint(equalTo: copyrightLabel.trailingAnchor, constant: 8), + organizationNameLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -16), + ]) + } + + // MARK: - Actions + @objc private func languageClicked() { + navigateToLanguageSettings() + } + + @objc private func signInClicked() { + performSegue(withIdentifier: "Welcome2SignIn", sender: nil) + } + + @objc private func signUpClicked() { + performSegue(withIdentifier: "Welcome2SignUp", sender: nil) + } + + private func isInternetAvailable() { + let monitor = NWPathMonitor() + let queue = DispatchQueue.global(qos: .background) + + monitor.pathUpdateHandler = { path in + monitor.cancel() // Stop monitoring after checking + } + + monitor.start(queue: queue) + } + +} diff --git a/iphone/Maps/Tourism/Presentation/Components/AppSearchBar.swift b/iphone/Maps/Tourism/Presentation/Components/AppSearchBar.swift new file mode 100644 index 0000000000..84c142730e --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Components/AppSearchBar.swift @@ -0,0 +1,55 @@ +import SwiftUI + +struct AppSearchBar: View { + @Binding var query: String + var onSearchClicked: ((String) -> Void)? + var onClearClicked: () -> Void + + @State private var isActive = false + + var body: some View { + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(Color.hint) + .onTapGesture { + onSearchClicked?(query) + } + + TextField("Search", text: $query, onCommit: { + onSearchClicked?(query) + }) + .textFieldStyle(PlainTextFieldStyle()) + .font(.medium(size: 16)) + .foregroundColor(Color.onBackground) + + if !query.isEmpty { + Button(action: onClearClicked) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(Color.hint) + } + } + } + .padding() + .background(Color.surface) + .cornerRadius(16) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.border, lineWidth: 1) + ) + .onTapGesture { + isActive = true + } + } +} + +struct AppSearchBar_Previews: PreviewProvider { + static var previews: some View { + AppSearchBar( + query: .constant(""), + onSearchClicked: { _ in }, + onClearClicked: {} + ) + .previewLayout(.sizeThatFits) + .padding() + } +} diff --git a/iphone/Maps/Tourism/Presentation/Components/AuthBackButton.swift b/iphone/Maps/Tourism/Presentation/Components/AuthBackButton.swift new file mode 100644 index 0000000000..860eafc993 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Components/AuthBackButton.swift @@ -0,0 +1,22 @@ +import UIKit + +class BackButton: UIButton { + + override init(frame: CGRect) { + super.init(frame: frame) + setupButton() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupButton() + } + + private func setupButton() { + self.setTitle("←", for: .normal) + self.setTitleColor(.white, for: .normal) + self.titleLabel?.font = UIFont.systemFont(ofSize: 32) + self.layer.borderColor = UIColor.clear.cgColor + self.backgroundColor = .clear + } +} diff --git a/iphone/Maps/Tourism/Presentation/Components/Buttons/PrimaryButton.swift b/iphone/Maps/Tourism/Presentation/Components/Buttons/PrimaryButton.swift new file mode 100644 index 0000000000..8bbbbd36dd --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Components/Buttons/PrimaryButton.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct PrimaryButton: View { + var label: String + var onClick: () -> Void + var isLoading: Bool = false + var enabled: Bool = true + + var body: some View { + Button(action: onClick) { + HStack { + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + } else { + Text(label) + .font(.headline) + .fontWeight(.bold) + .foregroundColor(Color.onPrimary) + .padding() + .frame(maxWidth: .infinity) + } + } + .frame( + maxWidth: .infinity, + minHeight: 50 + ) + .background(Color.primary) + .cornerRadius(16) + } + .disabled(!enabled || isLoading) + } +} diff --git a/iphone/Maps/Tourism/Presentation/Components/Buttons/SecondaryButton.swift b/iphone/Maps/Tourism/Presentation/Components/Buttons/SecondaryButton.swift new file mode 100644 index 0000000000..9bb57ad0c0 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Components/Buttons/SecondaryButton.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct SecondaryButton: View { + var label: String + var loading: Bool = false + var icon: (() -> AnyView)? = nil + var onClick: () -> Void + + var body: some View { + Button(action: onClick) { + HStack(alignment: .center) { + if loading { + // Loading indicator (you can customize this part) + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + } else { + if let icon = icon { + icon() + .frame(width: 30, height: 30) + } + Text(label) + .font(.headline) + .fontWeight(.semibold) + .foregroundColor(Color.primary) + .padding() + } + } + .frame( + maxWidth: .infinity, + minHeight: 50 + ) + .background(SwiftUI.Color.clear) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.primary, lineWidth: 1) + ) + } + } +} diff --git a/iphone/Maps/Tourism/Presentation/Components/CountryAsLabel.swift b/iphone/Maps/Tourism/Presentation/Components/CountryAsLabel.swift new file mode 100644 index 0000000000..fb7ef88c41 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Components/CountryAsLabel.swift @@ -0,0 +1,3 @@ +import UIKit +import CountryPickerView + diff --git a/iphone/Maps/Tourism/Presentation/Components/CountryPickerUtils.swift b/iphone/Maps/Tourism/Presentation/Components/CountryPickerUtils.swift new file mode 100644 index 0000000000..c486a3e355 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Components/CountryPickerUtils.swift @@ -0,0 +1,99 @@ +import UIKit +import SwiftUI +import CountryPickerView + +func getCountryPickerView() -> CountryPickerView { + let cpv = CountryPickerView() + cpv.translatesAutoresizingMaskIntoConstraints = false + cpv.textColor = UIKitColor.onBackground + cpv.showCountryNameInView = true + cpv.showPhoneCodeInView = false + cpv.showCountryCodeInView = false + return cpv +} + +struct UICountryPickerView: UIViewRepresentable { + @State var prevValue: String = "" + let cpv = getCountryPickerView() + let onCountryChanged: (String) -> Void + + init(code: String, onCountryChanged: @escaping (String) -> Void) { + prevValue = code + cpv.setCountryByCode(code) + self.onCountryChanged = onCountryChanged + observeCodeAndUpdate() + } + + func observeCodeAndUpdate() { + Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in + if cpv.selectedCountry.code != prevValue { + self.prevValue = cpv.selectedCountry.code + onCountryChanged(cpv.selectedCountry.code) + } + } + } + + func makeUIView(context: Context) -> CountryPickerView { + return cpv + } + + func updateUIView(_ uiView: CountryPickerView, context: Context) { + + } +} + + +func getCountryAsLabel(code: String) -> CountryPickerView { + let cpv = CountryPickerView() + cpv.translatesAutoresizingMaskIntoConstraints = false + cpv.textColor = UIKitColor.onBackground + cpv.font = UIKitFont.h4.font + cpv.showCountryNameInView = true + cpv.showPhoneCodeInView = false + cpv.showCountryCodeInView = false + cpv.isUserInteractionEnabled = false + cpv.setCountryByCode(code) + return cpv +} + +struct UICountryAsLabelView: UIViewRepresentable { + let code: String + init(code: String) { + self.code = code + } + + func makeUIView(context: Context) -> CountryPickerView { + return getCountryAsLabel(code: code) + } + + func updateUIView(_ uiView: CountryPickerView, context: Context) { + // nothing, go home + } +} + + +func getCountryFlag(code: String) -> CountryPickerView { + let cpv = CountryPickerView() + cpv.translatesAutoresizingMaskIntoConstraints = false + cpv.showCountryNameInView = false + cpv.showPhoneCodeInView = false + cpv.showCountryCodeInView = false + cpv.isUserInteractionEnabled = false + cpv.setCountryByCode(code) + return cpv +} + +struct UICountryFlagView: UIViewRepresentable { + let code: String + init(code: String) { + self.code = code + } + + func makeUIView(context: Context) -> CountryPickerView { + return getCountryFlag(code: code) + } + + func updateUIView(_ uiView: CountryPickerView, context: Context) { + // nothing, go home + } +} diff --git a/iphone/Maps/Tourism/Presentation/Components/EmptyUI.swift b/iphone/Maps/Tourism/Presentation/Components/EmptyUI.swift new file mode 100644 index 0000000000..10fce78629 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Components/EmptyUI.swift @@ -0,0 +1,7 @@ +import SwiftUI + +struct EmptyUI: View { + var body: some View { + Text(L("no_content")) + } +} diff --git a/iphone/Maps/Tourism/Presentation/Components/FlowLayout.swift b/iphone/Maps/Tourism/Presentation/Components/FlowLayout.swift new file mode 100644 index 0000000000..e93450bab1 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Components/FlowLayout.swift @@ -0,0 +1,93 @@ +import SwiftUI + +struct FlowStack: View where Data.Element: Hashable { + let data: Data + let spacing: CGFloat + let alignment: HorizontalAlignment + @ViewBuilder let content: (Data.Element) -> Content + + @State private var availableWidth: CGFloat = 0 + + var body: some View { + ZStack(alignment: Alignment(horizontal: alignment, vertical: .center)) { + SwiftUI.Color.clear + .frame(height: 1) + .readSize { size in + availableWidth = size.width + } + + _FlowStack( + availableWidth: availableWidth, + data: data, + spacing: spacing, + alignment: alignment, + content: content + ) + } + } +} + +private struct _FlowStack: View where Data.Element: Hashable { + let availableWidth: CGFloat + let data: Data + let spacing: CGFloat + let alignment: HorizontalAlignment + @ViewBuilder let content: (Data.Element) -> Content + + @State private var elementsSize: [Data.Element: CGSize] = [:] + + var body: some View { + VStack(alignment: alignment, spacing: spacing) { + ForEach(computeRows(), id: \.self) { rowElements in + HStack(spacing: spacing) { + ForEach(rowElements, id: \.self) { element in + content(element) + .fixedSize() + .readSize { size in + elementsSize[element] = size + } + } + } + } + } + } + + private func computeRows() -> [[Data.Element]] { + var rows: [[Data.Element]] = [[]] + var currentRow = 0 + var remainingWidth = availableWidth + + for element in data { + let elementSize = elementsSize[element, default: CGSize(width: availableWidth, height: 1)] + + if remainingWidth - (elementSize.width + spacing) >= 0 { + rows[currentRow].append(element) + } else { + currentRow += 1 + rows.append([element]) + remainingWidth = availableWidth + } + + remainingWidth -= elementSize.width + spacing + } + + return rows + } +} + +private extension View { + func readSize(onChange: @escaping (CGSize) -> Void) -> some View { + background( + GeometryReader { geometryProxy in + SwiftUI.Color.clear + .preference(key: SizePreferenceKey.self, value: geometryProxy.size) + } + ) + .onPreferenceChange(SizePreferenceKey.self, perform: onChange) + } +} + +private struct SizePreferenceKey: PreferenceKey { + static var defaultValue: CGSize = .zero + static func reduce(value: inout CGSize, nextValue: () -> CGSize) {} +} diff --git a/iphone/Maps/Tourism/Presentation/Components/HorizontalSingleChoice.swift b/iphone/Maps/Tourism/Presentation/Components/HorizontalSingleChoice.swift new file mode 100644 index 0000000000..9e05696f6c --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Components/HorizontalSingleChoice.swift @@ -0,0 +1,80 @@ +import SwiftUI + +struct SingleChoiceItem : Identifiable { + let id: T + let label: String +} + +struct HorizontalSingleChoice: View { + let items: [SingleChoiceItem] + @Binding var selected: SingleChoiceItem? + var onSelectedChanged: (SingleChoiceItem) -> Void + var selectedColor: SwiftUI.Color = Color.selected + var unselectedColor: SwiftUI.Color = Color.background + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack() { + HorizontalSpace(width: 16) + ForEach(items, id: \.id) { item in + SingleChoiceItemView( + item: item, + isSelected: item.id == selected?.id, + onClick: { + selected = item + onSelectedChanged(item) + }, + selectedColor: selectedColor, + unselectedColor: unselectedColor + ) + HorizontalSpace(width: 8) + } + } + } + } +} + +struct SingleChoiceItemView: View { + let item: SingleChoiceItem + let isSelected: Bool + let onClick: () -> Void + let selectedColor: SwiftUI.Color + let unselectedColor: SwiftUI.Color + + var body: some View { + Text(item.label) + .font(.medium(size: 16)) + .foregroundColor(isSelected ? Color.onSelected : Color.onBackground) + .padding(12) + .background(isSelected ? selectedColor : unselectedColor) + .cornerRadius(16) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.border, lineWidth: 1) + ) + .onTapGesture(perform: onClick) + } +} + +struct HorizontalSingleChoice_Previews: PreviewProvider { + static var previews: some View { + PreviewWrapper() + } + + struct PreviewWrapper: View { + @State private var selected: SingleChoiceItem? + let items = [ + SingleChoiceItem(id: "1", label: "Option 1"), + SingleChoiceItem(id: "2", label: "Option 2"), + SingleChoiceItem(id: "3", label: "Option 3") + ] + + var body: some View { + HorizontalSingleChoice( + items: items, + selected: $selected, + onSelectedChanged: { _ in } + ) + } + } +} diff --git a/iphone/Maps/Tourism/Presentation/Components/ImagePicker.swift b/iphone/Maps/Tourism/Presentation/Components/ImagePicker.swift new file mode 100644 index 0000000000..7b986370c7 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Components/ImagePicker.swift @@ -0,0 +1,44 @@ +import SwiftUI + +struct ImagePicker: UIViewControllerRepresentable { + @Environment(\.presentationMode) private var presentationMode + var sourceType: UIImagePickerController.SourceType = .photoLibrary + @Binding var selectedImage: UIImage + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIImagePickerController { + + let imagePicker = UIImagePickerController() + imagePicker.allowsEditing = false + imagePicker.sourceType = sourceType + imagePicker.delegate = context.coordinator + + return imagePicker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext) { + + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + + var parent: ImagePicker + + init(_ parent: ImagePicker) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + + if let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage { + parent.selectedImage = image + } + + parent.presentationMode.wrappedValue.dismiss() + } + + } +} diff --git a/iphone/Maps/Tourism/Presentation/Components/LoadImg.swift b/iphone/Maps/Tourism/Presentation/Components/LoadImg.swift new file mode 100644 index 0000000000..42bb5858e3 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Components/LoadImg.swift @@ -0,0 +1,49 @@ +import SwiftUI +import SDWebImageSwiftUI + +struct LoadImageView: View { + let url: String? + + @State var isError = false + + var body: some View { + if let urlString = url, urlString != "" { + ZStack(alignment: .center) { + WebImage(url: URL(string: urlString)) + .onSuccess(perform: { Image, data, cache in + // delay is here to avoid any updates during ui update and stop seing messages about it + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.isError = false + } + }) + .onFailure(perform: { isError in + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.isError = true + } + }) + .resizable() + .indicator(.activity) + .scaledToFill() + .frame(maxWidth: UIScreen.main.bounds.width, maxHeight: .infinity) + .clipped() + .transition(.fade(duration: 0.2)) + if(isError) { + Image(systemName: "exclamationmark.circle") + .font(.system(size: 30)) + .background(SwiftUI.Color.clear) + .foregroundColor(Color.hint) + .clipShape(Circle()) + } + } + } else { + Text(L("no_image")) + .foregroundColor(Color.hint) + } + } +} + +struct LoadImageView_Previews: PreviewProvider { + static var previews: some View { + LoadImageView(url: Constants.imageUrlExample) + } +} diff --git a/iphone/Maps/Tourism/Presentation/Components/MultiImagePicker.swift b/iphone/Maps/Tourism/Presentation/Components/MultiImagePicker.swift new file mode 100644 index 0000000000..5c8250cd31 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Components/MultiImagePicker.swift @@ -0,0 +1,53 @@ +import SwiftUI +import PhotosUI + +struct MultiImagePicker: UIViewControllerRepresentable { + @Environment(\.presentationMode) private var presentationMode + @Binding var selectedImages: [UIImage] + let limit = 10 + + func makeUIViewController(context: Context) -> PHPickerViewController { + var config = PHPickerConfiguration() + config.selectionLimit = limit + config.filter = .images + + let picker = PHPickerViewController(configuration: config) + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) { + if(self.selectedImages.count > limit) { + let numOfRedundantImages = self.selectedImages.count - limit + selectedImages.removeLast(numOfRedundantImages) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, PHPickerViewControllerDelegate { + var parent: MultiImagePicker + + init(_ parent: MultiImagePicker) { + self.parent = parent + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + parent.presentationMode.wrappedValue.dismiss() + + for result in results { + if result.itemProvider.canLoadObject(ofClass: UIImage.self) { + result.itemProvider.loadObject(ofClass: UIImage.self) { (image, error) in + if let image = image as? UIImage { + DispatchQueue.main.async { + self.parent.selectedImages.append(image) + } + } + } + } + } + } + } +} diff --git a/iphone/Maps/Tourism/Presentation/Components/Nav/AppBackButton.swift b/iphone/Maps/Tourism/Presentation/Components/Nav/AppBackButton.swift new file mode 100644 index 0000000000..d1ffe5c330 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Components/Nav/AppBackButton.swift @@ -0,0 +1,15 @@ +import SwiftUI + +struct AppBackButton: View { + var onBackClick: () -> Void + var tint: SwiftUI.Color = .primary + + var body: some View { + Button(action: onBackClick) { + Image(systemName: "arrow.left") + .scaleEffect(1.5) + .foregroundColor(tint) + .padding(4) + } + } +} diff --git a/iphone/Maps/Tourism/Presentation/Components/Nav/AppTopBar.swift b/iphone/Maps/Tourism/Presentation/Components/Nav/AppTopBar.swift new file mode 100644 index 0000000000..887ecc6b91 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Components/Nav/AppTopBar.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct AppTopBar: View { + var title: String + var onBackClick: (() -> Void)? + var actions: [TopBarActionData] = [] + + var body: some View { + VStack(alignment: .leading) { + HStack { + if let onBackClick = onBackClick { + AppBackButton(onBackClick: onBackClick) + .padding(.bottom) + } + + Spacer() + + HStack { + ForEach(actions, id: \.id) { action in + TopBarAction( + iconName: action.iconName, + color: action.color, + onClick: action.onClick + ) + } + } + } + + Text(title) + .textStyle(TextStyle.h1) + .foregroundColor(.primary) + .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16)) + + VerticalSpace(height: 12) + } + } +} + +struct TopBarActionData { + let id = UUID() + let iconName: String + let color: SwiftUI.Color? = nil + let onClick: () -> Void +} + +struct TopBarAction: View { + var iconName: String + var color: SwiftUI.Color? = nil + var onClick: () -> Void + + var body: some View { + Button(action: onClick) { + Image(systemName: iconName) + .resizable() + .frame(width: 24, height: 24) + .foregroundColor(color ?? .primary) + } + } +} diff --git a/iphone/Maps/Tourism/Presentation/Components/Nav/BackButtonWithText.swift b/iphone/Maps/Tourism/Presentation/Components/Nav/BackButtonWithText.swift new file mode 100644 index 0000000000..a1f3c74d72 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Components/Nav/BackButtonWithText.swift @@ -0,0 +1,17 @@ +import SwiftUI + +struct BackButtonWithText: View { + var onBackClick: () -> Void + + var body: some View { + Button(action: onBackClick) { + HStack { + Image(systemName: "chevron.left") + .resizable() + .frame(width: 8, height: 16) + Text(L("back")) + .font(.body) + } + } + } +} diff --git a/iphone/Maps/Tourism/Presentation/Components/New Group/AppButton.swift b/iphone/Maps/Tourism/Presentation/Components/New Group/AppButton.swift new file mode 100644 index 0000000000..29b272530e --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Components/New Group/AppButton.swift @@ -0,0 +1,103 @@ +import UIKit + +class AppButton: UIButton { + + private var originalButtonText: String? + private var activityIndicator: UIActivityIndicatorView! + + var isLoading: Bool = false { + didSet { + updateLoadingState() + } + } + + private let highlightScale: CGFloat = 0.95 + + init(label: String, isPrimary: Bool, icon: UIImage? = nil, target: Any?, action: Selector) { + super.init(frame: .zero) + + originalButtonText = label + setTitle(label, for: .normal) + + isPrimary ? setPrimaryAppearance() : setSecondaryAppearance() + heightAnchor.constraint(equalToConstant: 56).isActive = true + + translatesAutoresizingMaskIntoConstraints = false + + addTarget(target, action: action, for: .touchUpInside) + addTarget(self, action: #selector(handleTouchDown), for: .touchDown) + addTarget(self, action: #selector(handleTouchUp), for: .touchUpInside) + addTarget(self, action: #selector(handleTouchUp), for: .touchCancel) + + setupActivityIndicator() + + if let icon = icon { + setImage(icon, for: .normal) + imageEdgeInsets = UIEdgeInsets(top: 0, left: -8, bottom: 0, right: 8) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Styles + private func setPrimaryAppearance() { + setTitleColor(.white, for: .normal) + self.backgroundColor = UIKitColor.primary + if let lab = self.titleLabel { + UIKitFont.applyStyle(to: lab, style: UIKitFont.h4) + } + layer.cornerRadius = 16 + } + + private func setSecondaryAppearance() { + setTitleColor(.white, for: .normal) + backgroundColor = .clear + layer.cornerRadius = 16 + layer.borderWidth = 1 + layer.borderColor = UIColor.white.cgColor + } + + // MARK: click animation + @objc private func handleTouchDown() { + animate(scale: highlightScale) + } + + @objc private func handleTouchUp() { + animate(scale: 1.0) + } + + private func animate(scale: CGFloat) { + UIView.animate(withDuration: 0.2, animations: { + self.transform = CGAffineTransform(scaleX: scale, y: scale) + }) + } + + // MARK: loading + private func setupActivityIndicator() { + activityIndicator = UIActivityIndicatorView(style: .white) + activityIndicator.color = .white + activityIndicator.hidesWhenStopped = true + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + addSubview(activityIndicator) + + // Center the activity indicator in the button + NSLayoutConstraint.activate([ + activityIndicator.centerXAnchor.constraint(equalTo: centerXAnchor), + activityIndicator.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + } + + private func updateLoadingState() { + if isLoading { + setTitle("", for: .normal) + activityIndicator.startAnimating() + isUserInteractionEnabled = false + } else { + setTitle(originalButtonText, for: .normal) + activityIndicator.stopAnimating() + isUserInteractionEnabled = true + } + } +} diff --git a/iphone/Maps/Tourism/Presentation/Components/Spacers.swift b/iphone/Maps/Tourism/Presentation/Components/Spacers.swift new file mode 100644 index 0000000000..bee6031e15 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Components/Spacers.swift @@ -0,0 +1,17 @@ +import SwiftUI + +struct HorizontalSpace: View { + let width: CGFloat + + var body: some View { + Spacer().frame(width: width) + } +} + +struct VerticalSpace: View { + let height: CGFloat + + var body: some View { + Spacer().frame(height: height) + } +} diff --git a/iphone/Maps/Tourism/Presentation/Components/Special/PlacesItem.swift b/iphone/Maps/Tourism/Presentation/Components/Special/PlacesItem.swift new file mode 100644 index 0000000000..7150fc087b --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Components/Special/PlacesItem.swift @@ -0,0 +1,250 @@ +import SwiftUI +import WebKit + +struct PlacesItem: View { + let place: PlaceShort + let onPlaceClick: (PlaceShort) -> Void + let onFavoriteChanged: (Bool) -> Void + + private let height: CGFloat = 130 + + var body: some View { + HStack { + LoadImageView(url: place.cover) + .frame(width: height, height: height) + .clipShape(RoundedRectangle(cornerRadius: 20)) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(place.name) + .font(.semiBold(size: 20)) + .foregroundColor(Color.onBackground) + .lineLimit(1) + + Spacer() + + Button(action: { + onFavoriteChanged(!place.isFavorite) + }) { + Image(systemName: place.isFavorite ? "heart.fill" : "heart") + .foregroundColor(Color.heartRed) + } + } + + HStack { + Text(String(format: "%.1f", place.rating ?? 0.0)) + .font(.regular(size: 14)) + .foregroundColor(Color.onBackground) + Image(systemName: "star.fill") + .foregroundColor(Color.starYellow) + .font(.system(size: 12)) + } + + if let excerpt = place.excerpt { + AttributedText(excerpt) + .font(.regular(size: 14)) + .foregroundColor(Color.onBackground) + .lineLimit(3) + } + } + .padding(8) + } + .frame(height: height) + .background(Color.background) + .cornerRadius(20) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Color.border, lineWidth: 1) + ) + .onTapGesture { + onPlaceClick(place) + } + } +} + +struct HTMLStringView: UIViewRepresentable { + let htmlContent: String + + func makeUIView(context: Context) -> WKWebView { + return WKWebView() + } + + func updateUIView(_ uiView: WKWebView, context: Context) { + uiView.loadHTMLString(htmlContent, baseURL: nil) + } +} + + + +/** + AttributedText is a view for displaying some HTML-tagged text using SwiftUI Text View. + + - warning: **Only single-word tags are supported**. Tags with more than one word or + containing any characters besides **letters** or **numbers** are ignored and not removed. + + # Notes + 1. Basic modifiers can still be applied, such as changing the font and color of the text. + 2. Handles unopened/unclosed tags. + 3. Supports overlapping tags. + 4. Deletes tags that have no modifiers. + 5. Does **not** handle HTML characters such as `&`. + + # Example + ``` + AttributedText("This is bold and italic text.") + .foregroundColor(.blue) + .font(.title) + .padding() + ``` + */ +public struct AttributedText: View { + /// Set of supported tags and associated modifiers. This is used by default for all AttributedText + /// instances except those for which this parameter is defined in the initializer. + public static var tags: Dictionary (Text)> = [ + // This modifier set is presented just for reference. + // Set the necessary attributes and modifiers for your needs before use. + "h1": { $0.font(.largeTitle) }, + "h2": { $0.font(.title) }, + "h3": { $0.font(.headline) }, + "h4": { $0.font(.subheadline) }, + "h5": { $0.font(.callout) }, + "h6": { $0.font(.caption) }, + + "i": { $0.italic() }, + "u": { $0.underline() }, + "s": { $0.strikethrough() }, + "b": { $0.fontWeight(.bold) }, + + "sup": { $0.baselineOffset(10).font(.footnote) }, + "sub": { $0.baselineOffset(-10).font(.footnote) } + ] + /// Parser formatted text. + private let text: Text + + /** + Creates a text view that displays formatted content. + + - parameter htmlString: HTML-tagged string. + - parameter tags: Set of supported tags and associated modifiers for a particular instance. + */ + public init(_ htmlString: String, tags: Dictionary (Text)>? = nil) { + let parser = HTML2TextParser(htmlString, availableTags: tags == nil ? AttributedText.tags : tags!) + parser.parse() + text = parser.formattedText + } + + public var body: some View { + text + } +} + +struct AttributedText_Previews: PreviewProvider { + static var previews: some View { + AttributedText("This is bold and italic text.") + .foregroundColor(.blue) + .font(.title) + .padding() + } +} + + +/** + Parser for converting HTML-tagged text to SwiftUI Text View. + + - warning: **Only single-word tags are supported**. Tags with more than one word or + containing any characters besides **letters** or **numbers** are ignored and not removed. + + # Notes: + 1. Handles unopened/unclosed tags. + 2. Deletes tags that have no modifiers. + 3. Does **not** handle HTML characters, for example `<`. + */ +internal class HTML2TextParser { + /// The result of the parser's work. + internal private(set) var formattedText = Text("") + /// HTML-tagged text. + private let htmlString: String + /// Set of currently active tags. + private var tags: Set = [] + /// Set of supported tags and associated modifiers. + private let availableTags: Dictionary (Text)> + + /** + Creates a new parser instance. + + - parameter htmlString: HTML-tagged string. + - parameter availableTags: Set of supported tags and associated modifiers. + */ + internal init(_ htmlString: String, availableTags: Dictionary (Text)>) { + self.htmlString = htmlString + self.availableTags = availableTags + } + + /// Starts the text parsing process. The results of this method will be placed in the `formattedText` variable. + internal func parse() { + var tag: String? = nil + var endTag: Bool = false + var startIndex = htmlString.startIndex + var endIndex = htmlString.startIndex + + for index in htmlString.indices { + switch htmlString[index] { + case "<": + tag = String() + endIndex = index + continue + + case "/": + if index != htmlString.startIndex && htmlString[htmlString.index(before: index)] == "<" { + endTag = true + } else { + tag = nil + } + continue + + case ">": + if let tag = tag { + addChunkOfText(String(htmlString[startIndex.. AnyView)? = nil + var trailingIcon: (() -> AnyView)? = nil + + @State private var isFocused: Bool = false + + var body: some View { + VStack(alignment: .leading) { + ZStack(alignment: .leading) { + // Main text field + TextField("", text: $value, onEditingChanged: { isEditing in + isFocused = isEditing + }, onCommit: { + isFocused = false + }) + .font(.system(size: textSize)) + .frame(height: textFieldHeight) + .padding(.leading, leadingIcon != nil ? 40 : 0) + .padding(.trailing, trailingIcon != nil ? 40 : 0) + .background(Color.background) + .padding(.bottom, 5) + .overlay( + // Underline + Rectangle() + .frame(height: 1) + .foregroundColor(colorForState()) + .padding(.top, textFieldHeight / 2) + ) + + // Label + if let label = label { + Text(label) + .font(.system(size: labelFontSize)) + .foregroundColor(Color.onBackground) + .padding(.bottom, 50) + } + + // Hint text + Text(hint) + .font(.system(size: hintFontSize)) + .foregroundColor(value.isEmpty ? Color.hint : SwiftUI.Color.clear) + .padding(.bottom, 5) + + if let leadingIcon = leadingIcon { + leadingIcon() + .frame(width: 30, height: 30) + .padding(.leading, 10) + } + + if let trailingIcon = trailingIcon { + trailingIcon() + .frame(width: 30, height: 30) + .padding(.trailing, 10) + } + } + .frame(height: textFieldHeight) + } + } + + private func colorForState() -> SwiftUI.Color { + if isError != nil { return errorColor } + return isFocused ? Color.primary : Color.onBackground + } +} diff --git a/iphone/Maps/Tourism/Presentation/Components/TextFields/AuhtTextField.swift b/iphone/Maps/Tourism/Presentation/Components/TextFields/AuhtTextField.swift new file mode 100644 index 0000000000..f99f3ea34e --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Components/TextFields/AuhtTextField.swift @@ -0,0 +1,46 @@ +import UIKit + +class AuthTextField: UITextField { + private let underline: UIView = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + setupTextField() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupTextField() + } + + private func setupTextField() { + self.backgroundColor = .clear + self.borderStyle = .none + self.attributedPlaceholder = NSAttributedString( + string: "Enter text here", + attributes: [NSAttributedString.Key.foregroundColor: UIColor.white] + ) + self.textColor = .white + self.leftViewMode = .always + self.font = UIFont.systemFont(ofSize: 16) + self.keyboardType = .default + self.returnKeyType = .done + + self.heightAnchor.constraint(equalToConstant: 50) + + addUnderline() + } + + private func addUnderline() { + underline.translatesAutoresizingMaskIntoConstraints = false + underline.backgroundColor = .white + self.addSubview(underline) + + NSLayoutConstraint.activate([ + underline.leadingAnchor.constraint(equalTo: self.leadingAnchor), + underline.trailingAnchor.constraint(equalTo: self.trailingAnchor), + underline.topAnchor.constraint(equalTo: self.bottomAnchor, constant: 8), + underline.heightAnchor.constraint(equalToConstant: 1) + ]) + } +} diff --git a/iphone/Maps/Tourism/Presentation/Components/TextFields/PasswordTextField.swift b/iphone/Maps/Tourism/Presentation/Components/TextFields/PasswordTextField.swift new file mode 100644 index 0000000000..c79271d70c --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Components/TextFields/PasswordTextField.swift @@ -0,0 +1,59 @@ +import UIKit + +class PasswordTextField: AuthTextField { + + private let toggleVisibilityButton: UIButton = { + let button = UIButton(type: .custom) + let eyeSlashImg = UIImage(named: "eye_slash") + let eyeImage = UIImage(named: "eye") + button.setImage(eyeSlashImg, for: .normal) + button.setImage(eyeImage, for: .selected) + button.tintColor = .white + button.addTarget(self, action: #selector(togglePasswordVisibility), for: .touchUpInside) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setupPasswordTextField() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupPasswordTextField() + } + + private func setupPasswordTextField() { + self.isSecureTextEntry = true + self.rightView = toggleVisibilityButton + self.rightViewMode = .always + + // Ensure the button is laid out correctly + self.addSubview(toggleVisibilityButton) + NSLayoutConstraint.activate([ + toggleVisibilityButton.widthAnchor.constraint(equalToConstant: 24), + toggleVisibilityButton.heightAnchor.constraint(equalToConstant: 24), + toggleVisibilityButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8), + toggleVisibilityButton.centerYAnchor.constraint(equalTo: self.centerYAnchor) + ]) + } + + @objc private func togglePasswordVisibility() { + self.isSecureTextEntry.toggle() + toggleVisibilityButton.isSelected = !self.isSecureTextEntry + } + + override func layoutSubviews() { + super.layoutSubviews() + // Adjust the frame of the right view if needed + let buttonWidth: CGFloat = 24 + let padding: CGFloat = 8 + self.rightView?.frame = CGRect( + x: self.bounds.width - buttonWidth - padding, + y: (self.bounds.height - buttonWidth) / 2, + width: buttonWidth, + height: buttonWidth + ) + } +} diff --git a/iphone/Maps/Tourism/Presentation/Components/ToastView/ToastView.swift b/iphone/Maps/Tourism/Presentation/Components/ToastView/ToastView.swift new file mode 100644 index 0000000000..4309b9092d --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Components/ToastView/ToastView.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct ToastView: View { + let message: String + @Binding var isPresented: Bool + + var body: some View { + VStack { + Text(message) + .padding() + .foregroundColor(Color.onSurface) + .background(Color.surface) + .cornerRadius(10) + .shadow(radius: 5) + } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + withAnimation { + isPresented = false + } + } + } + } +} diff --git a/iphone/Maps/Tourism/Presentation/Extensions/UIScreenExtensions.swift b/iphone/Maps/Tourism/Presentation/Extensions/UIScreenExtensions.swift new file mode 100644 index 0000000000..feb312a1fc --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Extensions/UIScreenExtensions.swift @@ -0,0 +1,5 @@ +extension UIScreen { + static let screenWidth = UIScreen.main.bounds.size.width + static let screenHeight = UIScreen.main.bounds.size.height + static let screenSize = UIScreen.main.bounds.size +} diff --git a/iphone/Maps/Tourism/Presentation/Extensions/UITextFieldExtensions.swift b/iphone/Maps/Tourism/Presentation/Extensions/UITextFieldExtensions.swift new file mode 100644 index 0000000000..94913340fc --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Extensions/UITextFieldExtensions.swift @@ -0,0 +1,10 @@ +import Foundation +import Combine + +extension UITextField { + var textPublisher: AnyPublisher { + NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: self) + .compactMap { ($0.object as? UITextField)?.text } + .eraseToAnyPublisher() + } +} diff --git a/iphone/Maps/Tourism/Presentation/Extensions/UIViewControllerExtensions.swift b/iphone/Maps/Tourism/Presentation/Extensions/UIViewControllerExtensions.swift new file mode 100644 index 0000000000..a1032f1f20 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Extensions/UIViewControllerExtensions.swift @@ -0,0 +1,37 @@ +import UIKit +import SwiftUI + +extension UIViewController { + func integrateSwiftUIScreen(_ screen: Content) { + let hostingController = UIHostingController(rootView: screen) + + addChild(hostingController) + hostingController.view.frame = view.frame + hostingController.view.backgroundColor = UIKitColor.background + view.addSubview(hostingController.view) + hostingController.didMove(toParent: self) + } + + func showAlert(title: String, message: String) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) + present(alert, animated: true, completion: nil) + } + + func showToast(message : String) { + let toastLabel = UILabel(frame: CGRect(x: self.view.frame.size.width/2 - 75, y: self.view.frame.size.height-100, width: 150, height: 35)) + toastLabel.backgroundColor = UIColor.black.withAlphaComponent(0.6) + toastLabel.textColor = UIColor.white + toastLabel.textAlignment = .center; + toastLabel.text = message + toastLabel.alpha = 1.0 + toastLabel.layer.cornerRadius = 10; + toastLabel.clipsToBounds = true + self.view.addSubview(toastLabel) + UIView.animate(withDuration: 4.0, delay: 0.1, options: .curveEaseOut, animations: { + toastLabel.alpha = 0.0 + }, completion: {(isCompleted) in + toastLabel.removeFromSuperview() + }) + } +} diff --git a/iphone/Maps/Tourism/Presentation/Extensions/UIViewExtensions.swift b/iphone/Maps/Tourism/Presentation/Extensions/UIViewExtensions.swift new file mode 100644 index 0000000000..ce51138708 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Extensions/UIViewExtensions.swift @@ -0,0 +1,20 @@ +extension UIView { + func showToast(message: String, duration: TimeInterval = 2.0) { + let toastLabel = UILabel(frame: CGRect(x: self.frame.size.width/2 - 75, y: self.frame.size.height-100, width: 150, height: 35)) + toastLabel.backgroundColor = UIColor.black.withAlphaComponent(0.6) + toastLabel.textColor = UIColor.white + toastLabel.textAlignment = .center + toastLabel.font = UIFont.systemFont(ofSize: 12.0) + toastLabel.text = message + toastLabel.alpha = 1.0 + toastLabel.layer.cornerRadius = 10 + toastLabel.clipsToBounds = true + self.addSubview(toastLabel) + + UIView.animate(withDuration: duration, delay: 0.1, options: .curveEaseOut, animations: { + toastLabel.alpha = 0.0 + }, completion: { (isCompleted) in + toastLabel.removeFromSuperview() + }) + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/DataSyncer.swift b/iphone/Maps/Tourism/Presentation/Home/DataSyncer.swift new file mode 100644 index 0000000000..144acaa800 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/DataSyncer.swift @@ -0,0 +1,79 @@ +import Network +import SystemConfiguration + +class DataSyncer { + private let reviewsRepository: ReviewsRepository + private let placesRepository: PlacesRepository + + init(reviewsRepository: ReviewsRepository, placesRepository: PlacesRepository) { + self.reviewsRepository = reviewsRepository + self.placesRepository = placesRepository + } + + private let monitor = NWPathMonitor() + private let queue = DispatchQueue.global(qos: .background) + + var isConnected: Bool = false + var isExpensive: Bool = false + + func startMonitoring() { + monitor.pathUpdateHandler = { path in + self.isConnected = path.status == .satisfied + self.isExpensive = path.isExpensive + + // delay is here because in the beginning there probably won't be stable connection + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + if Reachability.isConnectedToNetwork() { + print("Connected to the internet.") + self.reviewsRepository.syncReviews() + self.placesRepository.syncFavorites() + } else { + print("No internet connection.") + } + } + } + + monitor.start(queue: queue) + } + + func stopMonitoring() { + monitor.cancel() + } +} + + +public class Reachability { + + class func isConnectedToNetwork() -> Bool { + + var zeroAddress = sockaddr_in(sin_len: 0, sin_family: 0, sin_port: 0, sin_addr: in_addr(s_addr: 0), sin_zero: (0, 0, 0, 0, 0, 0, 0, 0)) + zeroAddress.sin_len = UInt8(MemoryLayout.size(ofValue: zeroAddress)) + zeroAddress.sin_family = sa_family_t(AF_INET) + + let defaultRouteReachability = withUnsafePointer(to: &zeroAddress) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {zeroSockAddress in + SCNetworkReachabilityCreateWithAddress(nil, zeroSockAddress) + } + } + + var flags: SCNetworkReachabilityFlags = SCNetworkReachabilityFlags(rawValue: 0) + if SCNetworkReachabilityGetFlags(defaultRouteReachability!, &flags) == false { + return false + } + + /* Only Working for WIFI + let isReachable = flags == .reachable + let needsConnection = flags == .connectionRequired + + return isReachable && !needsConnection + */ + + // Working for Cellular and WIFI + let isReachable = (flags.rawValue & UInt32(kSCNetworkFlagsReachable)) != 0 + let needsConnection = (flags.rawValue & UInt32(kSCNetworkFlagsConnectionRequired)) != 0 + let ret = (isReachable && !needsConnection) + + return ret + + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Categories/CategoriesViewController.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Categories/CategoriesViewController.swift new file mode 100644 index 0000000000..ac3f951ae9 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Categories/CategoriesViewController.swift @@ -0,0 +1,119 @@ +import SwiftUI + +class CategoriesViewController: UIViewController { + private var categoriesVM: CategoriesViewModel + private var searchVM: SearchViewModel + private var goToMap: () -> Void + private let onCreateRoute: (PlaceLocation) -> Void + + init( + categoriesVM: CategoriesViewModel, + searchVM: SearchViewModel, + goToMap: @escaping () -> Void, + onCreateRoute: @escaping (PlaceLocation) -> Void + ) { + self.categoriesVM = categoriesVM + self.searchVM = searchVM + self.goToMap = goToMap + self.onCreateRoute = onCreateRoute + + super.init( + nibName: nil, + bundle: nil + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + integrateSwiftUIScreen( + CategoriesScreen( + categoriesVM: categoriesVM, + goToSearchScreen: { query in + self.searchVM.query = query + let destinationVC = SearchViewController( + searchVM: self.searchVM, + goToMap: self.goToMap, + onCreateRoute: self.onCreateRoute + ) + self.navigationController?.pushViewController(destinationVC, animated: true) + }, + goToPlaceScreen: { id in + self.goToPlaceScreen( + id: id, + onMapClick: self.goToMap, + onCreateRoute: self.onCreateRoute + ) + }, + goToMap: goToMap + ) + ) + } +} + +struct CategoriesScreen: View { + @ObservedObject var categoriesVM: CategoriesViewModel + var goToSearchScreen: (String) -> Void + var goToPlaceScreen: (Int64) -> Void + var goToMap: () -> Void + + var body: some View { + ScrollView { + VStack(alignment: .leading) { + VerticalSpace(height: 16) + VStack { + AppTopBar( + title: L("categories"), + actions: [ + TopBarActionData( + iconName: "map", + onClick: goToMap + ) + ] + ) + + AppSearchBar( + query: $categoriesVM.query, + onSearchClicked: { query in + goToSearchScreen(query) + }, + onClearClicked: { + categoriesVM.clearQuery() + } + ) + } + .padding(16) + + VStack(spacing: 20) { + HorizontalSingleChoice( + items: categoriesVM.categories, + selected: $categoriesVM.selectedCategory, + onSelectedChanged: { item in + categoriesVM.setSelectedCategory(item) + } + ) + + LazyVStack(spacing: 16) { + ForEach(categoriesVM.places) { place in + PlacesItem( + place: place, + onPlaceClick: { place in + goToPlaceScreen(place.id) + }, + onFavoriteChanged: { isFavorite in + categoriesVM.toggleFavorite(for: place.id, isFavorite: isFavorite) + } + ) + } + } + .padding(.horizontal, 16) + } + VerticalSpace(height: 32) + } + } + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Categories/CategoriesViewModel.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Categories/CategoriesViewModel.swift new file mode 100644 index 0000000000..dbe0ed2877 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Categories/CategoriesViewModel.swift @@ -0,0 +1,57 @@ +import Combine + +class CategoriesViewModel: ObservableObject { + private var cancellables = Set() + + private let placesRepository: PlacesRepository + + @Published var query = "" + + func clearQuery() { query = "" } + + var categories = [ + SingleChoiceItem(id: PlaceCategory.sights, label: L("sights")), + SingleChoiceItem(id: PlaceCategory.restaurants, label: L("restaurants")), + SingleChoiceItem(id: PlaceCategory.hotels, label: L("hotels")) + ] + + @Published var selectedCategory: SingleChoiceItem? + + func setSelectedCategory(_ item: SingleChoiceItem?) { + selectedCategory = item + } + + @Published var places: [PlaceShort] = [] + + init(placesRepository: PlacesRepository) { + self.placesRepository = placesRepository + + if let firstCategory = categories.first { + self.selectedCategory = firstCategory + } + + observeCategoryPlaces() + } + + func observeCategoryPlaces() { + placesRepository.placesByCategoryResource.sink { completion in + if case .failure(_) = completion { + // nothing + } + } receiveValue: { places in + self.places = places + } + .store(in: &cancellables) + + $selectedCategory.sink { category in + if let id = category?.id.id { + self.placesRepository.observePlacesByCategoryAndUpdate(categoryId: id) + } + } + .store(in: &cancellables) + } + + func toggleFavorite(for placeId: Int64, isFavorite: Bool) { + placesRepository.setFavorite(placeId: placeId, isFavorite: isFavorite) + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Favorites/FavoritesViewController.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Favorites/FavoritesViewController.swift new file mode 100644 index 0000000000..dbfeb84a28 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Favorites/FavoritesViewController.swift @@ -0,0 +1,88 @@ +import SwiftUI + +class FavoritesViewController: UIViewController { + private var favoritesVM: FavoritesViewModel + private var goToMap: () -> Void + private let onCreateRoute: (PlaceLocation) -> Void + + init( + favoritesVM: FavoritesViewModel, + goToMap: @escaping () -> Void, + onCreateRoute: @escaping (PlaceLocation) -> Void + ) { + self.favoritesVM = favoritesVM + self.goToMap = goToMap + self.onCreateRoute = onCreateRoute + + super.init( + nibName: nil, + bundle: nil + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + integrateSwiftUIScreen( + FavoritesScreen( + favoritesVM: favoritesVM, + goToPlaceScreen: { id in + self.goToPlaceScreen( + id: id, + onMapClick: self.goToMap, + onCreateRoute: self.onCreateRoute + ) + } + ) + ) + } +} + +struct FavoritesScreen: View { + @ObservedObject var favoritesVM: FavoritesViewModel + var goToPlaceScreen: (Int64) -> Void + + var body: some View { + ScrollView { + VStack(alignment: .leading) { + VerticalSpace(height: 16) + VStack { + AppTopBar(title: L("favorites")) + + AppSearchBar( + query: $favoritesVM.query, + onSearchClicked: { query in + // nothing, actually, it will be real time + }, + onClearClicked: { + favoritesVM.clearQuery() + } + ) + } + .padding(16) + + VStack(spacing: 20) { + LazyVStack(spacing: 16) { + ForEach(favoritesVM.places) { place in + PlacesItem( + place: place, + onPlaceClick: { place in + goToPlaceScreen(place.id) + }, + onFavoriteChanged: { isFavorite in + favoritesVM.toggleFavorite(for: place.id, isFavorite: isFavorite) + } + ) + } + } + .padding(.horizontal, 16) + } + VerticalSpace(height: 32) + } + } + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Favorites/FavoritesViewModel.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Favorites/FavoritesViewModel.swift new file mode 100644 index 0000000000..4c15dd17e4 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Favorites/FavoritesViewModel.swift @@ -0,0 +1,40 @@ +import Combine + +class FavoritesViewModel: ObservableObject { + private var cancellables = Set() + + private let placesRepository: PlacesRepository + + @Published var query = "" + + func clearQuery() { query = "" } + + @Published var places: [PlaceShort] = [] + + init(placesRepository: PlacesRepository) { + self.placesRepository = placesRepository + + self.placesRepository.observeFavorites(query: query) + observeFavorites() + } + + func observeFavorites() { + placesRepository.favoritesResource.sink { completion in + if case .failure(_) = completion { + // nothing + } + } receiveValue: { places in + self.places = places + } + .store(in: &cancellables) + + $query.sink { q in + self.placesRepository.observeFavorites(query: q) + } + .store(in: &cancellables) + } + + func toggleFavorite(for placeId: Int64, isFavorite: Bool) { + placesRepository.setFavorite(placeId: placeId, isFavorite: isFavorite) + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Home/HomeViewModel.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Home/HomeViewModel.swift new file mode 100644 index 0000000000..f65c845030 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Home/HomeViewModel.swift @@ -0,0 +1,70 @@ +import Combine + +class HomeViewModel : ObservableObject { + private let placesRepository: PlacesRepository + + @Published var downloadProgress = DownloadProgress.idle + @Published var errorMessage = "" + + @Published var query = "" + + func clearQuery() { query = "" } + + @Published var sights: [PlaceShort]? = nil + @Published var restaurants: [PlaceShort]? = nil + + private var cancellables = Set() + + init(placesRepository: PlacesRepository) { + self.placesRepository = placesRepository + + downloadAllDataIfDidnt() + observeDownloadProgress() + observeTopSightsAndUpdate() + observeTopRestaurantsAndUpdate() + } + + func observeDownloadProgress() { + placesRepository.downloadProgress.sink { completion in + if case let .failure(error) = completion { + Task { await MainActor.run { + self.downloadProgress = .error + }} + self.errorMessage = error.errorDescription + } + } receiveValue: { progress in + Task { await MainActor.run { + self.downloadProgress = progress + }} + } + .store(in: &cancellables) + } + + func downloadAllDataIfDidnt() { + Task { + try await placesRepository.downloadAllData() + } + } + + func observeTopSightsAndUpdate() { + placesRepository.topSightsResource.sink { _ in } receiveValue: { places in + self.sights = places + } + .store(in: &cancellables) + + placesRepository.observeTopSightsAndUpdate() + } + + func observeTopRestaurantsAndUpdate() { + placesRepository.topRestaurantsResource.sink { _ in } receiveValue: { places in + self.restaurants = places + } + .store(in: &cancellables) + + placesRepository.observeTopRestaurantsAndUpdate() + } + + func toggleFavorite(for placeId: Int64, isFavorite: Bool) { + placesRepository.setFavorite(placeId: placeId, isFavorite: isFavorite) + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Home/HorizontalPlaces.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Home/HorizontalPlaces.swift new file mode 100644 index 0000000000..d04e5b9ca6 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Home/HorizontalPlaces.swift @@ -0,0 +1,114 @@ +import SwiftUI + +struct HorizontalPlaces: View { + let title: String + let items: [PlaceShort] + let onPlaceClick: (PlaceShort) -> Void + let setFavoriteChanged: (PlaceShort, Bool) -> Void + + var body: some View { + VStack(alignment: .leading) { + Text(title) + .font(.semiBold(size: 24)) + .foregroundColor(Color.onBackground) + .padding(.horizontal) + + ScrollView(.horizontal, showsIndicators: false) { + HStack { + HorizontalSpace(width: 16) + + ForEach(items, id: \.id) { place in + Place( + place: place, + onPlaceClick: { onPlaceClick(place) }, + isFavorite: place.isFavorite, + onFavoriteChanged: { isFavorite in + setFavoriteChanged(place, isFavorite) + } + ) + HorizontalSpace(width: 16) + } + } + } + } + } +} + +struct Place: View { + let place: PlaceShort + let onPlaceClick: () -> Void + let isFavorite: Bool + let onFavoriteChanged: (Bool) -> Void + + let width = 230.0 + let height = 250.0 + + var body: some View { + ZStack() { + // cover + LoadImageView(url: place.cover) + .frame(width: width, height: height) + } + .overlay( + // title and rating + HStack() { + VStack(alignment: .leading) { + Text(place.name) + .font(.semiBold(size: 15)) + .foregroundColor(.white) + .lineLimit(2) + VerticalSpace(height: 4) + + HStack(alignment: .center) { + Text(String(format: "%.1f", place.rating ?? 0.0)) + .font(.semiBold(size: 15)) + .foregroundColor(.white) + Image(systemName: "star.fill") + .resizable() + .foregroundColor(Color.starYellow) + .frame(width: 10, height: 10) + } + } + .padding(12) + + Spacer() + } + .frame(width: width) + .background(SwiftUI.Color.black.opacity(0.5)), + alignment: .bottom + ) + .overlay( + // favorite button + Button(action: { + onFavoriteChanged(!isFavorite) + }) { + Image(systemName: isFavorite ? "heart.fill" : "heart") + .foregroundColor(.white) + .padding(12) + .background(SwiftUI.Color.white.opacity(0.2)) + .clipShape(Circle()) + } + .padding(12), + alignment: .topTrailing + ) + .frame(width: width, height: height) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .contentShape(Rectangle()) + .onTapGesture(perform: onPlaceClick) + } +} + + +struct HorizontalPlaces_Previews: PreviewProvider { + static var previews: some View { + HorizontalPlaces( + title: "Popular Places", + items: [ + PlaceShort(id: 1, name: "Place 1", cover: "url1", rating: 4.5, excerpt: "yep, just a placeyep, just a placeyep, just a placeyep, just a placeyep, just a place", isFavorite: false), + PlaceShort(id: 2, name: "Place 2", cover: "url2", rating: 4.0, excerpt: "yep, just a place", isFavorite: true) + ], + onPlaceClick: { _ in }, + setFavoriteChanged: { _, _ in } + ) + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/HomeScreen.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/HomeScreen.swift new file mode 100644 index 0000000000..a635996398 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/HomeScreen.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct HomeScreen: View { + var body: some View { + VStack() { + VStack(spacing: 10) { + Text("Oh, Hi Mark!") + } + Spacer() + } + } +} + +struct HomeScreen_Previews: PreviewProvider { + static var previews: some View { + HomeScreen() + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/New Group/HomeViewController.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/New Group/HomeViewController.swift new file mode 100644 index 0000000000..35ce786d59 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/New Group/HomeViewController.swift @@ -0,0 +1,173 @@ +import SwiftUI +import Combine + +class HomeViewController: UIViewController { + private var homeVM: HomeViewModel + private var categoriesVM: CategoriesViewModel + private var searchVM: SearchViewModel + private var goToCategoriesTab: () -> Void + private var goToMap: () -> Void + private let onCreateRoute: (PlaceLocation) -> Void + + init( + homeVM: HomeViewModel, + categoriesVM: CategoriesViewModel, + searchVM: SearchViewModel, + goToCategoriesTab: @escaping () -> Void, + goToMap: @escaping () -> Void, + onCreateRoute: @escaping (PlaceLocation) -> Void + ) { + self.homeVM = homeVM + self.categoriesVM = categoriesVM + self.searchVM = searchVM + self.goToCategoriesTab = goToCategoriesTab + self.goToMap = goToMap + self.onCreateRoute = onCreateRoute + + super.init( + nibName: nil, + bundle: nil + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + override func viewDidLoad() { + super.viewDidLoad() + + integrateSwiftUIScreen( + HomeScreen( + homeVM: homeVM, + categoriesVM: categoriesVM, + goToCategoriesTab: goToCategoriesTab, + goToSearchScreen: { query in + self.searchVM.query = query + let destinationVC = SearchViewController( + searchVM: self.searchVM, + goToMap: self.goToMap, + onCreateRoute: self.onCreateRoute + ) + self.navigationController?.pushViewController(destinationVC, animated: false) + }, + goToPlaceScreen: { id in + self.goToPlaceScreen( + id: id, + onMapClick: self.goToMap, + onCreateRoute: self.onCreateRoute + ) + }, + goToMap: goToMap + ) + ) + } +} + +struct HomeScreen: View { + @ObservedObject var homeVM: HomeViewModel + @ObservedObject var categoriesVM: CategoriesViewModel + var goToCategoriesTab: () -> Void + var goToSearchScreen: (String) -> Void + var goToPlaceScreen: (Int64) -> Void + var goToMap: () -> Void + + @State var top30: SingleChoiceItem? = SingleChoiceItem(id: 1, label: L("top30")) + + var body: some View { + if(homeVM.downloadProgress == .loading) { + VStack(spacing: 16) { + ProgressView() + Text(L("plz_wait_dowloading")) + } + } else if (homeVM.downloadProgress == .error) { + VStack(spacing: 16) { + Text(L("download_failed")) + Text(homeVM.errorMessage) + } + } else if (homeVM.downloadProgress == .success) { + ScrollView { + VStack (alignment: .leading) { + VerticalSpace(height: 16) + VStack { + AppTopBar( + title: L("tjk"), + actions: [ + TopBarActionData( + iconName: "map", + onClick: goToMap + ) + ] + ) + + AppSearchBar( + query: $homeVM.query, + onSearchClicked: { query in + goToSearchScreen(query) + }, + onClearClicked: { + homeVM.clearQuery() + } + ) + } + .padding(16) + + VStack(spacing: 20) { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + HorizontalSpace(width: 16) + SingleChoiceItemView( + item: top30!, + isSelected: true, + onClick: { + // nothing, just static + }, + selectedColor: Color.selected, + unselectedColor: Color.background + ) + + HorizontalSingleChoice( + items: categoriesVM.categories, + selected: $categoriesVM.selectedCategory, + onSelectedChanged: { item in + categoriesVM.setSelectedCategory(item) + goToCategoriesTab() + } + ) + } + } + + if let sights = homeVM.sights { + HorizontalPlaces( + title: L("sights"), + items: sights, + onPlaceClick: { place in + goToPlaceScreen(place.id) + }, + setFavoriteChanged: { place, isFavorite in + homeVM.toggleFavorite(for: place.id, isFavorite: isFavorite) + } + ) + } + + if let restaurants = homeVM.restaurants { + HorizontalPlaces( + title: L("restaurants"), + items: restaurants, + onPlaceClick: { place in + goToPlaceScreen(place.id) + }, + setFavoriteChanged: { place, isFavorite in + homeVM.toggleFavorite(for: place.id, isFavorite: isFavorite) + } + ) + } + } + } + VerticalSpace(height: 32) + } + } + } +} + diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PersonalDataViewController.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PersonalDataViewController.swift new file mode 100644 index 0000000000..86c9220739 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PersonalDataViewController.swift @@ -0,0 +1,136 @@ +import UIKit +import SwiftUI +import PhotosUI + +class PersonalDataViewController: UIViewController { + private var profileVM: ProfileViewModel + + init(profileVM: ProfileViewModel) { + self.profileVM = profileVM + super.init( + nibName: nil, + bundle: nil + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + integrateSwiftUIScreen(PersonalDataScreen(profileVM: profileVM)) + } +} + +struct PersonalDataScreen: View { + @ObservedObject var profileVM: ProfileViewModel + @Environment(\.presentationMode) var presentationMode: Binding + + @State private var showImagePicker = false + + var body: some View { + ScrollView { + VStack(alignment: .leading) { + AppTopBar( + title: L("personal_data"), + onBackClick: { + presentationMode.wrappedValue.dismiss() + } + ) + VerticalSpace(height: 24) + + HStack(alignment: .center) { + // pfp + Group { + if profileVM.isImagePickerUsed { + Image(uiImage: profileVM.pfpToUpload) + .resizable() + } else { + LoadImageView(url: profileVM.pfpFromRemote?.absoluteString) + } + } + .scaledToFill() + .frame(width: 100, height: 100) + .background(Color.surface) + .clipShape(Circle()) + + Spacer().frame(width: 32) + + // photo picker + Group { + Image(systemName: "photo.badge.arrow.down") + .foregroundColor(Color.onBackground) + Spacer().frame(width: 8) + + Text(L("upload_photo")) + .textStyle(TextStyle.h4) + } + .onTapGesture { + showImagePicker = true + } + } + VerticalSpace(height: 36) + + AppTextField( + value: $profileVM.fullName, + hint: L("full_name"), + label: L("full_name") + ) + VerticalSpace(height: 16) + + AppTextField( + value: $profileVM.email, + hint: L("email"), + label: L("email") + ) + VerticalSpace(height: 8) + + if let code = profileVM.countryCodeName { + Text(L("country")) + .font(.system(size: 13)) + .foregroundColor(Color.onBackground) + + UICountryPickerView( + code: code, + onCountryChanged: { code in + profileVM.setCountryCodeName(code) + } + ) + .frame(height: 56) + .overlay( + // Underline + Rectangle() + .frame(height: 1) + .foregroundColor(Color.onBackground) + .padding(.top, 50 / 2) + ) + + VerticalSpace(height: 32) + } + + PrimaryButton( + label: L("save"), + onClick: { + profileVM.save() + } + ) + } + .padding() + .sheet(isPresented: $showImagePicker) { + ImagePicker(sourceType: .photoLibrary, selectedImage: $profileVM.pfpToUpload) + } + } + .overlay( + Group { + if profileVM.shouldShowMessageOnPersonalDataScreen { + ToastView(message: profileVM.messageToShowOnPersonalDataScreen, isPresented: $profileVM.shouldShowMessageOnPersonalDataScreen) + .padding(.bottom) + } + }, + alignment: .bottom + ) + .background(Color.background) + } +} + diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/AllPicsScreen.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/AllPicsScreen.swift new file mode 100644 index 0000000000..397320a8ae --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/AllPicsScreen.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct AllPicsScreen: View { + let urls: [String] + + @Environment(\.presentationMode) var presentationMode + + let minWidth = UIScreen.main.bounds.width / 2 - 16 + let maxHeight = 150.0 + + var body: some View { + VStack(alignment: .leading) { + BackButtonWithText { + presentationMode.wrappedValue.dismiss() + } + ScrollView { + LazyVGrid( + columns: [ + GridItem(.flexible(minimum: minWidth, maximum: minWidth)), + GridItem(.flexible(minimum: minWidth, maximum: minWidth)) + ], + spacing: 16 + ) { + ForEach(urls, id: \.self) { url in + NavigationLink(destination: FullscreenImageViewer(selectedImageUrl: url, imageUrls: urls)) { + LoadImageView(url: url) + .frame(maxWidth: minWidth, maxHeight: maxHeight) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .scaledToFill() + } + } + } + } + } + .padding(.horizontal, 16) + .padding(.top, statusBarHeight()) + .padding(.bottom, 48) + .background(Color.background) + .ignoresSafeArea() + } +} + diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Components/PlaceTopBar.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Components/PlaceTopBar.swift new file mode 100644 index 0000000000..170509a884 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Components/PlaceTopBar.swift @@ -0,0 +1,101 @@ +import SwiftUI + +struct PlaceTopBar: View { + let title: String? + let picUrl: String? + let onBackClick: (() -> Void)? + let isFavorite: Bool + let onFavoriteChanged: (Bool) -> Void + let onMapClick: () -> Void + + private let height: CGFloat = 150 + private let padding: CGFloat = 16 + private let shape = RoundedCornerShape(corners: [.bottomLeft, .bottomRight], radius: 20) + + var body: some View { + ZStack { + // Load image + LoadImageView(url: picUrl) + .frame(height: height) + .clipShape(shape + ) + + // Black overlay with opacity + SwiftUI.Color.black.opacity(0.3) + .frame(height: height) + .clipShape(shape) + + // Top actions: Back, Favorite, Map + VStack { + HStack { + if let onBackClick = onBackClick { + PlaceTopBarAction( + iconName: "chevron.left", + onClick: onBackClick + ) + } + + Spacer() + + PlaceTopBarAction( + iconName: isFavorite ? "heart.fill" : "heart", + onClick: { onFavoriteChanged(!isFavorite) } + ) + + PlaceTopBarAction( + iconName: "map", + onClick: onMapClick + ) + } + .padding(.horizontal, padding) + .padding(.top, statusBarHeight()) + + VerticalSpace(height: 32) + + // Title + if let title = title { + Text(title) + .textStyle(TextStyle.h2) + .foregroundColor(.white) + .padding(.horizontal, padding) + .padding(.bottom, padding) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .frame(width: UIScreen.main.bounds.width, height: height) + } +} + +struct PlaceTopBarAction: View { + let iconName: String + let onClick: () -> Void + + var body: some View { + Button(action: onClick) { + Image(systemName: iconName) + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + .padding(8) + .background(SwiftUI.Color.white.opacity(0.2)) + .clipShape(Circle()) + .foregroundColor(.white) + } + } +} + +struct PlaceTopBar_Previews: PreviewProvider { + static var previews: some View { + PlaceTopBar( + title: "Place Title", + picUrl: "https://example.com/image.jpg", + onBackClick: { print("Back clicked") }, + isFavorite: true, + onFavoriteChanged: { isFavorite in print("Favorite changed: \(isFavorite)") }, + onMapClick: { print("Map clicked") } + ) + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Description/DescriptionScreen.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Description/DescriptionScreen.swift new file mode 100644 index 0000000000..e8ec1a089f --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Description/DescriptionScreen.swift @@ -0,0 +1,56 @@ +import SwiftUI + +struct DescriptionScreen: View { + var description: String? + var onCreateRoute: (() -> Void)? + + var body: some View { + ZStack { + // description + if let description = description { + ScrollView { + VStack(alignment: .leading) { + VerticalSpace(height: 16) + description.htmlToAttributedString() + .textStyle(.b1) + } + VerticalSpace(height: 100) // it's needed for visibility over the button below + } + } else { + EmptyUI() + } + + // create route button + if let onCreateRoute = onCreateRoute { + VStack() { + Spacer() + + PrimaryButton( + label: NSLocalizedString("show_route", comment: ""), + onClick: onCreateRoute + ) + .padding(.bottom, 32) + .frame(maxWidth: .infinity, alignment: .bottom) + }.frame(minHeight: 0, maxHeight: .infinity) + } + }.padding(.horizontal, 16) + } +} + +extension String { + func htmlToAttributedString() -> Text { + // Assuming you have a function to convert HTML to an attributed string + // Here's a basic version: + guard let data = self.data(using: .utf8) else { return Text(self) } + + if let attributedString = try? NSAttributedString( + data: data, + options: [.documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue], + documentAttributes: nil + ) { + return Text(attributedString.string) + } + + return Text(self) + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/FullscreenImageViewer.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/FullscreenImageViewer.swift new file mode 100644 index 0000000000..f008602ee2 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/FullscreenImageViewer.swift @@ -0,0 +1,140 @@ +import SwiftUI +import Combine + +struct FullscreenImageViewer: View { + let imageUrls: [String] + @State private var currentPage: Int + @State private var scale: CGFloat = 1.0 + @State private var lastScale: CGFloat = 1.0 + @State private var offset: CGSize = .zero + @State private var lastOffset: CGSize = .zero + @GestureState private var magnifyBy = 1.0 + + @Environment(\.presentationMode) var presentationMode + + init(selectedImageUrl: String, imageUrls: [String]) { + self.imageUrls = imageUrls + let initialIndex = imageUrls.firstIndex(of: selectedImageUrl) ?? 0 + _currentPage = State(initialValue: initialIndex) + } + + var body: some View { + GeometryReader { geometry in + ZStack { + // images + SwiftUI.TabView(selection: $currentPage) { + ForEach(imageUrls.indices, id: \.self) { index in + ZoomableImageView( + imageUrl: imageUrls[index], + scale: $scale, + offset: $offset + ) + .tag(index) + } + } + .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) + .gesture( + MagnificationGesture() + .updating($magnifyBy) { currentState, gestureState, _ in + gestureState = currentState + } + .onEnded { value in + scale = min(max(1, scale * value), 3) + } + ) + .simultaneousGesture( + DragGesture() + .onChanged { value in + if scale > 1 { + offset = CGSize( + width: lastOffset.width + value.translation.width, + height: lastOffset.height + value.translation.height + ) + } + } + .onEnded { _ in + if scale > 1 { + lastOffset = offset + } else { + offset = .zero + lastOffset = .zero + } + } + ) + + // back button + VStack { + HStack { + BackButtonWithText { + presentationMode.wrappedValue.dismiss() + }.padding(.leading, 16) + Spacer() + } + Spacer() + } + + //page indicator + VStack { + Spacer() + HStack(spacing: 8) { + ForEach(imageUrls.indices, id: \.self) { index in + Circle() + .fill(currentPage == index ? Color.primary : Color.primary.opacity(0.25)) + .frame(width: 8, height: 8) + } + } + } + .padding(.bottom, 16) + } + } + } +} + +struct ZoomableImageView: View { + let imageUrl: String + @Binding var scale: CGFloat + @Binding var offset: CGSize + @StateObject private var imageLoader = ImageLoader() + + var body: some View { + Group { + if let image = imageLoader.image { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .scaleEffect(scale) + .offset(offset) + } else if imageLoader.isLoading { + ProgressView() + } else { + Text(L("error")) + } + } + .onAppear { + imageLoader.load(fromURLString: imageUrl) + } + } +} + +class ImageLoader: ObservableObject { + @Published var image: UIImage? + @Published var isLoading = false + private var cancellable: AnyCancellable? + + func load(fromURLString urlString: String) { + guard let url = URL(string: urlString) else { return } + + cancellable?.cancel() + self.image = nil + self.isLoading = true + + cancellable = URLSession.shared.dataTaskPublisher(for: url) + .map { UIImage(data: $0.data) } + .replaceError(with: nil) + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.image = $0 + self?.isLoading = false + } + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Gallery/GalleryScreen.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Gallery/GalleryScreen.swift new file mode 100644 index 0000000000..6f56582144 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Gallery/GalleryScreen.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct GalleryScreen: View { + let urls: [String]? + + let secondRowHeight = 130.0 + let shape = RoundedRectangle(cornerRadius: 8) + @State var goToAllGalleryScreen = false + + var body: some View { + if let urls = urls, !urls.isEmpty { + VStack { + NavigationLink(destination: FullscreenImageViewer(selectedImageUrl: urls[0], imageUrls: urls)) { + LoadImageView(url: urls.first) + .frame(maxWidth: UIScreen.main.bounds.width - 32, minHeight: 200, maxHeight: 200) + .clipShape(shape) + } + + VerticalSpace(height: 16) + + GeometryReader { geometry in + let secondColumnWidth = geometry.size.width / 2 - 8 + + HStack(spacing: 16) { + if urls.count > 1 { + NavigationLink(destination: FullscreenImageViewer(selectedImageUrl: urls[1], imageUrls: urls)) { + LoadImageView(url: urls[1]) + .frame(width: secondColumnWidth, height: secondRowHeight) + .clipShape(shape) + } + if (urls.count == 2) { Spacer() } + + if urls.count > 2 { + NavigationLink(destination: AllPicsScreen(urls: urls)) { + ZStack { + LoadImageView(url: urls[2]) + + if urls.count > 3 { + SwiftUI.Color.black.opacity(0.5) + .frame(height: secondRowHeight) + .clipShape(shape) + + Text("+\(urls.count - 3)") + .font(.headline) + .foregroundColor(.white) + } + } + .frame(width: secondColumnWidth, height: secondRowHeight) + .clipShape(shape) + } + } + } + }.frame(width: geometry.size.width) + } + Spacer() + }.padding(.horizontal, 16) + } else { + EmptyUI() + } + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Gallery/PlaceTabsBar.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Gallery/PlaceTabsBar.swift new file mode 100644 index 0000000000..fd10083304 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Gallery/PlaceTabsBar.swift @@ -0,0 +1,42 @@ +import SwiftUI + +let tabBarShape = RoundedRectangle(cornerRadius: 50) + +struct PlaceTabsBar: View { + let tabTitles = [L("description_tourism"), L("gallery"), L("reviews")] + + @Binding var selectedTab: Int + + + var body: some View { + HStack { + ForEach(0.. Void + + var body: some View { + Button(action: action) { + Text(title) + .textStyle(TextStyle.b1) + .padding(.vertical, 4) + .padding(.horizontal, 6) + .background(isSelected ? Color.selected : SwiftUI.Color.clear) + .foregroundColor(isSelected ? Color.onSelected : Color.onSurface) + .clipShape(tabBarShape) + } + } + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/PlaceViewController.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/PlaceViewController.swift new file mode 100644 index 0000000000..c25128eab2 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/PlaceViewController.swift @@ -0,0 +1,132 @@ +import SwiftUI + +class PlaceViewController: UIViewController { + let placeId: Int64 + let onMapClick: () -> Void + let onCreateRoute: (PlaceLocation) -> Void + + init( + placeId: Int64, + onMapClick: @escaping () -> Void, + onCreateRoute: @escaping (PlaceLocation) -> Void + ) { + self.placeId = placeId + self.onMapClick = onMapClick + self.onCreateRoute = onCreateRoute + + super.init( + nibName: nil, + bundle: nil + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + override func viewDidLoad() { + super.viewDidLoad() + + let placesRepository = PlacesRepositoryImpl( + placesService: PlacesServiceImpl(), + placesPersistenceController: PlacesPersistenceController.shared, + reviewsPersistenceController: ReviewsPersistenceController.shared, + hashesPersistenceController: HashesPersistenceController.shared + ) + let placeVM = PlaceViewModel(placesRepository: placesRepository, id: self.placeId) + + integrateSwiftUIScreen(PlaceScreen( + placeVM: placeVM, + id: placeId, + showBottomBar: { + self.tabBarController?.tabBar.isHidden = false + }, + onMapClick: onMapClick, + onCreateRoute: onCreateRoute + )) + } +} + +struct PlaceScreen: View { + @ObservedObject var placeVM: PlaceViewModel + let reviewsVM: ReviewsViewModel + let id: Int64 + let showBottomBar: () -> Void + let onMapClick: () -> Void + let onCreateRoute: (PlaceLocation) -> Void + + @State private var selectedTab = 0 + + @Environment(\.presentationMode) var presentationMode: Binding + + init( + placeVM: PlaceViewModel, + id: Int64, + showBottomBar: @escaping () -> Void, + onMapClick: @escaping () -> Void, + onCreateRoute: @escaping (PlaceLocation) -> Void + ) { + self.placeVM = placeVM + self.id = id + self.showBottomBar = showBottomBar + self.onMapClick = onMapClick + self.onCreateRoute = onCreateRoute + + self.reviewsVM = ReviewsViewModel( + reviewsRepository: ReviewsRepositoryImpl( + reviewsPersistenceController: ReviewsPersistenceController.shared, + reviewsService: ReviewsServiceImpl(userPreferences: UserPreferences.shared) + ), + userPreferences: UserPreferences.shared, + id: id + ) + } + + var body: some View { + if let place = placeVM.place { + VStack { + PlaceTopBar( + title: place.name, + picUrl: place.cover, + onBackClick: { + showBottomBar() + presentationMode.wrappedValue.dismiss() + }, + isFavorite: place.isFavorite, + onFavoriteChanged: { isFavorite in + placeVM.toggleFavorite(for: place.id, isFavorite: isFavorite) + }, + onMapClick: onMapClick + ) + + VStack { + PlaceTabsBar(selectedTab: $selectedTab) + .padding() + + SwiftUI.TabView(selection: $selectedTab) { + DescriptionScreen( + description: place.description, + onCreateRoute: { + if let location = place.placeLocation { + onCreateRoute(location) + } + } + ) + .tag(0) + GalleryScreen(urls: place.pics) + .tag(1) + ReviewsScreen( + reviewsVM: reviewsVM, + placeId: place.id, + rating: place.rating + ) + .tag(2) + } + .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) + } + } + .edgesIgnoringSafeArea(.all) + } + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/PlaceViewModel.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/PlaceViewModel.swift new file mode 100644 index 0000000000..d80bbc0026 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/PlaceViewModel.swift @@ -0,0 +1,29 @@ +import Combine + +class PlaceViewModel : ObservableObject { + private var cancellables = Set() + + private let placesRepository: PlacesRepository + + @Published var place: PlaceFull? + + init(placesRepository: PlacesRepository, id: Int64) { + self.placesRepository = placesRepository + + observePlace(id: id) + } + + func observePlace(id: Int64) { + placesRepository.placeResource + .sink(receiveCompletion: { _ in }, receiveValue: { place in + self.place = place + }) + .store(in: &cancellables) + + placesRepository.observePlaceById(id) + } + + func toggleFavorite(for placeId: Int64, isFavorite: Bool) { + placesRepository.setFavorite(placeId: placeId, isFavorite: isFavorite) + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Reviews/AllReviewsScreen.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Reviews/AllReviewsScreen.swift new file mode 100644 index 0000000000..d5df437303 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Reviews/AllReviewsScreen.swift @@ -0,0 +1,28 @@ +import SwiftUI +import Combine + +struct AllReviewsScreen: View { + @ObservedObject var reviewsVM: ReviewsViewModel + @Environment(\.presentationMode) var presentationMode + + var body: some View { + VStack(alignment: .leading) { + BackButtonWithText { + presentationMode.wrappedValue.dismiss() + } + ScrollView { + LazyVStack(spacing: 16) { + ForEach(reviewsVM.reviews, id: \.self) { review in + ReviewView(review: review, onDeleteClick: nil) + } + } + } + } + .padding(.horizontal, 16) + .padding(.top, statusBarHeight()) + .padding(.bottom, 48) + .background(Color.background) + .ignoresSafeArea() + } +} + diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Reviews/Components/PostReviewView.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Reviews/Components/PostReviewView.swift new file mode 100644 index 0000000000..5aa374e296 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Reviews/Components/PostReviewView.swift @@ -0,0 +1,149 @@ +import SwiftUI +import Combine +import PhotosUI + +struct PostReviewView: View { + @ObservedObject var postReviewVM: PostReviewViewModel + let placeId: Int64 + let onPostReviewSuccess: () -> Void + + @State private var showImagePicker = false + + @State private var message = "" + @State private var showMessage = false + + private func showMessage(_ message: String) { + self.message = message + self.showMessage = true + } + + var body: some View { + ScrollView { + VStack { + Spacer().frame(height: 32) + + Text(L("review_title")) + .font(.title) + Spacer().frame(height: 32) + + VStack(alignment: .center) { + Text(L("tap_to_rate")) + .font(.body) + Spacer().frame(height: 4) + RatingBarView(rating: $postReviewVM.rating, size: 25) + } + Spacer().frame(height: 16) + + MultilineTextField(L("text"), text: $postReviewVM.comment, minHeight: 80) + Spacer().frame(height: 16) + + // Display the selected images + FlowStack(data: postReviewVM.images, spacing: 16, alignment: .center) { file in + ImagePreviewView(image: file) { + postReviewVM.removeFile(file) + } + } + Spacer().frame(height: 32) + + if(postReviewVM.images.count < 10) { + VStack(alignment: .leading) { + PrimaryButton( + label: L("upload_photo"), + onClick: { + showImagePicker = true + } + ) + Spacer().frame(height: 4) + Text(L("images_number_warning")) + .textStyle(TextStyle.b1) + .foregroundColor(Color.hint) + Spacer().frame(height: 16) + } + } + + PrimaryButton( + label: L("send"), + onClick: { + postReviewVM.postReview(placeId: placeId) + }, + isLoading: postReviewVM.isPosting + ) + + Spacer().frame(height: 64) + } + .padding(.horizontal, 16) + .onReceive(postReviewVM.uiEvents) { event in + switch event { + case .closeReviewBottomSheet: + onPostReviewSuccess() + case .showToast(let message): + showMessage(message) + } + } + .overlay( + Group { + if showMessage { + ToastView(message: message, isPresented: $showMessage) + .padding(.bottom) + } + }, + alignment: .bottom + ) + .sheet(isPresented: $showImagePicker) { + MultiImagePicker(selectedImages: $postReviewVM.images) + } + } + } +} + +struct ImagePreviewView: View { + let image: UIImage + let onDelete: () -> Void + + var body: some View { + ZStack(alignment: .topTrailing) { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: 100, height: 100) + .cornerRadius(12) + Button(action: onDelete) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + } + .offset(x: 10, y: -10) + } + } +} + + +struct MultilineTextField: View { + @Binding var text: String + let placeholder: String + let minHeight: CGFloat + + init(_ placeholder: String, text: Binding, minHeight: CGFloat = 100) { + self._text = text + self.placeholder = placeholder + self.minHeight = minHeight + } + + var body: some View { + ZStack(alignment: .topLeading) { + TextEditor(text: $text) + .frame(minHeight: minHeight) + .padding(4) + + if text.isEmpty { + Text(placeholder) + .foregroundColor(SwiftUI.Color(.placeholderText)) + .padding(.horizontal, 8) + .padding(.vertical, 12) + } + } + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(SwiftUI.Color.gray.opacity(0.2), lineWidth: 1) + ) + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Reviews/Components/ReviewView.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Reviews/Components/ReviewView.swift new file mode 100644 index 0000000000..f63efa6cfe --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Reviews/Components/ReviewView.swift @@ -0,0 +1,159 @@ +import SwiftUI + +struct ReviewView: View { + let review: Review + let onDeleteClick: (() -> Void)? + + @State private var expandedComment = false + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Divider() + + HStack { + if let user = review.user { + UserView(user: user) + Spacer() + } + if review.deletionPlanned { + Text(L("deletionPlanned")) + .textStyle(TextStyle.b2) + .foregroundColor(Color.onBackground) + } else if let date = review.date { + Text(date) + .textStyle(TextStyle.b2) + .foregroundColor(Color.onBackground) + } + } + + ReadOnlyRatingBarView(rating: Double(review.rating), size: 24) + + if !review.picsUrls.isEmpty { + HStack(spacing: 8) { + ForEach(Array(review.picsUrls.prefix(3).enumerated()), id: \.offset) { index, url in + if index == 2 && review.picsUrls.count > 3 { + NavigationLink(destination: AllPicsScreen(urls: review.picsUrls)) { + ShowMoreView(url: url, remaining: review.picsUrls.count - 3) + } + } else { + NavigationLink(destination: FullscreenImageViewer(selectedImageUrl: url, imageUrls: review.picsUrls)) { + ReviewPicView(url: url) + } + } + } + } + } + + if let comment = review.comment, !comment.isEmpty { + CommentView(comment: comment, expanded: $expandedComment) + } + + if let onDeleteClick = onDeleteClick { + Button(action: onDeleteClick) { + Text(L("delete_review")) + .foregroundColor(Color.heartRed) + } + } + } + } +} + +struct UserView: View { + let user: User + + var body: some View { + HStack { + if let pfpUrl = user.pfpUrl { + LoadImageView(url: pfpUrl) + .frame(width: 66, height: 66) + .background(Color.surface) + .clipShape(Circle()) + } + HStack() { + Text(user.name) + .textStyle(TextStyle.h4) + .foregroundColor(Color.onBackground) + UICountryFlagView(code: user.countryCodeName) + .scaledToFit() + .frame(height: 30) + } + Spacer() + } + } +} + +struct ReadOnlyRatingBarView: View { + let rating: Double + let size: CGFloat + + var body: some View { + HStack(spacing: 4) { + ForEach(0..<5) { index in + Image(systemName: index < Int(rating) ? "star.fill" : "star") + .resizable() + .frame(width: size, height: size) + .foregroundColor(Color.starYellow) + } + } + } +} + +struct CommentView: View { + let comment: String + @Binding var expanded: Bool + + var body: some View { + VStack(alignment: .leading) { + Text(comment) + .textStyle(TextStyle.b1) + .lineLimit(expanded ? nil : 2) + .onTapGesture { + expanded.toggle() + } + + VerticalSpace(height: 16) + + if !expanded { + Button(L("more")) { expanded.toggle() } + .foregroundColor(Color.primary) + } else { + Button(L("less")) { expanded.toggle() } + .foregroundColor(Color.primary) + } + } + .padding() + .background(Color.surface) + .cornerRadius(10) + } +} + +let reviewPicWidth = 73.0 +let reviewPicHeight = 65.0 + +struct ReviewPicView: View { + let url: String + + var body: some View { + LoadImageView(url: url) + .frame(width: reviewPicWidth, height: reviewPicHeight) + .background(Color.surface) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} + +struct ShowMoreView: View { + let url: String + let remaining: Int + + var body: some View { + ZStack { + ReviewPicView(url: url) + SwiftUI.Color.black.opacity(0.5) + Text("+\(remaining)") + .textStyle(TextStyle.h3) + .foregroundColor(SwiftUI.Color.white) + } + .frame(width: reviewPicWidth, height: reviewPicHeight) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Reviews/PostReviewViewModel.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Reviews/PostReviewViewModel.swift new file mode 100644 index 0000000000..9795b21888 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Reviews/PostReviewViewModel.swift @@ -0,0 +1,64 @@ +import Foundation +import SwiftUI +import Combine + +class PostReviewViewModel: ObservableObject { + private var cancellables = Set() + + private let reviewsRepository: ReviewsRepository + + @Published var rating: Double = 5 + @Published var comment: String = "" + @Published var images: [UIImage] = [] + @Published var isPosting: Bool = false + + @Published var retrievedImages: [UIImage] = [] + + let uiEvents = PassthroughSubject() + + init(reviewsRepository: ReviewsRepository) { + self.reviewsRepository = reviewsRepository + } + + func setRating(_ value: Double) { + rating = value + } + + func removeFile(_ file: UIImage) { + images.removeAll { $0 == file } + } + + func postReview(placeId: Int64) { + isPosting = true + + let urls = saveMultipleImages(images, placeId: placeId) + print(urls) + let review = ReviewToPost( + placeId: placeId, + comment: comment, + rating: Int(rating), + images: urls + ) + + reviewsRepository.postReview(review: review) + .receive(on: DispatchQueue.main) + .sink { completion in + self.isPosting = false + switch completion { + case .finished: + self.uiEvents.send(.showToast(message: "Review Posted")) + self.uiEvents.send(.closeReviewBottomSheet) + case .failure(let error): + self.uiEvents.send(.showToast(message: error.errorDescription)) + } + } receiveValue: { response in + print(response) + } + .store(in: &cancellables) + } +} + +enum UiEvent { + case closeReviewBottomSheet + case showToast(message: String) +} diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Reviews/ReviewsScreen.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Reviews/ReviewsScreen.swift new file mode 100644 index 0000000000..423f984aab --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Reviews/ReviewsScreen.swift @@ -0,0 +1,88 @@ +import SwiftUI + +struct ReviewsScreen: View { + @ObservedObject var reviewsVM: ReviewsViewModel + + let placeId: Int64 + let rating: Double? + + init( + reviewsVM: ReviewsViewModel, + placeId: Int64, + rating: Double? + ) { + self.reviewsVM = reviewsVM + self.placeId = placeId + self.rating = rating + } + + @State private var showReviewSheet = false + + var body: some View { + ScrollView { + VStack { + // overall rating + HStack(alignment: .center) { + Image(systemName: "star.fill") + .resizable() + .frame(width: 30, height: 30) + .foregroundColor(Color.starYellow) + + if let rating = rating { + Text("\(String(format: "%.1f", rating) )/5") + .font(.system(size: 30)) + } + + Spacer() + + Button(L("compose_review")) { + showReviewSheet = true + } + .foregroundColor(Color.primary) + } + VerticalSpace(height: 16) + + HStack { + Spacer() + + NavigationLink(destination: AllReviewsScreen(reviewsVM: reviewsVM)) { + Text(L("see_all_reviews")) + .foregroundColor(Color.primary) + } + } + + // user review + if let userReview = reviewsVM.userReview, !reviewsVM.isThereReviewPlannedToPublish { + ReviewView( + review: userReview, + onDeleteClick: { + reviewsVM.deleteReview() + } + ) + } + // most recent recent review + if let mostRecentReview = reviewsVM.latestReview { + ReviewView( + review: mostRecentReview, + onDeleteClick: nil + ) + } + } + } + .padding(.horizontal, 16) + .sheet(isPresented: $showReviewSheet) { + PostReviewView( + postReviewVM: PostReviewViewModel( + reviewsRepository: ReviewsRepositoryImpl( + reviewsPersistenceController: ReviewsPersistenceController.shared, + reviewsService: ReviewsServiceImpl(userPreferences: UserPreferences.shared) + ) + ), + placeId: placeId, + onPostReviewSuccess: { + showReviewSheet = false + } + ) + } + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Reviews/ReviewsViewModel.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Reviews/ReviewsViewModel.swift new file mode 100644 index 0000000000..d81547accb --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/PlaceDetails/Reviews/ReviewsViewModel.swift @@ -0,0 +1,91 @@ +import Combine + +class ReviewsViewModel: ObservableObject { + private var cancellables = Set() + + private let reviewsRepository: ReviewsRepository + private let userPreferences: UserPreferences + + private let placeId: Int64 + + @Published var messageToShowOnReviewsScreen = "" + @Published var shouldShowMessageOnReviewsScreen = false + + @Published var reviews: [Review] = [] + @Published var userReview: Review? = nil + @Published var latestReview: Review? = nil + @Published var isThereReviewPlannedToPublish = false + + init(reviewsRepository: ReviewsRepository, userPreferences: UserPreferences, id: Int64) { + self.reviewsRepository = reviewsRepository + self.userPreferences = userPreferences + self.placeId = id + + observeReviews() + observeIfThereIsReviewPlannedToPost() + } + + func observeReviews() { + reviewsRepository.observeReviewsForPlace(id: placeId) + + reviewsRepository.reviewsResource.sink { _ in } receiveValue: { reviews in + self.reviews = reviews + + self.getUserReview() + self.getLatestReview() + } + .store(in: &cancellables) + } + + private func getUserReview() { + let userId = userPreferences.getUserId() + let first = reviews.filter { + if let user = $0.user { + return String(user.id) == userId + } else { + return false + } + }.first + + if let userReview = first { + self.userReview = userReview + } + } + + private func getLatestReview() { + if let latest = reviews.first { + self.latestReview = latest + } + } + + private func observeIfThereIsReviewPlannedToPost() { + reviewsRepository.checkIfThereIsReviewPlannedToPublish(for: placeId) + + reviewsRepository.isThereReviewPlannedToPublishResource.sink { _ in } receiveValue: { isThere in + self.isThereReviewPlannedToPublish = isThere + } + .store(in: &cancellables) + } + + func deleteReview() { + if let id = userReview?.id { + reviewsRepository.deleteReview(id: id) + .sink(receiveCompletion: { completion in + switch completion { + case .finished: + self.userReview = nil + case .failure(let error): + self.showMessageOnReviewsScreen(error.localizedDescription) + } + }, receiveValue: { response in + self.showMessageOnReviewsScreen(response.message) + }) + .store(in: &cancellables) + } + } + + func showMessageOnReviewsScreen(_ message: String) { + messageToShowOnReviewsScreen = message + shouldShowMessageOnReviewsScreen = true + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/ProfileViewController.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/ProfileViewController.swift new file mode 100644 index 0000000000..243fb4f53b --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/ProfileViewController.swift @@ -0,0 +1,276 @@ +import UIKit +import SwiftUI + +class ProfileViewController: UIViewController { + private var profileVM: ProfileViewModel + + init(profileVM: ProfileViewModel) { + self.profileVM = profileVM + super.init( + nibName: nil, + bundle: nil + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + integrateSwiftUIScreen( + ProfileScreen( + profileVM: profileVM, + onPersonalDataClick: { + let destinationVC = PersonalDataViewController(profileVM: self.profileVM) + self.navigationController?.pushViewController(destinationVC, animated: true) + } + ) + ) + } +} + +struct ProfileScreen: View { + @ObservedObject var profileVM: ProfileViewModel + let onPersonalDataClick: () -> Void + @ObservedObject var themeVM: ThemeViewModel = ThemeViewModel( + profileRepository: ProfileRepositoryImpl( + profileService: ProfileServiceImpl(userPreferences: UserPreferences.shared), + personalDataPersistenceController: PersonalDataPersistenceController.shared + ), + userPreferences: UserPreferences.shared + ) + @State private var isSheetOpen = false + @State private var signOutLoading = false + + func onLanguageClick () { + navigateToLanguageSettings() + profileVM.setLanguageOnSystemLocaleChange() + } + + var body: some View { + ScrollView { + VStack (alignment: .leading) { + AppTopBar(title: L("tourism_profile")) + VerticalSpace(height: 16) + + if let personalData = profileVM.personalData { + ProfileBar(personalData: personalData) + } + VerticalSpace(height: 32) + + VStack(spacing: 20) { + if let currencyRates = profileVM.currencyRates { + CurrencyRatesView(currencyRates: currencyRates) + } + + GenericProfileItem( + label: L("personal_data"), + icon: "person.circle", + onClick: { + onPersonalDataClick() + } + ) + + GenericProfileItem( + label: L("language"), + icon: "globe", + onClick: { + onLanguageClick() + } + ) + + ThemeSwitch(themeViewModel: themeVM) + + GenericProfileItem( + label: L("sign_out"), + icon: "rectangle.portrait.and.arrow.right", + isLoading: signOutLoading, + onClick: { + isSheetOpen = true + } + ) + } + } + .padding(16) + } + .overlay( + Group { + if profileVM.shouldShowMessageOnProfileScreen { + ToastView(message: profileVM.messageToShowOnProfileScreen, isPresented: $profileVM.shouldShowMessageOnProfileScreen) + .padding(.bottom) + } + }, + alignment: .bottom + ) + .sheet(isPresented: $isSheetOpen) { + SignOutWarning( + onSignOutClick: { + isSheetOpen = false + signOutLoading = true + profileVM.signOut() + }, + onCancelClick: { + isSheetOpen = false + } + ) + } + } +} + +struct ProfileBar: View { + var personalData: PersonalData + + var body: some View { + HStack(alignment: .center) { + LoadImageView(url: personalData.pfpUrl) + .frame(width: 100, height: 100) + .background(Color.surface) + .clipShape(Circle()) + + HorizontalSpace(width: 16) + + VStack(alignment: .leading) { + Text(personalData.fullName) + .textStyle(TextStyle.h2) + + UICountryAsLabelView(code: personalData.country) + .frame(height: 30) + } + } + } +} + + +struct CurrencyRatesView: View { + var currencyRates: CurrencyRates + + var body: some View { + HStack(spacing: 16) { + CurrencyRatesItem(countryCode: "US", flagEmoji: "🇺🇸", value: String(format: "%.2f", currencyRates.usd)) + CurrencyRatesItem(countryCode: "EU", flagEmoji: "🇪🇺", value: String(format: "%.2f", currencyRates.eur)) + CurrencyRatesItem(countryCode: "RU", flagEmoji: "🇷🇺", value: String(format: "%.2f", currencyRates.rub)) + } + .frame(maxWidth: .infinity, maxHeight: profileItemHeight) + .padding(16) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Color.border, lineWidth: 2) + ) + .cornerRadius(20) + } +} + +struct CurrencyRatesItem: View { + var countryCode: String + var flagEmoji: String + var value: String + + var body: some View { + HStack { + Text(flagEmoji) + .font(.system(size: 33)) + + Text(value) + } + } +} + +struct GenericProfileItem: View { + var label: String + var icon: String + var isLoading: Bool = false + var onClick: () -> Void + + var body: some View { + HStack { + Text(label) + .textStyle(TextStyle.b1) + + Spacer() + + if isLoading { + ProgressView() + } else { + Image(systemName: icon) + .foregroundColor(Color.border) + } + } + .contentShape(Rectangle()) + .onTapGesture { + onClick() + } + .frame(height: profileItemHeight) + .padding() + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Color.border, lineWidth: 2) + ) + .cornerRadius(20) + } +} + +struct ThemeSwitch: View { + @Environment(\.colorScheme) var colorScheme + @ObservedObject var themeViewModel: ThemeViewModel + + var body: some View { + HStack { + Text(L("Dark Theme")) + .textStyle(TextStyle.b1) + + Spacer() + + Toggle(isOn: Binding( + get: { + colorScheme == .dark + }, + set: { isDark in + let themeCode = isDark ? "dark" : "light" + themeViewModel.setTheme(themeCode: themeCode) + changeTheme(themeCode: themeCode) + themeViewModel.updateThemeOnServer(themeCode: themeCode) + } + )) { + Text("") + } + .labelsHidden() + .frame(height: 10) + } + .frame(height: profileItemHeight) + .padding() + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Color.border, lineWidth: 2) + ) + .cornerRadius(20) + } +} + +struct SignOutWarning: View { + var onSignOutClick: () -> Void + var onCancelClick: () -> Void + + var body: some View { + VStack(spacing: 16) { + Text("Are you sure you want to sign out?") + .font(.headline) + + HStack { + SecondaryButton( + label: L("cancel"), + onClick: onCancelClick + ) + + PrimaryButton( + label: L("sign_out"), + onClick: onSignOutClick + ) + } + } + .padding() + } +} + +let profileItemHeight: CGFloat = 25 diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/ProfileViewModel.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/ProfileViewModel.swift new file mode 100644 index 0000000000..4e75cd71b2 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Profile/ProfileViewModel.swift @@ -0,0 +1,171 @@ +import Foundation +import Combine +import SwiftUI + +class ProfileViewModel: ObservableObject { + private let currencyRepository: CurrencyRepository + private let profileRepository: ProfileRepository + private let authRepository: AuthRepository + private let userPreferences: UserPreferences + var onSignOutCompleted: (() -> Void)? = nil + + @Published var messageToShowOnProfileScreen = "" + @Published var shouldShowMessageOnProfileScreen = false + + @Published var messageToShowOnPersonalDataScreen = "" + @Published var shouldShowMessageOnPersonalDataScreen = false + + @Published var pfpFromRemote: URL? = nil + @Published var pfpToUpload = UIImage() + @Published var isImagePickerUsed: Bool = false + + @Published var fullName: String = "" + var currentEmail: String = "" // it changes only when confirmed by server + @Published var email: String = "" + @Published var countryCodeName: String? = nil + @Published var personalData: PersonalData? = nil + @Published var currencyRates: CurrencyRates? = nil + + private var cancellables = Set() + + init( + currencyRepository: CurrencyRepository, + profileRepository: ProfileRepository, + authRepository: AuthRepository, + userPreferences: UserPreferences + ) { + self.currencyRepository = currencyRepository + self.profileRepository = profileRepository + self.authRepository = authRepository + self.userPreferences = userPreferences + // Automatically fetch data when initialized + getPersonalData() + getCurrency() + $pfpToUpload.sink { image in + if image != self.pfpToUpload { + self.isImagePickerUsed = true + } + }.store(in: &cancellables) + } + + // MARK: - Methods + + func setPfp(_ pfp: URL) { + self.pfpFromRemote = pfp + } + + func setFullName(_ value: String) { + self.fullName = value + } + + func setEmail(_ value: String) { + self.email = value + } + + func setCountryCodeName(_ value: String?) { + self.countryCodeName = value + } + + func getPersonalData() { + profileRepository.personalDataPassThroughSubject + .sink { completion in + if case let .failure(error) = completion { + if(error == ResourceError.unauthed) { + self.onSignOutCompleted?() + } + self.showMessageOnProfileScreen(error.errorDescription) + } + } receiveValue: { resource in + self.personalData = resource + if let pfpUrl = resource.pfpUrl { + self.pfpFromRemote = URL(string: pfpUrl) + } + self.fullName = resource.fullName + self.currentEmail = resource.email + self.email = resource.email + self.countryCodeName = resource.country + } + .store(in: &cancellables) + + profileRepository.getPersonalData() + } + + func save() { + if(!fullName.isEmpty && ((countryCodeName?.isEmpty) != nil) && !email.isEmpty) { + profileRepository.updateProfile( + fullName: fullName, + country: countryCodeName!, + email: email, + pfpUrl: pfpToUpload + ) + .sink { completion in + if case let .failure(error) = completion { + self.showMessageOnPersonalDataScreen(error.errorDescription) + } + } receiveValue: { resource in + self.updatePersonalDataInMemory(personalData: resource) + self.showMessageOnPersonalDataScreen(L("saved")) + } + .store(in: &cancellables) + } else { + self.showMessageOnPersonalDataScreen(L("please_fill_all_fields")) + } + } + + private func updatePersonalDataInMemory(personalData: PersonalData) { + if let pfpUrl = personalData.pfpUrl { + self.pfpFromRemote = URL(string: pfpUrl) + } + self.fullName = personalData.fullName + self.email = personalData.email + self.countryCodeName = personalData.country + } + + func getCurrency() { + currencyRepository.currencyPassThroughSubject + .sink { completion in + if case let .failure(error) = completion { + self.showMessageOnProfileScreen(error.errorDescription) + } + } receiveValue: { resource in + self.currencyRates = resource + } + .store(in: &cancellables) + + currencyRepository.getCurrency() + } + + // TODO: this doesn't work, try to find some other solutions + // I tried to update language remotely after user set the new language + func setLanguageOnSystemLocaleChange() { + NotificationCenter.default.addObserver(self, selector: #selector(localeChanged), name: NSLocale.currentLocaleDidChangeNotification, object: nil) + } + + @objc func localeChanged() { + profileRepository.updateLanguage(code: NSLocale.current.identifier) + } + + func signOut() { + authRepository.signOut() + .sink { completion in + if case let .failure(error) = completion { + self.showMessageOnProfileScreen(error.errorDescription) + } + } receiveValue: { response in + self.userPreferences.setToken(value: nil) + self.showMessageOnProfileScreen(response.message) + self.onSignOutCompleted?() + } + .store(in: &cancellables) + } + + func showMessageOnPersonalDataScreen(_ message: String) { + messageToShowOnPersonalDataScreen = message + shouldShowMessageOnPersonalDataScreen = true + } + + func showMessageOnProfileScreen(_ message: String) { + messageToShowOnProfileScreen = message + shouldShowMessageOnProfileScreen = true + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Search/SearchViewController.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Search/SearchViewController.swift new file mode 100644 index 0000000000..bf2e6f1530 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Search/SearchViewController.swift @@ -0,0 +1,91 @@ +import SwiftUI + +class SearchViewController: UIViewController { + private var searchVM: SearchViewModel + private var goToMap: () -> Void + private let onCreateRoute: (PlaceLocation) -> Void + + init( + searchVM: SearchViewModel, + goToMap: @escaping () -> Void, + onCreateRoute: @escaping (PlaceLocation) -> Void + ) { + self.searchVM = searchVM + self.goToMap = goToMap + self.onCreateRoute = onCreateRoute + + + super.init( + nibName: nil, + bundle: nil + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + integrateSwiftUIScreen(SearchScreen( + searchVM: searchVM, + goToPlaceScreen: { id in + self.goToPlaceScreen( + id: id, + onMapClick: self.goToMap, + onCreateRoute: self.onCreateRoute + ) + } + )) + } +} + +struct SearchScreen: View { + @ObservedObject var searchVM: SearchViewModel + @Environment(\.presentationMode) var presentationMode: Binding + var goToPlaceScreen: (Int64) -> Void + + var body: some View { + ScrollView { + VStack(alignment: .leading) { + VerticalSpace(height: 16) + VStack { + AppTopBar( + title: L("search"), + onBackClick: { + presentationMode.wrappedValue.dismiss() + } + ) + + AppSearchBar( + query: $searchVM.query, + onClearClicked: { + searchVM.clearQuery() + } + ) + } + .padding(16) + + VStack(spacing: 20) { + + LazyVStack(spacing: 16) { + ForEach(searchVM.places) { place in + PlacesItem( + place: place, + onPlaceClick: { place in + goToPlaceScreen(place.id) + }, + onFavoriteChanged: { isFavorite in + searchVM.toggleFavorite(for: place.id, isFavorite: isFavorite) + } + ) + } + } + .padding(.horizontal, 16) + } + VerticalSpace(height: 32) + } + } + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/Screens/Search/SearchViewModel.swift b/iphone/Maps/Tourism/Presentation/Home/Screens/Search/SearchViewModel.swift new file mode 100644 index 0000000000..b574d9de9c --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/Screens/Search/SearchViewModel.swift @@ -0,0 +1,39 @@ +import Combine + +class SearchViewModel: ObservableObject { + private var cancellables = Set() + + private let placesRepository: PlacesRepository + + @Published var query = "" + + func clearQuery() { query = "" } + + @Published var places: [PlaceShort] = [] + + init(placesRepository: PlacesRepository) { + self.placesRepository = placesRepository + + observeSearch() + } + + func observeSearch() { + placesRepository.searchResource.sink { completion in + if case .failure(_) = completion { + // nothing + } + } receiveValue: { places in + self.places = places + } + .store(in: &cancellables) + + $query.sink { q in + self.placesRepository.observeSearch(query: q) + } + .store(in: &cancellables) + } + + func toggleFavorite(for placeId: Int64, isFavorite: Bool) { + placesRepository.setFavorite(placeId: placeId, isFavorite: isFavorite) + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/TabBarController.swift b/iphone/Maps/Tourism/Presentation/Home/TabBarController.swift new file mode 100644 index 0000000000..4f46657743 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/TabBarController.swift @@ -0,0 +1,121 @@ +import UIKit +import SwiftUI +import Combine + +class TabBarController: UITabBarController { + + override func viewDidAppear(_ animated: Bool) { + if let theme = UserPreferences.shared.getTheme() { + changeTheme(themeCode: theme.code) + } + } + + override func viewDidLoad() { + super.viewDidLoad() + + hidesBottomBarWhenPushed = true + + // navigation functions + let goToCategoriesTab = { self.selectedIndex = 1 } + + // we use dismiss, because we present screens modally + let goToMap = { + self.dismiss(animated: false) + } + let goToAuth = { + UserPreferences.shared.setShouldGoToAuth(value: true) + self.dismiss(animated: true) + } + let goToMapAndCreateRoute: (PlaceLocation) -> Void = { location in + UserPreferences.shared.setLocation(value: location) + self.dismiss(animated: true) + } + + // creating tabs + let homeTab = UITabBarItem(title: L("home"), image: UIImage(systemName: "house"), selectedImage: UIImage(systemName: "house.fill")) + let categoriesTab = UITabBarItem(title: L("categories"), image: UIImage(systemName: "list.bullet.rectangle"), selectedImage: UIImage(systemName: "list.bullet.rectangle.fill")) + let favoritesTab = UITabBarItem(title: L("favorites"), image: UIImage(systemName: "heart"), selectedImage: UIImage(systemName: "heart.fill")) + let profileTab = UITabBarItem(title: L("tourism_profile"), image: UIImage(systemName: "person"), selectedImage: UIImage(systemName: "person.fill")) + + // creating navs + let homeNav = UINavigationController() + let categoriesNav = UINavigationController() + let favoritesNav = UINavigationController() + let profileNav = UINavigationController() + + // creating repositories and shared ViewModels + let placesRepository = PlacesRepositoryImpl( + placesService: PlacesServiceImpl(), + placesPersistenceController: PlacesPersistenceController.shared, + reviewsPersistenceController: ReviewsPersistenceController.shared, + hashesPersistenceController: HashesPersistenceController.shared + ) + let currencyRepository = CurrencyRepositoryImpl( + currencyService: CurrencyServiceImpl(), + currencyPersistenceController: CurrencyPersistenceController.shared + ) + let profileRepository = ProfileRepositoryImpl ( + profileService: ProfileServiceImpl(userPreferences: UserPreferences.shared), + personalDataPersistenceController: PersonalDataPersistenceController.shared + ) + let authRepository = AuthRepositoryImpl(authService: AuthServiceImpl()) + + // monitoring network for sync + let dataSyncer = DataSyncer( + reviewsRepository: ReviewsRepositoryImpl( + reviewsPersistenceController: ReviewsPersistenceController.shared, + reviewsService: ReviewsServiceImpl(userPreferences: UserPreferences.shared) + ), + placesRepository: placesRepository + ) + dataSyncer.startMonitoring() + + // creating shared viewModels() + let homeVM = HomeViewModel(placesRepository: placesRepository) + let categoriesVM = CategoriesViewModel(placesRepository: placesRepository) + let favoritesVM = FavoritesViewModel(placesRepository: placesRepository) + let searchVM = SearchViewModel(placesRepository: placesRepository) + let profileVM = ProfileViewModel( + currencyRepository: currencyRepository, + profileRepository: profileRepository, + authRepository: authRepository, + userPreferences: UserPreferences.shared + ) + profileVM.onSignOutCompleted = goToAuth + + // creating ViewControllers + let homeVC = HomeViewController( + homeVM: homeVM, + categoriesVM: categoriesVM, + searchVM: searchVM, + goToCategoriesTab: goToCategoriesTab, + goToMap: goToMap, + onCreateRoute: goToMapAndCreateRoute + ) + let categoriesVC = CategoriesViewController( + categoriesVM: categoriesVM, + searchVM: searchVM, + goToMap: goToMap, + onCreateRoute: goToMapAndCreateRoute + ) + let favoritesVC = FavoritesViewController( + favoritesVM: favoritesVM, + goToMap: goToMap, + onCreateRoute: goToMapAndCreateRoute + ) + let profileVC = ProfileViewController(profileVM: profileVM) + + // setting up navigation + homeNav.viewControllers = [homeVC] + categoriesNav.viewControllers = [categoriesVC] + favoritesNav.viewControllers = [favoritesVC] + profileNav.viewControllers = [profileVC] + + homeNav.tabBarItem = homeTab + categoriesNav.tabBarItem = categoriesTab + favoritesNav.tabBarItem = favoritesTab + profileNav.tabBarItem = profileTab + + viewControllers = [homeNav, categoriesNav, favoritesNav, profileNav] + } +} diff --git a/iphone/Maps/Tourism/Presentation/Home/ThemeViewModel.swift b/iphone/Maps/Tourism/Presentation/Home/ThemeViewModel.swift new file mode 100644 index 0000000000..ceffbbb0ee --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/ThemeViewModel.swift @@ -0,0 +1,27 @@ +import Foundation +import Combine + +class ThemeViewModel: ObservableObject { + private let profileRepository: ProfileRepository + private let userPreferences: UserPreferences + + @Published var theme: UserPreferences.Theme? + + init( + profileRepository: ProfileRepository, + userPreferences: UserPreferences) { + self.profileRepository = profileRepository + self.userPreferences = userPreferences + + self.theme = userPreferences.getTheme() + } + + func setTheme(themeCode: String) { + profileRepository.updateTheme(code: themeCode) + } + + func updateThemeOnServer(themeCode: String) { + profileRepository.updateTheme(code: themeCode) + } +} + diff --git a/iphone/Maps/Tourism/Presentation/Home/TourismMain.storyboard b/iphone/Maps/Tourism/Presentation/Home/TourismMain.storyboard new file mode 100644 index 0000000000..e25e615b25 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/TourismMain.storyboard @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iphone/Maps/Tourism/Presentation/Home/TourismMainNavigationControllerViewController.swift b/iphone/Maps/Tourism/Presentation/Home/TourismMainNavigationControllerViewController.swift new file mode 100644 index 0000000000..16def19e6f --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Home/TourismMainNavigationControllerViewController.swift @@ -0,0 +1,9 @@ +import UIKit + +class TourismMainNavigationController: UINavigationController { + + override func viewDidLoad() { + super.viewDidLoad() + } + +} diff --git a/iphone/Maps/Tourism/Presentation/Theme/Color.swift b/iphone/Maps/Tourism/Presentation/Theme/Color.swift new file mode 100644 index 0000000000..0ad515faf3 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Theme/Color.swift @@ -0,0 +1,118 @@ +import SwiftUI + +class Color { + class var background: SwiftUI.Color { + return SwiftUI.Color("Background") + } + + class var border: SwiftUI.Color { + return SwiftUI.Color("Border") + } + + class var error: SwiftUI.Color { + return SwiftUI.Color("Error") + } + + class var heartRed: SwiftUI.Color { + return SwiftUI.Color("HeartRed") + } + + class var hint: SwiftUI.Color { + return SwiftUI.Color("Hint") + } + + class var onBackground: SwiftUI.Color { + return SwiftUI.Color("OnBackground") + } + + class var onError: SwiftUI.Color { + return SwiftUI.Color("OnError") + } + + class var onPrimary: SwiftUI.Color { + return SwiftUI.Color("OnPrimary") + } + + class var onSelected: SwiftUI.Color { + return SwiftUI.Color("OnSelected") + } + + class var onSurface: SwiftUI.Color { + return SwiftUI.Color("OnSurface") + } + + class var primary: SwiftUI.Color { + return SwiftUI.Color("Primary") + } + + class var selected: SwiftUI.Color { + return SwiftUI.Color("Selected") + } + + class var starYellow: SwiftUI.Color { + return SwiftUI.Color("StarYellow") + } + + class var surface: SwiftUI.Color { + return SwiftUI.Color("Surface") + } +} + + +class UIKitColor { + class var background: UIColor { + return UIColor(named: "Background")! + } + + class var border: UIColor { + return UIColor(named: "Border")! + } + + class var error: UIColor { + return UIColor(named: "Error")! + } + + class var heartRed: UIColor { + return UIColor(named: "HeartRed")! + } + + class var hint: UIColor { + return UIColor(named: "Hint")! + } + + class var onBackground: UIColor { + return UIColor(named: "OnBackground")! + } + + class var onError: UIColor { + return UIColor(named: "OnError")! + } + + class var onPrimary: UIColor { + return UIColor(named: "OnPrimary")! + } + + class var onSelected: UIColor { + return UIColor(named: "OnSelected")! + } + + class var onSurface: UIColor { + return UIColor(named: "OnSurface")! + } + + class var primary: UIColor { + return UIColor(named: "Primary")! + } + + class var selected: UIColor { + return UIColor(named: "Selected")! + } + + class var starYellow: UIColor { + return UIColor(named: "StarYellow")! + } + + class var surface: UIColor { + return UIColor(named: "Surface")! + } +} diff --git a/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Background.colorset/Contents.json b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Background.colorset/Contents.json new file mode 100644 index 0000000000..e1a3d76428 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x32", + "green" : "0x18", + "red" : "0x10" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Border.colorset/Contents.json b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Border.colorset/Contents.json new file mode 100644 index 0000000000..5f666415ae --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Border.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE7", + "green" : "0xD4", + "red" : "0xC9" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Contents.json b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Error.colorset/Contents.json b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Error.colorset/Contents.json new file mode 100644 index 0000000000..a7a7183ef1 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Error.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/HeartRed.colorset/Contents.json b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/HeartRed.colorset/Contents.json new file mode 100644 index 0000000000..8d84e42759 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/HeartRed.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x61", + "green" : "0x6C", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x61", + "green" : "0x6C", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Hint.colorset/Contents.json b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Hint.colorset/Contents.json new file mode 100644 index 0000000000..d212d58ebd --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Hint.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xAD", + "green" : "0xAB", + "red" : "0xAA" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xAD", + "green" : "0xAB", + "red" : "0xAA" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/OnBackground.colorset/Contents.json b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/OnBackground.colorset/Contents.json new file mode 100644 index 0000000000..e7e005ef5c --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/OnBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x33", + "green" : "0x2D", + "red" : "0x2B" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/OnError.colorset/Contents.json b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/OnError.colorset/Contents.json new file mode 100644 index 0000000000..1421f313e0 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/OnError.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/OnPrimary.colorset/Contents.json b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/OnPrimary.colorset/Contents.json new file mode 100644 index 0000000000..2536dc2d13 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/OnPrimary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/OnSelected.colorset/Contents.json b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/OnSelected.colorset/Contents.json new file mode 100644 index 0000000000..be9d677bbd --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/OnSelected.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/OnSurface.colorset/Contents.json b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/OnSurface.colorset/Contents.json new file mode 100644 index 0000000000..e7e005ef5c --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/OnSurface.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x33", + "green" : "0x2D", + "red" : "0x2B" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Primary.colorset/Contents.json b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Primary.colorset/Contents.json new file mode 100644 index 0000000000..08dbc8d11b --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Primary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE7", + "green" : "0x88", + "red" : "0x06" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE7", + "green" : "0x88", + "red" : "0x06" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Selected.colorset/Contents.json b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Selected.colorset/Contents.json new file mode 100644 index 0000000000..6446f4a847 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Selected.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.100", + "blue" : "0xE7", + "green" : "0x88", + "red" : "0x06" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/StarYellow.colorset/Contents.json b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/StarYellow.colorset/Contents.json new file mode 100644 index 0000000000..6589ae8539 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/StarYellow.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x49", + "green" : "0xD7", + "red" : "0xF8" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x49", + "green" : "0xD7", + "red" : "0xF8" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Surface.colorset/Contents.json b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Surface.colorset/Contents.json new file mode 100644 index 0000000000..a5bb0dcf63 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Theme/Colors.xcassets/Surface.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xF4", + "red" : "0xEE" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x46", + "green" : "0x2F", + "red" : "0x27" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iphone/Maps/Tourism/Presentation/Theme/Font.swift b/iphone/Maps/Tourism/Presentation/Theme/Font.swift new file mode 100644 index 0000000000..66dc77bdcd --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Theme/Font.swift @@ -0,0 +1,222 @@ +import SwiftUI +// MARK: - used in SwiftUI +extension Font { + static func black(size: CGFloat) -> Font { + return Font.custom("Gilroy-Black", size: size) + } + + static func bold(size: CGFloat) -> Font { + return Font.custom("Gilroy-Bold", size: size) + } + + static func extraBold(size: CGFloat) -> Font { + return Font.custom("Gilroy-ExtraBold", size: size) + } + + static func heavy(size: CGFloat) -> Font { + return Font.custom("Gilroy-Heavy", size: size) + } + + static func light(size: CGFloat) -> Font { + return Font.custom("Gilroy-Light", size: size) + } + + static func medium(size: CGFloat) -> Font { + return Font.custom("Gilroy-Medium", size: size) + } + + static func regular(size: CGFloat) -> Font { + return Font.custom("Gilroy-Regular", size: size) + } + + static func semiBold(size: CGFloat) -> Font { + return Font.custom("Gilroy-SemiBold", size: size) + } + + static func thin(size: CGFloat) -> Font { + return Font.custom("Gilroy-Thin", size: size) + } + + static func ultraLight(size: CGFloat) -> Font { + return Font.custom("Gilroy-UltraLight", size: size) + } +} + +extension TextStyle { + static let genericStyle = TextStyle( + font: .regular(size: 16.0), + lineHeight: 18 + ) + + static let humongous = TextStyle( + font: .extraBold(size: 36.0), + lineHeight: 40 + ) + + static let h1 = TextStyle( + font: .semiBold(size: 32.0), + lineHeight: 36 + ) + + static let h2 = TextStyle( + font: .semiBold(size: 24.0), + lineHeight: 36 + ) + + static let h3 = TextStyle( + font: .semiBold(size: 20.0), + lineHeight: 22 + ) + + static let h4 = TextStyle( + font: .medium(size: 16.0), + lineHeight: 18 + ) + + static let b1 = TextStyle( + font: .regular(size: 14.0), + lineHeight: 16 + ) + + static let b2 = TextStyle( + font: .regular(size: 12.0), + lineHeight: 14 + ) + + static let b3 = TextStyle( + font: .regular(size: 10.0), + lineHeight: 12 + ) +} + + +struct TextStyle { + let font: Font + let lineHeight: CGFloat +} + +extension Text { + func textStyle(_ style: TextStyle) -> some View { + let calibrationFactor: CGFloat = 0.2 + return self + .font(style.font) + .lineSpacing(style.lineHeight * calibrationFactor) + } +} + + +// MARK: - used in UIKit + +class UIKitFont { + // MARK: - Font by Weights + class func black(size: CGFloat) -> UIFont { + return getCustomFont(withName: "Gilroy-Black", size: size) + } + + class func bold(size: CGFloat) -> UIFont { + return getCustomFont(withName: "Gilroy-Bold", size: size) + } + + class func extraBold(size: CGFloat) -> UIFont { + return getCustomFont(withName: "Gilroy-ExtraBold", size: size) + } + + class func heavy(size: CGFloat) -> UIFont { + return getCustomFont(withName: "Gilroy-Heavy", size: size) + } + + class func light(size: CGFloat) -> UIFont { + return getCustomFont(withName: "Gilroy-Light", size: size) + } + + class func medium(size: CGFloat) -> UIFont { + return getCustomFont(withName: "Gilroy-Medium", size: size) + } + + class func regular(size: CGFloat) -> UIFont { + return getCustomFont(withName: "Gilroy-Regular", size: size) + } + + class func semiBold(size: CGFloat) -> UIFont { + return getCustomFont(withName: "Gilroy-SemiBold", size: size) + } + + class func thin(size: CGFloat) -> UIFont { + return getCustomFont(withName: "Gilroy-Thin", size: size) + } + + class func ultraLight(size: CGFloat) -> UIFont { + return getCustomFont(withName: "Gilroy-UltraLight", size: size) + } + + // MARK: - Font by Styles + static let genericStyle = UIKitTextStyle( + font: regular(size: 16.0), + lineHeight: 18 + ) + + static let humongous = UIKitTextStyle( + font: extraBold(size: 36.0), + lineHeight: 40 + ) + + static let h1 = UIKitTextStyle( + font: semiBold(size: 32.0), + lineHeight: 36 + ) + + static let h2 = UIKitTextStyle( + font: semiBold(size: 24.0), + lineHeight: 36 + ) + + static let h3 = UIKitTextStyle( + font: semiBold(size: 20.0), + lineHeight: 22 + ) + + static let h4 = UIKitTextStyle( + font: medium(size: 16.0), + lineHeight: 18 + ) + + static let b1 = UIKitTextStyle( + font: regular(size: 14.0), + lineHeight: 16 + ) + + static let b2 = UIKitTextStyle( + font: regular(size: 12.0), + lineHeight: 14 + ) + + static let b3 = UIKitTextStyle( + font: regular(size: 10.0), + lineHeight: 12 + ) + + // MARK: - funcs + private class func getCustomFont(withName name: String, size: CGFloat) -> UIFont { + if let font = UIFont(name: name, size: size) { + return font + } + return UIFont.systemFont(ofSize: size) + } + + static func applyStyle(to label: UILabel, style: UIKitTextStyle) { + label.font = style.font + label.adjustsFontForContentSizeCategory = true + let lineHeight = style.lineHeight + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = lineHeight - label.font.lineHeight + + label.adjustsFontForContentSizeCategory = true + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: style.font) + label.attributedText = NSAttributedString(string: label.text ?? "", attributes: [.paragraphStyle: paragraphStyle]) + } +} + +struct UIKitTextStyle { + let font: UIFont + let lineHeight: CGFloat +} diff --git a/iphone/Maps/Tourism/Presentation/Utils/Alert.swift b/iphone/Maps/Tourism/Presentation/Utils/Alert.swift new file mode 100644 index 0000000000..6bfb935cd2 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Utils/Alert.swift @@ -0,0 +1,3 @@ +import UIKit + + diff --git a/iphone/Maps/Tourism/Presentation/Utils/Layouting.swift b/iphone/Maps/Tourism/Presentation/Utils/Layouting.swift new file mode 100644 index 0000000000..e3f451af88 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Utils/Layouting.swift @@ -0,0 +1,6 @@ +func applyWrapContent(label: UILabel) { + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + label.setContentCompressionResistancePriority(.required, for: .vertical) + label.setContentHuggingPriority(.defaultLow, for: .vertical) +} diff --git a/iphone/Maps/Tourism/Presentation/Utils/NavigationUtils.swift b/iphone/Maps/Tourism/Presentation/Utils/NavigationUtils.swift new file mode 100644 index 0000000000..01db71d268 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Utils/NavigationUtils.swift @@ -0,0 +1,15 @@ +extension UIViewController { + func goToPlaceScreen( + id: Int64, + onMapClick: @escaping () -> Void, + onCreateRoute: @escaping (PlaceLocation) -> Void + ) { + let destinationVC = PlaceViewController( + placeId: id, + onMapClick: onMapClick, + onCreateRoute: onCreateRoute + ) + self.navigationController?.pushViewController(destinationVC, animated: false) + self.tabBarController?.tabBar.isHidden = true + } +} diff --git a/iphone/Maps/Tourism/Presentation/Utils/RoundedCornerShape.swift b/iphone/Maps/Tourism/Presentation/Utils/RoundedCornerShape.swift new file mode 100644 index 0000000000..1f3b280a6f --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Utils/RoundedCornerShape.swift @@ -0,0 +1,15 @@ +import SwiftUI + +struct RoundedCornerShape: Shape { + var corners: UIRectCorner + var radius: CGFloat + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath( + roundedRect: rect, + byRoundingCorners: corners, + cornerRadii: CGSize(width: radius, height: radius) + ) + return Path(path.cgPath) + } +} diff --git a/iphone/Maps/Tourism/Presentation/Utils/changeTheme.swift b/iphone/Maps/Tourism/Presentation/Utils/changeTheme.swift new file mode 100644 index 0000000000..7d021c03b6 --- /dev/null +++ b/iphone/Maps/Tourism/Presentation/Utils/changeTheme.swift @@ -0,0 +1,4 @@ +func changeTheme(themeCode: String) { + let style: UIUserInterfaceStyle = (themeCode == "light") ? .light : (themeCode == "dark") ? .dark : .unspecified + UIApplication.shared.keyWindow?.overrideUserInterfaceStyle = style +} diff --git a/iphone/Maps/Tourism/Resources/Fonts/Gilroy-Black.ttf b/iphone/Maps/Tourism/Resources/Fonts/Gilroy-Black.ttf new file mode 100644 index 0000000000..3e1a57e152 Binary files /dev/null and b/iphone/Maps/Tourism/Resources/Fonts/Gilroy-Black.ttf differ diff --git a/iphone/Maps/Tourism/Resources/Fonts/Gilroy-Bold.ttf b/iphone/Maps/Tourism/Resources/Fonts/Gilroy-Bold.ttf new file mode 100644 index 0000000000..1aea716b2e Binary files /dev/null and b/iphone/Maps/Tourism/Resources/Fonts/Gilroy-Bold.ttf differ diff --git a/iphone/Maps/Tourism/Resources/Fonts/Gilroy-ExtraBold.ttf b/iphone/Maps/Tourism/Resources/Fonts/Gilroy-ExtraBold.ttf new file mode 100644 index 0000000000..01eb343222 Binary files /dev/null and b/iphone/Maps/Tourism/Resources/Fonts/Gilroy-ExtraBold.ttf differ diff --git a/iphone/Maps/Tourism/Resources/Fonts/Gilroy-Heavy.ttf b/iphone/Maps/Tourism/Resources/Fonts/Gilroy-Heavy.ttf new file mode 100644 index 0000000000..726e3718b5 Binary files /dev/null and b/iphone/Maps/Tourism/Resources/Fonts/Gilroy-Heavy.ttf differ diff --git a/iphone/Maps/Tourism/Resources/Fonts/Gilroy-Light.ttf b/iphone/Maps/Tourism/Resources/Fonts/Gilroy-Light.ttf new file mode 100644 index 0000000000..b08db4e85e Binary files /dev/null and b/iphone/Maps/Tourism/Resources/Fonts/Gilroy-Light.ttf differ diff --git a/iphone/Maps/Tourism/Resources/Fonts/Gilroy-Medium.ttf b/iphone/Maps/Tourism/Resources/Fonts/Gilroy-Medium.ttf new file mode 100644 index 0000000000..06d6a9431c Binary files /dev/null and b/iphone/Maps/Tourism/Resources/Fonts/Gilroy-Medium.ttf differ diff --git a/iphone/Maps/Tourism/Resources/Fonts/Gilroy-Regular.ttf b/iphone/Maps/Tourism/Resources/Fonts/Gilroy-Regular.ttf new file mode 100644 index 0000000000..ad17f71cbe Binary files /dev/null and b/iphone/Maps/Tourism/Resources/Fonts/Gilroy-Regular.ttf differ diff --git a/iphone/Maps/Tourism/Resources/Fonts/Gilroy-SemiBold.ttf b/iphone/Maps/Tourism/Resources/Fonts/Gilroy-SemiBold.ttf new file mode 100644 index 0000000000..cb3cbb610e Binary files /dev/null and b/iphone/Maps/Tourism/Resources/Fonts/Gilroy-SemiBold.ttf differ diff --git a/iphone/Maps/Tourism/Resources/Fonts/Gilroy-Thin.ttf b/iphone/Maps/Tourism/Resources/Fonts/Gilroy-Thin.ttf new file mode 100644 index 0000000000..c6daeb7439 Binary files /dev/null and b/iphone/Maps/Tourism/Resources/Fonts/Gilroy-Thin.ttf differ diff --git a/iphone/Maps/Tourism/Resources/Fonts/Gilroy-UltraLight.ttf b/iphone/Maps/Tourism/Resources/Fonts/Gilroy-UltraLight.ttf new file mode 100644 index 0000000000..adc3e33e32 Binary files /dev/null and b/iphone/Maps/Tourism/Resources/Fonts/Gilroy-UltraLight.ttf differ diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/Image.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/Image.imageset/Contents.json new file mode 100644 index 0000000000..c55afb67e3 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/Image.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/add.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/add.imageset/Contents.json new file mode 100644 index 0000000000..9f185bd9db --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/add.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "add.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/add.imageset/add.svg b/iphone/Maps/Tourism/Resources/Images.xcassets/add.imageset/add.svg new file mode 100644 index 0000000000..f568b59ea4 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/add.imageset/add.svg @@ -0,0 +1,4 @@ + + + + diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/back.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/back.imageset/Contents.json new file mode 100644 index 0000000000..391020dcb8 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/back.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "back.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/back.imageset/back.svg b/iphone/Maps/Tourism/Resources/Images.xcassets/back.imageset/back.svg new file mode 100644 index 0000000000..6585371749 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/back.imageset/back.svg @@ -0,0 +1,3 @@ + + + diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/categories.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/categories.imageset/Contents.json new file mode 100644 index 0000000000..3774f0e708 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/categories.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "categories.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/categories.imageset/categories.svg b/iphone/Maps/Tourism/Resources/Images.xcassets/categories.imageset/categories.svg new file mode 100644 index 0000000000..83e333ba0f --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/categories.imageset/categories.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/categories_selected.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/categories_selected.imageset/Contents.json new file mode 100644 index 0000000000..36cf033e79 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/categories_selected.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "categories_selected.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/categories_selected.imageset/categories_selected.svg b/iphone/Maps/Tourism/Resources/Images.xcassets/categories_selected.imageset/categories_selected.svg new file mode 100644 index 0000000000..47eae3b77e --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/categories_selected.imageset/categories_selected.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/check_circle_fill.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/check_circle_fill.imageset/Contents.json new file mode 100644 index 0000000000..3b74c5e49f --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/check_circle_fill.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "check_circle_fill.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/check_circle_fill.imageset/check_circle_fill.svg b/iphone/Maps/Tourism/Resources/Images.xcassets/check_circle_fill.imageset/check_circle_fill.svg new file mode 100644 index 0000000000..f9a168753c --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/check_circle_fill.imageset/check_circle_fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/chevron_down.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/chevron_down.imageset/Contents.json new file mode 100644 index 0000000000..8f4c57ff85 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/chevron_down.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "chevron_down.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/chevron_down.imageset/chevron_down.svg b/iphone/Maps/Tourism/Resources/Images.xcassets/chevron_down.imageset/chevron_down.svg new file mode 100644 index 0000000000..f87d1768a3 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/chevron_down.imageset/chevron_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/close.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/close.imageset/Contents.json new file mode 100644 index 0000000000..ce4276fe0a --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/close.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "close.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/close.imageset/close.svg b/iphone/Maps/Tourism/Resources/Images.xcassets/close.imageset/close.svg new file mode 100644 index 0000000000..0b8c2442d7 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/close.imageset/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/eye.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/eye.imageset/Contents.json new file mode 100644 index 0000000000..8965be5fcd --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/eye.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "eye.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/eye.imageset/eye.svg b/iphone/Maps/Tourism/Resources/Images.xcassets/eye.imageset/eye.svg new file mode 100644 index 0000000000..36ed4da108 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/eye.imageset/eye.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/eye_slash.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/eye_slash.imageset/Contents.json new file mode 100644 index 0000000000..0dd4eb3aea --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/eye_slash.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "eye-slash.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/eye_slash.imageset/eye-slash.svg b/iphone/Maps/Tourism/Resources/Images.xcassets/eye_slash.imageset/eye-slash.svg new file mode 100644 index 0000000000..6dc0e47a41 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/eye_slash.imageset/eye-slash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/globe.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/globe.imageset/Contents.json new file mode 100644 index 0000000000..82c869261f --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/globe.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "globe.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/globe.imageset/globe.svg b/iphone/Maps/Tourism/Resources/Images.xcassets/globe.imageset/globe.svg new file mode 100644 index 0000000000..74de75fdff --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/globe.imageset/globe.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/heart.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/heart.imageset/Contents.json new file mode 100644 index 0000000000..bea90a2c9f --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/heart.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "heart.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/heart.imageset/heart.svg b/iphone/Maps/Tourism/Resources/Images.xcassets/heart.imageset/heart.svg new file mode 100644 index 0000000000..56cbad653c --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/heart.imageset/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/heart_selected.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/heart_selected.imageset/Contents.json new file mode 100644 index 0000000000..dee9c6a1f5 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/heart_selected.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "heart_selected.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/heart_selected.imageset/heart_selected.svg b/iphone/Maps/Tourism/Resources/Images.xcassets/heart_selected.imageset/heart_selected.svg new file mode 100644 index 0000000000..1fa489f053 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/heart_selected.imageset/heart_selected.svg @@ -0,0 +1,3 @@ + + + diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/home.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/home.imageset/Contents.json new file mode 100644 index 0000000000..365f6c410f --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/home.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "home.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/home.imageset/home.svg b/iphone/Maps/Tourism/Resources/Images.xcassets/home.imageset/home.svg new file mode 100644 index 0000000000..94fe5e9229 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/home.imageset/home.svg @@ -0,0 +1,4 @@ + + + + diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/home_selected.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/home_selected.imageset/Contents.json new file mode 100644 index 0000000000..1030913ac2 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/home_selected.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "home_selected.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/home_selected.imageset/home_selected.svg b/iphone/Maps/Tourism/Resources/Images.xcassets/home_selected.imageset/home_selected.svg new file mode 100644 index 0000000000..6e9b515745 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/home_selected.imageset/home_selected.svg @@ -0,0 +1,3 @@ + + + diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/image_down.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/image_down.imageset/Contents.json new file mode 100644 index 0000000000..ee448c5037 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/image_down.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "image_down.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/image_down.imageset/image_down.svg b/iphone/Maps/Tourism/Resources/Images.xcassets/image_down.imageset/image_down.svg new file mode 100644 index 0000000000..e29e16a84b --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/image_down.imageset/image_down.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/map.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/map.imageset/Contents.json new file mode 100644 index 0000000000..e051c50407 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/map.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "map.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/map.imageset/map.svg b/iphone/Maps/Tourism/Resources/Images.xcassets/map.imageset/map.svg new file mode 100644 index 0000000000..8414c1f56c --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/map.imageset/map.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/profile.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/profile.imageset/Contents.json new file mode 100644 index 0000000000..305cb38066 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/profile.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "profile.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/profile.imageset/profile.svg b/iphone/Maps/Tourism/Resources/Images.xcassets/profile.imageset/profile.svg new file mode 100644 index 0000000000..900a02e77a --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/profile.imageset/profile.svg @@ -0,0 +1,4 @@ + + + + diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/profile_selected.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/profile_selected.imageset/Contents.json new file mode 100644 index 0000000000..8d5fecd6eb --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/profile_selected.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "profile_selected.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/profile_selected.imageset/profile_selected.svg b/iphone/Maps/Tourism/Resources/Images.xcassets/profile_selected.imageset/profile_selected.svg new file mode 100644 index 0000000000..11ae4f707d --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/profile_selected.imageset/profile_selected.svg @@ -0,0 +1,4 @@ + + + + diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/search.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/search.imageset/Contents.json new file mode 100644 index 0000000000..c258d38ab3 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/search.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "search.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/search.imageset/search.svg b/iphone/Maps/Tourism/Resources/Images.xcassets/search.imageset/search.svg new file mode 100644 index 0000000000..87a7e4afc3 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/search.imageset/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/sign_out.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/sign_out.imageset/Contents.json new file mode 100644 index 0000000000..2e7664fc46 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/sign_out.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "sign_out.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/sign_out.imageset/sign_out.svg b/iphone/Maps/Tourism/Resources/Images.xcassets/sign_out.imageset/sign_out.svg new file mode 100644 index 0000000000..329e4ab86d --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/sign_out.imageset/sign_out.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/splash_background.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/splash_background.imageset/Contents.json new file mode 100644 index 0000000000..2ce999338e --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/splash_background.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "splash_background.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/splash_background.imageset/splash_background.png b/iphone/Maps/Tourism/Resources/Images.xcassets/splash_background.imageset/splash_background.png new file mode 100644 index 0000000000..bab37574db Binary files /dev/null and b/iphone/Maps/Tourism/Resources/Images.xcassets/splash_background.imageset/splash_background.png differ diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/star.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/star.imageset/Contents.json new file mode 100644 index 0000000000..3119f94e3f --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/star.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "star.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/star.imageset/star.svg b/iphone/Maps/Tourism/Resources/Images.xcassets/star.imageset/star.svg new file mode 100644 index 0000000000..1cabdae9b1 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/star.imageset/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/star_border.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/star_border.imageset/Contents.json new file mode 100644 index 0000000000..ae1555db5f --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/star_border.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "star_border.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/star_border.imageset/star_border.svg b/iphone/Maps/Tourism/Resources/Images.xcassets/star_border.imageset/star_border.svg new file mode 100644 index 0000000000..95c5eac6c3 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/star_border.imageset/star_border.svg @@ -0,0 +1,3 @@ + + + diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/unchecked.imageset/Contents.json b/iphone/Maps/Tourism/Resources/Images.xcassets/unchecked.imageset/Contents.json new file mode 100644 index 0000000000..d033f0e167 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/unchecked.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "unchecked.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Tourism/Resources/Images.xcassets/unchecked.imageset/unchecked.svg b/iphone/Maps/Tourism/Resources/Images.xcassets/unchecked.imageset/unchecked.svg new file mode 100644 index 0000000000..1cc94529b4 --- /dev/null +++ b/iphone/Maps/Tourism/Resources/Images.xcassets/unchecked.imageset/unchecked.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/iphone/Maps/Tourism/Resources/Tajikistan.mwm b/iphone/Maps/Tourism/Resources/Tajikistan.mwm new file mode 100644 index 0000000000..c8be6efb1a Binary files /dev/null and b/iphone/Maps/Tourism/Resources/Tajikistan.mwm differ diff --git a/iphone/Maps/Tourism/Utils/FileManagerBridge.h b/iphone/Maps/Tourism/Utils/FileManagerBridge.h new file mode 100644 index 0000000000..49b19d9d6d --- /dev/null +++ b/iphone/Maps/Tourism/Utils/FileManagerBridge.h @@ -0,0 +1,14 @@ +#ifndef FileManagerBridge_h +#define FileManagerBridge_h + +#ifdef __cplusplus +extern "C" { +#endif + +void copyFileToDocuments(const char* fileName, const char* fileExtension, const char* subdirectory); + +#ifdef __cplusplus +} +#endif + +#endif /* FileManagerBridge_h */ diff --git a/iphone/Maps/Tourism/Utils/FileManagerBridge.mm b/iphone/Maps/Tourism/Utils/FileManagerBridge.mm new file mode 100644 index 0000000000..2250ccc327 --- /dev/null +++ b/iphone/Maps/Tourism/Utils/FileManagerBridge.mm @@ -0,0 +1,13 @@ +#import "FileManagerBridge.h" +#import "SwiftBridge.h" + +void copyFileToDocuments(const char* fileName, const char* fileExtension, const char* subdirectory) { + NSString *nsFileName = [NSString stringWithUTF8String:fileName]; + NSString *nsFileExtension = [NSString stringWithUTF8String:fileExtension]; + NSString *nsSubdirectory = [NSString stringWithUTF8String:subdirectory]; + + [FileManagerHelper copyProjectFileToDocumentsWithFileName:nsFileName + fileExtension:nsFileExtension + toSubdirectory:nsSubdirectory]; +} + diff --git a/iphone/Maps/Tourism/Utils/FileManagerUtils.swift b/iphone/Maps/Tourism/Utils/FileManagerUtils.swift new file mode 100644 index 0000000000..b08e43de66 --- /dev/null +++ b/iphone/Maps/Tourism/Utils/FileManagerUtils.swift @@ -0,0 +1,57 @@ +import Foundation + +@objc class FileManagerHelper: NSObject { + + + @objc static func copyProjectFileToDocuments(fileName: String, fileExtension: String, toSubdirectory subdirectory: String) { + let fileManager = FileManager.default + + guard let bundleURL = Bundle.main.url(forResource: fileName, withExtension: fileExtension) else { + print("File not found in bundle.") + return + } + + if let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { + let subdirectoryURL = documentsDirectory.appendingPathComponent(subdirectory) + + if !fileManager.fileExists(atPath: subdirectoryURL.path) { + do { + try fileManager.createDirectory(at: subdirectoryURL, withIntermediateDirectories: true, attributes: nil) + print("Created directory: \(subdirectoryURL.path)") + } catch { + print("Error creating directory: \(error.localizedDescription)") + return + } + } + + let destinationURL = subdirectoryURL.appendingPathComponent("\(fileName).\(fileExtension)") + + do { + if fileManager.fileExists(atPath: destinationURL.path) { + print("File already exists at: \(destinationURL.path)") + } else { + try fileManager.copyItem(at: bundleURL, to: destinationURL) + print("File copied successfully to: \(destinationURL.path)") + } + } catch { + print("Error copying file: \(error.localizedDescription)") + } + } + } + + @objc static func fileExistsInDocuments(subdirectory: String, fileName: String, fileExtension: String) -> Bool { + let fileManager = FileManager.default + + guard let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { + print("Could not access Documents directory.") + return false + } + + let subdirectoryURL = documentsDirectory.appendingPathComponent(subdirectory) + let fileURL = subdirectoryURL.appendingPathComponent("\(fileName).\(fileExtension)") + + return fileManager.fileExists(atPath: fileURL.path) + } + +} + diff --git a/iphone/Maps/Tourism/Utils/ImageStoreUtils.swift b/iphone/Maps/Tourism/Utils/ImageStoreUtils.swift new file mode 100644 index 0000000000..5fdc369ede --- /dev/null +++ b/iphone/Maps/Tourism/Utils/ImageStoreUtils.swift @@ -0,0 +1,33 @@ +import UIKit + +func saveMultipleImages(_ images: [UIImage], placeId: Int64) -> [URL] { + let fileManager = FileManager.default + let documentsDirectory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0] + + return images.enumerated().compactMap { (index, image) in + let fileName = "image_\(index)_placeId\(placeId).jpg" + let fileURL = documentsDirectory.appendingPathComponent(fileName) + + guard let data = image.jpegData(compressionQuality: 0.01) else { return nil } + + do { + try data.write(to: fileURL) + return fileURL + } catch { + print("Error saving image \(fileName): \(error)") + return nil + } + } +} + +func retrieveMultipleImages(urls: [URL]) -> [UIImage] { + return urls.compactMap { url in + do { + let imageData = try Data(contentsOf: url) + return UIImage(data: imageData) + } catch { + print("Error retrieving image at \(url): \(error)") + return nil + } + } +} diff --git a/iphone/Maps/Tourism/Utils/LanguageUtils.swift b/iphone/Maps/Tourism/Utils/LanguageUtils.swift new file mode 100644 index 0000000000..aa2d0e6480 --- /dev/null +++ b/iphone/Maps/Tourism/Utils/LanguageUtils.swift @@ -0,0 +1,4 @@ +func navigateToLanguageSettings() { + guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else {return} + UIApplication.shared.open(settingsURL, options: [:], completionHandler: nil) +} diff --git a/iphone/Maps/UI/BottomMenu/TabBar/BottomTabBarViewController.swift b/iphone/Maps/UI/BottomMenu/TabBar/BottomTabBarViewController.swift index af3da08ffa..18230870fb 100644 --- a/iphone/Maps/UI/BottomMenu/TabBar/BottomTabBarViewController.swift +++ b/iphone/Maps/UI/BottomMenu/TabBar/BottomTabBarViewController.swift @@ -12,7 +12,9 @@ class BottomTabBarViewController: UIViewController { @IBOutlet var helpBadge: UIView! private var avaliableArea = CGRect.zero - @objc var isHidden: Bool = false { + + // we don't need BottomTabBarViewController, so we hide it + @objc var isHidden: Bool = true { didSet { updateFrame(animated: true) } diff --git a/iphone/Maps/UI/PlacePage/Components/ActionBarViewController.swift b/iphone/Maps/UI/PlacePage/Components/ActionBarViewController.swift index 35c8843e3f..3008c85643 100644 --- a/iphone/Maps/UI/PlacePage/Components/ActionBarViewController.swift +++ b/iphone/Maps/UI/PlacePage/Components/ActionBarViewController.swift @@ -89,17 +89,6 @@ class ActionBarViewController: UIViewController { } private func configButton1() { - if let mapNodeAttributes = placePageData.mapNodeAttributes { - switch mapNodeAttributes.nodeStatus { - case .onDiskOutOfDate, .onDisk, .undefined: - break - case .downloading, .applying, .inQueue, .error, .notDownloaded, .partly: - visibleButtons.append(.download) - return - @unknown default: - fatalError() - } - } var buttons: [ActionBarButtonType] = [] if isRoutePlanning { buttons.append(.routeFrom) @@ -123,13 +112,13 @@ class ActionBarViewController: UIViewController { if canAddStop { buttons.append(.routeAddStop) } - buttons.append(.bookmark) +// buttons.append(.bookmark) - assert(buttons.count > 0) - visibleButtons.append(buttons[0]) - if buttons.count > 1 { - additionalButtons.append(contentsOf: buttons.suffix(from: 1)) - } +// assert(buttons.count > 0) +// visibleButtons.append(buttons[0]) +// if buttons.count > 1 { +// additionalButtons.append(contentsOf: buttons.suffix(from: 1)) +// } } private func configButton3() { @@ -137,12 +126,12 @@ class ActionBarViewController: UIViewController { } private func configButton4() { - if additionalButtons.isEmpty { - visibleButtons.append(.share) - } else { - additionalButtons.append(.share) - visibleButtons.append(.more) - } +// if additionalButtons.isEmpty { +// visibleButtons.append(.share) +// } else { +// additionalButtons.append(.share) +// visibleButtons.append(.more) +// } } private func showMore() { diff --git a/iphone/Maps/UI/Storyboard/LaunchScreen.storyboard b/iphone/Maps/UI/Storyboard/LaunchScreen.storyboard index fa3015c8e4..e90755f18b 100644 --- a/iphone/Maps/UI/Storyboard/LaunchScreen.storyboard +++ b/iphone/Maps/UI/Storyboard/LaunchScreen.storyboard @@ -1,11 +1,10 @@ - - - - + + - + + @@ -17,8 +16,14 @@ - + + + + + + + @@ -26,4 +31,10 @@ + + + + + + diff --git a/iphone/Maps/UI/Storyboard/Main.storyboard b/iphone/Maps/UI/Storyboard/Main.storyboard index b625fc2aad..dffc69cb09 100644 --- a/iphone/Maps/UI/Storyboard/Main.storyboard +++ b/iphone/Maps/UI/Storyboard/Main.storyboard @@ -1,14 +1,24 @@ - + - + + + + + + + + + + + @@ -17,7 +27,7 @@ - + @@ -28,7 +38,7 @@ - - - + @@ -1061,7 +1073,7 @@ - + @@ -1113,7 +1125,9 @@ - + + + @@ -1135,6 +1149,16 @@ + + + + + + + + + + diff --git a/routing/routing_integration_tests/jni/Application.mk b/routing/routing_integration_tests/jni/Application.mk deleted file mode 120000 index 43a1529450..0000000000 --- a/routing/routing_integration_tests/jni/Application.mk +++ /dev/null @@ -1 +0,0 @@ -../../../android/UnitTests/jni/Application.mk \ No newline at end of file diff --git a/xcode/map/map.xcodeproj/project.pbxproj b/xcode/map/map.xcodeproj/project.pbxproj index 97d510969c..8fdc914f36 100644 --- a/xcode/map/map.xcodeproj/project.pbxproj +++ b/xcode/map/map.xcodeproj/project.pbxproj @@ -786,7 +786,10 @@ buildSettings = { EXECUTABLE_PREFIX = lib; PRODUCT_NAME = "$(TARGET_NAME)"; - WARNING_CFLAGS = "-Wno-deprecated-register "; + WARNING_CFLAGS = ( + "-Wno-deprecated-register", + " ", + ); }; name = Debug; }; @@ -795,7 +798,10 @@ buildSettings = { EXECUTABLE_PREFIX = lib; PRODUCT_NAME = "$(TARGET_NAME)"; - WARNING_CFLAGS = "-Wno-deprecated-register "; + WARNING_CFLAGS = ( + "-Wno-deprecated-register", + " ", + ); }; name = Release; }; diff --git a/xcode/minizip/minizip.xcodeproj/project.pbxproj b/xcode/minizip/minizip.xcodeproj/project.pbxproj index eb539e22a9..1977903f12 100644 --- a/xcode/minizip/minizip.xcodeproj/project.pbxproj +++ b/xcode/minizip/minizip.xcodeproj/project.pbxproj @@ -202,6 +202,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MARKETING_VERSION = 1.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.mapswithme.minizip-ios"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; @@ -229,6 +230,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.mapswithme.minizip-ios"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos;