diff --git a/.github/assets/add-light.png b/.github/assets/add-light.png new file mode 100644 index 0000000..e2f0938 Binary files /dev/null and b/.github/assets/add-light.png differ diff --git a/.github/assets/filter-light.png b/.github/assets/filter-light.png new file mode 100644 index 0000000..b59d3a7 Binary files /dev/null and b/.github/assets/filter-light.png differ diff --git a/.github/assets/logo.png b/.github/assets/logo.png new file mode 100644 index 0000000..14f84b1 Binary files /dev/null and b/.github/assets/logo.png differ diff --git a/.github/assets/main-display-dark.png b/.github/assets/main-display-dark.png new file mode 100644 index 0000000..4a904f4 Binary files /dev/null and b/.github/assets/main-display-dark.png differ diff --git a/.github/assets/main-display-light.png b/.github/assets/main-display-light.png new file mode 100644 index 0000000..3bb16ea Binary files /dev/null and b/.github/assets/main-display-light.png differ diff --git a/.github/assets/search-light.png b/.github/assets/search-light.png new file mode 100644 index 0000000..5b73aed Binary files /dev/null and b/.github/assets/search-light.png differ diff --git a/.gitignore b/.gitignore index 39b6783..bd39932 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ captures/ .idea/dictionaries .idea/libraries .idea/caches +.idea/sonarlint # Keystore files # Uncomment the following line if you do not want to check your keystore files in. @@ -63,3 +64,16 @@ fastlane/Preview.html fastlane/screenshots fastlane/test_output fastlane/readme.md + +# Default gitignore from Android Studio +*.iml +.gradle +/local.properties +/.idea/caches/build_file_checksums.ser +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +.DS_Store +/build +/captures +.externalNativeBuild diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..30aa626 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..77523ec --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..a6b0c90 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..7f68460 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..8725429 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Marc Donald + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e31755f..780a1c8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,73 @@ + + # Earworm -An app for tracking your favourite songs throughout time +An app for tracking your favourite music throughout time. + +## Download +[Click Here to Go To the Latest Release](https://github.com/MarcDonald/Earworm/releases/latest) + +## Features +- Add the names of songs/albums/artists that you have been listening to recently +- Add artwork +- Search by artist name/album name/song name/genre +- Filter by date or type +- Dark theme + +## Screenshots +| Main Screen | Add Screen | Main Screen Dark | +|:-:|:-:|:-:| +| ![Main Screen](/.github/assets/main-display-light.png?raw=true) | ![Add Screen](/.github/assets/add-light.png?raw=true) |![Dark Theme](/.github/assets/main-display-dark.png?raw=true) + +| Search | Filter | +|:-:|:-:| +| ![Search](/.github/assets/search-light.png?raw=true) | ![Filter](/.github/assets/filter-light.png?raw=true) | + +## Open Source Libraries Used +### [Timber](https://github.com/JakeWharton/timber) +Used for logging + +Apache 2 License + +### [Glide](https://github.com/bumptech/glide) +Used for image loading and caching + +[License](https://github.com/bumptech/glide/blob/master/LICENSE) + +### [Material Design Icons](https://github.com/google/material-design-icons) +Used for all the icons within the app + +Apache 2 License + +### [Android File Picker](https://github.com/DroidNinja/Android-FilePicker) +Used for selecting an image + +Apache 2 License + +### [Material Components for Android](https://github.com/material-components/material-components-android) +Used for implementing material design components + +Apache 2 License + +## License +``` +The MIT License (MIT) + +Copyright (c) 2018 Marc Donald + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..109feca --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,60 @@ +apply plugin: 'com.android.application' + +apply plugin: 'kotlin-android' + +apply plugin: 'kotlin-android-extensions' + +apply plugin: 'kotlin-kapt' + +android { + compileSdkVersion 28 + defaultConfig { + applicationId "app.marcdev.earworm" + minSdkVersion 23 + targetSdkVersion 28 + versionCode 12 + versionName "1.0.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation 'androidx.appcompat:appcompat:1.0.2' + implementation 'androidx.preference:preference:1.0.0' + implementation 'androidx.constraintlayout:constraintlayout:2.0.0-alpha2' + implementation 'com.google.android.material:material:1.0.0' + testImplementation 'junit:junit:4.12' + androidTestImplementation 'androidx.test:runner:1.1.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.0' + + // Timber for logging + implementation('com.jakewharton.timber:timber:4.7.0') + + // Room components + implementation 'androidx.room:room-runtime:2.0.0' + kapt 'androidx.room:room-compiler:2.0.0' + androidTestImplementation 'androidx.room:room-testing:2.0.0' + + // Kotlin co-routines for asynchronous code + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.0.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.0.0' + + // Glide for image loading and caching + implementation 'com.github.bumptech.glide:glide:4.8.0' + kapt 'com.github.bumptech.glide:compiler:4.8.0' + + // Android File Picker for choosing an image + implementation 'com.droidninja:filepicker:2.2.0' +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /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 diff --git a/app/src/androidTest/java/app/marcdev/earworm/database/DAOTest.kt b/app/src/androidTest/java/app/marcdev/earworm/database/DAOTest.kt new file mode 100644 index 0000000..f7311c2 --- /dev/null +++ b/app/src/androidTest/java/app/marcdev/earworm/database/DAOTest.kt @@ -0,0 +1,246 @@ +package app.marcdev.earworm.database + +import androidx.room.Room +import androidx.test.platform.app.InstrumentationRegistry +import app.marcdev.earworm.utils.SONG +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +class DAOTest { + + private var database: AppDatabase? = null + private var dao: DAO? = null + + // Default values + private val testName = "Test Song Name" + private val testAlbum = "Test Album Name" + private val testArtist = "Test Artist Name" + private val testGenre = "Test Genre Name" + private val testImageName = "testimagename.jpg" + private val testDay = 1 + private val testMonth = 1 + private val testYear = 2018 + private val testType: Int = SONG + + @Before + fun setUp() { + database = + Room.inMemoryDatabaseBuilder(InstrumentationRegistry.getInstrumentation().context, AppDatabase::class.java) + .allowMainThreadQueries().build() + dao = database!!.dao() + } + + @After + fun tearDown() { + database?.close() + } + + private fun createTestItem(): FavouriteItem { + return FavouriteItem(testName, testAlbum, testArtist, testGenre, testDay, testMonth, testYear, testType, testImageName) + } + + @Test + fun insertOneSong_getAllItems() { + val testItem = createTestItem() + + val returnedItemsWhenNothingInserted: MutableList = database?.dao()!!.getAllItems() + Assert.assertEquals(0, returnedItemsWhenNothingInserted.size) + + database?.dao()!!.insertOrUpdateItem(testItem) + + val returnedItemsWhenOneInserted: MutableList = database?.dao()!!.getAllItems() + Assert.assertEquals(1, returnedItemsWhenOneInserted.size) + } + + @Test + fun insertMultipleSongs_getAllItems() { + val testItem1 = createTestItem() + val testItem2 = createTestItem() + + val returnedItemsWhenNothingInserted: MutableList = database?.dao()!!.getAllItems() + Assert.assertEquals(0, returnedItemsWhenNothingInserted.size) + + database?.dao()!!.insertOrUpdateItem(testItem1) + database?.dao()!!.insertOrUpdateItem(testItem2) + + val returnedItemsWhenOneInserted: MutableList = database?.dao()!!.getAllItems() + Assert.assertEquals(2, returnedItemsWhenOneInserted.size) + } + + @Test + fun insertOneSong_getItemById_deleteSong() { + val testId = 1 + + val testItem = createTestItem() + testItem.id = testId + + val returnedItemsWhenNothingInserted: MutableList = database?.dao()!!.getItemById(testId) + Assert.assertEquals(0, returnedItemsWhenNothingInserted.size) + + database?.dao()!!.insertOrUpdateItem(testItem) + + val returnedItemsWhenOneInserted: MutableList = database?.dao()!!.getItemById(testId) + Assert.assertEquals(1, returnedItemsWhenOneInserted.size) + + database?.dao()!!.deleteItemById(testId) + + val returnedItemsWhenOneDeleted: MutableList = database?.dao()!!.getItemById(testId) + Assert.assertEquals(0, returnedItemsWhenOneDeleted.size) + } + + @Test + fun insertTwoSongs_deleteOneSong() { + val testId1 = 1 + val testId2 = 2 + + val testItem1 = createTestItem() + testItem1.id = testId1 + val testItem2 = createTestItem() + testItem2.id = testId2 + + val returnedItemsWhenNothingInserted: MutableList = database?.dao()!!.getAllItems() + Assert.assertEquals(0, returnedItemsWhenNothingInserted.size) + + database?.dao()!!.insertOrUpdateItem(testItem1) + database?.dao()!!.insertOrUpdateItem(testItem2) + + val returnedItemsWhenTwoInserted: MutableList = database?.dao()!!.getAllItems() + Assert.assertEquals(2, returnedItemsWhenTwoInserted.size) + + database?.dao()!!.deleteItemById(testId1) + + val returnedItemsWhenOneDeleted: MutableList = database?.dao()!!.getAllItems() + Assert.assertEquals(1, returnedItemsWhenOneDeleted.size) + + val returnedItemsWhenDeletedOneSearched: MutableList = database?.dao()!!.getItemById(testId1) + Assert.assertEquals(0, returnedItemsWhenDeletedOneSearched.size) + } + + @Test + fun insertMultipleSongs_getOneById() { + val testId1 = 1 + val testId2 = 2 + + val testItem1 = createTestItem() + testItem1.id = testId1 + val testItem2 = createTestItem() + testItem2.id = testId2 + + val returnedItemsWhenNothingInserted: MutableList = database?.dao()!!.getAllItems() + Assert.assertEquals(0, returnedItemsWhenNothingInserted.size) + + database?.dao()!!.insertOrUpdateItem(testItem1) + database?.dao()!!.insertOrUpdateItem(testItem2) + + val returnedAllItemsWhenTwoInserted: MutableList = database?.dao()!!.getAllItems() + Assert.assertEquals(2, returnedAllItemsWhenTwoInserted.size) + + val returnedItemsWhenSearchedById1: MutableList = database?.dao()!!.getItemById(testId1) + Assert.assertEquals(1, returnedItemsWhenSearchedById1.size) + } + + @Test + fun insertMultipleItemsUsingDifferentImages_countEach() { + val testImage1 = "testImage1.jpg" + val testImage2 = "testImage2.jpg" + + val testItem1 = createTestItem() + testItem1.imageName = testImage1 + + val testItem2 = createTestItem() + testItem2.imageName = testImage2 + + val testItem3 = createTestItem() + testItem3.imageName = testImage2 + + database?.dao()!!.insertOrUpdateItem(testItem1) + database?.dao()!!.insertOrUpdateItem(testItem2) + database?.dao()!!.insertOrUpdateItem(testItem3) + + val returnedValueWhenSearchedForTestImage1: Int = database?.dao()!!.getNumberOfEntriesUsingImage(testImage1) + Assert.assertEquals(1, returnedValueWhenSearchedForTestImage1) + + val returnedValueWhenSearchedForTestImage2: Int = database?.dao()!!.getNumberOfEntriesUsingImage(testImage2) + Assert.assertEquals(2, returnedValueWhenSearchedForTestImage2) + } + + @Test + fun insertMultipleItemsUsingDifferentImages_countEachAndDelete() { + val testImage1 = "testImage1.jpg" + val testImage2 = "testImage2.jpg" + val testId1 = 1 + val testId2 = 2 + val testId3 = 3 + + val testItem1 = createTestItem() + testItem1.imageName = testImage1 + testItem1.id = testId1 + + val testItem2 = createTestItem() + testItem2.imageName = testImage2 + testItem2.id = testId2 + + val testItem3 = createTestItem() + testItem3.imageName = testImage2 + testItem3.id = testId3 + + database?.dao()!!.insertOrUpdateItem(testItem1) + database?.dao()!!.insertOrUpdateItem(testItem2) + database?.dao()!!.insertOrUpdateItem(testItem3) + + val returnedValueWhenSearchedForTestImage1: Int = database?.dao()!!.getNumberOfEntriesUsingImage(testImage1) + Assert.assertEquals(1, returnedValueWhenSearchedForTestImage1) + + val returnedValueWhenSearchedForTestImage2: Int = database?.dao()!!.getNumberOfEntriesUsingImage(testImage2) + Assert.assertEquals(2, returnedValueWhenSearchedForTestImage2) + + database?.dao()!!.deleteItemById(testId2) + + val returnedValueWhenSearchedForTestImage1AfterDelete: Int = database?.dao()!!.getNumberOfEntriesUsingImage(testImage1) + Assert.assertEquals(1, returnedValueWhenSearchedForTestImage1AfterDelete) + + val returnedValueWhenSearchedForTestImage2AfterDelete: Int = database?.dao()!!.getNumberOfEntriesUsingImage(testImage2) + Assert.assertEquals(1, returnedValueWhenSearchedForTestImage2AfterDelete) + } + + @Test + fun insertMultipleItemsUsingDifferentImages_countEachAndDeleteOneCompletely() { + val testImage1 = "testImage1.jpg" + val testImage2 = "testImage2.jpg" + val testId1 = 1 + val testId2 = 2 + val testId3 = 3 + + val testItem1 = createTestItem() + testItem1.imageName = testImage1 + testItem1.id = testId1 + + val testItem2 = createTestItem() + testItem2.imageName = testImage2 + testItem2.id = testId2 + + val testItem3 = createTestItem() + testItem3.imageName = testImage2 + testItem3.id = testId3 + + database?.dao()!!.insertOrUpdateItem(testItem1) + database?.dao()!!.insertOrUpdateItem(testItem2) + database?.dao()!!.insertOrUpdateItem(testItem3) + + val returnedValueWhenSearchedForTestImage1: Int = database?.dao()!!.getNumberOfEntriesUsingImage(testImage1) + Assert.assertEquals(1, returnedValueWhenSearchedForTestImage1) + + val returnedValueWhenSearchedForTestImage2: Int = database?.dao()!!.getNumberOfEntriesUsingImage(testImage2) + Assert.assertEquals(2, returnedValueWhenSearchedForTestImage2) + + database?.dao()!!.deleteItemById(testId1) + + val returnedValueWhenSearchedForTestImage1AfterDelete: Int = database?.dao()!!.getNumberOfEntriesUsingImage(testImage1) + Assert.assertEquals(0, returnedValueWhenSearchedForTestImage1AfterDelete) + + val returnedValueWhenSearchedForTestImage2AfterDelete: Int = database?.dao()!!.getNumberOfEntriesUsingImage(testImage2) + Assert.assertEquals(2, returnedValueWhenSearchedForTestImage2AfterDelete) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/app/marcdev/earworm/repository/FavouriteItemRepositoryTest.kt b/app/src/androidTest/java/app/marcdev/earworm/repository/FavouriteItemRepositoryTest.kt new file mode 100644 index 0000000..d56cfce --- /dev/null +++ b/app/src/androidTest/java/app/marcdev/earworm/repository/FavouriteItemRepositoryTest.kt @@ -0,0 +1,240 @@ +package app.marcdev.earworm.repository + +import androidx.room.Room +import androidx.test.platform.app.InstrumentationRegistry +import app.marcdev.earworm.database.AppDatabase +import app.marcdev.earworm.database.DAO +import app.marcdev.earworm.database.FavouriteItem +import app.marcdev.earworm.utils.SONG +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +class FavouriteItemRepositoryTest { + + private var database: AppDatabase? = null + private var repository: FavouriteItemRepository? = null + private var dao: DAO? = null + + // Default values + private val testName = "Test Song Name" + private val testAlbum = "Test Album Name" + private val testArtist = "Test Artist Name" + private val testGenre = "Test Genre Name" + private val testImageName = "testimagename.jpg" + private val testDay = 1 + private val testMonth = 1 + private val testYear = 2018 + private val testType: Int = SONG + + @Before + fun setUp() { + database = + Room.inMemoryDatabaseBuilder(InstrumentationRegistry.getInstrumentation().context, AppDatabase::class.java) + .allowMainThreadQueries().build() + + dao = database!!.dao() + repository = FavouriteItemRepositoryImpl(dao!!) + } + + @After + fun tearDown() { + database!!.close() + } + + private fun createTestItem(): FavouriteItem { + return FavouriteItem(testName, testAlbum, testArtist, testGenre, testDay, testMonth, testYear, testType, testImageName) + } + + @Test + fun insertMultipleSongs_getAllItems() = runBlocking { + val testItem1 = createTestItem() + val testItem2 = createTestItem() + + val returnedItemsWhenNothingInserted: MutableList = repository!!.getAllItems() + Assert.assertEquals(0, returnedItemsWhenNothingInserted.size) + + repository!!.insertOrUpdateItem(testItem1) + repository!!.insertOrUpdateItem(testItem2) + + val returnedItemsWhenOneInserted: MutableList = repository!!.getAllItems() + Assert.assertEquals(2, returnedItemsWhenOneInserted.size) + } + + @Test + fun insertOneSong_getItemById_deleteSong() = runBlocking { + val testId = 1 + + val testItem = createTestItem() + testItem.id = testId + + val returnedItemsWhenNothingInserted: MutableList = repository!!.getItem(testId) + Assert.assertEquals(0, returnedItemsWhenNothingInserted.size) + + repository!!.insertOrUpdateItem(testItem) + + val returnedItemsWhenOneInserted: MutableList = repository!!.getItem(testId) + Assert.assertEquals(1, returnedItemsWhenOneInserted.size) + + repository!!.deleteItem(testId) + + val returnedItemsWhenOneDeleted: MutableList = repository!!.getItem(testId) + Assert.assertEquals(0, returnedItemsWhenOneDeleted.size) + } + + @Test + fun insertTwoSongs_deleteOneSong() = runBlocking { + val testId1 = 1 + val testId2 = 2 + + val testItem1 = createTestItem() + testItem1.id = testId1 + val testItem2 = createTestItem() + testItem2.id = testId2 + + val returnedItemsWhenNothingInserted: MutableList = repository!!.getAllItems() + Assert.assertEquals(0, returnedItemsWhenNothingInserted.size) + + repository!!.insertOrUpdateItem(testItem1) + repository!!.insertOrUpdateItem(testItem2) + + val returnedItemsWhenTwoInserted: MutableList = repository!!.getAllItems() + Assert.assertEquals(2, returnedItemsWhenTwoInserted.size) + + repository!!.deleteItem(testId1) + + val returnedItemsWhenOneDeleted: MutableList = repository!!.getAllItems() + Assert.assertEquals(1, returnedItemsWhenOneDeleted.size) + + val returnedItemsWhenDeletedOneSearched: MutableList = repository!!.getItem(testId1) + Assert.assertEquals(0, returnedItemsWhenDeletedOneSearched.size) + } + + @Test + fun insertMultipleSongs_getOneById() = runBlocking { + val testId1 = 1 + val testId2 = 2 + + val testItem1 = createTestItem() + testItem1.id = testId1 + val testItem2 = createTestItem() + testItem2.id = testId2 + + val returnedItemsWhenNothingInserted: MutableList = repository!!.getAllItems() + Assert.assertEquals(0, returnedItemsWhenNothingInserted.size) + + repository!!.insertOrUpdateItem(testItem1) + repository!!.insertOrUpdateItem(testItem2) + + val returnedAllItemsWhenTwoInserted: MutableList = repository!!.getAllItems() + Assert.assertEquals(2, returnedAllItemsWhenTwoInserted.size) + + val returnedItemsWhenSearchedById1: MutableList = repository!!.getItem(testId1) + Assert.assertEquals(1, returnedItemsWhenSearchedById1.size) + } + + @Test + fun insertMultipleItemsUsingDifferentImages_countEach() = runBlocking { + val testImage1 = "testImage1.jpg" + val testImage2 = "testImage2.jpg" + + val testItem1 = createTestItem() + testItem1.imageName = testImage1 + + val testItem2 = createTestItem() + testItem2.imageName = testImage2 + + val testItem3 = createTestItem() + testItem3.imageName = testImage2 + + repository!!.insertOrUpdateItem(testItem1) + repository!!.insertOrUpdateItem(testItem2) + repository!!.insertOrUpdateItem(testItem3) + + val returnedValueWhenSearchedForTestImage1: Int = repository!!.countUsesOfImage(testImage1) + Assert.assertEquals(1, returnedValueWhenSearchedForTestImage1) + + val returnedValueWhenSearchedForTestImage2: Int = repository!!.countUsesOfImage(testImage2) + Assert.assertEquals(2, returnedValueWhenSearchedForTestImage2) + } + + @Test + fun insertMultipleItemsUsingDifferentImages_countEachAndDelete() = runBlocking { + val testImage1 = "testImage1.jpg" + val testImage2 = "testImage2.jpg" + val testId1 = 1 + val testId2 = 2 + val testId3 = 3 + + val testItem1 = createTestItem() + testItem1.imageName = testImage1 + testItem1.id = testId1 + + val testItem2 = createTestItem() + testItem2.imageName = testImage2 + testItem2.id = testId2 + + val testItem3 = createTestItem() + testItem3.imageName = testImage2 + testItem3.id = testId3 + + repository!!.insertOrUpdateItem(testItem1) + repository!!.insertOrUpdateItem(testItem2) + repository!!.insertOrUpdateItem(testItem3) + + val returnedValueWhenSearchedForTestImage1: Int = repository!!.countUsesOfImage(testImage1) + Assert.assertEquals(1, returnedValueWhenSearchedForTestImage1) + + val returnedValueWhenSearchedForTestImage2: Int = repository!!.countUsesOfImage(testImage2) + Assert.assertEquals(2, returnedValueWhenSearchedForTestImage2) + + repository!!.deleteItem(testId2) + + val returnedValueWhenSearchedForTestImage1AfterDelete: Int = repository!!.countUsesOfImage(testImage1) + Assert.assertEquals(1, returnedValueWhenSearchedForTestImage1AfterDelete) + + val returnedValueWhenSearchedForTestImage2AfterDelete: Int = repository!!.countUsesOfImage(testImage2) + Assert.assertEquals(1, returnedValueWhenSearchedForTestImage2AfterDelete) + } + + @Test + fun insertMultipleItemsUsingDifferentImages_countEachAndDeleteOneCompletely() = runBlocking { + val testImage1 = "testImage1.jpg" + val testImage2 = "testImage2.jpg" + val testId1 = 1 + val testId2 = 2 + val testId3 = 3 + + val testItem1 = createTestItem() + testItem1.imageName = testImage1 + testItem1.id = testId1 + + val testItem2 = createTestItem() + testItem2.imageName = testImage2 + testItem2.id = testId2 + + val testItem3 = createTestItem() + testItem3.imageName = testImage2 + testItem3.id = testId3 + + repository!!.insertOrUpdateItem(testItem1) + repository!!.insertOrUpdateItem(testItem2) + repository!!.insertOrUpdateItem(testItem3) + + val returnedValueWhenSearchedForTestImage1: Int = repository!!.countUsesOfImage(testImage1) + Assert.assertEquals(1, returnedValueWhenSearchedForTestImage1) + + val returnedValueWhenSearchedForTestImage2: Int = repository!!.countUsesOfImage(testImage2) + Assert.assertEquals(2, returnedValueWhenSearchedForTestImage2) + + repository!!.deleteItem(testId1) + + val returnedValueWhenSearchedForTestImage1AfterDelete: Int = repository!!.countUsesOfImage(testImage1) + Assert.assertEquals(0, returnedValueWhenSearchedForTestImage1AfterDelete) + + val returnedValueWhenSearchedForTestImage2AfterDelete: Int = repository!!.countUsesOfImage(testImage2) + Assert.assertEquals(2, returnedValueWhenSearchedForTestImage2AfterDelete) + } +} \ 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 0000000..98564a5 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-web.png b/app/src/main/ic_launcher-web.png new file mode 100644 index 0000000..14f84b1 Binary files /dev/null and b/app/src/main/ic_launcher-web.png differ diff --git a/app/src/main/java/app/marcdev/earworm/Earworm.kt b/app/src/main/java/app/marcdev/earworm/Earworm.kt new file mode 100644 index 0000000..ccbdf72 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/Earworm.kt @@ -0,0 +1,15 @@ +package app.marcdev.earworm + +import android.app.Application +import timber.log.Timber + +class Earworm : Application() { + + override fun onCreate() { + super.onCreate() + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + Timber.i("Log: Timber Debug Tree planted") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/MainActivity.kt b/app/src/main/java/app/marcdev/earworm/MainActivity.kt new file mode 100644 index 0000000..4a56954 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/MainActivity.kt @@ -0,0 +1,67 @@ +package app.marcdev.earworm + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.coordinatorlayout.widget.CoordinatorLayout +import app.marcdev.earworm.mainscreen.MainFragmentViewImpl +import app.marcdev.earworm.utils.DARK_THEME +import app.marcdev.earworm.utils.LIGHT_THEME +import app.marcdev.earworm.utils.getTheme +import app.marcdev.earworm.utils.setFragment +import timber.log.Timber + +class MainActivity : AppCompatActivity() { + + private lateinit var mainFrame: CoordinatorLayout + private var activityTheme: Int = -1 + + override fun onCreate(savedInstanceState: Bundle?) { + Timber.d("Log: onCreate: Started") + + /* Theme changes must be done before super.onCreate otherwise it will be overridden with the value + in the manifest */ + if(getTheme(applicationContext) == DARK_THEME) { + Timber.v("Log: onCreate: Is dark mode") + setTheme(R.style.Earworm_DarkTheme) + activityTheme = DARK_THEME + } else { + Timber.v("Log: onCreate: Is not dark mode") + setTheme(R.style.Earworm_LightTheme) + activityTheme = LIGHT_THEME + } + + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + bindViews() + + setDefaultFragment() + } + + private fun bindViews() { + Timber.v("Log: bindViews: Started") + this.mainFrame = findViewById(R.id.frame_main) + } + + private fun setDefaultFragment() { + Timber.v("Log: setDefaultFragment: Started") + val fragment = MainFragmentViewImpl() + + if(intent.action == "app.marcdev.earworm.intent.ADD_ITEM") { + Timber.d("Log: onCreate: Started from Add Item app shortcut") + val args = Bundle() + args.putBoolean("add_item", true) + fragment.arguments = args + } + + setFragment(fragment, supportFragmentManager, R.id.frame_main) + } + + override fun onResume() { + super.onResume() + if(getTheme(applicationContext) != activityTheme) { + Timber.d("Log: onResume: Theme was changed, recreating activity") + recreate() + } + } +} diff --git a/app/src/main/java/app/marcdev/earworm/additem/AddItemBottomSheet.kt b/app/src/main/java/app/marcdev/earworm/additem/AddItemBottomSheet.kt new file mode 100644 index 0000000..27b8660 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/additem/AddItemBottomSheet.kt @@ -0,0 +1,383 @@ +package app.marcdev.earworm.additem + +import android.Manifest +import android.app.Activity +import android.app.Dialog +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.preference.PreferenceManager +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import app.marcdev.earworm.R +import app.marcdev.earworm.database.FavouriteItem +import app.marcdev.earworm.uicomponents.RoundedBottomDialogFragment +import app.marcdev.earworm.utils.* +import com.bumptech.glide.Glide +import com.bumptech.glide.request.RequestOptions +import com.google.android.material.button.MaterialButton +import com.google.android.material.chip.Chip +import droidninja.filepicker.FilePickerBuilder +import droidninja.filepicker.FilePickerConst +import timber.log.Timber +import java.util.* + +class AddItemBottomSheet : RoundedBottomDialogFragment(), AddItemView { + + private lateinit var saveButton: MaterialButton + private lateinit var primaryInput: EditText + private lateinit var secondaryInput: EditText + private lateinit var songButton: ImageButton + private lateinit var albumButton: ImageButton + private lateinit var artistButton: ImageButton + private lateinit var presenter: AddItemPresenter + private lateinit var datePickerDialog: Dialog + private lateinit var datePicker: DatePicker + private lateinit var datePickerOk: MaterialButton + private lateinit var datePickerCancel: MaterialButton + private lateinit var iconImageView: ImageView + private lateinit var dateChip: Chip + private lateinit var confirmDeleteDialog: Dialog + private val dateChosen = Calendar.getInstance() + // If the itemID is anything other than -1 then it is in edit mode + private var itemId: Int = -1 + + private var type: Int = 0 + private var recyclerUpdateView: RecyclerUpdateView? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.dialog_add_item, container, false) + presenter = AddItemPresenterImpl(this, activity!!.applicationContext) + bindViews(view) + + arguments?.let { + Timber.d("Log: onCreateView: Arguments not null") + this.itemId = arguments!!.getInt("item_id") + presenter.getItem(itemId) + } ?: run { + Timber.d("Log: onCreateView: Arguments null") + setupDefaults() + } + return view + } + + override fun convertToEditMode(item: FavouriteItem) { + Timber.d("Log: convertToEditMode: Started") + + when(item.type) { + SONG -> { + activateButton(songButton) + primaryInput.setText(item.songName) + secondaryInput.setText(item.artistName) + } + + ALBUM -> { + activateButton(albumButton) + primaryInput.setText(item.albumName) + secondaryInput.setText(item.artistName) + } + + ARTIST -> { + activateButton(artistButton) + primaryInput.setText(item.artistName) + secondaryInput.setText(item.genre) + } + } + + if(item.imageName.isNotBlank()) { + presenter.updateFilePath(getArtworkDirectory(requireContext()) + item.imageName) + } + updateDateAndDisplay(item.day, item.month, item.year) + } + + fun bindRecyclerUpdateView(view: RecyclerUpdateView) { + Timber.d("Log: bindRecyclerUpdateView: Started") + this.recyclerUpdateView = view + } + + private fun bindViews(view: View) { + Timber.v("Log: bindViews: Started") + this.saveButton = view.findViewById(R.id.btn_add_item_save) + saveButton.setOnClickListener(saveOnClickListener) + + this.primaryInput = view.findViewById(R.id.edt_item_primary_input) + this.secondaryInput = view.findViewById(R.id.edt_item_secondary_input) + + this.songButton = view.findViewById(R.id.btn_add_item_song_choice) + songButton.setOnClickListener(songOnClickListener) + + this.albumButton = view.findViewById(R.id.btn_add_item_album_choice) + albumButton.setOnClickListener(albumOnClickListener) + + this.artistButton = view.findViewById(R.id.btn_add_item_artist_choice) + artistButton.setOnClickListener(artistOnClickListener) + + this.datePickerDialog = Dialog(this.requireActivity()) + datePickerDialog.setContentView(R.layout.dialog_datepicker) + datePickerDialog.window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + + this.datePicker = datePickerDialog.findViewById(R.id.datepicker) + + this.datePickerOk = datePickerDialog.findViewById(R.id.btn_datepicker_ok) + datePickerOk.setOnClickListener(dateOnOkClickListener) + + this.datePickerCancel = datePickerDialog.findViewById(R.id.btn_datepicker_cancel) + datePickerCancel.setOnClickListener(dateOnCancelClickListener) + + this.dateChip = view.findViewById(R.id.chip_add_item_date_display) + dateChip.setOnClickListener(dateOnClickListener) + + this.iconImageView = view.findViewById(R.id.img_add_icon) + iconImageView.setOnClickListener(iconOnClickListener) + iconImageView.setOnLongClickListener(iconOnLongClickListener) + // Convert to dark mode if needed + changeColorOfDrawable(requireContext(), iconImageView.drawable, false) + + initEditDialog() + } + + private val saveOnClickListener = View.OnClickListener { + Timber.d("Log: SaveClick: Clicked") + if(itemId == -1) { + Timber.d("Log: saveOnClickListener: Adding new item") + presenter.addItem(primaryInput.text.toString(), secondaryInput.text.toString(), type, dateChosen, null) + } else { + Timber.d("Log: saveOnClickListener: Updating item with id = $itemId") + presenter.addItem(primaryInput.text.toString(), secondaryInput.text.toString(), type, dateChosen, itemId) + } + } + + private val dateOnClickListener = View.OnClickListener { + Timber.d("Log: DateClick: Clicked") + datePicker.updateDate(dateChosen.get(Calendar.YEAR), dateChosen.get(Calendar.MONTH), dateChosen.get(Calendar.DAY_OF_MONTH)) + datePickerDialog.show() + } + + private val dateOnOkClickListener = View.OnClickListener { + Timber.d("Log: DateOkClick: Clicked") + + val day = datePicker.dayOfMonth + val monthRaw = datePicker.month + val year = datePicker.year + + updateDateAndDisplay(day, monthRaw, year) + + datePickerDialog.dismiss() + } + + private val dateOnCancelClickListener = View.OnClickListener { + Timber.d("Log: DateCancelClick: Clicked") + datePickerDialog.dismiss() + } + + private val songOnClickListener = View.OnClickListener { + Timber.d("Log: SongClick: Clicked") + activateButtonIfNecessary(songButton) + } + + private val albumOnClickListener = View.OnClickListener { + Timber.d("Log: AlbumClick: Clicked") + activateButtonIfNecessary(albumButton) + } + + private val artistOnClickListener = View.OnClickListener { + Timber.d("Log: ArtistClick: Clicked") + activateButtonIfNecessary(artistButton) + } + + private val iconOnClickListener = View.OnClickListener { + Timber.d("Log: IconClick: Started") + + if(ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + askForStoragePermissions() + } else { + FilePickerBuilder.instance.setMaxCount(1) + .setActivityTheme(R.style.Earworm_DarkTheme) + .setActivityTitle(resources.getString(R.string.choose_an_image)) + .pickPhoto(this) + } + } + + private val iconOnLongClickListener = View.OnLongClickListener { + Timber.d("Log: IconLongClick: Started") + + confirmDeleteDialog.show() + return@OnLongClickListener true + } + + private fun initEditDialog() { + this.confirmDeleteDialog = Dialog(requireContext()) + confirmDeleteDialog.setContentView(R.layout.dialog_delete_image) + confirmDeleteDialog.window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + + val confirmDeleteButton = confirmDeleteDialog.findViewById(R.id.btn_delete_image_confirm) + confirmDeleteButton.setOnClickListener(confirmDeleteOnClickListener) + val cancelButton = confirmDeleteDialog.findViewById(R.id.btn_delete_image_cancel) + cancelButton.setOnClickListener(cancelDeleteOnClickListener) + } + + private val confirmDeleteOnClickListener = View.OnClickListener { + Timber.d("Log: ConfirmDelete: Clicked") + presenter.updateFilePath("") + confirmDeleteDialog.dismiss() + } + + private val cancelDeleteOnClickListener = View.OnClickListener { + Timber.d("Log: CancelDelete: Clicked") + confirmDeleteDialog.dismiss() + } + + private fun askForStoragePermissions() { + Timber.d("Log: askForStoragePermissions: Started") + ActivityCompat.requestPermissions(requireActivity(), arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 1) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + if(requestCode == FilePickerConst.REQUEST_CODE_PHOTO && resultCode == Activity.RESULT_OK && data != null) { + val photoPathArray = data.getStringArrayListExtra(FilePickerConst.KEY_SELECTED_MEDIA) + val photoPath = photoPathArray[0] + presenter.updateFilePath(photoPath) + + displayImage(photoPath) + } + } + + private fun setupDefaults() { + Timber.d("Log: setupDefaults: Started") + type = SONG + changeColorOfImageButtonDrawable(activity!!.applicationContext, songButton, true) + changeColorOfImageButtonDrawable(activity!!.applicationContext, albumButton, false) + changeColorOfImageButtonDrawable(activity!!.applicationContext, artistButton, false) + dateChip.text = resources.getString(R.string.today) + } + + private fun activateButtonIfNecessary(button: ImageButton) { + Timber.d("Log: activateButtonIfNecessary: Started") + + if(type == SONG && button == songButton + || type == ALBUM && button == albumButton + || type == ARTIST && button == artistButton + ) { + Timber.d("Log: activateButtonIfNecessary: No need to update") + } else { + activateButton(button) + } + } + + private fun activateButton(button: ImageButton) { + Timber.d("Log: activateButtonIfNecessary: Activating button $button") + + when(button) { + songButton -> { + changeColorOfImageButtonDrawable(activity!!.applicationContext, songButton, true) + changeColorOfImageButtonDrawable(activity!!.applicationContext, albumButton, false) + changeColorOfImageButtonDrawable(activity!!.applicationContext, artistButton, false) + type = SONG + primaryInput.hint = resources.getString(R.string.song_name) + secondaryInput.hint = resources.getString(R.string.artist) + } + + albumButton -> { + changeColorOfImageButtonDrawable(activity!!.applicationContext, songButton, false) + changeColorOfImageButtonDrawable(activity!!.applicationContext, albumButton, true) + changeColorOfImageButtonDrawable(activity!!.applicationContext, artistButton, false) + type = ALBUM + primaryInput.hint = resources.getString(R.string.album) + secondaryInput.hint = resources.getString(R.string.artist) + } + + artistButton -> { + changeColorOfImageButtonDrawable(activity!!.applicationContext, songButton, false) + changeColorOfImageButtonDrawable(activity!!.applicationContext, albumButton, false) + changeColorOfImageButtonDrawable(activity!!.applicationContext, artistButton, true) + type = ARTIST + primaryInput.hint = resources.getString(R.string.artist) + secondaryInput.hint = resources.getString(R.string.genre) + } + } + + if(PreferenceManager.getDefaultSharedPreferences(requireContext()).getString(PREF_CLEAR_INPUTS, resources.getString(R.string.yes)) == resources.getString(R.string.yes)) { + primaryInput.setText("") + secondaryInput.setText("") + primaryInput.requestFocus() + } + } + + private fun updateDateAndDisplay(day: Int, month: Int, year: Int) { + val todayCalendar = Calendar.getInstance() + val todayDay = todayCalendar.get(Calendar.DAY_OF_MONTH) + val todayMonthRaw = todayCalendar.get(Calendar.MONTH) + val todayYear = todayCalendar.get(Calendar.YEAR) + + if(day == todayDay + && month == todayMonthRaw + && year == todayYear + ) { + // Display "Today" on chip + datePickerDialog.dismiss() + dateChip.text = resources.getString(R.string.today) + setDate(todayDay, todayMonthRaw, todayYear) + } else { + + val date = formatDateForDisplay(day, month, year) + dateChip.text = date + + setDate(day, month, year) + datePickerDialog.dismiss() + } + } + + private fun setDate(day: Int, month: Int, year: Int) { + Timber.d("Log: setDate: Started with day = $day, month = $month, year = $year") + dateChosen.set(Calendar.DAY_OF_MONTH, day) + dateChosen.set(Calendar.MONTH, month) + dateChosen.set(Calendar.YEAR, year) + } + + override fun saveCallback() { + Timber.d("Log: saveCallback: Started") + if(recyclerUpdateView == null) { + Timber.e("Log: saveCallback: RecyclerUpdateView is null, cannot update recycler") + } else { + recyclerUpdateView!!.fillData() + } + Toast.makeText(activity, resources.getString(R.string.item_added), Toast.LENGTH_SHORT).show() + dismiss() + } + + override fun displayImage(imagePath: String) { + Timber.d("Log: displayImage: Started with imagePath = $imagePath") + + if(imagePath.isBlank()) { + Glide.with(this) + .load(resources.getDrawable(R.drawable.ic_add_a_photo_24px, null)) + .apply(RequestOptions().centerCrop()) + .apply(RequestOptions().error(resources.getDrawable(R.drawable.ic_error_24px, null))) + .into(iconImageView) + } else { + Glide.with(this) + .load(imagePath) + .apply(RequestOptions().centerCrop()) + .apply(RequestOptions().error(resources.getDrawable(R.drawable.ic_error_24px, null))) + .into(iconImageView) + } + } + + override fun displayEmptyToast() { + Timber.d("Log: displayEmptyToast: Started") + Toast.makeText(activity, resources.getString(R.string.empty), Toast.LENGTH_SHORT).show() + } + + override fun displayErrorToast() { + Timber.d("Log: displayErrorToast: Started") + Toast.makeText(activity, resources.getString(R.string.error), Toast.LENGTH_SHORT).show() + } +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/additem/AddItemModel.kt b/app/src/main/java/app/marcdev/earworm/additem/AddItemModel.kt new file mode 100644 index 0000000..28f1c8e --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/additem/AddItemModel.kt @@ -0,0 +1,17 @@ +package app.marcdev.earworm.additem + +import app.marcdev.earworm.database.FavouriteItem +import java.io.File + +interface AddItemModel { + + fun addItemAsync(item: FavouriteItem) + + fun getItemAsync(itemId: Int) + + fun saveFileToAppStorage(file: File) + + fun countUsesOfImage(filePath: String) + + fun deleteImage(filePath: String) +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/additem/AddItemModelImpl.kt b/app/src/main/java/app/marcdev/earworm/additem/AddItemModelImpl.kt new file mode 100644 index 0000000..75a5600 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/additem/AddItemModelImpl.kt @@ -0,0 +1,95 @@ +package app.marcdev.earworm.additem + +import android.content.Context +import app.marcdev.earworm.database.AppDatabase +import app.marcdev.earworm.database.FavouriteItem +import app.marcdev.earworm.repository.FavouriteItemRepository +import app.marcdev.earworm.repository.FavouriteItemRepositoryImpl +import app.marcdev.earworm.utils.getArtworkDirectory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import timber.log.Timber +import java.io.File + +class AddItemModelImpl(private val presenter: AddItemPresenter, private val context: Context) : AddItemModel { + + private var repository: FavouriteItemRepository + + init { + val db: AppDatabase = AppDatabase.getDatabase(context) + repository = FavouriteItemRepositoryImpl(db.dao()) + } + + override fun addItemAsync(item: FavouriteItem) { + Timber.d("Log: addItemAsync: Started") + + GlobalScope.launch(Dispatchers.Main) { + async(Dispatchers.IO) { + repository.insertOrUpdateItem(item) + }.await() + + presenter.addItemCallback() + } + } + + override fun getItemAsync(itemId: Int) { + Timber.d("Log: getItemAsync: Started") + + GlobalScope.launch(Dispatchers.Main) { + val item = async(Dispatchers.IO) { + repository.getItem(itemId) + }.await() + + presenter.getItemCallback(item) + } + } + + override fun saveFileToAppStorage(file: File) { + Timber.d("Log: saveFileToAppStorage: Started") + val toPath = getArtworkDirectory(context) + file.name + Timber.d("Log: saveFileToAppStorage: fileName = ${file.name}") + val toFile = File(toPath) + if(toFile.compareTo(file) != 0) { + Timber.d("Log: saveFileToAppStorage: External file, saving") + try { + file.copyTo(toFile, true) + presenter.saveFileToAppStorageCallback(file.name, null) + } catch(e: NoSuchFileException) { + presenter.saveFileToAppStorageCallback("", e) + Timber.e("Log: saveFileToAppStorage: $e") + } + } else { + Timber.d("Log: saveFileToAppStorage: Internal file of same path, not saving") + presenter.saveFileToAppStorageCallback(file.name, null) + } + } + + override fun countUsesOfImage(filePath: String) { + Timber.d("Log: countUsesOfImage: Started with filePath = $filePath") + val file = File(filePath) + val fileName = file.name + + GlobalScope.launch(Dispatchers.Main) { + val uses = async(Dispatchers.IO) { + repository.countUsesOfImage(fileName) + }.await() + + presenter.countUsesOfImageCallback(filePath, uses) + } + } + + override fun deleteImage(filePath: String) { + Timber.d("Log: deleteImage: Started with filePath = $filePath") + + val file = File(filePath) + if(file.exists()) { + Timber.d("Log: deleteImage: File exists, deleting") + val deletionStatus = file.delete() + Timber.d("Log: deleteImage: Deletion: $deletionStatus") + } else { + Timber.w("Log: deleteImage: File doesn't exist") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/additem/AddItemPresenter.kt b/app/src/main/java/app/marcdev/earworm/additem/AddItemPresenter.kt new file mode 100644 index 0000000..3361d06 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/additem/AddItemPresenter.kt @@ -0,0 +1,21 @@ +package app.marcdev.earworm.additem + +import app.marcdev.earworm.database.FavouriteItem +import java.util.* + +interface AddItemPresenter { + + fun addItem(primaryInput: String, secondaryInput: String, type: Int, dateChosen: Calendar, itemId: Int?) + + fun addItemCallback() + + fun getItem(itemId: Int) + + fun getItemCallback(items: MutableList) + + fun saveFileToAppStorageCallback(fileName: String, exception: NoSuchFileException?) + + fun updateFilePath(filePath: String) + + fun countUsesOfImageCallback(filePath: String, uses: Int) +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/additem/AddItemPresenterImpl.kt b/app/src/main/java/app/marcdev/earworm/additem/AddItemPresenterImpl.kt new file mode 100644 index 0000000..d7ff254 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/additem/AddItemPresenterImpl.kt @@ -0,0 +1,119 @@ +package app.marcdev.earworm.additem + +import android.content.Context +import app.marcdev.earworm.database.FavouriteItem +import app.marcdev.earworm.utils.ALBUM +import app.marcdev.earworm.utils.ARTIST +import app.marcdev.earworm.utils.SONG +import app.marcdev.earworm.utils.getArtworkDirectory +import timber.log.Timber +import java.io.File +import java.util.* + +class AddItemPresenterImpl(private val view: AddItemView, private val context: Context) : AddItemPresenter { + + private val model: AddItemModel + private var imageFilePath: String = "" + private var oldImageFilePath: String = "" + + init { + model = AddItemModelImpl(this, context) + } + + override fun addItem(primaryInput: String, secondaryInput: String, type: Int, dateChosen: Calendar, itemId: Int?) { + Timber.d("Log: addItem: Started") + + if(primaryInput.isBlank() || secondaryInput.isBlank()) { + Timber.d("Log: addItem: Empty input") + view.displayEmptyToast() + } else { + Timber.d("Log: addItem: Adding item") + val day = dateChosen.get(Calendar.DAY_OF_MONTH) + val month = dateChosen.get(Calendar.MONTH) + val year = dateChosen.get(Calendar.YEAR) + + var imageName = "" + + if(imageFilePath.isNotBlank()) { + Timber.d("Log: addItem: imageFilePath = $imageFilePath") + + val imageFile = File(imageFilePath) + imageName = imageFile.name + model.saveFileToAppStorage(imageFile) + } + + val item: FavouriteItem = when(type) { + SONG -> FavouriteItem(primaryInput, "", secondaryInput, "", day, month, year, type, imageName) + ALBUM -> FavouriteItem("", primaryInput, secondaryInput, "", day, month, year, type, imageName) + ARTIST -> FavouriteItem("", "", primaryInput, secondaryInput, day, month, year, type, imageName) + else -> FavouriteItem("", "", "", "", 0, 0, 0, type, imageName) + } + + if(itemId != null) { + Timber.d("Log: addItem: Updating old item with ID = $itemId") + item.id = itemId + } + + model.addItemAsync(item) + } + } + + override fun addItemCallback() { + Timber.d("Log: addItemCallback: Started") + view.saveCallback() + } + + override fun getItem(itemId: Int) { + Timber.d("Log: getItem: Started") + model.getItemAsync(itemId) + } + + override fun getItemCallback(items: MutableList) { + Timber.d("Log: getItemCallback: Started") + + if(items.isNotEmpty()) { + view.convertToEditMode(items.first()) + if(items.first().imageName.isNotBlank()) { + view.displayImage(getArtworkDirectory(context) + items.first().imageName) + } + } else { + Timber.e("Log: getItemCallback: Returned empty list") + } + } + + override fun saveFileToAppStorageCallback(fileName: String, exception: NoSuchFileException?) { + Timber.d("Log: saveFileToAppStorageCallback: Started") + if(exception != null) { + view.displayErrorToast() + } + } + + override fun updateFilePath(filePath: String) { + Timber.d("Log: updateFilePath: Started") + + this.oldImageFilePath = imageFilePath + this.imageFilePath = filePath + Timber.d("Log: updateFilePath: oldImageFilePath = $oldImageFilePath") + Timber.d("Log: updateFilePath: imageFilePath = $imageFilePath") + + if(oldImageFilePath.isNotBlank()) { + model.countUsesOfImage(oldImageFilePath) + } + + if(imageFilePath.isBlank()) { + view.displayImage("") + model.countUsesOfImage(oldImageFilePath) + } + } + + override fun countUsesOfImageCallback(filePath: String, uses: Int) { + Timber.d("Log: countUsesOfImageCallback: Started with filePath = $filePath and uses = $uses") + + if(uses <= 1) { + Timber.d("Log: countUsesOfImageCallback: No other uses, deleting image at $filePath") + model.deleteImage(filePath) + } else { + Timber.d("Log: countUsesOfImageCallback: Used elsewhere, not deleting") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/additem/AddItemView.kt b/app/src/main/java/app/marcdev/earworm/additem/AddItemView.kt new file mode 100644 index 0000000..aa06a9d --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/additem/AddItemView.kt @@ -0,0 +1,16 @@ +package app.marcdev.earworm.additem + +import app.marcdev.earworm.database.FavouriteItem + +interface AddItemView { + + fun saveCallback() + + fun displayEmptyToast() + + fun convertToEditMode(item: FavouriteItem) + + fun displayErrorToast() + + fun displayImage(imagePath: String) +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/additem/RecyclerUpdateView.kt b/app/src/main/java/app/marcdev/earworm/additem/RecyclerUpdateView.kt new file mode 100644 index 0000000..55045bc --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/additem/RecyclerUpdateView.kt @@ -0,0 +1,5 @@ +package app.marcdev.earworm.additem + +interface RecyclerUpdateView { + fun fillData() +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/database/AppDatabase.kt b/app/src/main/java/app/marcdev/earworm/database/AppDatabase.kt new file mode 100644 index 0000000..3cb8774 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/database/AppDatabase.kt @@ -0,0 +1,34 @@ +package app.marcdev.earworm.database + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +@Database(entities = [FavouriteItem::class], version = 4) +abstract class AppDatabase : RoomDatabase() { + + abstract fun dao(): DAO + + companion object { + @Volatile + private var INSTANCE: AppDatabase? = null + + fun getDatabase(context: Context): AppDatabase { + val tempInstance = INSTANCE + + if(tempInstance != null) { + return tempInstance + } + + synchronized(this) { + + val instance = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "AppDatabase.db") + .fallbackToDestructiveMigration().build() + + INSTANCE = instance + return instance + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/database/DAO.kt b/app/src/main/java/app/marcdev/earworm/database/DAO.kt new file mode 100644 index 0000000..b799ce7 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/database/DAO.kt @@ -0,0 +1,25 @@ +package app.marcdev.earworm.database + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface DAO { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertOrUpdateItem(item: FavouriteItem) + + @Query("SELECT * FROM favourite_items") + fun getAllItems(): MutableList + + @Query("SELECT * FROM favourite_items where id = :id") + fun getItemById(id: Int): MutableList + + @Query("DELETE FROM favourite_items where id = :id") + fun deleteItemById(id: Int) + + @Query("SELECT COUNT(*) FROM favourite_items WHERE imageName = :imageName") + fun getNumberOfEntriesUsingImage(imageName: String): Int +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/database/FavouriteItem.kt b/app/src/main/java/app/marcdev/earworm/database/FavouriteItem.kt new file mode 100644 index 0000000..45fb289 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/database/FavouriteItem.kt @@ -0,0 +1,21 @@ +package app.marcdev.earworm.database + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "favourite_items") +data class FavouriteItem( + var songName: String, + var albumName: String, + var artistName: String, + var genre: String, + var day: Int, + var month: Int, + var year: Int, + var type: Int, + var imageName: String +) { + + @PrimaryKey(autoGenerate = true) + var id: Int? = null +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/mainscreen/MainFragmentModel.kt b/app/src/main/java/app/marcdev/earworm/mainscreen/MainFragmentModel.kt new file mode 100644 index 0000000..b6ac979 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/mainscreen/MainFragmentModel.kt @@ -0,0 +1,17 @@ +package app.marcdev.earworm.mainscreen + +import app.marcdev.earworm.database.FavouriteItem +import app.marcdev.earworm.utils.ItemFilter + +interface MainFragmentModel { + + fun getAllItemsAsync() + + fun getAllItemsAsync(filter: ItemFilter) + + fun deleteItemAsync(item: FavouriteItem) + + fun countUsesOfImage(item: FavouriteItem) + + fun deleteImage(filePath: String) +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/mainscreen/MainFragmentModelImpl.kt b/app/src/main/java/app/marcdev/earworm/mainscreen/MainFragmentModelImpl.kt new file mode 100644 index 0000000..27eccdd --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/mainscreen/MainFragmentModelImpl.kt @@ -0,0 +1,89 @@ +package app.marcdev.earworm.mainscreen + +import android.content.Context +import app.marcdev.earworm.database.AppDatabase +import app.marcdev.earworm.database.FavouriteItem +import app.marcdev.earworm.repository.FavouriteItemRepository +import app.marcdev.earworm.repository.FavouriteItemRepositoryImpl +import app.marcdev.earworm.utils.ItemFilter +import app.marcdev.earworm.utils.getArtworkDirectory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import timber.log.Timber +import java.io.File + +class MainFragmentModelImpl(private val presenter: MainFragmentPresenter, private val context: Context) : MainFragmentModel { + + private var repository: FavouriteItemRepository + + init { + val db: AppDatabase = AppDatabase.getDatabase(context) + repository = FavouriteItemRepositoryImpl(db.dao()) + } + + override fun getAllItemsAsync() { + Timber.d("Log: getAllItemsAsync: Started") + + GlobalScope.launch(Dispatchers.Main) { + val allItems = async(Dispatchers.IO) { + repository.getAllItems() + }.await() + + presenter.getAllItemsCallback(allItems) + } + } + + override fun getAllItemsAsync(filter: ItemFilter) { + Timber.d("Log: getAllItemsAsync: Started") + + GlobalScope.launch(Dispatchers.Main) { + val allItems = async(Dispatchers.IO) { + repository.getAllItems() + }.await() + + presenter.getAllItemsCallback(allItems, filter) + } + } + + override fun deleteItemAsync(item: FavouriteItem) { + Timber.d("Log: deleteItemAsync: Started") + + GlobalScope.launch(Dispatchers.Main) { + async(Dispatchers.IO) { + repository.deleteItem(item.id!!) + }.await() + + presenter.deleteItemCallback() + } + } + + override fun countUsesOfImage(item: FavouriteItem) { + Timber.d("Log: countUsesOfImage: Started with item = $item") + val filePath = getArtworkDirectory(context) + item.imageName + val file = File(filePath) + val fileName = file.name + + GlobalScope.launch(Dispatchers.Main) { + val uses = async(Dispatchers.IO) { + repository.countUsesOfImage(fileName) + }.await() + + presenter.countUsesOfImageCallback(item, uses) + } + } + + override fun deleteImage(filePath: String) { + Timber.d("Log: deleteImage: Started with filePath = $filePath") + + val file = File(filePath) + if(file.exists()) { + Timber.d("Log: deleteImage: File exists, deleting") + val deletionStatus = file.delete() + Timber.d("Log: deleteImage: Deletion: $deletionStatus") + } else { + Timber.w("Log: deleteImage: File doesn't exist") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/mainscreen/MainFragmentPresenter.kt b/app/src/main/java/app/marcdev/earworm/mainscreen/MainFragmentPresenter.kt new file mode 100644 index 0000000..8aae525 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/mainscreen/MainFragmentPresenter.kt @@ -0,0 +1,25 @@ +package app.marcdev.earworm.mainscreen + +import app.marcdev.earworm.database.FavouriteItem +import app.marcdev.earworm.utils.ItemFilter + +interface MainFragmentPresenter { + + fun getAllItems() + + fun getAllItems(filter: ItemFilter) + + fun getAllItemsCallback(items: MutableList) + + fun getAllItemsCallback(items: MutableList, filter: ItemFilter) + + fun deleteItem(item: FavouriteItem) + + fun deleteItemCallback() + + fun editItemClick(itemId: Int) + + fun search(input: String) + + fun countUsesOfImageCallback(item: FavouriteItem, uses: Int) +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/mainscreen/MainFragmentPresenterImpl.kt b/app/src/main/java/app/marcdev/earworm/mainscreen/MainFragmentPresenterImpl.kt new file mode 100644 index 0000000..b8cd93c --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/mainscreen/MainFragmentPresenterImpl.kt @@ -0,0 +1,109 @@ +package app.marcdev.earworm.mainscreen + +import android.content.Context +import app.marcdev.earworm.database.FavouriteItem +import app.marcdev.earworm.utils.* +import timber.log.Timber + +class MainFragmentPresenterImpl(val view: MainFragmentView, val context: Context) : MainFragmentPresenter { + private var model = MainFragmentModelImpl(this, context) + + override fun getAllItems() { + Timber.d("Log: getAllItems: Started") + model.getAllItemsAsync() + } + + override fun getAllItems(filter: ItemFilter) { + Timber.d("Log: getAllItems with Filter: Started") + Timber.i("Log: getAllItems: Input Filter = $filter") + if(filter != DEFAULT_FILTER.copy()) { + view.activateFilterIcon(true) + } else { + view.activateFilterIcon(false) + } + model.getAllItemsAsync(filter) + } + + override fun getAllItemsCallback(items: MutableList) { + Timber.d("Log: getAllItemsCallback: Started") + + val sortedItems = sortByDateDescending(items) + val itemsWithHeaders = addListHeaders(sortedItems) + + view.updateRecycler(itemsWithHeaders) + view.displayProgress(false) + + if(itemsWithHeaders.isEmpty()) { + view.displayNoEntriesWarning(true) + } else { + view.displayNoEntriesWarning(false) + } + } + + override fun getAllItemsCallback(items: MutableList, filter: ItemFilter) { + Timber.d("Log: getAllItemsCallback: Started with filter = $filter") + + val sortedItems = applyFilter(items, filter) + val itemsWithHeaders = addListHeaders(sortedItems) + + view.updateRecycler(itemsWithHeaders) + view.displayProgress(false) + + if(itemsWithHeaders.isEmpty()) { + view.displayNoFilteredResultsWarning(true) + } else { + view.displayNoFilteredResultsWarning(false) + } + } + + override fun deleteItem(item: FavouriteItem) { + Timber.d("Log: deleteItem: Started") + if(item.imageName != "") { + Timber.d("Log: deleteItem: Item has an image, checking if used elsewhere") + model.countUsesOfImage(item) + } else { + Timber.d("Log: deleteItem: Item does not have an image, deleting item") + model.deleteItemAsync(item) + } + } + + override fun deleteItemCallback() { + Timber.d("Log: deleteItemCallback: Started") + if(view.getActiveFilter() != DEFAULT_FILTER.copy()) { + getAllItems(view.getActiveFilter()) + } else { + getAllItems() + } + } + + override fun countUsesOfImageCallback(item: FavouriteItem, uses: Int) { + Timber.d("Log: countUsesOfImageCallback: Started with imageName = ${item.imageName} and uses = $uses") + + if(uses <= 1) { + val filePath = getArtworkDirectory(context) + item.imageName + model.deleteImage(filePath) + } + + model.deleteItemAsync(item) + } + + override fun editItemClick(itemId: Int) { + Timber.d("Log: editItemClick: Started") + view.displayEditItemSheet(itemId) + } + + override fun search(input: String) { + Timber.d("Log: search: Started with input = $input") + if(input.isBlank()) { + val inputFilter = view.getActiveFilter() + inputFilter.searchTerm = "" + model.getAllItemsAsync(inputFilter) + view.changeSearchIcon(true) + } else { + val inputFilter = view.getActiveFilter() + inputFilter.searchTerm = input.trim() + model.getAllItemsAsync(inputFilter) + view.changeSearchIcon(false) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/mainscreen/MainFragmentView.kt b/app/src/main/java/app/marcdev/earworm/mainscreen/MainFragmentView.kt new file mode 100644 index 0000000..86660e2 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/mainscreen/MainFragmentView.kt @@ -0,0 +1,27 @@ +package app.marcdev.earworm.mainscreen + +import app.marcdev.earworm.database.FavouriteItem +import app.marcdev.earworm.utils.ItemFilter + +interface MainFragmentView { + + fun displayNoEntriesWarning(display: Boolean) + + fun displayNoFilteredResultsWarning(display: Boolean) + + fun displayAddedToast() + + fun displayItemDeletedToast() + + fun updateRecycler(items: List) + + fun displayProgress(isVisible: Boolean) + + fun displayEditItemSheet(itemId: Int) + + fun getActiveFilter(): ItemFilter + + fun changeSearchIcon(isSearch: Boolean) + + fun activateFilterIcon(isActive: Boolean) +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/mainscreen/MainFragmentViewImpl.kt b/app/src/main/java/app/marcdev/earworm/mainscreen/MainFragmentViewImpl.kt new file mode 100644 index 0000000..0024ef0 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/mainscreen/MainFragmentViewImpl.kt @@ -0,0 +1,273 @@ +package app.marcdev.earworm.mainscreen + +import android.content.Intent +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.core.widget.NestedScrollView +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import app.marcdev.earworm.R +import app.marcdev.earworm.additem.AddItemBottomSheet +import app.marcdev.earworm.additem.RecyclerUpdateView +import app.marcdev.earworm.database.FavouriteItem +import app.marcdev.earworm.mainscreen.mainrecycler.MainRecyclerAdapter +import app.marcdev.earworm.settingsscreen.SettingsActivity +import app.marcdev.earworm.uicomponents.FilterDialog +import app.marcdev.earworm.utils.* +import com.google.android.material.floatingactionbutton.FloatingActionButton +import timber.log.Timber + +class MainFragmentViewImpl : Fragment(), MainFragmentView, RecyclerUpdateView { + + private lateinit var fab: FloatingActionButton + private lateinit var noEntriesWarning: TextView + private lateinit var noEntriesWarningImage: ImageView + private lateinit var noFilteredResultsWarning: TextView + private lateinit var noFilteredResultsWarningImage: ImageView + private lateinit var progressBar: ProgressBar + private lateinit var searchInput: EditText + private lateinit var searchButton: ImageButton + private lateinit var filterButton: ImageButton + private lateinit var settingsButton: ImageButton + private lateinit var filterDialog: FilterDialog + private lateinit var recyclerAdapter: MainRecyclerAdapter + private lateinit var presenter: MainFragmentPresenter + private var isSearchMode = true + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + Timber.d("Log: onCreateView: Started") + val view = inflater.inflate(R.layout.fragment_mainscreen, container, false) + + presenter = MainFragmentPresenterImpl(this, activity!!.applicationContext) + bindViews(view) + + if(getTheme(requireContext()) == DARK_THEME) { + Timber.d("Log: onCreateView: Is dark mode, converting") + convertToDarkMode() + } + + setupRecycler(view) + fillData() + + // If arguments is not null, see if the app has been opened from an app shortcut + arguments?.let { + if(arguments!!.getBoolean("add_item", false)) { + fabOnClickListener.onClick(view) + } + } + + return view + } + + private fun bindViews(view: View) { + Timber.v("Log: bindViews: Started") + this.fab = view.findViewById(R.id.fab_main) + fab.setOnClickListener(fabOnClickListener) + + this.noEntriesWarning = view.findViewById(R.id.txt_noEntries) + this.noEntriesWarningImage = view.findViewById(R.id.img_noEntries) + + this.noFilteredResultsWarning = view.findViewById(R.id.txt_noFilteredResults) + this.noFilteredResultsWarning.visibility = View.GONE + this.noFilteredResultsWarningImage = view.findViewById(R.id.img_noFilteredResults) + this.noFilteredResultsWarningImage.visibility = View.GONE + + this.progressBar = view.findViewById(R.id.prog_main) + + this.searchInput = view.findViewById(R.id.edt_filter_input) + searchInput.setOnKeyListener(searchOnEnterListener) + searchInput.addTextChangedListener(searchOnTextChangedListener) + + this.searchButton = view.findViewById(R.id.img_search) + searchButton.setOnClickListener(searchOnClickListener) + + this.filterButton = view.findViewById(R.id.img_filter) + filterButton.setOnClickListener(filterOnClickListener) + + val nestedScrollView: NestedScrollView = view.findViewById(R.id.scroll_main) + nestedScrollView.setOnScrollChangeListener(scrollViewOnScrollChangeListener) + + this.filterDialog = FilterDialog(requireActivity(), presenter) + + this.settingsButton = view.findViewById(R.id.img_settings) + settingsButton.setOnClickListener(settingsOnClickListener) + } + + private val fabOnClickListener = View.OnClickListener { + Timber.d("Log: Fab Clicked") + val addDialog = AddItemBottomSheet() + addDialog.bindRecyclerUpdateView(this) + addDialog.show(fragmentManager, "Add Item Bottom Sheet Dialog") + } + + private val settingsOnClickListener = View.OnClickListener { + Timber.d("Log: Settings Clicked") + val intent = Intent(requireContext(), SettingsActivity::class.java) + startActivity(intent) + } + + private val searchOnEnterListener: View.OnKeyListener = View.OnKeyListener { _: View, keyCode: Int, keyEvent: KeyEvent -> + testIfSubmitButtonClicked(keyEvent, keyCode) + } + + private val searchOnTextChangedListener = object : TextWatcher { + override fun afterTextChanged(s: Editable?) { /* Necessary */ + } + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { /* Necessary */ + } + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + if(s.isNullOrBlank()) { + presenter.search("") + } + } + } + + private fun testIfSubmitButtonClicked(keyEvent: KeyEvent, keyCode: Int): Boolean { + Timber.d("Log: testIfSubmitButtonClicked: Submit button clicked") + if((keyEvent.action == KeyEvent.ACTION_DOWN) && keyCode == KeyEvent.KEYCODE_ENTER) { + presenter.search(searchInput.text.toString()) + return true + } + return false + } + + private val searchOnClickListener = View.OnClickListener { + Timber.d("Log: Search Clicked") + if(isSearchMode) { + presenter.search(searchInput.text.toString()) + } else { + searchInput.setText("") + } + } + + private val filterOnClickListener = View.OnClickListener { + Timber.d("Log: Filter Clicked") + filterDialog.show() + } + + private var scrollViewOnScrollChangeListener = { _: View, _: Int, scrollY: Int, _: Int, oldScrollY: Int -> hideFabOnScroll(scrollY, oldScrollY) } + + private fun hideFabOnScroll(scrollY: Int, oldScrollY: Int) { + if(scrollY > oldScrollY) { + fab.hide() + } else { + fab.show() + } + } + + private fun setupRecycler(view: View) { + Timber.v("Log: setupRecycler: Started") + val recycler: RecyclerView = view.findViewById(R.id.recycler_main) + this.recyclerAdapter = MainRecyclerAdapter(context, presenter) + recycler.adapter = recyclerAdapter + recycler.layoutManager = LinearLayoutManager(context) + } + + override fun fillData() { + Timber.d("Log: fillData: Started") + displayProgress(true) + displayNoEntriesWarning(false) + presenter.getAllItems() + } + + override fun displayNoEntriesWarning(display: Boolean) { + Timber.d("Log: displayNoEntriesWarning: Started with display = $display") + + if(display) { + Timber.d("Log: displayNoEntriesWarning: Displaying") + noEntriesWarning.visibility = View.VISIBLE + noEntriesWarningImage.visibility = View.VISIBLE + } else { + Timber.d("Log: displayNoEntriesWarning: Hiding") + noEntriesWarning.visibility = View.GONE + noEntriesWarningImage.visibility = View.GONE + } + } + + override fun updateRecycler(items: List) { + Timber.d("Log: updateRecycler: Started") + recyclerAdapter.updateItems(items) + } + + override fun displayAddedToast() { + Timber.d("Log: displayAddedToast: Started") + Toast.makeText(activity, resources.getString(R.string.item_added), Toast.LENGTH_SHORT).show() + } + + override fun displayItemDeletedToast() { + Timber.d("Log: displayItemDeletedToast: Started") + Toast.makeText(activity, resources.getString(R.string.item_deleted), Toast.LENGTH_SHORT).show() + } + + override fun displayProgress(isVisible: Boolean) { + Timber.d("Log: displayProgress: Started with isVisible = $isVisible") + if(isVisible) { + progressBar.visibility = View.VISIBLE + } else { + progressBar.visibility = View.GONE + } + } + + override fun displayEditItemSheet(itemId: Int) { + Timber.d("Log: displayEditItemSheet: Started with itemId = $itemId") + val addDialog = AddItemBottomSheet() + addDialog.bindRecyclerUpdateView(this) + + val args = Bundle() + args.putInt("item_id", itemId) + addDialog.arguments = args + + addDialog.show(fragmentManager, "Add Item Bottom Sheet Dialog") + } + + override fun displayNoFilteredResultsWarning(display: Boolean) { + Timber.d("Log: displayNoFilteredResultsWarning: Started with display = $display") + + if(display && (noEntriesWarning.visibility == View.GONE)) { + this.noFilteredResultsWarning.visibility = View.VISIBLE + this.noFilteredResultsWarningImage.visibility = View.VISIBLE + } else { + this.noFilteredResultsWarning.visibility = View.GONE + this.noFilteredResultsWarningImage.visibility = View.GONE + } + } + + override fun getActiveFilter(): ItemFilter { + Timber.d("Log: getActiveFilter: Started") + return filterDialog.activeFilter + } + + override fun changeSearchIcon(isSearch: Boolean) { + Timber.d("Log: changeSearchIcon: Started") + if(isSearch) { + searchButton.setImageDrawable(resources.getDrawable(R.drawable.ic_search_24px, null)) + this.isSearchMode = true + } else { + searchButton.setImageDrawable(resources.getDrawable(R.drawable.ic_close_24px, null)) + this.isSearchMode = false + } + } + + override fun activateFilterIcon(isActive: Boolean) { + Timber.d("Log: activateFilterIcon: Started with isActive = $isActive") + + changeColorOfImageButtonDrawable(context!!, filterButton, isActive) + } + + private fun convertToDarkMode() { + changeColorOfImageButtonDrawable(requireContext(), filterButton, false) + changeColorOfImageButtonDrawable(requireContext(), settingsButton, false) + changeColorOfImageButtonDrawable(requireContext(), searchButton, false) + changeColorOfDrawable(requireContext(), noEntriesWarningImage.drawable, false) + changeColorOfDrawable(requireContext(), noFilteredResultsWarningImage.drawable, false) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerAdapter.kt b/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerAdapter.kt new file mode 100644 index 0000000..7c6bce7 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerAdapter.kt @@ -0,0 +1,78 @@ +package app.marcdev.earworm.mainscreen.mainrecycler + +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import app.marcdev.earworm.R +import app.marcdev.earworm.database.FavouriteItem +import app.marcdev.earworm.mainscreen.MainFragmentPresenter +import app.marcdev.earworm.utils.* +import timber.log.Timber + +class MainRecyclerAdapter(context: Context?, private val presenter: MainFragmentPresenter) : RecyclerView.Adapter(), MainRecyclerView { + + private var items: List = mutableListOf() + private var inflater: LayoutInflater = LayoutInflater.from(context) + + override fun getItemViewType(position: Int): Int { + Timber.v("Log: getItemViewType: Started") + return items[position].type + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainRecyclerViewHolder { + Timber.v("Log: onCreateViewHolder: Started") + + lateinit var viewHolder: MainRecyclerViewHolder + + when(viewType) { + HEADER -> { + Timber.v("Log: onCreateViewHolder: Type == Header") + val view = inflater.inflate(R.layout.item_header, parent, false) + viewHolder = MainRecyclerViewHolderHeader(view) + } + SONG -> { + Timber.v("Log: onCreateViewHolder: Type == Song") + val view = inflater.inflate(R.layout.item_mainrecycler_song, parent, false) + viewHolder = MainRecyclerViewHolderSong(view) + } + + ALBUM -> { + Timber.v("Log: onCreateViewHolder: Type == Album") + val view = inflater.inflate(R.layout.item_mainrecycler_album, parent, false) + viewHolder = MainRecyclerViewHolderAlbum(view) + } + + ARTIST -> { + Timber.v("Log: onCreateViewHolder: Type == Artist") + val view = inflater.inflate(R.layout.item_mainrecycler_artist, parent, false) + viewHolder = MainRecyclerViewHolderArtist(view) + } + + GENRE -> { + Timber.v("Log: onCreateViewHolder: Type == Genre") + val view = inflater.inflate(R.layout.item_mainrecycler_genre, parent, false) + viewHolder = MainRecyclerViewHolderGenre(view) + } + } + + return viewHolder + } + + override fun onBindViewHolder(holder: MainRecyclerViewHolder, position: Int) { + Timber.v("Log: onBindViewHolder: $position") + holder.display(items[position]) + holder.bindPresenterAndItem(presenter, items[position]) + } + + override fun getItemCount(): Int { + Timber.v("Log: getItemCount: ${items.size}") + return items.size + } + + override fun updateItems(items: List) { + Timber.d("Log: updateItems: Started") + this.items = items + notifyDataSetChanged() + } +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerView.kt b/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerView.kt new file mode 100644 index 0000000..27a2735 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerView.kt @@ -0,0 +1,7 @@ +package app.marcdev.earworm.mainscreen.mainrecycler + +import app.marcdev.earworm.database.FavouriteItem + +interface MainRecyclerView { + fun updateItems(items: List) +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerViewHolder.kt b/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerViewHolder.kt new file mode 100644 index 0000000..ae2a229 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerViewHolder.kt @@ -0,0 +1,78 @@ +package app.marcdev.earworm.mainscreen.mainrecycler + +import android.app.Dialog +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.preference.PreferenceManager +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import app.marcdev.earworm.R +import app.marcdev.earworm.database.FavouriteItem +import app.marcdev.earworm.mainscreen.MainFragmentPresenter +import com.google.android.material.button.MaterialButton +import com.google.android.material.snackbar.Snackbar +import timber.log.Timber + +open class MainRecyclerViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + + private lateinit var displayedItem: FavouriteItem + private lateinit var presenter: MainFragmentPresenter + private lateinit var editDialog: Dialog + private val prefs = PreferenceManager.getDefaultSharedPreferences(itemView.context) + + private val snackbarActionListener = View.OnClickListener { + Timber.d("Log: snackbarActionListener: Clicked") + prefs.edit().putBoolean("pref_show_tips", false).apply() + } + + private val itemClickListener = View.OnClickListener { + if(prefs.getBoolean("pref_show_tips", true)) { + val snackbar = Snackbar.make(it, itemView.resources.getString(R.string.long_click_hint), Snackbar.LENGTH_SHORT) + snackbar.setAction(itemView.resources.getString(R.string.dont_show), snackbarActionListener) + snackbar.show() + } + } + + private val itemLongClickListener = View.OnLongClickListener { + editDialog.show() + return@OnLongClickListener true + } + + private val editOnClickListener = View.OnClickListener { + Timber.d("Log: editOnClickListener: Clicked") + editDialog.dismiss() + presenter.editItemClick(displayedItem.id!!) + } + + private val deleteOnClickListener = View.OnClickListener { + Timber.d("Log: deleteOnClickListener: Clicked") + presenter.deleteItem(displayedItem) + editDialog.dismiss() + } + + init { + initEditDialog() + itemView.setOnClickListener(itemClickListener) + itemView.setOnLongClickListener(itemLongClickListener) + } + + open fun display(favouriteItemToDisplay: FavouriteItem) { + // TO BE OVERRIDDEN + } + + fun bindPresenterAndItem(presenter: MainFragmentPresenter, item: FavouriteItem) { + this.presenter = presenter + this.displayedItem = item + } + + private fun initEditDialog() { + this.editDialog = Dialog(itemView.context) + editDialog.setContentView(R.layout.dialog_edit_or_delete) + editDialog.window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + + val editButton = editDialog.findViewById(R.id.btn_add_or_delete_edit) + editButton.setOnClickListener(editOnClickListener) + val deleteButton = editDialog.findViewById(R.id.btn_add_or_delete_delete) + deleteButton.setOnClickListener(deleteOnClickListener) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerViewHolderAlbum.kt b/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerViewHolderAlbum.kt new file mode 100644 index 0000000..b883c48 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerViewHolderAlbum.kt @@ -0,0 +1,46 @@ +package app.marcdev.earworm.mainscreen.mainrecycler + +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import app.marcdev.earworm.R +import app.marcdev.earworm.database.FavouriteItem +import app.marcdev.earworm.utils.formatDateForDisplay +import app.marcdev.earworm.utils.getArtworkDirectory +import com.bumptech.glide.Glide +import com.bumptech.glide.request.RequestOptions +import timber.log.Timber + +class MainRecyclerViewHolderAlbum(itemView: View) : MainRecyclerViewHolder(itemView) { + + private val albumNameDisplay: TextView = itemView.findViewById(R.id.txt_albumName) + private val albumDateDisplay: TextView = itemView.findViewById(R.id.txt_albumDate) + private val albumArtistDisplay: TextView = itemView.findViewById(R.id.txt_albumArtist) + private var albumImageDisplay: ImageView = itemView.findViewById(R.id.img_album_icon) + + override fun display(favouriteItemToDisplay: FavouriteItem) { + Timber.d("Log: display: $favouriteItemToDisplay") + albumNameDisplay.text = favouriteItemToDisplay.albumName + albumArtistDisplay.text = favouriteItemToDisplay.artistName + val date = formatDateForDisplay(favouriteItemToDisplay.day, favouriteItemToDisplay.month, favouriteItemToDisplay.year) + albumDateDisplay.text = date + + if(favouriteItemToDisplay.imageName.isNotBlank()) { + Timber.d("Log: display: ${favouriteItemToDisplay.imageName}") + + Glide.with(itemView) + .load(getArtworkDirectory(itemView.context) + favouriteItemToDisplay.imageName) + .apply(RequestOptions().centerCrop()) + .apply(RequestOptions().error(itemView.resources.getDrawable(R.drawable.ic_error_24px, null))) + .into(albumImageDisplay) + } else { + Timber.d("Log: display: No image to display") + + Glide.with(itemView) + .load(itemView.resources.getDrawable(R.drawable.ic_album_24px, null)) + .apply(RequestOptions().centerCrop()) + .apply(RequestOptions().error(itemView.resources.getDrawable(R.drawable.ic_error_24px, null))) + .into(albumImageDisplay) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerViewHolderArtist.kt b/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerViewHolderArtist.kt new file mode 100644 index 0000000..d733aee --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerViewHolderArtist.kt @@ -0,0 +1,46 @@ +package app.marcdev.earworm.mainscreen.mainrecycler + +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import app.marcdev.earworm.R +import app.marcdev.earworm.database.FavouriteItem +import app.marcdev.earworm.utils.formatDateForDisplay +import app.marcdev.earworm.utils.getArtworkDirectory +import com.bumptech.glide.Glide +import com.bumptech.glide.request.RequestOptions +import timber.log.Timber + +class MainRecyclerViewHolderArtist(itemView: View) : MainRecyclerViewHolder(itemView) { + + private val artistNameDisplay: TextView = itemView.findViewById(R.id.txt_artistName) + private val artistGenreDisplay: TextView = itemView.findViewById(R.id.txt_artistGenre) + private val artistDateDisplay: TextView = itemView.findViewById(R.id.txt_artistDate) + private val artistImageDisplay: ImageView = itemView.findViewById(R.id.img_artist_icon) + + override fun display(favouriteItemToDisplay: FavouriteItem) { + Timber.d("Log: display: $favouriteItemToDisplay") + artistNameDisplay.text = favouriteItemToDisplay.artistName + artistGenreDisplay.text = favouriteItemToDisplay.genre + val date = formatDateForDisplay(favouriteItemToDisplay.day, favouriteItemToDisplay.month, favouriteItemToDisplay.year) + artistDateDisplay.text = date + + if(favouriteItemToDisplay.imageName.isNotBlank()) { + Timber.d("Log: display: imageName = ${favouriteItemToDisplay.imageName}") + + Glide.with(itemView) + .load(getArtworkDirectory(itemView.context) + favouriteItemToDisplay.imageName) + .apply(RequestOptions().centerCrop()) + .apply(RequestOptions().error(itemView.resources.getDrawable(R.drawable.ic_error_24px, null))) + .into(artistImageDisplay) + } else { + Timber.d("Log: display: No image to display") + + Glide.with(itemView) + .load(itemView.resources.getDrawable(R.drawable.ic_person_24px, null)) + .apply(RequestOptions().centerCrop()) + .apply(RequestOptions().error(itemView.resources.getDrawable(R.drawable.ic_error_24px, null))) + .into(artistImageDisplay) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerViewHolderGenre.kt b/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerViewHolderGenre.kt new file mode 100644 index 0000000..b51afc8 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerViewHolderGenre.kt @@ -0,0 +1,21 @@ +package app.marcdev.earworm.mainscreen.mainrecycler + +import android.view.View +import android.widget.TextView +import app.marcdev.earworm.R +import app.marcdev.earworm.database.FavouriteItem +import app.marcdev.earworm.utils.formatDateForDisplay +import timber.log.Timber + +class MainRecyclerViewHolderGenre(itemView: View) : MainRecyclerViewHolder(itemView) { + + private var genreNameDisplay: TextView = itemView.findViewById(R.id.txt_genreName) + private var genreDateDisplay: TextView = itemView.findViewById(R.id.txt_genreDate) + + override fun display(favouriteItemToDisplay: FavouriteItem) { + Timber.d("Log: display: $favouriteItemToDisplay") + genreNameDisplay.text = favouriteItemToDisplay.genre + val date = formatDateForDisplay(favouriteItemToDisplay.day, favouriteItemToDisplay.month, favouriteItemToDisplay.year) + genreDateDisplay.text = date + } +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerViewHolderHeader.kt b/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerViewHolderHeader.kt new file mode 100644 index 0000000..19420dc --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerViewHolderHeader.kt @@ -0,0 +1,33 @@ +package app.marcdev.earworm.mainscreen.mainrecycler + +import android.view.View +import android.widget.TextView +import app.marcdev.earworm.R +import app.marcdev.earworm.database.FavouriteItem +import app.marcdev.earworm.utils.getMonthName +import timber.log.Timber + +open class MainRecyclerViewHolderHeader(itemView: View) : MainRecyclerViewHolder(itemView) { + + private var dateDisplay: TextView = itemView.findViewById(R.id.txt_header_title) + + private val itemClickListener = View.OnClickListener { + // Does nothing but overrides default click listener + } + + private val itemLongClickListener = View.OnLongClickListener { + // Does nothing but overrides default long click listener + return@OnLongClickListener true + } + + init { + itemView.setOnClickListener(itemClickListener) + itemView.setOnLongClickListener(itemLongClickListener) + } + + override fun display(favouriteItemToDisplay: FavouriteItem) { + Timber.d("Log: display: $favouriteItemToDisplay") + + dateDisplay.text = ("${getMonthName(favouriteItemToDisplay.month, itemView.context)} ${favouriteItemToDisplay.year}") + } +} diff --git a/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerViewHolderSong.kt b/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerViewHolderSong.kt new file mode 100644 index 0000000..0611378 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/mainscreen/mainrecycler/MainRecyclerViewHolderSong.kt @@ -0,0 +1,45 @@ +package app.marcdev.earworm.mainscreen.mainrecycler + +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import app.marcdev.earworm.R +import app.marcdev.earworm.database.FavouriteItem +import app.marcdev.earworm.utils.formatDateForDisplay +import app.marcdev.earworm.utils.getArtworkDirectory +import com.bumptech.glide.Glide +import com.bumptech.glide.request.RequestOptions +import timber.log.Timber + +class MainRecyclerViewHolderSong(itemView: View) : MainRecyclerViewHolder(itemView) { + + private val songNameDisplay: TextView = itemView.findViewById(R.id.txt_songName) + private val songDateDisplay: TextView = itemView.findViewById(R.id.txt_songDate) + private val songArtistDisplay: TextView = itemView.findViewById(R.id.txt_songArtist) + private val songImageDisplay: ImageView = itemView.findViewById(R.id.img_song_icon) + + override fun display(favouriteItemToDisplay: FavouriteItem) { + Timber.d("Log: display: $favouriteItemToDisplay") + songNameDisplay.text = favouriteItemToDisplay.songName + songArtistDisplay.text = favouriteItemToDisplay.artistName + val date = formatDateForDisplay(favouriteItemToDisplay.day, favouriteItemToDisplay.month, favouriteItemToDisplay.year) + songDateDisplay.text = date + + if(favouriteItemToDisplay.imageName.isNotBlank()) { + Timber.d("Log: display: ${favouriteItemToDisplay.imageName}") + Glide.with(itemView) + .load(getArtworkDirectory(itemView.context) + favouriteItemToDisplay.imageName) + .apply(RequestOptions().centerCrop()) + .apply(RequestOptions().error(itemView.resources.getDrawable(R.drawable.ic_error_24px, null))) + .into(songImageDisplay) + } else { + Timber.d("Log: display: No image to display") + + Glide.with(itemView) + .load(itemView.resources.getDrawable(R.drawable.ic_music_note_24px, null)) + .apply(RequestOptions().centerCrop()) + .apply(RequestOptions().error(itemView.resources.getDrawable(R.drawable.ic_error_24px, null))) + .into(songImageDisplay) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/repository/FavouriteItemRepository.kt b/app/src/main/java/app/marcdev/earworm/repository/FavouriteItemRepository.kt new file mode 100644 index 0000000..1b9cebe --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/repository/FavouriteItemRepository.kt @@ -0,0 +1,16 @@ +package app.marcdev.earworm.repository + +import app.marcdev.earworm.database.FavouriteItem + +interface FavouriteItemRepository { + + suspend fun insertOrUpdateItem(item: FavouriteItem) + + suspend fun getAllItems(): MutableList + + suspend fun getItem(id: Int): MutableList + + suspend fun deleteItem(id: Int) + + suspend fun countUsesOfImage(imageName: String): Int +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/repository/FavouriteItemRepositoryImpl.kt b/app/src/main/java/app/marcdev/earworm/repository/FavouriteItemRepositoryImpl.kt new file mode 100644 index 0000000..80407c4 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/repository/FavouriteItemRepositoryImpl.kt @@ -0,0 +1,27 @@ +package app.marcdev.earworm.repository + +import app.marcdev.earworm.database.DAO +import app.marcdev.earworm.database.FavouriteItem + +class FavouriteItemRepositoryImpl(private val dao: DAO) : FavouriteItemRepository { + + override suspend fun insertOrUpdateItem(item: FavouriteItem) { + return dao.insertOrUpdateItem(item) + } + + override suspend fun getAllItems(): MutableList { + return dao.getAllItems() + } + + override suspend fun getItem(id: Int): MutableList { + return dao.getItemById(id) + } + + override suspend fun deleteItem(id: Int) { + return dao.deleteItemById(id) + } + + override suspend fun countUsesOfImage(imageName: String): Int { + return dao.getNumberOfEntriesUsingImage(imageName) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/settingsscreen/LicensesActivity.kt b/app/src/main/java/app/marcdev/earworm/settingsscreen/LicensesActivity.kt new file mode 100644 index 0000000..d910e87 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/settingsscreen/LicensesActivity.kt @@ -0,0 +1,114 @@ +package app.marcdev.earworm.settingsscreen + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.widget.ImageButton +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.cardview.widget.CardView +import app.marcdev.earworm.R +import app.marcdev.earworm.utils.DARK_THEME +import app.marcdev.earworm.utils.changeColorOfImageButtonDrawable +import app.marcdev.earworm.utils.getTheme +import timber.log.Timber + +class LicensesActivity : AppCompatActivity() { + + private var isDarkMode: Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + Timber.d("Log: onCreate: Started") + + /* Theme changes must be done before super.onCreate otherwise it will be overridden with the value + in the manifest */ + if(getTheme(applicationContext) == DARK_THEME) { + Timber.v("Log: onCreate: Is dark mode") + setTheme(R.style.Earworm_DarkTheme) + isDarkMode = true + } else { + Timber.v("Log: onCreate: Is not dark mode") + setTheme(R.style.Earworm_LightTheme) + isDarkMode = false + } + + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_licenses) + + bindViews() + } + + private fun bindViews() { + Timber.v("Log: bindViews: Started") + val backButton = findViewById(R.id.img_backFromSettings) + backButton.setOnClickListener(backOnClickListener) + if(isDarkMode) { + changeColorOfImageButtonDrawable(applicationContext, backButton, false) + } + + val toolbarTitle = findViewById(R.id.txt_settingsToolbarTitle) + toolbarTitle.text = resources.getString(R.string.licenses) + + val glideCard = findViewById(R.id.card_glide) + glideCard.setOnClickListener(glideOnClickListener) + + val timberCard = findViewById(R.id.card_timber) + timberCard.setOnClickListener(timberOnClickListener) + + val materialIconsCard = findViewById(R.id.card_material_design_icons) + materialIconsCard.setOnClickListener(materialIconsOnClickListener) + + val materialComponentsCard = findViewById(R.id.card_material_design_components) + materialComponentsCard.setOnClickListener(materialComponentsOnClickListener) + + val filePickerCard = findViewById(R.id.card_android_file_picker) + filePickerCard.setOnClickListener(filePickerOnClickListener) + + } + + private val backOnClickListener = View.OnClickListener { + Timber.d("Log: backClick: Started") + finish() + } + + private val glideOnClickListener = View.OnClickListener { + Timber.d("Log: glideClick: Started") + val uriUrl = Uri.parse("https://github.com/bumptech/glide") + val launchBrowser = Intent(Intent.ACTION_VIEW) + launchBrowser.data = uriUrl + startActivity(launchBrowser) + } + + private val timberOnClickListener = View.OnClickListener { + Timber.d("Log: timberClick: Started") + val uriUrl = Uri.parse("https://github.com/JakeWharton/timber") + val launchBrowser = Intent(Intent.ACTION_VIEW) + launchBrowser.data = uriUrl + startActivity(launchBrowser) + } + + private val materialIconsOnClickListener = View.OnClickListener { + Timber.d("Log: materialIconsClick: Started") + val uriUrl = Uri.parse("https://github.com/google/material-design-icons") + val launchBrowser = Intent(Intent.ACTION_VIEW) + launchBrowser.data = uriUrl + startActivity(launchBrowser) + } + + private val materialComponentsOnClickListener = View.OnClickListener { + Timber.d("Log: materialComponentsClick: Started") + val uriUrl = Uri.parse("https://github.com/material-components/material-components-android") + val launchBrowser = Intent(Intent.ACTION_VIEW) + launchBrowser.data = uriUrl + startActivity(launchBrowser) + } + + private val filePickerOnClickListener = View.OnClickListener { + Timber.d("Log: filePickerClick: Started") + val uriUrl = Uri.parse("https://github.com/DroidNinja/Android-FilePicker") + val launchBrowser = Intent(Intent.ACTION_VIEW) + launchBrowser.data = uriUrl + startActivity(launchBrowser) + } +} diff --git a/app/src/main/java/app/marcdev/earworm/settingsscreen/SettingsActivity.kt b/app/src/main/java/app/marcdev/earworm/settingsscreen/SettingsActivity.kt new file mode 100644 index 0000000..d39ae06 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/settingsscreen/SettingsActivity.kt @@ -0,0 +1,49 @@ +package app.marcdev.earworm.settingsscreen + +import android.os.Bundle +import android.view.View +import android.widget.ImageButton +import androidx.appcompat.app.AppCompatActivity +import app.marcdev.earworm.R +import app.marcdev.earworm.utils.DARK_THEME +import app.marcdev.earworm.utils.changeColorOfImageButtonDrawable +import app.marcdev.earworm.utils.getTheme +import app.marcdev.earworm.utils.setFragment +import timber.log.Timber + +class SettingsActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + Timber.v("Log: onCreate: Started") + + if(getTheme(applicationContext) == DARK_THEME) { + Timber.v("Log: onCreate: Is dark mode") + setTheme(R.style.Earworm_DarkTheme) + } else { + Timber.v("Log: onCreate: Is not dark mode") + setTheme(R.style.Earworm_LightTheme) + } + + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_settings) + bindViews() + + setFragment(SettingsFragment(), supportFragmentManager, R.id.scroll_settings) + } + + private fun bindViews() { + Timber.v("Log: bindViews: Started") + val backButton = findViewById(R.id.img_backFromSettings) + backButton.setOnClickListener(backOnClickListener) + + if(getTheme(applicationContext) == DARK_THEME) { + Timber.v("Log: bindViews: Converting to dark mode") + changeColorOfImageButtonDrawable(applicationContext, backButton, false) + } + } + + private val backOnClickListener = View.OnClickListener { + Timber.d("Log: BackClick: Started") + this.finish() + } +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/settingsscreen/SettingsFragment.kt b/app/src/main/java/app/marcdev/earworm/settingsscreen/SettingsFragment.kt new file mode 100644 index 0000000..ecc7014 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/settingsscreen/SettingsFragment.kt @@ -0,0 +1,118 @@ +package app.marcdev.earworm.settingsscreen + +import android.content.Intent +import android.content.SharedPreferences +import android.net.Uri +import android.os.Bundle +import android.preference.ListPreference +import android.preference.PreferenceManager +import android.widget.Toast +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import app.marcdev.earworm.BuildConfig +import app.marcdev.earworm.R +import app.marcdev.earworm.utils.* +import timber.log.Timber + +class SettingsFragment : PreferenceFragmentCompat() { + + private lateinit var prefs: SharedPreferences + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + Timber.v("Log: onCreatePreferences: Started") + setPreferencesFromResource(R.xml.preferences, rootKey) + this.prefs = PreferenceManager.getDefaultSharedPreferences(requireActivity().applicationContext) + + val themePref = findPreference(PREF_THEME) + themePref.onPreferenceChangeListener = themeChangeListener + matchSummaryToSelection(themePref, PreferenceManager.getDefaultSharedPreferences(themePref.context).getString(themePref.key, "")!!) + changeColorOfDrawable(requireContext(), themePref.icon, false) + + val clearInputsPref = findPreference(PREF_CLEAR_INPUTS) + clearInputsPref.onPreferenceChangeListener = clearInputsChangeListener + matchSummaryToSelection(clearInputsPref, PreferenceManager.getDefaultSharedPreferences(clearInputsPref.context).getString(clearInputsPref.key, "")!!) + changeColorOfDrawable(requireContext(), clearInputsPref.icon, false) + + val tipsPref = findPreference(PREF_SHOW_TIPS) + tipsPref.onPreferenceClickListener = resetTipsListener + changeColorOfDrawable(requireContext(), tipsPref.icon, false) + + val versionPref = findPreference(PREF_BUILD_NUMBER) + versionPref.summary = BuildConfig.VERSION_NAME + versionPref.onPreferenceClickListener = versionClickListener + changeColorOfDrawable(requireContext(), versionPref.icon, false) + + val licensesPref = findPreference(PREF_LICENSES) + licensesPref.onPreferenceClickListener = licensesOnClickListener + changeColorOfDrawable(requireContext(), licensesPref.icon, false) + + val githubPref = findPreference(PREF_GITHUB) + githubPref.onPreferenceClickListener = githubOnClickListener + changeColorOfDrawable(requireContext(), githubPref.icon, false) + } + + private val themeChangeListener = Preference.OnPreferenceChangeListener { preference, newValue -> + Timber.d("Log: themeChangeListener: Theme changed to $newValue") + requireActivity().recreate() + matchSummaryToSelection(preference, newValue.toString()) + true + } + + private val clearInputsChangeListener = Preference.OnPreferenceChangeListener { preference, newValue -> + Timber.d("Log: clearInputsChangeListener: Value changed to $newValue") + matchSummaryToSelection(preference, newValue.toString()) + true + } + + private val resetTipsListener = Preference.OnPreferenceClickListener { + Timber.d("Log: ResetTipsClick: Clicked") + prefs.edit().putBoolean(PREF_SHOW_TIPS, true).apply() + Toast.makeText(requireContext(), resources.getString(R.string.reset_tips_confirmation), Toast.LENGTH_LONG).show() + return@OnPreferenceClickListener true + } + + private val versionClickListener = Preference.OnPreferenceClickListener { + Timber.d("Log: versionClick: Started") + val versionCodeString = resources.getString(R.string.build_code) + Toast.makeText(requireContext(), "$versionCodeString: ${BuildConfig.VERSION_CODE}", Toast.LENGTH_SHORT).show() + true + } + + private val licensesOnClickListener = Preference.OnPreferenceClickListener { + Timber.d("Log: licensesClick: Started") + val intent = Intent(requireContext(), LicensesActivity::class.java) + startActivity(intent) + true + } + + private val githubOnClickListener = Preference.OnPreferenceClickListener { + Timber.d("Log: githubClick: Started") + val uriUrl = Uri.parse("https://github.com/MarcDonald/Earworm") + val launchBrowser = Intent(Intent.ACTION_VIEW) + launchBrowser.data = uriUrl + startActivity(launchBrowser) + true + } + + private fun matchSummaryToSelection(preference: Preference, value: String) { + Timber.d("Log: themeOnChangeListener: Started") + Timber.d("Log: themeOnChangeListener: Value = $value") + + if(preference is ListPreference) { + val index = preference.findIndexOfValue(value) + + preference.setSummary( + if(index >= 0) { + Timber.d("Log: BindPreferenceSummaryToValue: Setting summary to ${preference.entries[index]}") + preference.entries[index] + } else { + Timber.w("Log: BindPreferenceSummaryToValue: Index < 0") + null + }) + + } else { + Timber.d("Log: BindPreferenceSummaryToValue: Setting summary to $value") + preference.summary = value + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/uicomponents/FilterDialog.kt b/app/src/main/java/app/marcdev/earworm/uicomponents/FilterDialog.kt new file mode 100644 index 0000000..f1c2394 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/uicomponents/FilterDialog.kt @@ -0,0 +1,163 @@ +package app.marcdev.earworm.uicomponents + +import android.app.Dialog +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.widget.CheckBox +import android.widget.CompoundButton +import android.widget.DatePicker +import app.marcdev.earworm.R +import app.marcdev.earworm.mainscreen.MainFragmentPresenter +import app.marcdev.earworm.utils.DEFAULT_FILTER +import app.marcdev.earworm.utils.ItemFilter +import app.marcdev.earworm.utils.formatDateForDisplay +import com.google.android.material.button.MaterialButton +import com.google.android.material.chip.Chip +import timber.log.Timber +import java.util.* + +class FilterDialog(context: Context, private val presenter: MainFragmentPresenter) : Dialog(context) { + + private lateinit var displaySongCheckbox: CheckBox + private lateinit var displayAlbumCheckbox: CheckBox + private lateinit var displayArtistCheckbox: CheckBox + private lateinit var startDateDisplay: Chip + private lateinit var endDateDisplay: Chip + private lateinit var startDatePickerDialog: Dialog + private lateinit var endDatePickerDialog: Dialog + var activeFilter: ItemFilter = DEFAULT_FILTER.copy() + + init { + Timber.d("Log: FilterDialog Init: Started") + setContentView(R.layout.dialog_filter) + window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + bindViews() + initCheckboxes() + } + + private fun bindViews() { + Timber.d("Log: bindViews: Started") + + initStartDatePickerDialog() + initEndDatePickerDialog() + + this.startDateDisplay = findViewById(R.id.chip_filter_start) + startDateDisplay.setOnClickListener { + Timber.d("Log: startDateClickListener: Started") + startDatePickerDialog.show() + } + + this.endDateDisplay = findViewById(R.id.chip_filter_end) + endDateDisplay.setOnClickListener { + Timber.d("Log: endDateClickListener: Started") + endDatePickerDialog.show() + } + + this.displaySongCheckbox = findViewById(R.id.chk_filter_song) + displaySongCheckbox.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> + activeFilter.includeSongs = isChecked + } + + this.displayAlbumCheckbox = findViewById(R.id.chk_filter_album) + displayAlbumCheckbox.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> + activeFilter.includeAlbums = isChecked + } + + this.displayArtistCheckbox = findViewById(R.id.chk_filter_artist) + displayArtistCheckbox.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> + activeFilter.includeArtists = isChecked + } + + val submitButton: MaterialButton = findViewById(R.id.btn_filter_ok) + submitButton.setOnClickListener { + Timber.d("Log: submitButtonOnClickListener: Started") + presenter.getAllItems(activeFilter) + dismiss() + } + } + + private fun initStartDatePickerDialog() { + Timber.d("Log: initStartDatePickerDialog: Started") + + this.startDatePickerDialog = Dialog(context) + startDatePickerDialog.setContentView(R.layout.dialog_datepicker_filter) + startDatePickerDialog.window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + + val datePicker: DatePicker = startDatePickerDialog.findViewById(R.id.datepicker_filter) + + val cancelButton: MaterialButton = startDatePickerDialog.findViewById(R.id.btn_datepicker_filter_cancel) + cancelButton.setOnClickListener { + Timber.d("Log: cancelButtonOnClickListener: Started") + startDatePickerDialog.dismiss() + } + + val okButton: MaterialButton = startDatePickerDialog.findViewById(R.id.btn_datepicker_filter_ok) + okButton.setOnClickListener { + Timber.d("Log: okButtonOnClickListener: Started") + activeFilter.startDay = datePicker.dayOfMonth + activeFilter.startMonth = datePicker.month + activeFilter.startYear = datePicker.year + startDateDisplay.text = formatDateForDisplay(datePicker.dayOfMonth, datePicker.month, datePicker.year) + startDatePickerDialog.dismiss() + } + + val startButton: MaterialButton = startDatePickerDialog.findViewById(R.id.btn_datepicker_filter_start_end) + startButton.setOnClickListener { + Timber.d("Log: startButtonOnClickListener: Started") + activeFilter.startDay = DEFAULT_FILTER.startDay + activeFilter.startMonth = DEFAULT_FILTER.startMonth + activeFilter.startYear = DEFAULT_FILTER.startYear + startDateDisplay.text = context.resources.getString(R.string.start) + val todayCalendar = Calendar.getInstance() + datePicker.updateDate(todayCalendar.get(Calendar.YEAR), todayCalendar.get(Calendar.MONTH), todayCalendar.get(Calendar.DAY_OF_MONTH)) + startDatePickerDialog.dismiss() + } + } + + private fun initEndDatePickerDialog() { + Timber.d("Log: initEndDatePickerDialog: Started") + + this.endDatePickerDialog = Dialog(context) + endDatePickerDialog.setContentView(R.layout.dialog_datepicker_filter) + endDatePickerDialog.window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + + val datePicker: DatePicker = endDatePickerDialog.findViewById(R.id.datepicker_filter) + + val cancelButton: MaterialButton = endDatePickerDialog.findViewById(R.id.btn_datepicker_filter_cancel) + cancelButton.setOnClickListener { + Timber.d("Log: cancelButtonOnClickListener: Started") + endDatePickerDialog.dismiss() + } + + val okButton: MaterialButton = endDatePickerDialog.findViewById(R.id.btn_datepicker_filter_ok) + okButton.setOnClickListener { + Timber.d("Log: okButtonOnClickListener: Started") + activeFilter.endDay = datePicker.dayOfMonth + activeFilter.endMonth = datePicker.month + activeFilter.endYear = datePicker.year + endDateDisplay.text = formatDateForDisplay(datePicker.dayOfMonth, datePicker.month, datePicker.year) + endDatePickerDialog.dismiss() + } + + val endButton: MaterialButton = endDatePickerDialog.findViewById(R.id.btn_datepicker_filter_start_end) + endButton.text = context.resources.getString(R.string.end) + endButton.setOnClickListener { + Timber.d("Log: startButtonOnClickListener: Started") + activeFilter.endDay = DEFAULT_FILTER.endDay + activeFilter.endMonth = DEFAULT_FILTER.endMonth + activeFilter.endYear = DEFAULT_FILTER.endYear + endDateDisplay.text = context.resources.getString(R.string.end) + val todayCalendar = Calendar.getInstance() + datePicker.updateDate(todayCalendar.get(Calendar.YEAR), todayCalendar.get(Calendar.MONTH), todayCalendar.get(Calendar.DAY_OF_MONTH)) + endDatePickerDialog.dismiss() + } + } + + private fun initCheckboxes() { + Timber.d("Log: initCheckboxes: Started") + displaySongCheckbox.isChecked = activeFilter.includeSongs + displayAlbumCheckbox.isChecked = activeFilter.includeAlbums + displayArtistCheckbox.isChecked = activeFilter.includeArtists + } +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/uicomponents/RoundedBottomDialogFragment.kt b/app/src/main/java/app/marcdev/earworm/uicomponents/RoundedBottomDialogFragment.kt new file mode 100644 index 0000000..f8eb9c1 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/uicomponents/RoundedBottomDialogFragment.kt @@ -0,0 +1,18 @@ +package app.marcdev.earworm.uicomponents + +import android.app.Dialog +import android.os.Bundle +import app.marcdev.earworm.R +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +open class RoundedBottomDialogFragment : BottomSheetDialogFragment() { + + override fun getTheme(): Int { + return R.style.Earworm_BottomSheetDialogTheme + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return BottomSheetDialog(requireContext(), theme) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/utils/EarwormUtils.kt b/app/src/main/java/app/marcdev/earworm/utils/EarwormUtils.kt new file mode 100644 index 0000000..9078c1f --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/utils/EarwormUtils.kt @@ -0,0 +1,128 @@ +package app.marcdev.earworm.utils + +import android.content.Context +import android.graphics.PorterDuff +import android.graphics.drawable.Drawable +import android.preference.PreferenceManager +import android.widget.ImageButton +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import app.marcdev.earworm.R +import timber.log.Timber + +// Song types +const val SONG = 0 +const val ALBUM = 1 +const val ARTIST = 2 +const val GENRE = 3 +const val HEADER = 4 + +val DEFAULT_FILTER = ItemFilter(1, 0, 1900, 31, 11, 2099, true, true, true, "") + +// Theme IDs +const val LIGHT_THEME = 0 +const val DARK_THEME = 1 + +// Preference keys +const val PREF_THEME = "pref_theme" +const val PREF_SHOW_TIPS = "pref_show_tips" +const val PREF_BUILD_NUMBER = "pref_build_number" +const val PREF_LICENSES = "pref_licenses" +const val PREF_GITHUB = "pref_github" +const val PREF_CLEAR_INPUTS = "pref_clear_inputs_on_type_change" + +/** + * Replaces a fragment in a frame with another fragment + * @param fragment The fragment to display + * @param fragmentManager The Fragment Manager + * @param frameId The ID of the frame to display the new fragment in + */ +fun setFragment(fragment: Fragment, fragmentManager: FragmentManager, frameId: Int) { + Timber.d("Log: setFragment: Replacing frame $frameId with fragment $fragment") + val fragmentTransaction = fragmentManager.beginTransaction() + fragmentTransaction.replace(frameId, fragment) + fragmentTransaction.commit() +} + +/** + * Converts date to a format suitable for display + * @param day The day + * @param month The month (indexed at 0 the same as the Java calendar, so January is 0) + * @param year The year + */ +fun formatDateForDisplay(day: Int, month: Int, year: Int): String { + Timber.d("Log: formatDateForDisplay: Started with day = $day, month = $month, year = $year") + // Add 1 to month to make it non-zero indexed (January will now be 1 rather than 0) + return "$day/${month + 1}/$year" +} + +/** + * Changes the color of a drawable in an ImageView to indicate whether it is activated or not. + * Deactivated will change the color to either black or 70% white depending on the theme + * @param context Context + * @param button The button to change the color of + * @param isActivated Whether or not the button should be put into the activated state + */ +fun changeColorOfImageButtonDrawable(context: Context, button: ImageButton, isActivated: Boolean) { + Timber.v("Log: changeColorOfImageButtonDrawable: Started") + + when { + isActivated -> button.setColorFilter(context.getColor(R.color.colorAccent)) + (getTheme(context) == DARK_THEME && !isActivated) -> button.setColorFilter(context.getColor(R.color.white60)) + else -> button.setColorFilter(context.getColor(R.color.black)) + } +} + +/** + * Changes the color of a drawable to indicate whether it is activated or not. Deactivated will + * change the color to either black or 70% white depending on the theme + * @param context Context + * @param drawable The drawable to change the color of + * @param isActivated Whether or not the button should be put into the activated state + */ +fun changeColorOfDrawable(context: Context, drawable: Drawable, isActivated: Boolean) { + Timber.v("Log: changeColorOfDrawable: Started") + + when { + isActivated -> drawable.setColorFilter(context.getColor(R.color.colorAccent), PorterDuff.Mode.SRC_IN) + (getTheme(context) == DARK_THEME && !isActivated) -> drawable.setColorFilter((context.getColor(R.color.white60)), PorterDuff.Mode.SRC_IN) + else -> drawable.setColorFilter((context.getColor(R.color.black)), PorterDuff.Mode.SRC_IN) + } +} + +/** + * Gets the full name of a month based on it's 0 indexed number + * @param month Integer value of the month, indexed at 0 + * @param context Context + * @return Full name of the month in string format + */ +fun getMonthName(month: Int, context: Context): String { + val monthArray = context.resources.getStringArray(R.array.months) + return monthArray[month] +} + +/** + * Gets the path of the application's image storage + * @param context Context + */ +fun getArtworkDirectory(context: Context): String { + Timber.d("Log: getArtworkDirectory: Started") + val returnValue = context.filesDir.path + "/artwork/" + Timber.d("Log: getArtworkDirectory: Returning $returnValue") + return returnValue +} + +/** + * Checks the shared preferences to see if the user has selected dark mode + * @param context Context + */ +fun getTheme(context: Context): Int { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val theme = prefs.getString("pref_theme", context.resources.getString(R.string.light)) + + return when(theme) { + context.resources.getString(R.string.light) -> LIGHT_THEME + context.resources.getString(R.string.dark) -> DARK_THEME + else -> -1 + } +} \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/utils/ItemFilter.kt b/app/src/main/java/app/marcdev/earworm/utils/ItemFilter.kt new file mode 100644 index 0000000..efd7aa3 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/utils/ItemFilter.kt @@ -0,0 +1,12 @@ +package app.marcdev.earworm.utils + +data class ItemFilter(var startDay: Int, + var startMonth: Int, + var startYear: Int, + var endDay: Int, + var endMonth: Int, + var endYear: Int, + var includeSongs: Boolean, + var includeAlbums: Boolean, + var includeArtists: Boolean, + var searchTerm: String) \ No newline at end of file diff --git a/app/src/main/java/app/marcdev/earworm/utils/ListUtils.kt b/app/src/main/java/app/marcdev/earworm/utils/ListUtils.kt new file mode 100644 index 0000000..563e2d4 --- /dev/null +++ b/app/src/main/java/app/marcdev/earworm/utils/ListUtils.kt @@ -0,0 +1,140 @@ +package app.marcdev.earworm.utils + +import app.marcdev.earworm.database.FavouriteItem +import timber.log.Timber + +/** + * Filters a list based on a filter input by the user + * @param allItems The complete list of items from the database + * @param filter The filter to apply + * @return Filtered list + */ +fun applyFilter(allItems: MutableList, filter: ItemFilter): MutableList { + val filteredItems = mutableListOf() + filteredItems.addAll(allItems) + + val startDayTwoDigit = if(filter.startDay < 10) + "0${filter.startDay}" + else { + "${filter.startDay}" + } + + val startMonthTwoDigit = if(filter.startMonth < 10) + "0${filter.startMonth}" + else { + "${filter.startMonth}" + } + + val filterCompleteDateStart: Int = "${filter.startYear}$startMonthTwoDigit$startDayTwoDigit".toInt() + + val endDayTwoDigit = if(filter.endDay < 10) + "0${filter.endDay}" + else { + "${filter.endDay}" + } + + val endMonthTwoDigit = if(filter.endMonth < 10) + "0${filter.endMonth}" + else { + "${filter.endMonth}" + } + + val filterCompleteDateEnd: Int = "${filter.endYear}$endMonthTwoDigit$endDayTwoDigit".toInt() + + for(x in 0 until allItems.size) { + if(allItems[x].type == SONG && !filter.includeSongs) { + filteredItems.remove(allItems[x]) + } + + if(allItems[x].type == ALBUM && !filter.includeAlbums) { + filteredItems.remove(allItems[x]) + } + + if(allItems[x].type == ARTIST && !filter.includeArtists) { + filteredItems.remove(allItems[x]) + } + + val dayTwoDigit = if(allItems[x].day < 10) + "0${allItems[x].day}" + else { + "${allItems[x].day}" + } + + val monthTwoDigit = if(allItems[x].month < 10) + "0${allItems[x].month}" + else { + "${allItems[x].month}" + } + + val completeDate = "${allItems[x].year}$monthTwoDigit$dayTwoDigit" + val completeDateI: Int = completeDate.toInt() + + if(completeDateI < filterCompleteDateStart || completeDateI > filterCompleteDateEnd) { + filteredItems.remove(allItems[x]) + } + + if(!(allItems[x].albumName.contains(filter.searchTerm, true)) + && !(allItems[x].artistName.contains(filter.searchTerm, true)) + && !(allItems[x].songName.contains(filter.searchTerm, true)) + && !(allItems[x].genre.contains(filter.searchTerm, true)) + ) { + filteredItems.remove(allItems[x]) + } + } + + return sortByDateDescending(filteredItems) +} + +/** + * Sorts a list by date in descending order + * @param items List to sort + * @return Sorted list of FavouriteItems + */ +fun sortByDateDescending(items: MutableList): MutableList { + val filteredItems = items.sortedWith( + compareBy( + { -it.year }, + { -it.month }, + { -it.day }, + { -it.id!! })) + + return filteredItems.toMutableList() +} + +/** + * Adds header items to a list on a monthly basis + * @param allItems List to add headers to (sorted by date descending) + * @return List with headers every new month + */ +fun addListHeaders(allItems: MutableList): List { + val listWithHeaders = mutableListOf() + listWithHeaders.addAll(allItems) + + var lastMonth = 12 + var lastYear = 9999 + if(allItems.isNotEmpty()) { + lastMonth = allItems.first().month + 1 + lastYear = allItems.first().year + } + + val headersToAdd = mutableListOf>() + + for(x in 0 until allItems.size) { + if(((allItems[x].month < lastMonth) && (allItems[x].year == lastYear)) + || (allItems[x].month > lastMonth) && (allItems[x].year < lastYear) + || (allItems[x].year < lastYear) + ) { + Timber.v("Log: addListHeaders: x = $x") + val header = FavouriteItem("", "", "", "", 0, allItems[x].month, allItems[x].year, HEADER, "") + lastMonth = allItems[x].month + lastYear = allItems[x].year + headersToAdd.add(Pair(x, header)) + } + } + + for((add, x) in (0 until headersToAdd.size).withIndex()) { + listWithHeaders.add(headersToAdd[x].first + add, headersToAdd[x].second) + } + + return listWithHeaders +} diff --git a/app/src/main/res/drawable/ic_add_24px.xml b/app/src/main/res/drawable/ic_add_24px.xml new file mode 100644 index 0000000..d70af2a --- /dev/null +++ b/app/src/main/res/drawable/ic_add_24px.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add_a_photo_24px.xml b/app/src/main/res/drawable/ic_add_a_photo_24px.xml new file mode 100644 index 0000000..1323196 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_a_photo_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_add_circle_24px.xml b/app/src/main/res/drawable/ic_add_circle_24px.xml new file mode 100644 index 0000000..ba908ba --- /dev/null +++ b/app/src/main/res/drawable/ic_add_circle_24px.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_album_24px.xml b/app/src/main/res/drawable/ic_album_24px.xml new file mode 100644 index 0000000..279ad3a --- /dev/null +++ b/app/src/main/res/drawable/ic_album_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_back_24px.xml b/app/src/main/res/drawable/ic_arrow_back_24px.xml new file mode 100644 index 0000000..8f329da --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_backspace_black_24dp.xml b/app/src/main/res/drawable/ic_backspace_black_24dp.xml new file mode 100644 index 0000000..3659b08 --- /dev/null +++ b/app/src/main/res/drawable/ic_backspace_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_build_black_24dp.xml b/app/src/main/res/drawable/ic_build_black_24dp.xml new file mode 100644 index 0000000..255931f --- /dev/null +++ b/app/src/main/res/drawable/ic_build_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_close_24px.xml b/app/src/main/res/drawable/ic_close_24px.xml new file mode 100644 index 0000000..dd91559 --- /dev/null +++ b/app/src/main/res/drawable/ic_close_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_code_black_24dp.xml b/app/src/main/res/drawable/ic_code_black_24dp.xml new file mode 100644 index 0000000..c6b6a06 --- /dev/null +++ b/app/src/main/res/drawable/ic_code_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_color_lens_black_24dp.xml b/app/src/main/res/drawable/ic_color_lens_black_24dp.xml new file mode 100644 index 0000000..f79e30b --- /dev/null +++ b/app/src/main/res/drawable/ic_color_lens_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_description_black_24dp.xml b/app/src/main/res/drawable/ic_description_black_24dp.xml new file mode 100644 index 0000000..bef4f34 --- /dev/null +++ b/app/src/main/res/drawable/ic_description_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_error_24px.xml b/app/src/main/res/drawable/ic_error_24px.xml new file mode 100644 index 0000000..be7674a --- /dev/null +++ b/app/src/main/res/drawable/ic_error_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_filter_list_24px.xml b/app/src/main/res/drawable/ic_filter_list_24px.xml new file mode 100644 index 0000000..0941934 --- /dev/null +++ b/app/src/main/res/drawable/ic_filter_list_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_help_black_24dp.xml b/app/src/main/res/drawable/ic_help_black_24dp.xml new file mode 100644 index 0000000..b1d2812 --- /dev/null +++ b/app/src/main/res/drawable/ic_help_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_library_music_black_24dp.xml b/app/src/main/res/drawable/ic_library_music_black_24dp.xml new file mode 100644 index 0000000..4881e45 --- /dev/null +++ b/app/src/main/res/drawable/ic_library_music_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_music_note_24px.xml b/app/src/main/res/drawable/ic_music_note_24px.xml new file mode 100644 index 0000000..1e268ef --- /dev/null +++ b/app/src/main/res/drawable/ic_music_note_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_person_24px.xml b/app/src/main/res/drawable/ic_person_24px.xml new file mode 100644 index 0000000..153c4e8 --- /dev/null +++ b/app/src/main/res/drawable/ic_person_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_search_24px.xml b/app/src/main/res/drawable/ic_search_24px.xml new file mode 100644 index 0000000..4e63214 --- /dev/null +++ b/app/src/main/res/drawable/ic_search_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sentiment_dissatisfied_black_24dp.xml b/app/src/main/res/drawable/ic_sentiment_dissatisfied_black_24dp.xml new file mode 100644 index 0000000..2e57e93 --- /dev/null +++ b/app/src/main/res/drawable/ic_sentiment_dissatisfied_black_24dp.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_settings_24px.xml b/app/src/main/res/drawable/ic_settings_24px.xml new file mode 100644 index 0000000..1de8ad9 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/rounded_bottom_sheet_background.xml b/app/src/main/res/drawable/rounded_bottom_sheet_background.xml new file mode 100644 index 0000000..8a6ae6d --- /dev/null +++ b/app/src/main/res/drawable/rounded_bottom_sheet_background.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_datepicker_background.xml b/app/src/main/res/drawable/rounded_datepicker_background.xml new file mode 100644 index 0000000..519d96a --- /dev/null +++ b/app/src/main/res/drawable/rounded_datepicker_background.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_dialog_background.xml b/app/src/main/res/drawable/rounded_dialog_background.xml new file mode 100644 index 0000000..1f1014e --- /dev/null +++ b/app/src/main/res/drawable/rounded_dialog_background.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_licenses.xml b/app/src/main/res/layout/activity_licenses.xml new file mode 100644 index 0000000..f828643 --- /dev/null +++ b/app/src/main/res/layout/activity_licenses.xml @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file 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 0000000..42bf02c --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 0000000..5e083d2 --- /dev/null +++ b/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_add_item.xml b/app/src/main/res/layout/dialog_add_item.xml new file mode 100644 index 0000000..5c8608a --- /dev/null +++ b/app/src/main/res/layout/dialog_add_item.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_datepicker.xml b/app/src/main/res/layout/dialog_datepicker.xml new file mode 100644 index 0000000..b91904d --- /dev/null +++ b/app/src/main/res/layout/dialog_datepicker.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_datepicker_filter.xml b/app/src/main/res/layout/dialog_datepicker_filter.xml new file mode 100644 index 0000000..dfe70bd --- /dev/null +++ b/app/src/main/res/layout/dialog_datepicker_filter.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_delete_image.xml b/app/src/main/res/layout/dialog_delete_image.xml new file mode 100644 index 0000000..9dbdfe9 --- /dev/null +++ b/app/src/main/res/layout/dialog_delete_image.xml @@ -0,0 +1,42 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_edit_or_delete.xml b/app/src/main/res/layout/dialog_edit_or_delete.xml new file mode 100644 index 0000000..47ec010 --- /dev/null +++ b/app/src/main/res/layout/dialog_edit_or_delete.xml @@ -0,0 +1,42 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_filter.xml b/app/src/main/res/layout/dialog_filter.xml new file mode 100644 index 0000000..cd957db --- /dev/null +++ b/app/src/main/res/layout/dialog_filter.xml @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_mainscreen.xml b/app/src/main/res/layout/fragment_mainscreen.xml new file mode 100644 index 0000000..b055482 --- /dev/null +++ b/app/src/main/res/layout/fragment_mainscreen.xml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_header.xml b/app/src/main/res/layout/item_header.xml new file mode 100644 index 0000000..dd26bae --- /dev/null +++ b/app/src/main/res/layout/item_header.xml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_mainrecycler_album.xml b/app/src/main/res/layout/item_mainrecycler_album.xml new file mode 100644 index 0000000..cbe666c --- /dev/null +++ b/app/src/main/res/layout/item_mainrecycler_album.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_mainrecycler_artist.xml b/app/src/main/res/layout/item_mainrecycler_artist.xml new file mode 100644 index 0000000..688b80e --- /dev/null +++ b/app/src/main/res/layout/item_mainrecycler_artist.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_mainrecycler_genre.xml b/app/src/main/res/layout/item_mainrecycler_genre.xml new file mode 100644 index 0000000..7636b28 --- /dev/null +++ b/app/src/main/res/layout/item_mainrecycler_genre.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_mainrecycler_song.xml b/app/src/main/res/layout/item_mainrecycler_song.xml new file mode 100644 index 0000000..3264a43 --- /dev/null +++ b/app/src/main/res/layout/item_mainrecycler_song.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/toolbar_filter.xml b/app/src/main/res/layout/toolbar_filter.xml new file mode 100644 index 0000000..8900531 --- /dev/null +++ b/app/src/main/res/layout/toolbar_filter.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/toolbar_main.xml b/app/src/main/res/layout/toolbar_main.xml new file mode 100644 index 0000000..c776ea8 --- /dev/null +++ b/app/src/main/res/layout/toolbar_main.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/toolbar_settings.xml b/app/src/main/res/layout/toolbar_settings.xml new file mode 100644 index 0000000..eb00be5 --- /dev/null +++ b/app/src/main/res/layout/toolbar_settings.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c9ad5f9 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..2b7f809 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..513d2b2 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..c15627b Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..513d2b2 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..1e38d4d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..1a6a425 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..1e38d4d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..70204be Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2d2e7ce Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..70204be Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..2fb167e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a9b0b52 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..2fb167e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..023376d Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..94e39df Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..023376d Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml new file mode 100644 index 0000000..38ecd6e --- /dev/null +++ b/app/src/main/res/values/attrs.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..56922ff --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,36 @@ + + + @color/white + #CCCCCC + #F57C00 + #FFAD42 + #BB4D00 + #B00020 + #4CAF50 + + + @color/white + @color/white + #CCCCCC + @color/black + @color/black70 + @color/black50 + + + #303030 + #212121 + #424242 + @color/white + @color/white70 + @color/white50 + + + + #FFFFFF + #B3FFFFFF + #99FFFFFF + #80FFFFFF + #000000 + #B3000000 + #80000000 + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..f71d525 --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,20 @@ + + + 8dp + 4dp + 16dp + 56dp + 48dp + 16dp + 8dp + 16dp + 8dp + 8dp + 8dp + 90dp + 56dp + 150dp + 40dp + 32dp + 16dp + \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..16cdd5a --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..0b1a775 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,102 @@ + + + Earworm + You haven\'t made any entries yet. To add a new entry, click the button in the bottom + right + + No results found that match that filter + Please ensure all fields are filled before saving + Please enter something to search for + Today + Hold to edit or delete + Don\'t Show Again + Search + Filter + Between + Include + Start Date + End Date + End + Start + An Error Occurred + Back + Add + Add an Entry + Shortcut Disabled + Yes + No + + + Song name + Song + Album + Artist + Genre + Date + + + Item Added + Item Deleted + Save + OK + Cancel + Edit or Delete Item + Edit + Delete + Are you sure you want to remove this image? + Add an Image + Choose an Image + + + Settings + Appearance + Theme Selection + Behaviour + Reset Tips + Clear Inputs on Type Change + Tips have been reset + App Info + Version + Build Code + Licenses + Github + + + @string/yes + @string/no + + + + Glide + Timber + Material Design Icons + Material Components for Android + Android File picker + Apache 2 + BSD, part MIT and Apache 2.0. See the LICENSE file for details + + + Light + Dark + + + @string/light + @string/dark + + + + January + February + March + April + May + June + July + August + September + October + November + December + + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..ab964e1 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml new file mode 100644 index 0000000..1a16dcf --- /dev/null +++ b/app/src/main/res/xml/preferences.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml new file mode 100644 index 0000000..0b35516 --- /dev/null +++ b/app/src/main/res/xml/shortcuts.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..ff630b8 --- /dev/null +++ b/build.gradle @@ -0,0 +1,27 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + ext.kotlin_version = '1.3.0' + repositories { + google() + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:3.2.1' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..3d8ce0c --- /dev/null +++ b/gradle.properties @@ -0,0 +1,17 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +android.useAndroidX=true +android.enableJetifier=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f6b961f Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..9a4163a --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..cccdd3d --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..f955316 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..e7b4def --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +include ':app'