diff --git a/.gitignore b/.gitignore index af6a089f8..ad7fb4ae1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,41 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + ### IntelliJ IDEA ### out/ !**/src/main/**/out/ @@ -6,6 +44,52 @@ out/ # IntelliJ *.iml .idea/ +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + misc.xml deploymentTargetDropDown.xml render.experimental.xml diff --git a/INSTALL_INSTRUCTIONS.txt b/INSTALL_INSTRUCTIONS.txt new file mode 100644 index 000000000..b2ba886d5 --- /dev/null +++ b/INSTALL_INSTRUCTIONS.txt @@ -0,0 +1,106 @@ +======================================== +ИНСТРУКЦИЯ ПО УСТАНОВКЕ И ЗАПУСКУ ПРОЕКТА +Android Contacts +======================================== + +ТРЕБОВАНИЯ: +----------- +1. Android Studio (последняя версия) +2. JDK 17 или выше +3. Android SDK (API Level 24 или выше) +4. Настроенный эмулятор Android или физическое устройство + + +ШАГИ ДЛЯ УСТАНОВКИ: +------------------- + +1. УСТАНОВКА ANDROID STUDIO: + - Скачайте Android Studio с официального сайта: + https://developer.android.com/studio + - Установите Android Studio со всеми рекомендуемыми компонентами + - При первом запуске установите Android SDK + +2. НАСТРОЙКА ПЕРЕМЕННЫХ ОКРУЖЕНИЯ: + - Откройте "Панель управления" -> "Система" -> "Дополнительные параметры системы" + - Нажмите "Переменные среды" + - Добавьте новую системную переменную: + Имя: ANDROID_HOME + Значение: C:\Users\[Ваше_Имя]\AppData\Local\Android\Sdk + (путь может отличаться, проверьте в Android Studio: Tools -> SDK Manager) + +3. СОЗДАНИЕ ЭМУЛЯТОРА: + - Откройте Android Studio + - Tools -> Device Manager (или AVD Manager) + - Нажмите "Create Device" + - Выберите любое устройство (рекомендуется Pixel 5) + - Выберите системный образ (рекомендуется API 33 или выше) + - Завершите создание и запустите эмулятор + +4. ОТКРЫТИЕ ПРОЕКТА: + - Откройте Android Studio + - File -> Open + - Выберите папку с проектом android-contacts + - Дождитесь синхронизации Gradle (может занять несколько минут) + + +ЗАПУСК ПРИЛОЖЕНИЯ: +------------------ + +СПОСОБ 1 - Через BAT-файл (РЕКОМЕНДУЕТСЯ): + 1. Запустите эмулятор через Android Studio или командой: + emulator -avd [имя_вашего_эмулятора] + + 2. Дважды кликните на файл: install_and_run.bat + + 3. Скрипт автоматически: + - Проверит окружение + - Соберёт проект + - Установит приложение на эмулятор + - Запустит приложение + +СПОСОБ 2 - Через Android Studio: + 1. Откройте проект в Android Studio + 2. Запустите эмулятор + 3. Нажмите кнопку "Run" (зелёный треугольник) или Shift+F10 + 4. Выберите эмулятор из списка устройств + +СПОСОБ 3 - Через командную строку: + 1. Запустите эмулятор + 2. Откройте командную строку в папке проекта + 3. Выполните команды: + gradlew.bat clean + gradlew.bat installDebug + adb shell am start -n com.example.contacts/.presentation.main.MainActivity + + +РЕШЕНИЕ ПРОБЛЕМ: +---------------- + +Проблема: "ANDROID_HOME не установлена" +Решение: Настройте переменную окружения ANDROID_HOME (см. пункт 2 выше) + +Проблема: "Эмулятор не запущен" +Решение: Запустите эмулятор через Android Studio или команду emulator -avd [имя] + +Проблема: "Gradle sync failed" +Решение: + - Убедитесь что установлен JDK 17 + - File -> Invalidate Caches / Restart в Android Studio + - Проверьте интернет-соединение + +Проблема: "SDK не найден" +Решение: Откройте Tools -> SDK Manager и установите необходимые компоненты: + - Android SDK Platform (API 33 или выше) + - Android SDK Build-Tools + - Android SDK Platform-Tools + - Android Emulator + + +КОНТАКТЫ: +--------- +При возникновении проблем свяжитесь с разработчиком. + + +======================================== +Дата создания: 27.10.2025 +======================================== diff --git a/README.md b/README.md index 63be1bfe0..e69de29bb 100644 --- a/README.md +++ b/README.md @@ -1 +0,0 @@ -# Пустой репозиторий для работы с Java кодом в Android Studio diff --git a/add_test_contacts.bat b/add_test_contacts.bat new file mode 100644 index 000000000..1c92a7657 --- /dev/null +++ b/add_test_contacts.bat @@ -0,0 +1,81 @@ +@echo off +setlocal + +echo ======================================== +echo Adding Test Contacts +echo ======================================== +echo. + +REM Check that emulator is connected +echo Checking device connection... +"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" devices +echo. + +REM Define device ID +for /f "skip=1 tokens=1" %%i in ('"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" devices') do ( + if "%%i"=="emulator-5554" ( + set DEVICE_ID=emulator-5554 + ) +) + +if not defined DEVICE_ID ( + echo Device not found. Make sure emulator is running. + pause + exit /b 1 +) + +echo Found device: %DEVICE_ID% +echo. + +echo Adding 10 test contacts... +echo. + +REM Add 10 test contacts to the Android device +echo Adding contact: John Smith +"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" shell am start -a android.intent.action.INSERT -t vnd.android.cursor.dir/contact -e name "John Smith" -e phone "+79123456789" -e email "smith@example.com" +ping -n 3 127.0.0.1 > nul + +echo Adding contact: Jane Doe +"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" shell am start -a android.intent.action.INSERT -t vnd.android.cursor.dir/contact -e name "Jane Doe" -e phone "+79123456780" -e email "doe@example.com" +ping -n 3 127.0.0.1 > nul + +echo Adding contact: Robert Johnson +"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" shell am start -a android.intent.action.INSERT -t vnd.android.cursor.dir/contact -e name "Robert Johnson" -e phone "+79123456781" -e email "johnson@example.com" +ping -n 3 127.0.0.1 > nul + +echo Adding contact: Emily Davis +"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" shell am start -a android.intent.action.INSERT -t vnd.android.cursor.dir/contact -e name "Emily Davis" -e phone "+79123456782" -e email "davis@example.com" +ping -n 3 127.0.0.1 > nul + +echo Adding contact: Michael Wilson +"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" shell am start -a android.intent.action.INSERT -t vnd.android.cursor.dir/contact -e name "Michael Wilson" -e phone "+79123456783" -e email "wilson@example.com" +ping -n 3 127.0.0.1 > nul + +echo Adding contact: Sarah Brown +"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" shell am start -a android.intent.action.INSERT -t vnd.android.cursor.dir/contact -e name "Sarah Brown" -e phone "+79123456784" -e email "brown@example.com" +ping -n 3 127.0.0.1 > nul + +echo Adding contact: David Taylor +"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" shell am start -a android.intent.action.INSERT -t vnd.android.cursor.dir/contact -e name "David Taylor" -e phone "+79123456785" -e email "taylor@example.com" +ping -n 3 127.0.0.1 > nul + +echo Adding contact: Lisa Anderson +"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" shell am start -a android.intent.action.INSERT -t vnd.android.cursor.dir/contact -e name "Lisa Anderson" -e phone "+79123456786" -e email "anderson@example.com" +ping -n 3 127.0.0.1 > nul + +echo Adding contact: James Thomas +"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" shell am start -a android.intent.action.INSERT -t vnd.android.cursor.dir/contact -e name "James Thomas" -e phone "+79123456787" -e email "thomas@example.com" +ping -n 3 127.0.0.1 > nul + +echo Adding contact: Jennifer Jackson +"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" shell am start -a android.intent.action.INSERT -t vnd.android.cursor.dir/contact -e name "Jennifer Jackson" -e phone "+79123456788" -e email "jackson@example.com" +ping -n 3 127.0.0.1 > nul + +echo. +echo ======================================== +echo Test contacts successfully added! +echo ======================================== +echo. +echo Restart the Android Contacts app to view the new contacts. +echo. +pause \ No newline at end of file diff --git a/add_test_contacts_manual.bat b/add_test_contacts_manual.bat new file mode 100644 index 000000000..c296cceaf --- /dev/null +++ b/add_test_contacts_manual.bat @@ -0,0 +1,75 @@ +@echo off +setlocal + +echo ======================================== +echo Instructions for Adding Test Contacts +echo ======================================== +echo. +echo The automated contact addition via ADB failed because: +echo 1. The Android contacts database is complex (multiple tables) +echo 2. The intent command was misinterpreted by the system +echo. +echo Please follow these manual steps to add test contacts: +echo. +echo 1. Open the "Contacts" app on your Android emulator +echo 2. Tap the "+" or "Add Contact" button +echo 3. Add these 10 test contacts one by one: +echo. +echo Contact 1: +echo Name: John Smith +echo Phone: +79123456789 +echo Email: smith@example.com +echo. +echo Contact 2: +echo Name: Jane Doe +echo Phone: +79123456780 +echo Email: doe@example.com +echo. +echo Contact 3: +echo Name: Robert Johnson +echo Phone: +79123456781 +echo Email: johnson@example.com +echo. +echo Contact 4: +echo Name: Emily Davis +echo Phone: +79123456782 +echo Email: davis@example.com +echo. +echo Contact 5: +echo Name: Michael Wilson +echo Phone: +79123456783 +echo Email: wilson@example.com +echo. +echo Contact 6: +echo Name: Sarah Brown +echo Phone: +79123456784 +echo Email: brown@example.com +echo. +echo Contact 7: +echo Name: David Taylor +echo Phone: +79123456785 +echo Email: taylor@example.com +echo. +echo Contact 8: +echo Name: Lisa Anderson +echo Phone: +79123456786 +echo Email: anderson@example.com +echo. +echo Contact 9: +echo Name: James Thomas +echo Phone: +79123456787 +echo Email: thomas@example.com +echo. +echo Contact 10: +echo Name: Jennifer Jackson +echo Phone: +79123456788 +echo Email: jackson@example.com +echo. +echo After adding the contacts, return to the Android Contacts app (ru.yandex.practicum.contacts) +echo to see the new contacts in the list. +echo. +echo If you need to restart the application, run: +echo adb shell am force-stop ru.yandex.practicum.contacts +echo adb shell am start -n ru.yandex.practicum.contacts/.presentation.main.MainActivity +echo. +pause \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 000000000..c95c1c8cc --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,47 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' +} + +android { + compileSdk 34 + namespace = "com.example.contacts" + + defaultConfig { + applicationId "com.example.contacts" + minSdk 24 + targetSdk 34 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + buildFeatures { + viewBinding true + } +} + +dependencies { + + implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'com.google.android.material:material:1.12.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.navigation:navigation-fragment:2.8.3' + implementation 'androidx.navigation:navigation-ui:2.8.3' + implementation 'com.github.bumptech.glide:glide:4.16.0' + annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/ru/yandex/practicum/contacts/ExampleInstrumentedTest.java b/app/src/androidTest/java/ru/yandex/practicum/contacts/ExampleInstrumentedTest.java new file mode 100644 index 000000000..8988aa556 --- /dev/null +++ b/app/src/androidTest/java/ru/yandex/practicum/contacts/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package ru.yandex.practicum.contacts; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + assertEquals("ru.yandex.practicum.contacts", appContext.getPackageName()); + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..2b9822095 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 000000000..e78269970 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/example/contacts/SplashActivity.java b/app/src/main/java/com/example/contacts/SplashActivity.java new file mode 100644 index 000000000..a73ec13b9 --- /dev/null +++ b/app/src/main/java/com/example/contacts/SplashActivity.java @@ -0,0 +1,80 @@ +package com.example.contacts; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.Intent; +import android.graphics.drawable.TransitionDrawable; +import android.net.Uri; +import android.os.Bundle; +import android.provider.Settings; +import android.view.View; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + +import com.example.contacts.databinding.SplashActivityBinding; +import com.example.contacts.presentation.main.MainActivity; +import com.example.contacts.utils.android.ContextUtils; + +@SuppressLint("CustomSplashScreen") +public class SplashActivity extends AppCompatActivity { + + private static final int ANIMATION_TIME = 250; + + private final ActivityResultLauncher requestPermissionLauncher = + registerForActivityResult(new ActivityResultContracts.RequestPermission(), this::onPermissionResult); + + private SplashActivityBinding binding; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = SplashActivityBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + binding.settingsButton.setOnClickListener(view -> navigateToSettings()); + + if (ContextUtils.hasContactPermissions(this)) { + navigateToMain(); + } else { + requestPermissionLauncher.launch(Manifest.permission.READ_CONTACTS); + } + } + + @Override + protected void onStart() { + super.onStart(); + if (ContextUtils.hasContactPermissions(this)) { + navigateToMain(); + } + } + + private void navigateToMain() { + final Intent intent = new Intent(this, MainActivity.class); + startActivity(intent); + finish(); + } + + private void showSettings() { + final TransitionDrawable drawable = (TransitionDrawable) binding.getRoot().getBackground(); + drawable.startTransition(ANIMATION_TIME); + binding.settingsButton.setVisibility(View.VISIBLE); + binding.logo.setVisibility(View.GONE); + } + + private void onPermissionResult(Boolean isGranted) { + if (isGranted) { + navigateToMain(); + } else { + showSettings(); + } + } + + private void navigateToSettings() { + final Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + final Uri uri = Uri.fromParts("package", getPackageName(), null); + intent.setData(uri); + startActivity(intent); + } +} diff --git a/app/src/main/java/com/example/contacts/interactor/ContactMerger.java b/app/src/main/java/com/example/contacts/interactor/ContactMerger.java new file mode 100644 index 000000000..87d07ffa7 --- /dev/null +++ b/app/src/main/java/com/example/contacts/interactor/ContactMerger.java @@ -0,0 +1,63 @@ +package com.example.contacts.interactor; + +import android.text.TextUtils; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +import com.example.contacts.model.Contact; +import com.example.contacts.model.ContactSource; +import com.example.contacts.model.MergedContact; +import com.example.contacts.utils.Constants; +import com.example.contacts.utils.model.ContactUtils; + +public class ContactMerger { + + public List getMergedContacts(Collection contacts, Collection sources) { + final Map sourcesMap = sources.stream() + .collect(Collectors.toMap(ContactSource::getName, Function.identity(), this::resolveConflicts)); + final Map> contactsMap = contacts.stream() + .filter(contact -> !TextUtils.isEmpty(ContactUtils.getDisplayName(contact))) + .collect(Collectors.groupingBy(ContactUtils::getDisplayName)); + final List mergedContacts = contactsMap.entrySet().stream() + .map(entry -> mergeContact(entry, sourcesMap)) + .collect(Collectors.toList()); + return Collections.unmodifiableList(mergedContacts); + } + + private MergedContact mergeContact(Map.Entry> entry, Map sources) { + final List contacts = entry.getValue(); + final Contact contact = contacts.get(0); + final List contactSources = contacts.stream() + .map(each -> Objects.requireNonNull(sources.get(each.getSource())).getPublicName()) + .distinct() + .collect(Collectors.toList()); + return new MergedContact( + contact.getId(), + contact.getFirstName(), + contact.getMiddleName(), + contact.getSurname(), + ContactUtils.getFirstPhone(contact), + ContactUtils.getFirstNormalizedPhone(contact), + ContactUtils.getFirstEmail(contact), + contactSources, + contact.getPhotoUri() + ); + } + + private ContactSource resolveConflicts(ContactSource source1, ContactSource source2) { + if (Objects.equals(source1.getType(), Constants.StorageType.GOOGLE)) { + return source1; + } + if (Objects.equals(source2.getType(), Constants.StorageType.GOOGLE)) { + return source2; + } + return source1; + } + +} diff --git a/app/src/main/java/com/example/contacts/mapper/ContactUiMapper.java b/app/src/main/java/com/example/contacts/mapper/ContactUiMapper.java new file mode 100644 index 000000000..e51e0c583 --- /dev/null +++ b/app/src/main/java/com/example/contacts/mapper/ContactUiMapper.java @@ -0,0 +1,30 @@ +package com.example.contacts.mapper; + +import android.text.TextUtils; + +import com.example.contacts.model.MergedContact; +import com.example.contacts.presentation.main.ContactUi; +import com.example.contacts.utils.model.MergedContactUtils; +import com.example.contacts.utils.model.PhoneUtils; + +public class ContactUiMapper { + + public ContactUi map(MergedContact contact) { + String displayName = (contact.getFirstName() + " " + contact.getSurname()).trim(); + String phone = PhoneUtils.format(contact.getPhone()); + if (TextUtils.isEmpty(displayName)) { + if (!TextUtils.isEmpty(phone)) { + displayName = phone; + phone = ""; + } else { + displayName = contact.getEmail(); + } + } + return new ContactUi( + displayName, + phone, + contact.getPhotoUri(), + MergedContactUtils.getContactTypes(contact) + ); + } +} diff --git a/app/src/main/java/com/example/contacts/model/Address.java b/app/src/main/java/com/example/contacts/model/Address.java new file mode 100644 index 000000000..aa617cc78 --- /dev/null +++ b/app/src/main/java/com/example/contacts/model/Address.java @@ -0,0 +1,48 @@ +package com.example.contacts.model; + +import androidx.annotation.NonNull; + +public class Address { + + private final String value; + private final int type; + private final String label; + + public Address(@NonNull String value, int type, @NonNull String label) { + this.value = value; + this.type = type; + this.label = label; + } + + public String getValue() { + return value; + } + + public int getType() { + return type; + } + + public String getLabel() { + return label; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Address address = (Address) o; + + if (type != address.type) return false; + if (!value.equals(address.value)) return false; + return label.equals(address.label); + } + + @Override + public int hashCode() { + int result = value.hashCode(); + result = 31 * result + type; + result = 31 * result + label.hashCode(); + return result; + } +} diff --git a/app/src/main/java/com/example/contacts/model/Contact.java b/app/src/main/java/com/example/contacts/model/Contact.java new file mode 100644 index 000000000..c57dee023 --- /dev/null +++ b/app/src/main/java/com/example/contacts/model/Contact.java @@ -0,0 +1,141 @@ +package com.example.contacts.model; + +import androidx.annotation.NonNull; + +import java.util.List; + +public class Contact { + + private final int id; + private final String prefix; + private final String firstName; + private final String middleName; + private final String surname; + private final String suffix; + private final String photoUri; + private final List phoneNumbers; + private final List emails; + private final List
addresses; + private final String source; + private final int starred; + private final int contactId; + private final String thumbnailUri; + + public Contact(int id, @NonNull String prefix, @NonNull String firstName, @NonNull String middleName, @NonNull String surname, + @NonNull String suffix, @NonNull String photoUri, @NonNull List phoneNumbers, @NonNull List emails, + @NonNull List
addresses, @NonNull String source, int starred, int contactId, @NonNull String thumbnailUri + ) { + this.id = id; + this.prefix = prefix; + this.firstName = firstName; + this.middleName = middleName; + this.surname = surname; + this.suffix = suffix; + this.photoUri = photoUri; + this.phoneNumbers = phoneNumbers; + this.emails = emails; + this.addresses = addresses; + this.source = source; + this.starred = starred; + this.contactId = contactId; + this.thumbnailUri = thumbnailUri; + } + + public int getId() { + return id; + } + + public String getPrefix() { + return prefix; + } + + public String getFirstName() { + return firstName; + } + + public String getMiddleName() { + return middleName; + } + + public String getSurname() { + return surname; + } + + public String getSuffix() { + return suffix; + } + + public String getPhotoUri() { + return photoUri; + } + + public List getPhoneNumbers() { + return phoneNumbers; + } + + public List getEmails() { + return emails; + } + + public List
getAddresses() { + return addresses; + } + + public String getSource() { + return source; + } + + public int getStarred() { + return starred; + } + + public int getContactId() { + return contactId; + } + + public String getThumbnailUri() { + return thumbnailUri; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Contact contact = (Contact) o; + + if (id != contact.id) return false; + if (starred != contact.starred) return false; + if (contactId != contact.contactId) return false; + if (!prefix.equals(contact.prefix)) return false; + if (!firstName.equals(contact.firstName)) return false; + if (!middleName.equals(contact.middleName)) return false; + if (!surname.equals(contact.surname)) return false; + if (!suffix.equals(contact.suffix)) return false; + if (!photoUri.equals(contact.photoUri)) return false; + if (!phoneNumbers.equals(contact.phoneNumbers)) return false; + if (!emails.equals(contact.emails)) return false; + if (!addresses.equals(contact.addresses)) return false; + if (!source.equals(contact.source)) return false; + return thumbnailUri.equals(contact.thumbnailUri); + } + + @Override + public int hashCode() { + int result = id; + result = 31 * result + prefix.hashCode(); + result = 31 * result + firstName.hashCode(); + result = 31 * result + middleName.hashCode(); + result = 31 * result + surname.hashCode(); + result = 31 * result + suffix.hashCode(); + result = 31 * result + photoUri.hashCode(); + result = 31 * result + phoneNumbers.hashCode(); + result = 31 * result + emails.hashCode(); + result = 31 * result + addresses.hashCode(); + result = 31 * result + source.hashCode(); + result = 31 * result + starred; + result = 31 * result + contactId; + result = 31 * result + thumbnailUri.hashCode(); + return result; + } +} diff --git a/app/src/main/java/com/example/contacts/model/ContactSource.java b/app/src/main/java/com/example/contacts/model/ContactSource.java new file mode 100644 index 000000000..1b762c5c7 --- /dev/null +++ b/app/src/main/java/com/example/contacts/model/ContactSource.java @@ -0,0 +1,48 @@ +package com.example.contacts.model; + +import androidx.annotation.NonNull; + +public class ContactSource { + + private final String name; + private final String type; + private final String publicName; + + public ContactSource(@NonNull String name, @NonNull String type, @NonNull String publicName) { + this.name = name; + this.type = type; + this.publicName = publicName; + } + + public String getName() { + return name; + } + + public String getType() { + return type; + } + + public String getPublicName() { + return publicName; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + ContactSource that = (ContactSource) o; + + if (!name.equals(that.name)) return false; + if (!type.equals(that.type)) return false; + return publicName.equals(that.publicName); + } + + @Override + public int hashCode() { + int result = name.hashCode(); + result = 31 * result + type.hashCode(); + result = 31 * result + publicName.hashCode(); + return result; + } +} diff --git a/app/src/main/java/com/example/contacts/model/ContactType.java b/app/src/main/java/com/example/contacts/model/ContactType.java new file mode 100644 index 000000000..a0df5816e --- /dev/null +++ b/app/src/main/java/com/example/contacts/model/ContactType.java @@ -0,0 +1,11 @@ +package com.example.contacts.model; + +public enum ContactType { + TELEGRAM, + WHATS_APP, + VIBER, + SIGNAL, + THREEMA, + PHONE, + EMAIL +} diff --git a/app/src/main/java/com/example/contacts/model/Email.java b/app/src/main/java/com/example/contacts/model/Email.java new file mode 100644 index 000000000..e760d8f15 --- /dev/null +++ b/app/src/main/java/com/example/contacts/model/Email.java @@ -0,0 +1,48 @@ +package com.example.contacts.model; + +import androidx.annotation.NonNull; + +public class Email { + + private final String value; + private final int type; + private final String label; + + public Email(@NonNull String value, int type, @NonNull String label) { + this.value = value; + this.type = type; + this.label = label; + } + + public String getValue() { + return value; + } + + public int getType() { + return type; + } + + public String getLabel() { + return label; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Email email = (Email) o; + + if (type != email.type) return false; + if (!value.equals(email.value)) return false; + return label.equals(email.label); + } + + @Override + public int hashCode() { + int result = value.hashCode(); + result = 31 * result + type; + result = 31 * result + label.hashCode(); + return result; + } +} diff --git a/app/src/main/java/com/example/contacts/model/MergedContact.java b/app/src/main/java/com/example/contacts/model/MergedContact.java new file mode 100644 index 000000000..f191ecf6d --- /dev/null +++ b/app/src/main/java/com/example/contacts/model/MergedContact.java @@ -0,0 +1,100 @@ +package com.example.contacts.model; + +import androidx.annotation.NonNull; + +import java.util.List; + +public class MergedContact { + + private final int id; + private final String firstName; + private final String middleName; + private final String surname; + private final String phone; + private final String normalizedNumber; + private final String email; + private final List contactTypes; + private final String photoUri; + + public MergedContact(int id, @NonNull String firstName, @NonNull String middleName, @NonNull String surname, @NonNull String phone, + @NonNull String normalizedNumber, @NonNull String email, @NonNull List contactTypes, @NonNull String photoUri + ) { + this.id = id; + this.firstName = firstName; + this.middleName = middleName; + this.surname = surname; + this.phone = phone; + this.normalizedNumber = normalizedNumber; + this.email = email; + this.contactTypes = contactTypes; + this.photoUri = photoUri; + } + + public int getId() { + return id; + } + + public String getFirstName() { + return firstName; + } + + public String getMiddleName() { + return middleName; + } + + public String getSurname() { + return surname; + } + + public String getPhone() { + return phone; + } + + public String getNormalizedNumber() { + return normalizedNumber; + } + + public String getEmail() { + return email; + } + + public List getContactTypes() { + return contactTypes; + } + + public String getPhotoUri() { + return photoUri; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + MergedContact that = (MergedContact) o; + + if (id != that.id) return false; + if (!firstName.equals(that.firstName)) return false; + if (!middleName.equals(that.middleName)) return false; + if (!surname.equals(that.surname)) return false; + if (!phone.equals(that.phone)) return false; + if (!normalizedNumber.equals(that.normalizedNumber)) return false; + if (!email.equals(that.email)) return false; + if (!contactTypes.equals(that.contactTypes)) return false; + return photoUri.equals(that.photoUri); + } + + @Override + public int hashCode() { + int result = id; + result = 31 * result + firstName.hashCode(); + result = 31 * result + middleName.hashCode(); + result = 31 * result + surname.hashCode(); + result = 31 * result + phone.hashCode(); + result = 31 * result + normalizedNumber.hashCode(); + result = 31 * result + email.hashCode(); + result = 31 * result + contactTypes.hashCode(); + result = 31 * result + photoUri.hashCode(); + return result; + } +} diff --git a/app/src/main/java/com/example/contacts/model/PhoneNumber.java b/app/src/main/java/com/example/contacts/model/PhoneNumber.java new file mode 100644 index 000000000..22e26d1be --- /dev/null +++ b/app/src/main/java/com/example/contacts/model/PhoneNumber.java @@ -0,0 +1,56 @@ +package com.example.contacts.model; + +import androidx.annotation.NonNull; + +public class PhoneNumber { + + private final String value; + private final int type; + private final String label; + private final String normalizedNumber; + + public PhoneNumber(@NonNull String value, int type, @NonNull String label, @NonNull String normalizedNumber) { + this.value = value; + this.type = type; + this.label = label; + this.normalizedNumber = normalizedNumber; + } + + public String getValue() { + return value; + } + + public int getType() { + return type; + } + + public String getLabel() { + return label; + } + + public String getNormalizedNumber() { + return normalizedNumber; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + PhoneNumber that = (PhoneNumber) o; + + if (type != that.type) return false; + if (!value.equals(that.value)) return false; + if (!label.equals(that.label)) return false; + return normalizedNumber.equals(that.normalizedNumber); + } + + @Override + public int hashCode() { + int result = value.hashCode(); + result = 31 * result + type; + result = 31 * result + label.hashCode(); + result = 31 * result + normalizedNumber.hashCode(); + return result; + } +} diff --git a/app/src/main/java/com/example/contacts/presentation/base/BaseBottomSheetDialogFragment.java b/app/src/main/java/com/example/contacts/presentation/base/BaseBottomSheetDialogFragment.java new file mode 100644 index 000000000..59df0eefa --- /dev/null +++ b/app/src/main/java/com/example/contacts/presentation/base/BaseBottomSheetDialogFragment.java @@ -0,0 +1,65 @@ +package com.example.contacts.presentation.base; + +import android.app.Dialog; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.ViewModelProvider; + +import com.google.android.material.bottomsheet.BottomSheetBehavior; +import com.google.android.material.bottomsheet.BottomSheetDialog; +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import com.example.contacts.databinding.FragmentBottomSheetBinding; + +public abstract class BaseBottomSheetDialogFragment extends BottomSheetDialogFragment { + + private final Class viewModelClass; + + protected FragmentBottomSheetBinding binding; + protected T viewModel; + + public BaseBottomSheetDialogFragment(Class viewModelClass) { + this.viewModelClass = viewModelClass; + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + binding = FragmentBottomSheetBinding.inflate(inflater, container, false); + return binding.getRoot(); + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + final Dialog dialog = super.onCreateDialog(savedInstanceState); + dialog.setOnShowListener(dialogInterface -> { + final BottomSheetDialog bottomSheetDialog = (BottomSheetDialog) dialogInterface; + final View bottomSheet = bottomSheetDialog.findViewById(com.google.android.material.R.id.design_bottom_sheet); + if (bottomSheet != null) { + final BottomSheetBehavior bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet); + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); + } + }); + return dialog; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + viewModel = new ViewModelProvider(this).get(viewModelClass); + binding.applyButton.setOnClickListener(v -> viewModel.onApplyClick()); + binding.resetButton.setOnClickListener(v -> viewModel.onResetClick()); + } + + @Override + public void onDestroy() { + binding = null; + super.onDestroy(); + } +} diff --git a/app/src/main/java/com/example/contacts/presentation/base/BaseBottomSheetViewModel.java b/app/src/main/java/com/example/contacts/presentation/base/BaseBottomSheetViewModel.java new file mode 100644 index 000000000..60e98511d --- /dev/null +++ b/app/src/main/java/com/example/contacts/presentation/base/BaseBottomSheetViewModel.java @@ -0,0 +1,8 @@ +package com.example.contacts.presentation.base; + +import androidx.lifecycle.ViewModel; + +public abstract class BaseBottomSheetViewModel extends ViewModel { + abstract public void onApplyClick(); + abstract public void onResetClick(); +} diff --git a/app/src/main/java/com/example/contacts/presentation/base/BaseListDiffCallback.java b/app/src/main/java/com/example/contacts/presentation/base/BaseListDiffCallback.java new file mode 100644 index 000000000..f207cd943 --- /dev/null +++ b/app/src/main/java/com/example/contacts/presentation/base/BaseListDiffCallback.java @@ -0,0 +1,17 @@ +package com.example.contacts.presentation.base; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; + +public class BaseListDiffCallback> extends DiffUtil.ItemCallback { + + @Override + public boolean areItemsTheSame(@NonNull T oldItem, @NonNull T newItem) { + return oldItem.theSameAs(newItem); + } + + @Override + public boolean areContentsTheSame(@NonNull T oldItem, @NonNull T newItem) { + return oldItem.equals(newItem); + } +} diff --git a/app/src/main/java/com/example/contacts/presentation/base/ListDiffInterface.java b/app/src/main/java/com/example/contacts/presentation/base/ListDiffInterface.java new file mode 100644 index 000000000..25b299e61 --- /dev/null +++ b/app/src/main/java/com/example/contacts/presentation/base/ListDiffInterface.java @@ -0,0 +1,6 @@ +package com.example.contacts.presentation.base; + +public interface ListDiffInterface { + boolean theSameAs(T other); + boolean equals(Object other); +} diff --git a/app/src/main/java/com/example/contacts/presentation/filter/FilterContactTypeAdapter.java b/app/src/main/java/com/example/contacts/presentation/filter/FilterContactTypeAdapter.java new file mode 100644 index 000000000..c1b8919d1 --- /dev/null +++ b/app/src/main/java/com/example/contacts/presentation/filter/FilterContactTypeAdapter.java @@ -0,0 +1,89 @@ +package com.example.contacts.presentation.filter; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.AdapterListUpdateCallback; +import androidx.recyclerview.widget.AsyncDifferConfig; +import androidx.recyclerview.widget.AsyncListDiffer; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.List; +import java.util.function.Consumer; + +import com.example.contacts.databinding.ItemFilterBinding; +import com.example.contacts.model.ContactType; +import com.example.contacts.presentation.base.BaseListDiffCallback; +import com.example.contacts.presentation.filter.model.FilterContactType; +import com.example.contacts.presentation.filter.model.FilterContactTypeUi; +import com.example.contacts.utils.model.ContactTypeUtils; +import com.example.contacts.utils.model.FilterContactTypeUtils; + +public class FilterContactTypeAdapter extends RecyclerView.Adapter { + + private final AsyncListDiffer differ = new AsyncListDiffer<>( + new AdapterListUpdateCallback(this), + new AsyncDifferConfig.Builder<>(new BaseListDiffCallback()).build() + ); + + private final Consumer clickListener; + + public FilterContactTypeAdapter(Consumer clickListener) { + this.clickListener = clickListener; + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + final LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + final ItemFilterBinding binding = ItemFilterBinding.inflate(inflater, parent, false); + return new ViewHolder(binding, clickListener); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + holder.bind(differ.getCurrentList().get(position)); + } + + @Override + public int getItemCount() { + return differ.getCurrentList().size(); + } + + public void setItems(List items) { + differ.submitList(items); + } + + static class ViewHolder extends RecyclerView.ViewHolder { + + private final ItemFilterBinding binding; + + private FilterContactTypeUi data; + + public ViewHolder(@NonNull ItemFilterBinding binding, Consumer clickListener) { + super(binding.getRoot()); + this.binding = binding; + this.binding.getRoot().setOnClickListener(v -> clickListener.accept(data)); + this.binding.selected.setOnClickListener(v -> clickListener.accept(data)); + } + + public void bind(FilterContactTypeUi data) { + this.data = data; + final int sortResId = FilterContactTypeUtils.getStringRes(data.getContactType()); + binding.text.setText(sortResId); + binding.selected.setChecked(data.isSelected()); + if (data.getContactType() == FilterContactType.ALL){ + binding.logo.setVisibility(View.GONE); + } else { + final ContactType contactType = FilterContactTypeUtils.toContactType(data.getContactType()); + final int iconRes = ContactTypeUtils.getIconRes(contactType); + binding.logo.setVisibility(View.VISIBLE); + binding.logo.setImageResource(iconRes); + } + } + } +} diff --git a/app/src/main/java/com/example/contacts/presentation/filter/FilterContactTypeDialogFragment.java b/app/src/main/java/com/example/contacts/presentation/filter/FilterContactTypeDialogFragment.java new file mode 100644 index 000000000..d9c92914e --- /dev/null +++ b/app/src/main/java/com/example/contacts/presentation/filter/FilterContactTypeDialogFragment.java @@ -0,0 +1,84 @@ +package com.example.contacts.presentation.filter; + +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import com.example.contacts.R; +import com.example.contacts.model.ContactType; +import com.example.contacts.presentation.base.BaseBottomSheetDialogFragment; +import com.example.contacts.presentation.filter.model.FilterContactTypeUi; +import com.example.contacts.ui.widget.DividerItemDecoration; + +public class FilterContactTypeDialogFragment extends BaseBottomSheetDialogFragment { + + public static final String REQUEST_KEY = "REQUEST_KEY_FILTER"; + public static final String ARG_SELECTED_FILTER_CONTACT_TYPE = "ARG_SELECTED_FILTER_CONTACT_TYPE"; + + private FilterContactTypeAdapter adapter; + + private FilterContactTypeDialogFragment() { + super(FilterContactTypeViewModel.class); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + iniViewModel(); + adapter = new FilterContactTypeAdapter(viewModel::onFilterTypeItemClick); + binding.recycler.setAdapter(adapter); + + final DividerItemDecoration decoration = new DividerItemDecoration( + requireActivity(), R.drawable.item_decoration_16dp, R.drawable.item_decoration_72dp, DividerItemDecoration.VERTICAL + ); + binding.recycler.addItemDecoration(decoration); + + viewModel.getFilterContactTypesLiveDate().observe(this, this::updateFilterContactTypes); + viewModel.getUiStateLiveDate().observe(this, this::updateState); + } + + private void iniViewModel() { + final Set defaultFilterContactTypes = from(getArguments()); + viewModel.init(defaultFilterContactTypes); + } + + private void updateFilterContactTypes(List filterTypes) { + adapter.setItems(filterTypes); + } + + private void updateState(FilterContactTypeViewModel.UiState state) { + binding.applyButton.setEnabled(state.isApplyEnable); + + if (!state.newSelectedContactTypes.isEmpty()) { + getParentFragmentManager().setFragmentResult(REQUEST_KEY, createBundle(state.newSelectedContactTypes)); + dismiss(); + } + } + + public static FilterContactTypeDialogFragment newInstance(Set selectedContactTypes) { + final FilterContactTypeDialogFragment fragment = new FilterContactTypeDialogFragment(); + fragment.setArguments(createBundle(selectedContactTypes)); + return fragment; + } + + @SuppressWarnings("unchecked") + public static Set from(@Nullable Bundle bundle) { + if (bundle == null) { + return Collections.emptySet(); + } + return (Set) bundle.getSerializable(ARG_SELECTED_FILTER_CONTACT_TYPE); + } + + private static Bundle createBundle(Set contactTypes) { + final Bundle bundle = new Bundle(); + bundle.putSerializable(ARG_SELECTED_FILTER_CONTACT_TYPE, new HashSet<>(contactTypes)); + return bundle; + } +} diff --git a/app/src/main/java/com/example/contacts/presentation/filter/FilterContactTypeViewModel.java b/app/src/main/java/com/example/contacts/presentation/filter/FilterContactTypeViewModel.java new file mode 100644 index 000000000..007fefb2b --- /dev/null +++ b/app/src/main/java/com/example/contacts/presentation/filter/FilterContactTypeViewModel.java @@ -0,0 +1,103 @@ +package com.example.contacts.presentation.filter; + +import androidx.lifecycle.MutableLiveData; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import com.example.contacts.model.ContactType; +import com.example.contacts.presentation.base.BaseBottomSheetViewModel; +import com.example.contacts.presentation.filter.model.FilterContactType; +import com.example.contacts.presentation.filter.model.FilterContactTypeUi; +import com.example.contacts.utils.model.ContactTypeUtils; +import com.example.contacts.utils.model.FilterContactTypeUtils; + +public class FilterContactTypeViewModel extends BaseBottomSheetViewModel { + + private final UiState uiState = new UiState(); + private final MutableLiveData> filterContactTypesLiveDate = new MutableLiveData<>(); + private final MutableLiveData uiStateLiveDate = new MutableLiveData<>(); + + private Set defaultFilterContactTypes; + private Set selectedFilterContactTypes; + + public void init(Set defaultFilterContactTypes) { + this.defaultFilterContactTypes = new HashSet<>(defaultFilterContactTypes); + this.selectedFilterContactTypes = new HashSet<>(defaultFilterContactTypes); + updateFilterContactTypes(); + updateUiState(); + } + + public void onFilterTypeItemClick(FilterContactTypeUi filterContactType) { + updateSelectedContactTypes(filterContactType.getContactType()); + updateFilterContactTypes(); + updateUiState(); + } + + @Override + public void onApplyClick() { + uiState.newSelectedContactTypes = selectedFilterContactTypes; + updateUiState(); + } + + @Override + public void onResetClick() { + selectedFilterContactTypes = new HashSet<>(defaultFilterContactTypes); + updateFilterContactTypes(); + updateUiState(); + } + + public MutableLiveData> getFilterContactTypesLiveDate() { + return filterContactTypesLiveDate; + } + + public MutableLiveData getUiStateLiveDate() { + return uiStateLiveDate; + } + + private void updateFilterContactTypes() { + final List filterContactTypesUi = new ArrayList<>(); + final boolean allSelected = selectedFilterContactTypes.size() == ContactType.values().length; + filterContactTypesUi.add(new FilterContactTypeUi(FilterContactType.ALL, allSelected)); + final List collect = Arrays.stream(ContactType.values()) + .map(contactType -> new FilterContactTypeUi( + ContactTypeUtils.toFilterContactType(contactType), + selectedFilterContactTypes.contains(contactType) + )) + .collect(Collectors.toList()); + filterContactTypesUi.addAll(collect); + filterContactTypesLiveDate.setValue(filterContactTypesUi); + } + + private void updateUiState() { + uiState.isApplyEnable = !defaultFilterContactTypes.equals(selectedFilterContactTypes) && !selectedFilterContactTypes.isEmpty(); + uiStateLiveDate.setValue(uiState); + } + + private void updateSelectedContactTypes(FilterContactType type) { + if (type == FilterContactType.ALL) { + if (selectedFilterContactTypes.size() == ContactType.values().length) { + selectedFilterContactTypes.clear(); + } else { + selectedFilterContactTypes.addAll(Arrays.asList(ContactType.values())); + } + return; + } + final ContactType contactType = FilterContactTypeUtils.toContactType(type); + if (selectedFilterContactTypes.contains(contactType)) { + selectedFilterContactTypes.remove(contactType); + } else { + selectedFilterContactTypes.add(contactType); + } + } + + static class UiState { + public boolean isApplyEnable = false; + public Set newSelectedContactTypes = Collections.emptySet(); + } +} diff --git a/app/src/main/java/com/example/contacts/presentation/filter/model/FilterContactType.java b/app/src/main/java/com/example/contacts/presentation/filter/model/FilterContactType.java new file mode 100644 index 000000000..768de8b76 --- /dev/null +++ b/app/src/main/java/com/example/contacts/presentation/filter/model/FilterContactType.java @@ -0,0 +1,12 @@ +package com.example.contacts.presentation.filter.model; + +public enum FilterContactType { + ALL, + TELEGRAM, + WHATS_APP, + VIBER, + SIGNAL, + THREEMA, + PHONE, + EMAIL +} diff --git a/app/src/main/java/com/example/contacts/presentation/filter/model/FilterContactTypeUi.java b/app/src/main/java/com/example/contacts/presentation/filter/model/FilterContactTypeUi.java new file mode 100644 index 000000000..8114724d0 --- /dev/null +++ b/app/src/main/java/com/example/contacts/presentation/filter/model/FilterContactTypeUi.java @@ -0,0 +1,47 @@ +package com.example.contacts.presentation.filter.model; + +import androidx.annotation.NonNull; + +import com.example.contacts.presentation.base.ListDiffInterface; + +public class FilterContactTypeUi implements ListDiffInterface { + + private final FilterContactType contactType; + private final boolean selected; + + public FilterContactTypeUi(@NonNull FilterContactType contactType, boolean selected) { + this.contactType = contactType; + this.selected = selected; + } + + public FilterContactType getContactType() { + return contactType; + } + + public boolean isSelected() { + return selected; + } + + @Override + public boolean theSameAs(FilterContactTypeUi other) { + return this.contactType == other.contactType; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + FilterContactTypeUi that = (FilterContactTypeUi) o; + + if (selected != that.selected) return false; + return contactType == that.contactType; + } + + @Override + public int hashCode() { + int result = contactType.hashCode(); + result = 31 * result + (selected ? 1 : 0); + return result; + } +} diff --git a/app/src/main/java/com/example/contacts/presentation/main/ContactAdapter.java b/app/src/main/java/com/example/contacts/presentation/main/ContactAdapter.java new file mode 100644 index 000000000..129a969b9 --- /dev/null +++ b/app/src/main/java/com/example/contacts/presentation/main/ContactAdapter.java @@ -0,0 +1,96 @@ +package com.example.contacts.presentation.main; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.AdapterListUpdateCallback; +import androidx.recyclerview.widget.AsyncDifferConfig; +import androidx.recyclerview.widget.AsyncListDiffer; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.Glide; + +import java.util.List; +import java.util.Objects; + +import com.example.contacts.R; +import com.example.contacts.databinding.ItemContactBinding; +import com.example.contacts.presentation.base.BaseListDiffCallback; + +public class ContactAdapter extends RecyclerView.Adapter { + + private final AsyncListDiffer differ = new AsyncListDiffer<>( + new AdapterListUpdateCallback(this), + new AsyncDifferConfig.Builder<>(new BaseListDiffCallback()).build() + ); + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + final LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + final ItemContactBinding binding = ItemContactBinding.inflate(inflater, parent, false); + return new ViewHolder(binding); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + holder.bind(differ.getCurrentList().get(position)); + } + + @Override + public int getItemCount() { + return differ.getCurrentList().size(); + } + + public void setItems(List items) { + differ.submitList(items); + } + + public void setItems(List items, @NonNull Runnable callback) { + differ.submitList(items, callback); + } + + static class ViewHolder extends RecyclerView.ViewHolder { + + private final ItemContactBinding binding; + + public ViewHolder(@NonNull ItemContactBinding binding) { + super(binding.getRoot()); + this.binding = binding; + binding.getRoot().setOnClickListener(view -> { + }); + } + + public void bind(ContactUi contact) { + binding.name.setText(contact.getName()); + loadAvatar(contact); + + final int phoneVisibility = TextUtils.isEmpty(contact.getPhone()) ? View.GONE : View.VISIBLE; + binding.phone.setText(contact.getPhone()); + binding.phone.setVisibility(phoneVisibility); + + binding.contactType.setData(contact.getTypes()); + } + + private void loadAvatar(ContactUi contact) { + final Context context = binding.contactPhoto.getContext(); + final Drawable drawable = Objects.requireNonNull(ContextCompat.getDrawable(context, R.drawable.ic_avatar)); + drawable.setTint(ContextCompat.getColor(context, R.color.color_light_grey)); + Glide.with(binding.contactPhoto) + .load(contact.getPhoto()) + .circleCrop() + .placeholder(drawable) + .fallback(drawable) + .error(drawable) + .into(binding.contactPhoto); + } + } +} diff --git a/app/src/main/java/com/example/contacts/presentation/main/ContactUi.java b/app/src/main/java/com/example/contacts/presentation/main/ContactUi.java new file mode 100644 index 000000000..597d820d7 --- /dev/null +++ b/app/src/main/java/com/example/contacts/presentation/main/ContactUi.java @@ -0,0 +1,71 @@ +package com.example.contacts.presentation.main; + +import androidx.annotation.NonNull; + +import java.util.List; + +import com.example.contacts.model.ContactType; +import com.example.contacts.presentation.base.ListDiffInterface; + +public class ContactUi implements ListDiffInterface { + + private final String name; + private final String phone; + private final String photo; + private final List types; + + public ContactUi( + @NonNull String name, + @NonNull String phone, + @NonNull String photo, + @NonNull List types + ) { + this.name = name; + this.phone = phone; + this.photo = photo; + this.types = types; + } + + public String getName() { + return name; + } + + public String getPhone() { + return phone; + } + + public String getPhoto() { + return photo; + } + + public List getTypes() { + return types; + } + + @Override + public boolean theSameAs(ContactUi other) { + return this.hashCode() == other.hashCode(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + ContactUi contact = (ContactUi) o; + + if (!name.equals(contact.name)) return false; + if (!phone.equals(contact.phone)) return false; + if (!photo.equals(contact.photo)) return false; + return types.equals(contact.types); + } + + @Override + public int hashCode() { + int result = name.hashCode(); + result = 31 * result + phone.hashCode(); + result = 31 * result + photo.hashCode(); + result = 31 * result + types.hashCode(); + return result; + } +} diff --git a/app/src/main/java/com/example/contacts/presentation/main/MainActivity.java b/app/src/main/java/com/example/contacts/presentation/main/MainActivity.java new file mode 100644 index 000000000..440a7bb0d --- /dev/null +++ b/app/src/main/java/com/example/contacts/presentation/main/MainActivity.java @@ -0,0 +1,201 @@ +package com.example.contacts.presentation.main; + +import android.annotation.SuppressLint; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.IdRes; +import androidx.annotation.OptIn; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; +import androidx.lifecycle.ViewModelProvider; + +import com.google.android.material.badge.BadgeDrawable; +import com.google.android.material.badge.BadgeUtils; +import com.google.android.material.badge.ExperimentalBadgeUtils; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import com.example.contacts.R; +import com.example.contacts.databinding.ActivityMainBinding; +import com.example.contacts.model.ContactType; +import com.example.contacts.presentation.filter.FilterContactTypeDialogFragment; +import com.example.contacts.presentation.sort.SortDialogFragment; +import com.example.contacts.presentation.main.model.MenuClick; +import com.example.contacts.presentation.sort.model.SortType; +import com.example.contacts.ui.widget.DividerItemDecoration; +import com.example.contacts.utils.widget.EditTextUtils; + +@SuppressLint("UnsafeExperimentalUsageError") +public class MainActivity extends AppCompatActivity { + + public static final String SORT_TAG = "SORT_TAG"; + public static final String FILTER_TAG = "FILTER_TAG"; + + private ActivityMainBinding binding; + private MainViewModel viewModel; + private ContactAdapter adapter; + + private final Map badges = new HashMap<>(); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + binding = ActivityMainBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + setSupportActionBar(binding.toolbar); + binding.toolbar.setTitleTextAppearance(this, R.style.Toolbar_Title); + + adapter = new ContactAdapter(); + binding.recycler.setAdapter(adapter); + + final DividerItemDecoration decoration = new DividerItemDecoration(this, R.drawable.item_decoration_72dp, DividerItemDecoration.VERTICAL); + binding.recycler.addItemDecoration(decoration); + + viewModel = new ViewModelProvider(this).get(MainViewModel.class); + viewModel.getContactsLiveDate().observe(this, this::updateContacts); + viewModel.getUiStateLiveDate().observe(this, this::updateUiState); + + createBadges(); + EditTextUtils.addTextListener(binding.searchLayout.searchText, query -> viewModel.updateSearchText(query.toString())); + EditTextUtils.debounce(binding.searchLayout.searchText, query -> viewModel.search()); + binding.searchLayout.resetButton.setOnClickListener(view -> clearSearch()); + + getSupportFragmentManager().setFragmentResultListener(SortDialogFragment.REQUEST_KEY, this, (requestKey, result) -> { + final SortType newSortType = SortDialogFragment.from(result); + viewModel.updateSortType(newSortType); + }); + + getSupportFragmentManager().setFragmentResultListener(FilterContactTypeDialogFragment.REQUEST_KEY, this, (requestKey, result) -> { + final Set newFilterContactTypes = FilterContactTypeDialogFragment.from(result); + viewModel.updateFilterContactTypes(newFilterContactTypes); + }); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.menu_main, menu); + attachBadges(); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int id = item.getItemId(); + if (id == R.id.menu_sort) { + viewModel.onMenuClick(MenuClick.SORT); + return true; + } + if (id == R.id.menu_filter) { + viewModel.onMenuClick(MenuClick.FILTER); + return true; + } + if (id == R.id.menu_search) { + viewModel.onMenuClick(MenuClick.SEARCH); + return true; + } + + return super.onOptionsItemSelected(item); + } + + private void showSortDialog(SortType sortType) { + SortDialogFragment.newInstance(sortType).show(getSupportFragmentManager(), SORT_TAG); + } + + private void showFilterContactTypeDialog(Set contactTypes) { + FilterContactTypeDialogFragment.newInstance(contactTypes).show(getSupportFragmentManager(), FILTER_TAG); + } + + @Override + public void onBackPressed() { + viewModel.onBackPressed(); + super.onBackPressed(); + } + + private void updateContacts(List contacts) { + adapter.setItems(contacts, () -> binding.recycler.scrollToPosition(0)); + if (contacts.size() > 0) { + binding.recycler.setVisibility(View.VISIBLE); + binding.nothingFound.setVisibility(View.GONE); + } else { + binding.recycler.setVisibility(View.GONE); + binding.nothingFound.setVisibility(View.VISIBLE); + } + } + + private void updateUiState(MainViewModel.UiState uiState) { + final Boolean finishActivity = uiState.actions.finishActivity.data; + if (finishActivity != null && finishActivity) { + finish(); + return; + } + binding.searchLayout.getRoot().setVisibility(uiState.searchVisibility ? View.VISIBLE : View.GONE); + binding.searchLayout.resetButton.setVisibility(uiState.resetSearchButtonVisibility ? View.VISIBLE : View.GONE); + if (uiState.actions.showSortTypeDialog.data != null) { + showSortDialog(uiState.actions.showSortTypeDialog.data); + } + final Set filterContactTypes = uiState.actions.showFilterContactTypeDialog.data; + if (filterContactTypes != null && filterContactTypes.size() > 0) { + showFilterContactTypeDialog(filterContactTypes); + } + updateBadges(uiState); + } + + private void updateBadges(MainViewModel.UiState uiState) { + updateBadge(uiState.menuBadges.sort, R.id.menu_sort); + updateBadge(uiState.menuBadges.filter, R.id.menu_filter); + updateBadge(uiState.menuBadges.search, R.id.menu_search); + } + + private void updateBadge(MainViewModel.UiState.MenuBadge badge, @IdRes int menuItemId) { + final BadgeDrawable drawable = Objects.requireNonNull(badges.get(menuItemId)); + if (badge != null) { + drawable.setVisible(true); + if (badge.value > 0) { + drawable.setNumber(badge.value); + } else { + drawable.clearNumber(); + } + } else { + drawable.setVisible(false); + } + } + + private void createBadges() { + badges.put(R.id.menu_sort, createBadge()); + badges.put(R.id.menu_filter, createBadge()); + badges.put(R.id.menu_search, createBadge()); + } + + @OptIn(markerClass = ExperimentalBadgeUtils.class) + private void attachBadges(){ + for (Map.Entry entry : badges.entrySet()) { + BadgeUtils.attachBadgeDrawable(entry.getValue(), binding.toolbar, entry.getKey()); + } + } + + private BadgeDrawable createBadge() { + final BadgeDrawable drawable = BadgeDrawable.create(this); + drawable.setBackgroundColor(ContextCompat.getColor(this, R.color.color_red)); + drawable.setVisible(false); + return drawable; + } + + private void clearSearch() { + binding.searchLayout.searchText.setText(""); + viewModel.search(); + } + + private void toast(@StringRes int res) { + Toast.makeText(this, res, Toast.LENGTH_SHORT).show(); + } +} diff --git a/app/src/main/java/com/example/contacts/presentation/main/MainState.java b/app/src/main/java/com/example/contacts/presentation/main/MainState.java new file mode 100644 index 000000000..121a86cc4 --- /dev/null +++ b/app/src/main/java/com/example/contacts/presentation/main/MainState.java @@ -0,0 +1,70 @@ +package com.example.contacts.presentation.main; + +import androidx.annotation.NonNull; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import com.example.contacts.model.ContactType; +import com.example.contacts.model.MergedContact; +import com.example.contacts.presentation.sort.model.SortType; + +public class MainState { + + private final SortType defaultSortType = SortType.BY_NAME; + private final Set defaultContactTypes = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(ContactType.values()))); + + private List allContacts = Collections.emptyList(); + private SortType sortType = defaultSortType; + private Set contactTypes = new HashSet<>(defaultContactTypes); + private String query = ""; + + @NonNull + public List getAllContacts() { + return allContacts; + } + + public void setAllContacts(@NonNull List allContacts) { + this.allContacts = allContacts; + } + + @NonNull + public SortType getDefaultSortType() { + return defaultSortType; + } + + @NonNull + public SortType getSortType() { + return sortType; + } + + public void setSortType(@NonNull SortType sortType) { + this.sortType = sortType; + } + + @NonNull + public Set getDefaultContactTypes() { + return defaultContactTypes; + } + + @NonNull + public Set getContactTypes() { + return contactTypes; + } + + public void setContactTypes(@NonNull Set contactTypes) { + this.contactTypes = contactTypes; + } + + @NonNull + public String getQuery() { + return query; + } + + public void setQuery(@NonNull String query) { + this.query = query; + } +} diff --git a/app/src/main/java/com/example/contacts/presentation/main/MainViewModel.java b/app/src/main/java/com/example/contacts/presentation/main/MainViewModel.java new file mode 100644 index 000000000..11f42b6ea --- /dev/null +++ b/app/src/main/java/com/example/contacts/presentation/main/MainViewModel.java @@ -0,0 +1,290 @@ +package com.example.contacts.presentation.main; + +import android.app.Application; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import com.example.contacts.model.Contact; +import com.example.contacts.model.ContactSource; +import com.example.contacts.model.ContactType; +import com.example.contacts.model.MergedContact; +import com.example.contacts.interactor.ContactMerger; +import com.example.contacts.repository.ContactRepository; +import com.example.contacts.repository.ContactSourceRepository; +import com.example.contacts.mapper.ContactUiMapper; +import com.example.contacts.presentation.main.model.MenuClick; +import com.example.contacts.presentation.sort.model.SortType; +import com.example.contacts.utils.java.ThreadUtils; +import com.example.contacts.utils.model.MergedContactUtils; + +public class MainViewModel extends AndroidViewModel { + + private final ContactSourceRepository contactSourceRepository; + private final ContactRepository contactRepository; + private final ContactMerger contactMerger; + private final ContactUiMapper uiMapper; + + private final MutableLiveData> contactsLiveDate = new MutableLiveData<>(); + private final MutableLiveData uiStateLiveDate = new MutableLiveData<>(); + + private final MainState state = new MainState(); + private final UiState uiState = new UiState(); + + public MainViewModel(@NonNull Application application) { + super(application); + contactSourceRepository = new ContactSourceRepository(application); + contactRepository = new ContactRepository(application); + contactMerger = new ContactMerger(); + uiMapper = new ContactUiMapper(); + ThreadUtils.runAsync(this::initLoading); + } + + public LiveData> getContactsLiveDate() { + return contactsLiveDate; + } + + public MutableLiveData getUiStateLiveDate() { + return uiStateLiveDate; + } + + public void initLoading() { + final Set sources = contactSourceRepository.getAllContactSources(); + final List sourceNames = sources.stream() + .map(ContactSource::getName) + .collect(Collectors.toList()); + final List contacts = contactRepository.getContacts(sourceNames); + + final List allContacts = contactMerger.getMergedContacts(contacts, sources); + state.setAllContacts(allContacts); + mapContactsAndUpdate(); + } + + public void search() { + mapContactsAndUpdate(); + } + + public void onMenuClick(MenuClick click) { + switch (click) { + case SORT: + uiState.actions.showSortTypeDialog.data = state.getSortType(); + break; + case FILTER: + uiState.actions.showFilterContactTypeDialog.data = new HashSet<>(state.getContactTypes()); + break; + case SEARCH: + uiState.searchVisibility = !uiState.searchVisibility; + break; + } + updateUiState(); + } + + public void updateSortType(SortType sortType) { + state.setSortType(sortType); + updateBadges(); + mapContactsAndUpdate(); + } + + public void updateFilterContactTypes(Set filterContactTypes) { + state.setContactTypes(filterContactTypes); + updateBadges(); + mapContactsAndUpdate(); + } + + public void onBackPressed() { + if (uiState.searchVisibility) { + uiState.searchVisibility = false; + } else { + uiState.actions.finishActivity.data = true; + } + updateUiState(); + } + + public void updateSearchText(String query) { + state.setQuery(query); + uiState.resetSearchButtonVisibility = state.getQuery().length() != 0; + updateUiState(); + } + + private void updateBadges() { + if (state.getSortType() != state.getDefaultSortType()) { + uiState.menuBadges.sort = new UiState.MenuBadge(0); + } else { + uiState.menuBadges.sort = null; + } + + if (!state.getContactTypes().equals(state.getDefaultContactTypes())) { + uiState.menuBadges.filter = new UiState.MenuBadge(state.getContactTypes().size()); + } else { + uiState.menuBadges.filter = null; + } + + updateUiState(); + } + + private void mapContactsAndUpdate() { + final List uiContacts = state.getAllContacts().stream() + .filter(contact -> MergedContactUtils.contains(contact, state.getQuery())) + .filter(contact -> MergedContactUtils.contains(contact, state.getContactTypes())) + .sorted(createComparator(state.getSortType())) + .map(uiMapper::map) + .collect(Collectors.toList()); + contactsLiveDate.postValue(uiContacts); + } + + private Comparator createComparator(SortType type) { + switch (type) { + case BY_NAME: + return createComparator(MergedContact::getFirstName) + .thenComparing(createComparator(MergedContact::getSurname)) + .thenComparing(createComparator(MergedContact::getNormalizedNumber)) + .thenComparing(createComparator(MergedContact::getEmail)); + case BY_NAME_REVERSED: + return createReversedComparator(MergedContact::getFirstName) + .thenComparing(createReversedComparator(MergedContact::getSurname)) + .thenComparing(createReversedComparator(MergedContact::getNormalizedNumber)) + .thenComparing(createReversedComparator(MergedContact::getEmail)); + case BY_SURNAME: + return createComparator(MergedContact::getSurname) + .thenComparing(createComparator(MergedContact::getFirstName)) + .thenComparing(createComparator(MergedContact::getNormalizedNumber)) + .thenComparing(createComparator(MergedContact::getEmail)); + case BY_SURNAME_REVERSED: + return createReversedComparator(MergedContact::getSurname) + .thenComparing(createReversedComparator(MergedContact::getFirstName)) + .thenComparing(createReversedComparator(MergedContact::getNormalizedNumber)) + .thenComparing(createReversedComparator(MergedContact::getEmail)); + default: + throw new IllegalArgumentException("Not supported SortType"); + } + } + + private Comparator createComparator(Function keyExtractor) { + return (left, right) -> { + final String leftField = keyExtractor.apply(left); + final String rightField = keyExtractor.apply(right); + if (!TextUtils.isEmpty(leftField) && !TextUtils.isEmpty(rightField)) { + return leftField.compareTo(rightField); + } + // Empty lines should be after + if (TextUtils.isEmpty(leftField) && !TextUtils.isEmpty(rightField)) { + return 1; + } + if (!TextUtils.isEmpty(leftField) && TextUtils.isEmpty(rightField)) { + return -1; + } + return 0; + }; + } + + private Comparator createReversedComparator(Function keyExtractor) { + return (left, right) -> { + final String leftField = keyExtractor.apply(left); + final String rightField = keyExtractor.apply(right); + if (!TextUtils.isEmpty(leftField) && !TextUtils.isEmpty(rightField)) { + return rightField.compareTo(leftField); + } + // Empty lines should be after + if (TextUtils.isEmpty(leftField) && !TextUtils.isEmpty(rightField)) { + return 1; + } + if (!TextUtils.isEmpty(leftField) && TextUtils.isEmpty(rightField)) { + return -1; + } + return 0; + }; + } + + private void updateUiState() { + uiStateLiveDate.setValue(uiState.copy()); + uiState.actions.clear(); + } + + public static class UiState { + + public boolean searchVisibility = false; + public boolean resetSearchButtonVisibility = false; + + public Actions actions = new Actions(); + public MenuBadges menuBadges = new MenuBadges(); + + @NonNull + public UiState copy() { + final UiState copy = new UiState(); + copy.searchVisibility = searchVisibility; + copy.resetSearchButtonVisibility = resetSearchButtonVisibility; + copy.actions = actions.copy(); + copy.menuBadges = menuBadges.copy(); + return copy; + } + + public static class Actions { + public Action finishActivity = new Action<>(false); + public Action showSortTypeDialog = new Action<>(null); + public Action> showFilterContactTypeDialog = new Action<>(Collections.emptySet()); + + @NonNull + public Actions copy() { + final Actions copy = new Actions(); + copy.finishActivity = new Action<>(finishActivity.data); + copy.showSortTypeDialog = new Action<>(showSortTypeDialog.data); + copy.showFilterContactTypeDialog = new Action<>(showFilterContactTypeDialog.data); + return copy; + } + + public void clear() { + finishActivity.data = false; + showSortTypeDialog.data = null; + showFilterContactTypeDialog.data = Collections.emptySet(); + } + } + + public static class Action { + @Nullable + public T data; + + public Action(@Nullable T value) { + this.data = value; + } + } + + public static class MenuBadges { + + @Nullable + public MenuBadge sort = null; + @Nullable + public MenuBadge filter = null; + @Nullable + public MenuBadge search = null; + + public MenuBadges copy() { + final MenuBadges copy = new MenuBadges(); + copy.sort = sort == null ? null : new MenuBadge(sort.value); + copy.filter = filter == null ? null : new MenuBadge(filter.value); + copy.search = search == null ? null : new MenuBadge(search.value); + return copy; + } + } + + public static class MenuBadge { + + public int value; + + public MenuBadge(int value) { + this.value = value; + } + } + } +} diff --git a/app/src/main/java/com/example/contacts/presentation/main/model/MenuClick.java b/app/src/main/java/com/example/contacts/presentation/main/model/MenuClick.java new file mode 100644 index 000000000..a339b2c38 --- /dev/null +++ b/app/src/main/java/com/example/contacts/presentation/main/model/MenuClick.java @@ -0,0 +1,5 @@ +package com.example.contacts.presentation.main.model; + +public enum MenuClick { + SORT, FILTER, SEARCH +} diff --git a/app/src/main/java/com/example/contacts/presentation/sort/SortDialogFragment.java b/app/src/main/java/com/example/contacts/presentation/sort/SortDialogFragment.java new file mode 100644 index 000000000..75e28150b --- /dev/null +++ b/app/src/main/java/com/example/contacts/presentation/sort/SortDialogFragment.java @@ -0,0 +1,80 @@ +package com.example.contacts.presentation.sort; + +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.DividerItemDecoration; + +import java.util.List; +import java.util.Objects; + +import com.example.contacts.R; +import com.example.contacts.presentation.base.BaseBottomSheetDialogFragment; +import com.example.contacts.presentation.sort.model.SortType; + +public class SortDialogFragment extends BaseBottomSheetDialogFragment { + + public static final String REQUEST_KEY = "REQUEST_KEY_SORT"; + public static final String ARG_SELECTED_SORT_TYPE = "ARG_SELECTED_SORT_TYPE"; + + private SortTypeAdapter adapter; + + private SortDialogFragment() { + super(SortViewModel.class); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + iniViewModel(); + adapter = new SortTypeAdapter(viewModel::onSortTypeItemClick); + binding.recycler.setAdapter(adapter); + + final DividerItemDecoration decoration = new DividerItemDecoration(requireActivity(), DividerItemDecoration.VERTICAL); + decoration.setDrawable(Objects.requireNonNull(ContextCompat.getDrawable(requireActivity(), R.drawable.item_decoration_16dp))); + binding.recycler.addItemDecoration(decoration); + + viewModel.getSortTypesLiveDate().observe(this, this::updateSortTypes); + viewModel.getUiStateLiveDate().observe(this, this::updateState); + } + + private void iniViewModel() { + final SortType defaultSortType = from(getArguments()); + viewModel.init(defaultSortType); + } + + private void updateSortTypes(List sortTypes) { + adapter.setItems(sortTypes); + } + + private void updateState(SortViewModel.UiState state) { + binding.applyButton.setEnabled(state.isApplyEnable); + + if (state.newSelectedSortType != null) { + getParentFragmentManager().setFragmentResult(REQUEST_KEY, createBundle(state.newSelectedSortType)); + dismiss(); + } + } + + public static SortDialogFragment newInstance(SortType selectedSortType) { + final SortDialogFragment fragment = new SortDialogFragment(); + fragment.setArguments(createBundle(selectedSortType)); + return fragment; + } + + public static SortType from(@Nullable Bundle bundle) { + if (bundle == null) { + return SortType.BY_NAME; + } + return (SortType) bundle.getSerializable(ARG_SELECTED_SORT_TYPE); + } + + private static Bundle createBundle(SortType sortType) { + final Bundle bundle = new Bundle(); + bundle.putSerializable(ARG_SELECTED_SORT_TYPE, sortType); + return bundle; + } +} diff --git a/app/src/main/java/com/example/contacts/presentation/sort/SortTypeAdapter.java b/app/src/main/java/com/example/contacts/presentation/sort/SortTypeAdapter.java new file mode 100644 index 000000000..124837dd3 --- /dev/null +++ b/app/src/main/java/com/example/contacts/presentation/sort/SortTypeAdapter.java @@ -0,0 +1,92 @@ +package com.example.contacts.presentation.sort; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.AdapterListUpdateCallback; +import androidx.recyclerview.widget.AsyncDifferConfig; +import androidx.recyclerview.widget.AsyncListDiffer; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.List; +import java.util.function.Consumer; + +import com.example.contacts.R; +import com.example.contacts.databinding.ItemSortBinding; +import com.example.contacts.presentation.base.BaseListDiffCallback; +import com.example.contacts.presentation.sort.model.SortType; + +public class SortTypeAdapter extends RecyclerView.Adapter { + + private final AsyncListDiffer differ = new AsyncListDiffer<>( + new AdapterListUpdateCallback(this), + new AsyncDifferConfig.Builder<>(new BaseListDiffCallback()).build() + ); + + private final Consumer clickListener; + + public SortTypeAdapter(Consumer clickListener) { + this.clickListener = clickListener; + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + final LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + final ItemSortBinding binding = ItemSortBinding.inflate(inflater, parent, false); + return new ViewHolder(binding, clickListener); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + holder.bind(differ.getCurrentList().get(position)); + } + + @Override + public int getItemCount() { + return differ.getCurrentList().size(); + } + + public void setItems(List items) { + differ.submitList(items); + } + + static class ViewHolder extends RecyclerView.ViewHolder { + + private final ItemSortBinding binding; + + private SortTypeUI data; + + public ViewHolder(@NonNull ItemSortBinding binding, Consumer clickListener) { + super(binding.getRoot()); + this.binding = binding; + this.binding.getRoot().setOnClickListener(v -> clickListener.accept(data)); + } + + public void bind(SortTypeUI data) { + this.data = data; + final int sortResId = resource(data.getSortType()); + binding.text.setText(sortResId); + binding.selected.setVisibility(data.isSelected() ? View.VISIBLE : View.GONE); + } + + private int resource(SortType sortType) { + switch (sortType) { + case BY_NAME: + return R.string.sort_by_name; + case BY_NAME_REVERSED: + return R.string.sort_by_name_reversed; + case BY_SURNAME: + return R.string.sort_by_surname; + case BY_SURNAME_REVERSED: + return R.string.sort_by_surname_reversed; + default: + throw new IllegalArgumentException("Not supported SortType"); + } + } + } +} diff --git a/app/src/main/java/com/example/contacts/presentation/sort/SortTypeUI.java b/app/src/main/java/com/example/contacts/presentation/sort/SortTypeUI.java new file mode 100644 index 000000000..09dbac797 --- /dev/null +++ b/app/src/main/java/com/example/contacts/presentation/sort/SortTypeUI.java @@ -0,0 +1,48 @@ +package com.example.contacts.presentation.sort; + +import androidx.annotation.NonNull; + +import com.example.contacts.presentation.base.ListDiffInterface; +import com.example.contacts.presentation.sort.model.SortType; + +public class SortTypeUI implements ListDiffInterface { + + private final SortType sortType; + private final boolean selected; + + public SortTypeUI(@NonNull SortType sortType, boolean selected) { + this.sortType = sortType; + this.selected = selected; + } + + public SortType getSortType() { + return sortType; + } + + public boolean isSelected() { + return selected; + } + + @Override + public boolean theSameAs(SortTypeUI other) { + return this.sortType == other.sortType; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + SortTypeUI that = (SortTypeUI) o; + + if (selected != that.selected) return false; + return sortType == that.sortType; + } + + @Override + public int hashCode() { + int result = sortType.hashCode(); + result = 31 * result + (selected ? 1 : 0); + return result; + } +} diff --git a/app/src/main/java/com/example/contacts/presentation/sort/SortViewModel.java b/app/src/main/java/com/example/contacts/presentation/sort/SortViewModel.java new file mode 100644 index 000000000..6da38a8fc --- /dev/null +++ b/app/src/main/java/com/example/contacts/presentation/sort/SortViewModel.java @@ -0,0 +1,74 @@ +package com.example.contacts.presentation.sort; + +import androidx.annotation.Nullable; +import androidx.lifecycle.MutableLiveData; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import com.example.contacts.presentation.base.BaseBottomSheetViewModel; +import com.example.contacts.presentation.sort.model.SortType; + +public class SortViewModel extends BaseBottomSheetViewModel { + + private final UiState uiState = new UiState(); + private final MutableLiveData> sortTypesLiveDate = new MutableLiveData<>(); + private final MutableLiveData uiStateLiveDate = new MutableLiveData<>(); + + private SortType defaultSortType; + private SortType selectedSortType; + + public void init(SortType defaultSortType) { + this.defaultSortType = defaultSortType; + this.selectedSortType = defaultSortType; + updateSortTypes(); + updateUiState(); + } + + public void onSortTypeItemClick(SortTypeUI sortType) { + selectedSortType = sortType.getSortType(); + updateSortTypes(); + updateUiState(); + } + + @Override + public void onApplyClick() { + uiState.newSelectedSortType = selectedSortType; + updateUiState(); + } + + @Override + public void onResetClick() { + selectedSortType = defaultSortType; + updateSortTypes(); + updateUiState(); + } + + public MutableLiveData> getSortTypesLiveDate() { + return sortTypesLiveDate; + } + + public MutableLiveData getUiStateLiveDate() { + return uiStateLiveDate; + } + + private void updateSortTypes() { + final SortType[] sortTypes = SortType.values(); + final List sortTypesUi = Arrays.stream(sortTypes) + .map(sortType -> new SortTypeUI(sortType, Objects.equals(sortType, selectedSortType))) + .collect(Collectors.toList()); + sortTypesLiveDate.setValue(sortTypesUi); + } + + private void updateUiState() { + uiState.isApplyEnable = defaultSortType != selectedSortType; + uiStateLiveDate.setValue(uiState); + } + + static class UiState { + public boolean isApplyEnable = false; + @Nullable public SortType newSelectedSortType = null; + } +} diff --git a/app/src/main/java/com/example/contacts/presentation/sort/model/SortType.java b/app/src/main/java/com/example/contacts/presentation/sort/model/SortType.java new file mode 100644 index 000000000..ce251e178 --- /dev/null +++ b/app/src/main/java/com/example/contacts/presentation/sort/model/SortType.java @@ -0,0 +1,8 @@ +package com.example.contacts.presentation.sort.model; + +public enum SortType { + BY_NAME, + BY_NAME_REVERSED, + BY_SURNAME, + BY_SURNAME_REVERSED +} diff --git a/app/src/main/java/com/example/contacts/repository/ContactRepository.java b/app/src/main/java/com/example/contacts/repository/ContactRepository.java new file mode 100644 index 000000000..252233a02 --- /dev/null +++ b/app/src/main/java/com/example/contacts/repository/ContactRepository.java @@ -0,0 +1,233 @@ +package com.example.contacts.repository; + +import android.content.Context; +import android.net.Uri; +import android.provider.ContactsContract.CommonDataKinds; +import android.provider.ContactsContract.CommonDataKinds.Organization; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.CommonDataKinds.StructuredName; +import android.provider.ContactsContract.CommonDataKinds.StructuredPostal; +import android.provider.ContactsContract.Data; +import android.provider.ContactsContract.RawContacts; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; +import android.util.SparseArray; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import com.example.contacts.model.Address; +import com.example.contacts.model.Contact; +import com.example.contacts.model.Email; +import com.example.contacts.model.PhoneNumber; +import com.example.contacts.utils.android.ContextUtils; +import com.example.contacts.utils.android.CursorUtils; + +public class ContactRepository { + + private final Context context; + + public ContactRepository(Context context) { + this.context = context; + } + + public List getContacts(Collection sources) { + if (!ContextUtils.hasContactPermissions(context)) { + return Collections.emptyList(); + } + + List contacts = new ArrayList<>(); + + final Uri uri = Data.CONTENT_URI; + final String[] projection = getContactProjection(); + SparseArray> emails = getEmails(sources); + SparseArray> phoneNumbers = getPhoneNumbers(sources); + SparseArray> addresses = getAddresses(sources); + + String[] mimeTypes = new String[]{Organization.CONTENT_ITEM_TYPE, StructuredName.CONTENT_ITEM_TYPE}; + for (String mimeType : mimeTypes) { + String selection = Data.MIMETYPE + " = ?"; + String[] selectionArgs = new String[]{mimeType}; + String sortOrder = Data.RAW_CONTACT_ID; + + ContextUtils.query(context, uri, projection, selection, selectionArgs, cursor -> { + String accountName = CursorUtils.getString(cursor, RawContacts.ACCOUNT_NAME); + + int id = CursorUtils.getInteger(cursor, Data.RAW_CONTACT_ID); + String prefix = ""; + String firstName = ""; + String middleName = ""; + String surname = ""; + String suffix = ""; + + // ignore names at Organization type contacts + if (Objects.equals(mimeType, StructuredName.CONTENT_ITEM_TYPE)) { + prefix = CursorUtils.getString(cursor, StructuredName.PREFIX); + firstName = CursorUtils.getString(cursor, StructuredName.GIVEN_NAME); + middleName = CursorUtils.getString(cursor, StructuredName.MIDDLE_NAME); + surname = CursorUtils.getString(cursor, StructuredName.FAMILY_NAME); + suffix = CursorUtils.getString(cursor, StructuredName.SUFFIX); + } + + String photoUri = CursorUtils.getString(cursor, StructuredName.PHOTO_URI); + int starred = CursorUtils.getInteger(cursor, StructuredName.STARRED); + int contactId = CursorUtils.getInteger(cursor, Data.CONTACT_ID); + String thumbnailUri = CursorUtils.getString(cursor, StructuredName.PHOTO_THUMBNAIL_URI); + + Contact contact = new Contact( + id, + prefix, + firstName, + middleName, + surname, + suffix, + photoUri, + phoneNumbers.get(id, new ArrayList<>()), + emails.get(id, new ArrayList<>()), + addresses.get(id, new ArrayList<>()), + accountName, + starred, + contactId, + thumbnailUri + ); + + contacts.add(contact); + }); + } + + return Collections.unmodifiableList(contacts); + } + + private String[] getContactProjection() { + return new String[]{ + Data.MIMETYPE, + Data.CONTACT_ID, + Data.RAW_CONTACT_ID, + StructuredName.PREFIX, + StructuredName.GIVEN_NAME, + StructuredName.MIDDLE_NAME, + StructuredName.FAMILY_NAME, + StructuredName.SUFFIX, + StructuredName.PHOTO_URI, + StructuredName.PHOTO_THUMBNAIL_URI, + StructuredName.STARRED, + StructuredName.CUSTOM_RINGTONE, + RawContacts.ACCOUNT_NAME, + RawContacts.ACCOUNT_TYPE + }; + } + + private SparseArray> getEmails(Collection sources) { + SparseArray> emails = new SparseArray<>(); + Uri uri = CommonDataKinds.Email.CONTENT_URI; + String[] projection = new String[]{ + Data.RAW_CONTACT_ID, + CommonDataKinds.Email.DATA, + CommonDataKinds.Email.TYPE, + CommonDataKinds.Email.LABEL + }; + + String selection = getSourcesSelection(sources.size()); + String[] selectionArgs = getSourcesSelectionArgs(sources); + + ContextUtils.query(context, uri, projection, selection, selectionArgs, cursor -> { + int id = CursorUtils.getInteger(cursor, Data.RAW_CONTACT_ID); + String email = CursorUtils.getString(cursor, CommonDataKinds.Email.DATA); + int type = CursorUtils.getInteger(cursor, CommonDataKinds.Email.TYPE); + String label = CursorUtils.getString(cursor, CommonDataKinds.Email.LABEL); + + if (!TextUtils.isEmpty(email)) { + if (emails.get(id) == null) { + emails.put(id, new ArrayList<>()); + } + emails.get(id).add(new Email(email, type, label)); + } + }); + return emails; + } + + private SparseArray> getPhoneNumbers(Collection sources) { + SparseArray> phoneNumbers = new SparseArray<>(); + Uri uri = Phone.CONTENT_URI; + String[] projection = new String[]{ + Data.RAW_CONTACT_ID, + Phone.NUMBER, + Phone.NORMALIZED_NUMBER, + Phone.TYPE, + Phone.LABEL + }; + + String selection = getSourcesSelection(sources.size()); + String[] selectionArgs = getSourcesSelectionArgs(sources); + + ContextUtils.query(context, uri, projection, selection, selectionArgs, cursor -> { + int id = CursorUtils.getInteger(cursor, Data.RAW_CONTACT_ID); + String number = CursorUtils.getString(cursor, Phone.NUMBER); + String normalizedNumber = CursorUtils.getString(cursor, Phone.NORMALIZED_NUMBER); + int type = CursorUtils.getInteger(cursor, Phone.TYPE); + String label = CursorUtils.getString(cursor, Phone.LABEL); + + if (!TextUtils.isEmpty(number)) { + if (TextUtils.isEmpty(normalizedNumber)) { + normalizedNumber = PhoneNumberUtils.normalizeNumber(number); + } + if (phoneNumbers.get(id) == null) { + phoneNumbers.put(id, new ArrayList<>()); + } + PhoneNumber phoneNumber = new PhoneNumber(number, type, label, normalizedNumber); + phoneNumbers.get(id).add(phoneNumber); + } + }); + + return phoneNumbers; + } + + private SparseArray> getAddresses(Collection sources) { + SparseArray> addresses = new SparseArray<>(); + Uri uri = StructuredPostal.CONTENT_URI; + String[] projection = new String[]{ + Data.RAW_CONTACT_ID, + StructuredPostal.FORMATTED_ADDRESS, + StructuredPostal.TYPE, + StructuredPostal.LABEL + }; + + String selection = getSourcesSelection(sources.size()); + String[] selectionArgs = getSourcesSelectionArgs(sources); + + ContextUtils.query(context, uri, projection, selection, selectionArgs, cursor -> { + int id = CursorUtils.getInteger(cursor, Data.RAW_CONTACT_ID); + String address = CursorUtils.getString(cursor, StructuredPostal.FORMATTED_ADDRESS); + int type = CursorUtils.getInteger(cursor, StructuredPostal.TYPE); + String label = CursorUtils.getString(cursor, StructuredPostal.LABEL); + + if (!TextUtils.isEmpty(address)) { + if (addresses.get(id) == null) { + addresses.put(id, new ArrayList<>()); + } + + addresses.get(id).add(new Address(address, type, label)); + } + }); + + return addresses; + } + + private String getSourcesSelection(int count) { + return RawContacts.ACCOUNT_NAME + " IN (" + getQuestionMarks(count) + ")"; + } + + private String getQuestionMarks(int count) { + final String[] symbols = new String[count]; + Arrays.fill(symbols, "?"); + return TextUtils.join(",", symbols); + } + + private String[] getSourcesSelectionArgs(Collection sources) { + return sources.toArray(new String[0]); + } +} diff --git a/app/src/main/java/com/example/contacts/repository/ContactSourceRepository.java b/app/src/main/java/com/example/contacts/repository/ContactSourceRepository.java new file mode 100644 index 000000000..a15360ad7 --- /dev/null +++ b/app/src/main/java/com/example/contacts/repository/ContactSourceRepository.java @@ -0,0 +1,147 @@ +package com.example.contacts.repository; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.content.ContentProviderOperation; +import android.content.ContentProviderResult; +import android.content.ContentResolver; +import android.content.Context; +import android.net.Uri; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Groups; +import android.provider.ContactsContract.RawContacts; +import android.provider.ContactsContract.Settings; + +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import com.example.contacts.model.ContactSource; +import com.example.contacts.utils.Constants; +import com.example.contacts.utils.android.ContextUtils; +import com.example.contacts.utils.android.CursorUtils; + +public class ContactSourceRepository { + + private final Context context; + + private boolean wasLocalAccountInitialized = false; + + public ContactSourceRepository(Context context) { + this.context = context; + } + + public Set getAllContactSources() { + if (!ContextUtils.hasContactPermissions(context)) { + return Collections.emptySet(); + } + if (!wasLocalAccountInitialized) { + initializeLocalPhoneAccount(); + wasLocalAccountInitialized = true; + } + + final Set contactSources = new HashSet<>(); + final Account[] accounts = AccountManager.get(context).getAccounts(); + final Set accountSources = Arrays.stream(accounts) + .map(this::getContactSource) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + final Set contentResolverSources = new HashSet<>(getContentResolverAccounts()); + + contactSources.addAll(accountSources); + contactSources.addAll(contentResolverSources); + + if (!containsRegularPhoneSource(contactSources)) { + contactSources.add(new ContactSource(Constants.StorageType.PHONE_STORAGE, Constants.StorageType.PHONE_STORAGE, Constants.StorageType.PHONE_STORAGE)); + } + + contactSources.add(new ContactSource(Constants.StorageType.SMT_PRIVATE, Constants.StorageType.SMT_PRIVATE, Constants.StorageType.PHONE_STORAGE_PRIVATE)); + + return Collections.unmodifiableSet(contactSources); + } + + @Nullable + private ContactSource getContactSource(Account account) { + if (isSyncable(account)) { + return new ContactSource(account.name, account.type, getAccountPublicName(account.type, account.name)); + } else { + return null; + } + } + + private Set getContentResolverAccounts() { + final Uri[] uris = {Groups.CONTENT_URI, Settings.CONTENT_URI, RawContacts.CONTENT_URI}; + final Set sources = new HashSet<>(); + for (Uri uri : uris) { + sources.addAll(fillSourcesFromUri(uri)); + } + return Collections.unmodifiableSet(sources); + } + + private Set fillSourcesFromUri(Uri uri) { + final Set sources = new HashSet<>(); + final String[] projection = {RawContacts.ACCOUNT_NAME, RawContacts.ACCOUNT_TYPE}; + ContextUtils.query(context, uri, projection, cursor -> { + final String name = CursorUtils.getString(cursor, RawContacts.ACCOUNT_NAME); + final String type = CursorUtils.getString(cursor, RawContacts.ACCOUNT_TYPE); + final ContactSource source = new ContactSource(name, type, getAccountPublicName(type, name)); + sources.add(source); + }); + return Collections.unmodifiableSet(sources); + } + + private boolean isSyncable(Account account) { + return ContentResolver.getIsSyncable(account, ContactsContract.AUTHORITY) == 1; + } + + private String getAccountPublicName(String type, String name) { + switch (type) { + case (Constants.Packages.GOOGLE): + return Constants.StorageType.GOOGLE; + case (Constants.Packages.TELEGRAM): + return Constants.StorageType.TELEGRAM; + case (Constants.Packages.SIGNAL): + return Constants.StorageType.SIGNAL; + case (Constants.Packages.WHATSAPP): + return Constants.StorageType.WHATSAPP; + case (Constants.Packages.VIBER): + return Constants.StorageType.VIBER; + case (Constants.Packages.THREEMA): + return Constants.StorageType.THREEMA; + default: + return name; + } + } + + private void initializeLocalPhoneAccount() { + try { + final ContentProviderOperation.Builder builder = ContentProviderOperation.newInsert(RawContacts.CONTENT_URI); + builder.withValue(RawContacts.ACCOUNT_NAME, null); + builder.withValue(RawContacts.ACCOUNT_TYPE, null); + + final ArrayList operations = new ArrayList<>(); + operations.add(builder.build()); + + final ContentProviderResult[] results = context.getContentResolver().applyBatch(ContactsContract.AUTHORITY, operations); + final Optional rawContactUri = Arrays.stream(results).findFirst(); + rawContactUri.ifPresent(value -> context.getContentResolver().delete(value.uri, null, null)); + } catch (Exception ignored) { + + } + } + + private boolean containsRegularPhoneSource(Set contactSources) { + return contactSources.stream().anyMatch(account -> { + final String accountType = account.getType(); + return accountType.startsWith("com.google") || accountType.startsWith("com.android") || accountType.startsWith("com.qualcomm"); + }); + } +} diff --git a/app/src/main/java/com/example/contacts/ui/widget/ContactTypeImageView.java b/app/src/main/java/com/example/contacts/ui/widget/ContactTypeImageView.java new file mode 100644 index 000000000..0273c02db --- /dev/null +++ b/app/src/main/java/com/example/contacts/ui/widget/ContactTypeImageView.java @@ -0,0 +1,35 @@ +package com.example.contacts.ui.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.ImageView; + +import androidx.annotation.NonNull; + +import com.example.contacts.model.ContactType; +import com.example.contacts.utils.model.ContactTypeUtils; + +public class ContactTypeImageView extends StackImageView { + + public ContactTypeImageView(Context context) { + super(context); + } + + public ContactTypeImageView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public ContactTypeImageView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public ContactTypeImageView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @Override + public void loadItem(ContactType item, @NonNull ImageView icon) { + int iconRes = ContactTypeUtils.getIconRes(item); + icon.setImageResource(iconRes); + } +} diff --git a/app/src/main/java/com/example/contacts/ui/widget/DividerItemDecoration.java b/app/src/main/java/com/example/contacts/ui/widget/DividerItemDecoration.java new file mode 100644 index 000000000..a52c5f41a --- /dev/null +++ b/app/src/main/java/com/example/contacts/ui/widget/DividerItemDecoration.java @@ -0,0 +1,133 @@ +package com.example.contacts.ui.widget; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.view.View; +import android.widget.LinearLayout; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.example.contacts.utils.android.ContextUtils; + + +public class DividerItemDecoration extends RecyclerView.ItemDecoration { + + public static final int HORIZONTAL = LinearLayout.HORIZONTAL; + public static final int VERTICAL = LinearLayout.VERTICAL; + + @NonNull + private final Drawable dividerFirst; + @NonNull + private final Drawable dividerOther; + + private int mOrientation; + + private final Rect mBounds = new Rect(); + + public DividerItemDecoration(@NonNull Context context, @DrawableRes int dividerFirst, @DrawableRes int dividerOther, int orientation) { + this.dividerFirst = ContextUtils.requireDrawable(context, dividerFirst); + this.dividerOther = ContextUtils.requireDrawable(context, dividerOther); + setOrientation(orientation); + } + + public DividerItemDecoration(@NonNull Context context, @DrawableRes int divider, int orientation) { + this(context, divider, divider, orientation); + } + + public void setOrientation(int orientation) { + if (orientation != HORIZONTAL && orientation != VERTICAL) { + throw new IllegalArgumentException("Invalid orientation. It should be either HORIZONTAL or VERTICAL"); + } + mOrientation = orientation; + } + + @Override + public void onDraw(@NonNull Canvas c, RecyclerView parent, @NonNull RecyclerView.State state) { + if (parent.getLayoutManager() == null) { + return; + } + if (mOrientation == VERTICAL) { + drawVertical(c, parent); + } else { + drawHorizontal(c, parent); + } + } + + private void drawVertical(Canvas canvas, RecyclerView parent) { + canvas.save(); + final int left; + final int right; + //noinspection AndroidLintNewApi - NewApi lint fails to handle overrides. + if (parent.getClipToPadding()) { + left = parent.getPaddingLeft(); + right = parent.getWidth() - parent.getPaddingRight(); + canvas.clipRect(left, parent.getPaddingTop(), right, + parent.getHeight() - parent.getPaddingBottom()); + } else { + left = 0; + right = parent.getWidth(); + } + + final int childCount = parent.getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = parent.getChildAt(i); + parent.getDecoratedBoundsWithMargins(child, mBounds); + final int bottom = mBounds.bottom + Math.round(child.getTranslationY()); + final int top = bottom - getDivider(i).getIntrinsicHeight(); + getDivider(i).setBounds(left, top, right, bottom); + getDivider(i).draw(canvas); + } + canvas.restore(); + } + + private void drawHorizontal(Canvas canvas, RecyclerView parent) { + canvas.save(); + final int top; + final int bottom; + //noinspection AndroidLintNewApi - NewApi lint fails to handle overrides. + if (parent.getClipToPadding()) { + top = parent.getPaddingTop(); + bottom = parent.getHeight() - parent.getPaddingBottom(); + canvas.clipRect(parent.getPaddingLeft(), top, + parent.getWidth() - parent.getPaddingRight(), bottom); + } else { + top = 0; + bottom = parent.getHeight(); + } + + final int childCount = parent.getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = parent.getChildAt(i); + parent.getDecoratedBoundsWithMargins(child, mBounds); + final int right = mBounds.right + Math.round(child.getTranslationX()); + final int left = right - getDivider(i).getIntrinsicWidth(); + getDivider(i).setBounds(left, top, right, bottom); + getDivider(i).draw(canvas); + } + canvas.restore(); + } + + @Override + public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, + @NonNull RecyclerView.State state) { + final int position = ((RecyclerView.LayoutParams) view.getLayoutParams()).getViewAdapterPosition(); + if (mOrientation == VERTICAL) { + outRect.set(0, 0, 0, getDivider(position).getIntrinsicHeight()); + } else { + outRect.set(0, 0, getDivider(position).getIntrinsicWidth(), 0); + } + } + + @NonNull + private Drawable getDivider(int index) { + if (index == 0) { + return dividerFirst; + } else { + return dividerOther; + } + } +} diff --git a/app/src/main/java/com/example/contacts/ui/widget/StackImageView.java b/app/src/main/java/com/example/contacts/ui/widget/StackImageView.java new file mode 100644 index 000000000..ae5274653 --- /dev/null +++ b/app/src/main/java/com/example/contacts/ui/widget/StackImageView.java @@ -0,0 +1,113 @@ +package com.example.contacts.ui.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.widget.FrameLayout; +import android.widget.ImageView; + +import androidx.annotation.NonNull; + +import java.util.List; + +import com.example.contacts.R; + +public abstract class StackImageView extends FrameLayout { + + protected static final int DEFAULT_COUNT = 3; + protected static final int DEFAULT_ICON_SIZE_RES = R.dimen.stack_image_view_icon_size; + protected static final int DEFAULT_BORDER_SIZE_RES = R.dimen.stack_image_view_icon_border; + protected static final int DEFAULT_ICON_OFFSET_RES = R.dimen.stack_image_view_icon_offset; + + protected int itemsCount; + protected int maxCount; + protected int iconSize; + protected int borderSize; + protected int iconOffset; + + private ImageView[] icons; + + public StackImageView(Context context) { + this(context, null, 0); + } + + public StackImageView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public StackImageView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + applyAttrs(attrs); + init(); + } + + public StackImageView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + applyAttrs(attrs); + init(); + } + + private void applyAttrs(AttributeSet attrs) { + TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable.StackImageView); + + maxCount = a.getInteger(R.styleable.StackImageView_iconsCount, DEFAULT_COUNT); + iconSize = a.getDimensionPixelSize(R.styleable.StackImageView_iconItemSize, + getResources().getDimensionPixelSize(DEFAULT_ICON_SIZE_RES)); + borderSize = a.getDimensionPixelSize(R.styleable.StackImageView_borderSize, + getResources().getDimensionPixelSize(DEFAULT_BORDER_SIZE_RES)); + iconOffset = a.getDimensionPixelSize(R.styleable.StackImageView_iconOffset, + getResources().getDimensionPixelSize(DEFAULT_ICON_OFFSET_RES)); + + a.recycle(); + + if (maxCount <= 0) { + throw new IllegalStateException("icons_count must be greater than zero"); + } + } + + private void init() { + icons = new ImageView[maxCount]; + for (int i = maxCount - 1; i >= 0; i--) { + ImageView view = new ImageView(getContext()); + + FrameLayout.LayoutParams layoutParams = new LayoutParams(iconSize, iconSize); + layoutParams.leftMargin = iconOffset * i; + view.setLayoutParams(layoutParams); + + view.setVisibility(GONE); + view.setBackgroundResource(R.drawable.bg_image_view_borger); + + addView(view); + icons[i] = view; + } + } + + public void setData(List items) { + itemsCount = items.size(); + int size = Math.min(maxCount, itemsCount); + + for (int i = 0; i < size; i++) { + ImageView icon = icons[i]; + icon.setVisibility(VISIBLE); + + T item = items.get(i); + if (i < size - 1 || size == itemsCount) { + loadItem(item, icon); + } else { + loadLastItem(item, icon); + } + } + + if (size < maxCount) { + for (int i = size; i < maxCount; i++) { + icons[i].setVisibility(GONE); + } + } + } + + public abstract void loadItem(T item, @NonNull ImageView icon); + + public void loadLastItem(T item, @NonNull ImageView icon) { + loadItem(item, icon); + } +} diff --git a/app/src/main/java/com/example/contacts/utils/Constants.java b/app/src/main/java/com/example/contacts/utils/Constants.java new file mode 100644 index 000000000..45433ec37 --- /dev/null +++ b/app/src/main/java/com/example/contacts/utils/Constants.java @@ -0,0 +1,28 @@ +package com.example.contacts.utils; + +public class Constants { + + public static class Packages { + public static final String GOOGLE = "com.google"; + public static final String TELEGRAM = "org.telegram.messenger"; + public static final String SIGNAL = "org.thoughtcrime.securesms"; + public static final String WHATSAPP = "com.whatsapp"; + public static final String VIBER = "com.viber.voip"; + public static final String THREEMA = "ch.threema.app"; + } + + public static class StorageType{ + public static final String SMT_PRIVATE = "smt_private" ; + public static final String GOOGLE = "Google"; + public static final String TELEGRAM = "Telegram"; + public static final String SIGNAL = "Signal"; + public static final String WHATSAPP = "WhatsApp"; + public static final String VIBER = "Viber"; + public static final String THREEMA = "Threema"; + public static final String PHONE_STORAGE = "Phone storage"; + public static final String PHONE_STORAGE_PRIVATE = "Phone storage (hidden)"; + } + + + +} diff --git a/app/src/main/java/com/example/contacts/utils/android/ContextUtils.java b/app/src/main/java/com/example/contacts/utils/android/ContextUtils.java new file mode 100644 index 000000000..268145ab4 --- /dev/null +++ b/app/src/main/java/com/example/contacts/utils/android/ContextUtils.java @@ -0,0 +1,55 @@ +package com.example.contacts.utils.android; + +import android.Manifest; +import android.content.Context; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.graphics.drawable.Drawable; +import android.net.Uri; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +import java.util.Objects; +import java.util.function.Consumer; + +public class ContextUtils { + + public static boolean hasContactPermissions(Context context) { + return hasPermission(context, Manifest.permission.READ_CONTACTS); + } + + private static boolean hasPermission(Context context, String permission) { + return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED; + } + + public static void query(Context context, Uri uri, String[] projection, Consumer callback) { + try (Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null)) { + if (cursor.moveToFirst()) { + do { + callback.accept(cursor); + } while (cursor.moveToNext()); + } + } catch (Exception ignored) { + + } + } + + public static void query(Context context, Uri uri, String[] projection, String selection, String[] selectionArgs, Consumer callback) { + try (Cursor cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs, null)) { + if (cursor.moveToFirst()) { + do { + callback.accept(cursor); + } while (cursor.moveToNext()); + } + } catch (Exception ignored) { + + } + } + + @NonNull + public static Drawable requireDrawable(Context context, @DrawableRes int id) { + return Objects.requireNonNull(ContextCompat.getDrawable(context, id)); + } +} diff --git a/app/src/main/java/com/example/contacts/utils/android/CursorUtils.java b/app/src/main/java/com/example/contacts/utils/android/CursorUtils.java new file mode 100644 index 000000000..6ea868ec3 --- /dev/null +++ b/app/src/main/java/com/example/contacts/utils/android/CursorUtils.java @@ -0,0 +1,25 @@ +package com.example.contacts.utils.android; + +import android.database.Cursor; + +public class CursorUtils { + + public static String getString(Cursor cursor, String key) { + final int index = cursor.getColumnIndex(key); + if (index >= 0) { + final String string = cursor.getString(index); + return string != null ? string : ""; + } else { + return ""; + } + } + + public static int getInteger(Cursor cursor, String key) { + final int index = cursor.getColumnIndex(key); + if (index >= 0) { + return cursor.getInt(index); + } else { + return -1; + } + } +} diff --git a/app/src/main/java/com/example/contacts/utils/android/Debouncer.java b/app/src/main/java/com/example/contacts/utils/android/Debouncer.java new file mode 100644 index 000000000..c5de9c50a --- /dev/null +++ b/app/src/main/java/com/example/contacts/utils/android/Debouncer.java @@ -0,0 +1,42 @@ +package com.example.contacts.utils.android; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; + +import androidx.annotation.NonNull; + +public class Debouncer { + + private static final int MESSAGE_ID = 1; + private static final int DELAY = 500; + + private final OnValueUpdateListener listener; + + public Debouncer(OnValueUpdateListener listener) { + this.listener = listener; + } + + @SuppressWarnings("unchecked") + private final Handler handler = new Handler(Looper.getMainLooper()) { + @Override + public void handleMessage(@NonNull Message message) { + if (message.what == MESSAGE_ID) { + listener.onValueUpdate((T) message.obj); + return; + } + super.handleMessage(message); + } + }; + + public void updateValue(T value) { + final Message message = Message.obtain(handler, MESSAGE_ID, value); + handler.removeMessages(MESSAGE_ID); + handler.sendMessageDelayed(message, DELAY); + } + + @FunctionalInterface + public interface OnValueUpdateListener { + void onValueUpdate(T value); + } +} diff --git a/app/src/main/java/com/example/contacts/utils/java/ThreadUtils.java b/app/src/main/java/com/example/contacts/utils/java/ThreadUtils.java new file mode 100644 index 000000000..290d19987 --- /dev/null +++ b/app/src/main/java/com/example/contacts/utils/java/ThreadUtils.java @@ -0,0 +1,25 @@ +package com.example.contacts.utils.java; + +import android.os.Handler; +import android.os.Looper; + +import java.util.function.Consumer; +import java.util.function.Supplier; + +public class ThreadUtils { + + public static void runAsync(Supplier supplier, Consumer consumer) { + final Handler handler = new Handler(Looper.getMainLooper()); + new Thread() { + @Override + public void run() { + final T value = supplier.get(); + handler.post(() -> consumer.accept(value)); + } + }.start(); + } + + public static void runAsync(Runnable runnable) { + new Thread(runnable).start(); + } +} diff --git a/app/src/main/java/com/example/contacts/utils/model/ContactTypeUtils.java b/app/src/main/java/com/example/contacts/utils/model/ContactTypeUtils.java new file mode 100644 index 000000000..9ceae265f --- /dev/null +++ b/app/src/main/java/com/example/contacts/utils/model/ContactTypeUtils.java @@ -0,0 +1,74 @@ +package com.example.contacts.utils.model; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.example.contacts.R; +import com.example.contacts.model.ContactType; +import com.example.contacts.presentation.filter.model.FilterContactType; +import com.example.contacts.utils.Constants; + +public class ContactTypeUtils { + + @DrawableRes + public static int getIconRes(@NonNull ContactType type) { + switch (type) { + case TELEGRAM: + return R.drawable.ic_type_telegram; + case WHATS_APP: + return R.drawable.ic_type_whatsapp; + case VIBER: + return R.drawable.ic_type_viber; + case SIGNAL: + return R.drawable.ic_type_signal; + case THREEMA: + return R.drawable.ic_type_threema; + case PHONE: + return R.drawable.ic_type_phone; + case EMAIL: + return R.drawable.ic_type_email; + default: + throw new IllegalArgumentException("Not supported type of ContactType"); + } + } + + public static FilterContactType toFilterContactType(ContactType type) { + switch (type) { + case TELEGRAM: + return FilterContactType.TELEGRAM; + case WHATS_APP: + return FilterContactType.WHATS_APP; + case VIBER: + return FilterContactType.VIBER; + case SIGNAL: + return FilterContactType.SIGNAL; + case THREEMA: + return FilterContactType.THREEMA; + case PHONE: + return FilterContactType.PHONE; + case EMAIL: + return FilterContactType.EMAIL; + default: + throw new IllegalArgumentException("Not supported ContactType"); + } + } + + @Nullable + public static ContactType parse(String value) { + switch (value) { + case Constants.StorageType.TELEGRAM: + return ContactType.TELEGRAM; + case Constants.StorageType.WHATSAPP: + return ContactType.WHATS_APP; + case Constants.StorageType.VIBER: + return ContactType.VIBER; + case Constants.StorageType.SIGNAL: + return ContactType.SIGNAL; + case Constants.StorageType.THREEMA: + return ContactType.THREEMA; + default: + return null; + } + } +} diff --git a/app/src/main/java/com/example/contacts/utils/model/ContactUtils.java b/app/src/main/java/com/example/contacts/utils/model/ContactUtils.java new file mode 100644 index 000000000..30ac49744 --- /dev/null +++ b/app/src/main/java/com/example/contacts/utils/model/ContactUtils.java @@ -0,0 +1,42 @@ +package com.example.contacts.utils.model; + +import android.text.TextUtils; + +import com.example.contacts.model.Contact; + +public class ContactUtils { + + public static String getDisplayName(Contact contact) { + final String fullName = getFullName(contact); + if (!TextUtils.isEmpty(fullName)) { + return fullName; + } + final String phone = PhoneUtils.format(getFirstPhone(contact)); + if (!TextUtils.isEmpty(phone)) { + return phone; + } + final String email = getFirstEmail(contact); + if (!TextUtils.isEmpty(email)) { + return email; + } + return ""; + } + + public static String getFullName(Contact contact) { + final String firstMiddle = (contact.getFirstName() + " " + contact.getMiddleName()).trim(); + final String suffixComma = TextUtils.isEmpty(contact.getSuffix()) ? "" : ", " + contact.getSuffix(); + return (contact.getPrefix() + " " + contact.getSurname() + " " + firstMiddle + suffixComma).trim(); + } + + public static String getFirstPhone(Contact contact) { + return !contact.getPhoneNumbers().isEmpty() ? contact.getPhoneNumbers().get(0).getValue().trim() : ""; + } + + public static String getFirstNormalizedPhone(Contact contact) { + return !contact.getPhoneNumbers().isEmpty() ? contact.getPhoneNumbers().get(0).getNormalizedNumber().trim() : ""; + } + + public static String getFirstEmail(Contact contact) { + return !contact.getEmails().isEmpty() ? contact.getEmails().get(0).getValue().trim() : ""; + } +} diff --git a/app/src/main/java/com/example/contacts/utils/model/FilterContactTypeUtils.java b/app/src/main/java/com/example/contacts/utils/model/FilterContactTypeUtils.java new file mode 100644 index 000000000..01b310e5f --- /dev/null +++ b/app/src/main/java/com/example/contacts/utils/model/FilterContactTypeUtils.java @@ -0,0 +1,57 @@ +package com.example.contacts.utils.model; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; + +import com.example.contacts.R; +import com.example.contacts.model.ContactType; +import com.example.contacts.presentation.filter.model.FilterContactType; + +public class FilterContactTypeUtils { + + @StringRes + public static int getStringRes(FilterContactType contactType) { + switch (contactType) { + case ALL: + return R.string.filter_contact_type_all; + case TELEGRAM: + return R.string.filter_contact_type_telegram; + case WHATS_APP: + return R.string.filter_contact_type_whatsapp; + case VIBER: + return R.string.filter_contact_type_viber; + case SIGNAL: + return R.string.filter_contact_type_signal; + case THREEMA: + return R.string.filter_contact_type_threema; + case PHONE: + return R.string.filter_contact_type_phone; + case EMAIL: + return R.string.filter_contact_type_email; + default: + throw new IllegalArgumentException("Not supported SortType"); + } + } + + @NonNull + public static ContactType toContactType(FilterContactType type) { + switch (type) { + case TELEGRAM: + return ContactType.TELEGRAM; + case WHATS_APP: + return ContactType.WHATS_APP; + case VIBER: + return ContactType.VIBER; + case SIGNAL: + return ContactType.SIGNAL; + case THREEMA: + return ContactType.THREEMA; + case PHONE: + return ContactType.PHONE; + case EMAIL: + return ContactType.EMAIL; + default: + throw new IllegalArgumentException("Not supported FilterContactType"); + } + } +} diff --git a/app/src/main/java/com/example/contacts/utils/model/MergedContactUtils.java b/app/src/main/java/com/example/contacts/utils/model/MergedContactUtils.java new file mode 100644 index 000000000..9ef5d4fa6 --- /dev/null +++ b/app/src/main/java/com/example/contacts/utils/model/MergedContactUtils.java @@ -0,0 +1,52 @@ +package com.example.contacts.utils.model; + +import android.text.TextUtils; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +import com.example.contacts.model.ContactType; +import com.example.contacts.model.MergedContact; + +public class MergedContactUtils { + + public static boolean contains(MergedContact contact, String query) { + final String lowerCaseQuery = query.toLowerCase(); + if (TextUtils.isEmpty(lowerCaseQuery)) { + return true; + } + return contact.getFirstName().toLowerCase().contains(lowerCaseQuery) || + contact.getMiddleName().toLowerCase().contains(lowerCaseQuery) || + contact.getSurname().toLowerCase().contains(lowerCaseQuery) || + contact.getNormalizedNumber().toLowerCase().contains(lowerCaseQuery) || + contact.getPhone().toLowerCase().contains(lowerCaseQuery) || + contact.getEmail().toLowerCase().contains(lowerCaseQuery); + } + + public static boolean contains(MergedContact contact, Set types) { + if (types.isEmpty() || types.size() == ContactType.values().length) { + return true; + } + final List contactTypes = getContactTypes(contact); + return !Collections.disjoint(contactTypes, types); + } + + public static List getContactTypes(MergedContact contact) { + final List allTypes = contact.getContactTypes().stream() + .map(ContactTypeUtils::parse) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + if (!TextUtils.isEmpty(contact.getPhone())) { + allTypes.add(ContactType.PHONE); + } + if (!TextUtils.isEmpty(contact.getEmail())) { + allTypes.add(ContactType.EMAIL); + } + Collections.sort(allTypes); + + return Collections.unmodifiableList(allTypes); + } +} diff --git a/app/src/main/java/com/example/contacts/utils/model/PhoneUtils.java b/app/src/main/java/com/example/contacts/utils/model/PhoneUtils.java new file mode 100644 index 000000000..86bf0d331 --- /dev/null +++ b/app/src/main/java/com/example/contacts/utils/model/PhoneUtils.java @@ -0,0 +1,14 @@ +package com.example.contacts.utils.model; + +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; + +import java.util.Locale; + +public class PhoneUtils { + + public static String format(String phone) { + final String formattedPhone = PhoneNumberUtils.formatNumber(phone, Locale.getDefault().getISO3Country()); + return TextUtils.isEmpty(formattedPhone) ? phone : formattedPhone; + } +} diff --git a/app/src/main/java/com/example/contacts/utils/widget/EditTextUtils.java b/app/src/main/java/com/example/contacts/utils/widget/EditTextUtils.java new file mode 100644 index 000000000..08c3699cb --- /dev/null +++ b/app/src/main/java/com/example/contacts/utils/widget/EditTextUtils.java @@ -0,0 +1,49 @@ +package com.example.contacts.utils.widget; + +import android.text.Editable; +import android.text.TextWatcher; +import android.widget.EditText; + +import java.util.function.Consumer; + +import com.example.contacts.utils.android.Debouncer; + +public class EditTextUtils { + + public static void debounce(EditText editText, Debouncer.OnValueUpdateListener listener) { + final Debouncer debouncer = new Debouncer<>(listener); + editText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + + @Override + public void afterTextChanged(Editable s) { + debouncer.updateValue(s); + } + }); + } + + public static void addTextListener(EditText editText, Consumer consumer) { + editText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + + } + + @Override + public void afterTextChanged(Editable s) { + consumer.accept(s); + } + }); + } +} diff --git a/app/src/main/res/color/text_button_blue.xml b/app/src/main/res/color/text_button_blue.xml new file mode 100644 index 000000000..53b750062 --- /dev/null +++ b/app/src/main/res/color/text_button_blue.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/color/text_button_white.xml b/app/src/main/res/color/text_button_white.xml new file mode 100644 index 000000000..427ba803d --- /dev/null +++ b/app/src/main/res/color/text_button_white.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_button_blue.xml b/app/src/main/res/drawable/bg_button_blue.xml new file mode 100644 index 000000000..7655fae8f --- /dev/null +++ b/app/src/main/res/drawable/bg_button_blue.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_button_white.xml b/app/src/main/res/drawable/bg_button_white.xml new file mode 100644 index 000000000..145cd99c4 --- /dev/null +++ b/app/src/main/res/drawable/bg_button_white.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_image_view_borger.xml b/app/src/main/res/drawable/bg_image_view_borger.xml new file mode 100644 index 000000000..8487a64d4 --- /dev/null +++ b/app/src/main/res/drawable/bg_image_view_borger.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_search_box.xml b/app/src/main/res/drawable/bg_search_box.xml new file mode 100644 index 000000000..5f66560ff --- /dev/null +++ b/app/src/main/res/drawable/bg_search_box.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_splash.xml b/app/src/main/res/drawable/bg_splash.xml new file mode 100644 index 000000000..e565e5259 --- /dev/null +++ b/app/src/main/res/drawable/bg_splash.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_avatar.xml b/app/src/main/res/drawable/ic_avatar.xml new file mode 100644 index 000000000..92961b4fa --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 000000000..c231419c4 --- /dev/null +++ b/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_done.xml b/app/src/main/res/drawable/ic_done.xml new file mode 100644 index 000000000..a7db8de6a --- /dev/null +++ b/app/src/main/res/drawable/ic_done.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..14482c7ab --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..a99df9103 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_menu_filter.xml b/app/src/main/res/drawable/ic_menu_filter.xml new file mode 100644 index 000000000..235aef9df --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_filter.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_menu_search.xml b/app/src/main/res/drawable/ic_menu_search.xml new file mode 100644 index 000000000..c75a5e7a3 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_menu_sort.xml b/app/src/main/res/drawable/ic_menu_sort.xml new file mode 100644 index 000000000..bf0699c88 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_sort.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_type_email.xml b/app/src/main/res/drawable/ic_type_email.xml new file mode 100644 index 000000000..f8dd390ba --- /dev/null +++ b/app/src/main/res/drawable/ic_type_email.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_type_phone.xml b/app/src/main/res/drawable/ic_type_phone.xml new file mode 100644 index 000000000..434b0dbe8 --- /dev/null +++ b/app/src/main/res/drawable/ic_type_phone.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_type_signal.xml b/app/src/main/res/drawable/ic_type_signal.xml new file mode 100644 index 000000000..4cd687267 --- /dev/null +++ b/app/src/main/res/drawable/ic_type_signal.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_type_telegram.xml b/app/src/main/res/drawable/ic_type_telegram.xml new file mode 100644 index 000000000..0b977f18a --- /dev/null +++ b/app/src/main/res/drawable/ic_type_telegram.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_type_threema.xml b/app/src/main/res/drawable/ic_type_threema.xml new file mode 100644 index 000000000..6ade9526e --- /dev/null +++ b/app/src/main/res/drawable/ic_type_threema.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_type_viber.xml b/app/src/main/res/drawable/ic_type_viber.xml new file mode 100644 index 000000000..27383dd8a --- /dev/null +++ b/app/src/main/res/drawable/ic_type_viber.xml @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_type_whatsapp.xml b/app/src/main/res/drawable/ic_type_whatsapp.xml new file mode 100644 index 000000000..bc2463481 --- /dev/null +++ b/app/src/main/res/drawable/ic_type_whatsapp.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/item_decoration_16dp.xml b/app/src/main/res/drawable/item_decoration_16dp.xml new file mode 100644 index 000000000..ccb568058 --- /dev/null +++ b/app/src/main/res/drawable/item_decoration_16dp.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/item_decoration_72dp.xml b/app/src/main/res/drawable/item_decoration_72dp.xml new file mode 100644 index 000000000..c3aed1096 --- /dev/null +++ b/app/src/main/res/drawable/item_decoration_72dp.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/font/ys_display_medium.ttf b/app/src/main/res/font/ys_display_medium.ttf new file mode 100644 index 000000000..860c389df Binary files /dev/null and b/app/src/main/res/font/ys_display_medium.ttf differ diff --git a/app/src/main/res/font/ys_display_regular.ttf b/app/src/main/res/font/ys_display_regular.ttf new file mode 100644 index 000000000..4ac0cc71e Binary files /dev/null and b/app/src/main/res/font/ys_display_regular.ttf differ diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 000000000..0a363b828 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_bottom_sheet.xml b/app/src/main/res/layout/fragment_bottom_sheet.xml new file mode 100644 index 000000000..1237c2b63 --- /dev/null +++ b/app/src/main/res/layout/fragment_bottom_sheet.xml @@ -0,0 +1,51 @@ + + + + + +