diff --git a/.circleci/config.yml b/.circleci/config.yml index 15f2efcb..65f71e5e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -35,6 +35,37 @@ references: jobs: + setup: + <<: *container_config + steps: + - checkout + + - restore_cache: + <<: *general_cache_key + + - run: + <<: *setup_env + + - run: + name: Download Dependencies + command: | + ./gradlew $KEY_CONFIG $SIGN_CONFIG \ + -PversionCode=$VERSION_CODE_TOUCH \ + -PversionName=${VERSION_NAME} \ + androidDependencies + + - save_cache: + <<: *general_cache_key + paths: + - "~/.gradle" + - "~/.m2" + - "/opt/android-sdk-linux/licenses/" + + - persist_to_workspace: + root: *workspace_root + paths: + - . + build: <<: *container_config steps: @@ -249,17 +280,20 @@ workflows: build_test_publish: jobs: - - build + - setup - check: requires: - - build + - setup - test: requires: - - build + - setup + - build: + requires: + - check + - test - publish-appcenter: requires: - - test - - check + - build filters: branches: only: @@ -267,8 +301,7 @@ workflows: - develop - publish-play: requires: - - test - - check + - build filters: branches: only: diff --git a/common/build.gradle b/common/build.gradle index 7ed77a11..832be640 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -147,6 +147,7 @@ dependencies { implementation 'com.squareup.retrofit2:retrofit:2.6.3' implementation 'com.squareup.retrofit2:converter-gson:2.6.2' + api "com.google.code.gson:gson:2.8.6" api 'com.google.android.exoplayer:exoplayer:2.9.6' api 'com.github.bumptech.glide:glide:4.9.0' @@ -175,9 +176,17 @@ dependencies { testImplementation "junit:junit:4.13" testRuntimeOnly "org.junit.vintage:junit-vintage-engine:5.6.0" + testImplementation "io.mockk:mockk:1.9" + testImplementation 'org.robolectric:robolectric:4.1' androidTestImplementation('androidx.test.espresso:espresso-core:3.1.0', { exclude group: 'com.android.support', module: 'support-annotations' }) + testImplementation "org.junit.jupiter:junit-jupiter-api:5.5.2" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.5.2" + + testImplementation "junit:junit:4.12" + testRuntimeOnly "org.junit.vintage:junit-vintage-engine:5.5.2" + } \ No newline at end of file diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/ImportItem.kt b/common/src/main/java/de/nicidienase/chaosflix/common/ImportItem.kt new file mode 100644 index 00000000..ed300a3c --- /dev/null +++ b/common/src/main/java/de/nicidienase/chaosflix/common/ImportItem.kt @@ -0,0 +1,10 @@ +package de.nicidienase.chaosflix.common + +import de.nicidienase.chaosflix.common.eventimport.FahrplanLecture +import de.nicidienase.chaosflix.common.mediadata.entities.recording.persistence.Event + +data class ImportItem( + val lecture: FahrplanLecture, + var event: Event?, + var selected: Boolean = false +) diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/OfflineItemManager.kt b/common/src/main/java/de/nicidienase/chaosflix/common/OfflineItemManager.kt index 61237d65..755ec1aa 100644 --- a/common/src/main/java/de/nicidienase/chaosflix/common/OfflineItemManager.kt +++ b/common/src/main/java/de/nicidienase/chaosflix/common/OfflineItemManager.kt @@ -173,8 +173,6 @@ class OfflineItemManager( private val offlineEventDao: OfflineEventDao, private val preferencesManager: PreferencesManager ) : BroadcastReceiver() { - private val TAG = DownloadCancelHandler::class.java.simpleName - override fun onReceive(p0: Context?, p1: Intent?) { val downloadId = p1?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0) if (downloadId != null && downloadId == id) { @@ -192,6 +190,10 @@ class OfflineItemManager( p0?.unregisterReceiver(this) } } + + companion object { + private val TAG = DownloadCancelHandler::class.java.simpleName + } } private fun getMovieDir(): String { diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/eventimport/FahrplanExport.kt b/common/src/main/java/de/nicidienase/chaosflix/common/eventimport/FahrplanExport.kt new file mode 100644 index 00000000..7990d691 --- /dev/null +++ b/common/src/main/java/de/nicidienase/chaosflix/common/eventimport/FahrplanExport.kt @@ -0,0 +1,6 @@ +package de.nicidienase.chaosflix.common.eventimport + +data class FahrplanExport( + val conference: String, + val lectures: List +) diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/eventimport/FahrplanLecture.kt b/common/src/main/java/de/nicidienase/chaosflix/common/eventimport/FahrplanLecture.kt new file mode 100644 index 00000000..9b6d8a0b --- /dev/null +++ b/common/src/main/java/de/nicidienase/chaosflix/common/eventimport/FahrplanLecture.kt @@ -0,0 +1,13 @@ +package de.nicidienase.chaosflix.common.eventimport + +import com.google.gson.annotations.SerializedName + +data class FahrplanLecture( + @SerializedName("lecture_id") + var lectureId: String? = null, + var title: String, + var subtitle: String? = null, + var links: String? = null, + var track: String? = null, + var description: String? = null +) diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/MediaRepository.kt b/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/MediaRepository.kt index f78f9aa3..816cd0df 100644 --- a/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/MediaRepository.kt +++ b/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/MediaRepository.kt @@ -18,6 +18,8 @@ import de.nicidienase.chaosflix.common.mediadata.entities.recording.persistence. import de.nicidienase.chaosflix.common.mediadata.entities.recording.persistence.RelatedEvent import de.nicidienase.chaosflix.common.mediadata.entities.recording.persistence.RelatedEventDao import de.nicidienase.chaosflix.common.mediadata.network.RecordingService +import de.nicidienase.chaosflix.common.userdata.entities.watchlist.WatchlistItem +import de.nicidienase.chaosflix.common.userdata.entities.watchlist.WatchlistItemDao import de.nicidienase.chaosflix.common.util.ConferenceUtil import de.nicidienase.chaosflix.common.util.LiveEvent import de.nicidienase.chaosflix.common.util.SingleLiveEvent @@ -42,6 +44,7 @@ class MediaRepository( private val eventDao: EventDao by lazy { database.eventDao() } private val recordingDao: RecordingDao by lazy { database.recordingDao() } private val relatedEventDao: RelatedEventDao by lazy { database.relatedEventDao() } + private val watchlistItemDao: WatchlistItemDao by lazy { database.watchlistItemDao() } fun updateConferencesAndGroups(): SingleLiveEvent, String>> { val updateState = SingleLiveEvent, String>>() @@ -232,11 +235,22 @@ class MediaRepository( return conferenceDao.findConferenceByAcronymSuspend(data.lastPathSegment) } - private suspend fun searchEvent(queryString: String): Event? { + suspend fun findEventByTitle(title: String): Event? { + var event: Event? = eventDao.findEventByTitleSuspend(title) + if (event == null) { + event = searchEvent(title, true) + } + return event + } + + private suspend fun searchEvent(queryString: String, updateConference: Boolean = false): Event? { val searchEvents = recordingApi.searchEvents(queryString) if (searchEvents.events.isNotEmpty()) { val eventDto = searchEvents.events[0] val conference = updateConferencesAndGet(eventDto.conferenceUrl.split("/").last()) + if (updateConference && conference != null) { + updateEventsForConference(conference) + } if (conference?.id != null) { val event = Event(eventDto, conference.id) eventDao.updateOrInsert(event) @@ -248,6 +262,10 @@ class MediaRepository( suspend fun getAllOfflineEvents(): List = database.offlineEventDao().getAllDownloadReferences() + suspend fun saveOrUpdate(watchlistItem: WatchlistItem) { + watchlistItemDao.updateOrInsert(watchlistItem) + } + fun getReleatedEvents(event: Event, viewModelScope: CoroutineScope): LiveData> { val data = MutableLiveData>() viewModelScope.launch(Dispatchers.IO) { diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/entities/recording/persistence/EventDao.kt b/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/entities/recording/persistence/EventDao.kt index 6cf69991..08edc832 100644 --- a/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/entities/recording/persistence/EventDao.kt +++ b/common/src/main/java/de/nicidienase/chaosflix/common/mediadata/entities/recording/persistence/EventDao.kt @@ -61,6 +61,15 @@ abstract class EventDao : BaseDao() { @Query("DElETE FROM event") abstract fun delete() + @Query("SELECT * FROM event WHERE link = :url LIMIT 1") + abstract suspend fun findEventByFahrplanUrl(url: String): Event? + + @Query("SELECT * FROM event WHERE title LIKE :search LIMIT 1") + abstract suspend fun findEventByTitleSuspend(search: String): Event? + + @Query("SELECT * FROM event WHERE title LIKE :title LIMIT 1") + abstract fun findSingleEventByTitle(title: String): LiveData + override suspend fun updateOrInsertInternal(item: Event) { if (item.id != 0L) { update(item) diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/userdata/entities/watchlist/WatchlistItemDao.kt b/common/src/main/java/de/nicidienase/chaosflix/common/userdata/entities/watchlist/WatchlistItemDao.kt index e9e9b1fc..09bd8e4f 100644 --- a/common/src/main/java/de/nicidienase/chaosflix/common/userdata/entities/watchlist/WatchlistItemDao.kt +++ b/common/src/main/java/de/nicidienase/chaosflix/common/userdata/entities/watchlist/WatchlistItemDao.kt @@ -6,24 +6,43 @@ import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import de.nicidienase.chaosflix.common.mediadata.entities.recording.persistence.BaseDao @Dao -interface WatchlistItemDao { - @Query("SELECT * from watchlist_item") - fun getAll(): LiveData> +abstract class WatchlistItemDao : BaseDao() { @Query("SELECT * from watchlist_item") - fun getAllSync(): List + abstract fun getAll(): LiveData> + + @Query("SELECT * from watchlist_item") + abstract fun getAllSync(): List @Query("SELECT * from watchlist_item WHERE event_guid = :guid LIMIT 1") - fun getItemForEvent(guid: String): LiveData + abstract fun getItemForEvent(guid: String): LiveData @Insert(onConflict = OnConflictStrategy.REPLACE) - fun saveItem(item: WatchlistItem) + abstract fun saveItem(item: WatchlistItem) @Delete - fun deleteItem(item: WatchlistItem) + abstract fun deleteItem(item: WatchlistItem) @Query("DELETE from watchlist_item WHERE event_guid = :guid") - fun deleteItem(guid: String) + abstract fun deleteItem(guid: String) + + @Query("SELECT * from watchlist_item WHERE event_guid = :guid LIMIT 1") + abstract suspend fun getItemForGuid(guid: String): WatchlistItem? + + override suspend fun updateOrInsertInternal(item: WatchlistItem) { + if (item.id != 0L) { + update(item) + } else { + val existingEvent = getItemForGuid(item.eventGuid) + if (existingEvent != null) { + item.id = existingEvent.id + update(item) + } else { + item.id = insert(item) + } + } + } } diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/viewmodel/FavoritesImportViewModel.kt b/common/src/main/java/de/nicidienase/chaosflix/common/viewmodel/FavoritesImportViewModel.kt new file mode 100644 index 00000000..d238b0a3 --- /dev/null +++ b/common/src/main/java/de/nicidienase/chaosflix/common/viewmodel/FavoritesImportViewModel.kt @@ -0,0 +1,148 @@ +package de.nicidienase.chaosflix.common.viewmodel + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Transformations +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.gson.Gson +import de.nicidienase.chaosflix.common.ImportItem +import de.nicidienase.chaosflix.common.eventimport.FahrplanExport +import de.nicidienase.chaosflix.common.mediadata.MediaRepository +import de.nicidienase.chaosflix.common.userdata.entities.watchlist.WatchlistItem +import de.nicidienase.chaosflix.common.util.LiveEvent +import de.nicidienase.chaosflix.common.util.SingleLiveEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class FavoritesImportViewModel( + private val mediaRepository: MediaRepository +) : ViewModel() { + + val state: SingleLiveEvent, String>> = SingleLiveEvent() + + private var lastImport: String = "" + + private val _items = MutableLiveData?>() + val items: LiveData?> + get() = _items + + private val _errorMessage = MutableLiveData() + val errorMessage: LiveData + get() = _errorMessage + + private val _working = MutableLiveData(false) + val working: LiveData + get() = _working + + private val _processCount = MutableLiveData> (0 to 0) + val processCount: LiveData> + get() = _processCount + + val importItemCount: LiveData = Transformations.map(items) { items -> + val selectedItems = items?.filter { it.selected && it.event != null } + return@map selectedItems?.count() ?: 0 + } + + val selectAll = MutableLiveData(true) + + fun handleLectures(jsonImport: String) { + if (jsonImport == lastImport) { + return + } + lastImport = jsonImport + viewModelScope.launch(Dispatchers.IO) { + loading { + handleLecturesInternal(jsonImport) + } + } + } + + internal suspend fun handleLecturesInternal(jsonImport: String) { + val export = Gson().fromJson(jsonImport, FahrplanExport::class.java) + val events: List + _processCount.postValue(0 to export.lectures.size) + try { + events = export.lectures.mapIndexed { index, lecture -> + val event = mediaRepository.findEventByTitle(lecture.title) + _processCount.postValue(index to export.lectures.size) + ImportItem( + lecture = lecture, + event = event, + selected = false + ) } + _items.postValue(events) + } catch (e: Exception) { + showErrorMessage(e.message ?: "An error occured while searching for recordings") + } + } + + fun importFavorites() { + viewModelScope.launch(Dispatchers.IO) { + val items: List = _items.value?.filter { it.selected } ?: emptyList() + if (items.isNotEmpty()) { + loading { + for (item in items) { + Log.d(TAG, "${item.lecture.title}: ${item.selected}") + val guid = item.event?.guid + if (item.selected && guid != null) { + mediaRepository.saveOrUpdate(WatchlistItem(eventGuid = guid)) + } + } + state.postValue(LiveEvent(State.IMPORT_DONE)) + } + } else { + showErrorMessage("No items to importFavorites") + } + } + } + + fun selectAll(selected: Boolean? = null) { + val items: List? = _items.value + val newList = items?.map { it.copy(selected = selected ?: (it.event != null)) } + _items.postValue(newList) + } + + fun itemChanged(item: ImportItem) { + val items: MutableList? = _items.value?.toMutableList() + val index = items?.indexOf(item) + if (index != null) { + items[index] = items[index].copy() + } + _items.postValue(items) + } + + fun selectNone() = selectAll(false) + + private suspend fun loading(work: suspend () -> Unit) { + _working.postValue(true) + work() + _working.postValue(false) + } + + fun errorShown() { + _errorMessage.postValue(null) + } + + private fun showErrorMessage(message: String) { + val currentMessage = _errorMessage.value + if (currentMessage != null) { + _errorMessage.postValue("$currentMessage\n$message") + } else { + _errorMessage.postValue(message) + } + } + + fun unavailableItemClicked(item: ImportItem) { + showErrorMessage("No recording for ${item.lecture.title} found, maybe it was not recorded or it has not been published yet.") + } + + enum class State { + IMPORT_DONE + } + + companion object { + private val TAG = FavoritesImportViewModel::class.java.simpleName + } +} diff --git a/common/src/main/java/de/nicidienase/chaosflix/common/viewmodel/ViewModelFactory.kt b/common/src/main/java/de/nicidienase/chaosflix/common/viewmodel/ViewModelFactory.kt index 3b8ff48e..b0770667 100644 --- a/common/src/main/java/de/nicidienase/chaosflix/common/viewmodel/ViewModelFactory.kt +++ b/common/src/main/java/de/nicidienase/chaosflix/common/viewmodel/ViewModelFactory.kt @@ -22,30 +22,50 @@ class ViewModelFactory private constructor(context: Context) : ViewModelProvider private val database by lazy { ChaosflixDatabase.getInstance(context) } private val streamingRepository by lazy { StreamingRepository(apiFactory.streamingApi) } private val preferencesManager = - PreferencesManager(PreferenceManager.getDefaultSharedPreferences(context.applicationContext)) + PreferencesManager(PreferenceManager.getDefaultSharedPreferences(context.applicationContext)) private val offlineItemManager = - OfflineItemManager(context.applicationContext, database.offlineEventDao(), preferencesManager) + OfflineItemManager( + context.applicationContext, + database.offlineEventDao(), + preferencesManager + ) private val externalFilesDir = Environment.getExternalStorageDirectory() private val resourcesFacade by lazy { ResourcesFacade(context) } private val mediaRepository by lazy { MediaRepository(apiFactory.recordingApi, database) } @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(BrowseViewModel::class.java)) { - return BrowseViewModel(offlineItemManager, mediaRepository, database, streamingRepository, preferencesManager, resourcesFacade) as T - } else if (modelClass.isAssignableFrom(PlayerViewModel::class.java)) { - return PlayerViewModel(database) as T - } else if (modelClass.isAssignableFrom(DetailsViewModel::class.java)) { - return DetailsViewModel(database, offlineItemManager, preferencesManager, mediaRepository) as T - } else if (modelClass.isAssignableFrom(PreferencesViewModel::class.java)) { - return PreferencesViewModel(mediaRepository, database.watchlistItemDao(), externalFilesDir) as T - } else if (modelClass.isAssignableFrom(SplashViewModel::class.java)) { - return SplashViewModel(mediaRepository) as T - } else { - throw UnsupportedOperationException("The requested ViewModel is currently unsupported. " + - "Please make sure to implement are correct creation of it. " + - " Request: ${modelClass.canonicalName}") + return when (modelClass) { + BrowseViewModel::class.java -> BrowseViewModel( + offlineItemManager, + mediaRepository, + database, + streamingRepository, + preferencesManager, + resourcesFacade) as T + PlayerViewModel::class.java -> PlayerViewModel(database) as T + DetailsViewModel::class.java -> DetailsViewModel( + database, + offlineItemManager, + preferencesManager, + mediaRepository + ) as T + PreferencesViewModel::class.java -> PreferencesViewModel( + mediaRepository, + database.watchlistItemDao(), + externalFilesDir + ) as T + FavoritesImportViewModel::class.java -> FavoritesImportViewModel( + mediaRepository + ) as T + SplashViewModel::class.java -> SplashViewModel(mediaRepository) as T + else -> throw UnsupportedOperationException( + "The requested ViewModel is currently unsupported. " + + "Please make sure to implement are correct creation of it. " + + " Request: ${modelClass.canonicalName}" + ) } } + companion object : SingletonHolder(::ViewModelFactory) - } +} diff --git a/common/src/main/res/drawable/ic_check_box.xml b/common/src/main/res/drawable/ic_check_box.xml new file mode 100644 index 00000000..9948171c --- /dev/null +++ b/common/src/main/res/drawable/ic_check_box.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_check_box_outline.xml b/common/src/main/res/drawable/ic_check_box_outline.xml new file mode 100644 index 00000000..cf8bfa24 --- /dev/null +++ b/common/src/main/res/drawable/ic_check_box_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 0f37205e..14ca075b 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -46,7 +46,8 @@ Allow downloads over metered networks Automatically choose recording Export Favorites - Import Favorites + Import to Favorites Event not found Chaosflix does not collect or transmit any personalized data. + No Recording found for diff --git a/common/src/test/java/de/nicidienase/chaosflix/common/InstantExecutorExtension.kt b/common/src/test/java/de/nicidienase/chaosflix/common/InstantExecutorExtension.kt new file mode 100644 index 00000000..1a1fb58e --- /dev/null +++ b/common/src/test/java/de/nicidienase/chaosflix/common/InstantExecutorExtension.kt @@ -0,0 +1,25 @@ +package de.nicidienase.chaosflix.common + +import androidx.arch.core.executor.ArchTaskExecutor +import androidx.arch.core.executor.TaskExecutor +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +class InstantExecutorExtension : BeforeEachCallback, AfterEachCallback { + + override fun beforeEach(context: ExtensionContext?) { + ArchTaskExecutor.getInstance() + .setDelegate(object : TaskExecutor() { + override fun executeOnDiskIO(runnable: Runnable) = runnable.run() + + override fun postToMainThread(runnable: Runnable) = runnable.run() + + override fun isMainThread(): Boolean = true + }) + } + + override fun afterEach(context: ExtensionContext?) { + ArchTaskExecutor.getInstance().setDelegate(null) + } +} diff --git a/common/src/test/java/de/nicidienase/chaosflix/common/viewmodel/FavoritesImportViewModelTest.kt b/common/src/test/java/de/nicidienase/chaosflix/common/viewmodel/FavoritesImportViewModelTest.kt new file mode 100644 index 00000000..33859f18 --- /dev/null +++ b/common/src/test/java/de/nicidienase/chaosflix/common/viewmodel/FavoritesImportViewModelTest.kt @@ -0,0 +1,98 @@ +package de.nicidienase.chaosflix.common.viewmodel + +import de.nicidienase.chaosflix.common.InstantExecutorExtension +import de.nicidienase.chaosflix.common.mediadata.MediaRepository +import de.nicidienase.chaosflix.common.mediadata.entities.recording.persistence.Event +import io.mockk.coEvery +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.junit5.MockKExtension +import io.mockk.slot +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(MockKExtension::class, InstantExecutorExtension::class) +internal class FavoritesImportViewModelTest { + + @RelaxedMockK + private lateinit var mediaRepository: MediaRepository + + private lateinit var favoritesImportViewModel: FavoritesImportViewModel + + @BeforeEach + fun setup() { + favoritesImportViewModel = FavoritesImportViewModel(mediaRepository) + } + + @Test + fun selectAll() { + val slot = slot() + coEvery { mediaRepository.findEventByTitle(capture(slot)) }.answers { + Event(title = slot.captured) + } + runBlocking { + favoritesImportViewModel.handleLecturesInternal(SAMPLE_JSON) + } + + favoritesImportViewModel.selectAll(null) + val items = favoritesImportViewModel.items.value ?: emptyList() + val selected = items.map { it.selected } + assertEquals(2, selected.size) + selected.map { value -> + assertEquals(true, value) + } + } + + @Test + fun unselectAll() { + val slot = slot() + coEvery { mediaRepository.findEventByTitle(capture(slot)) }.answers { + Event(title = slot.captured) + } + runBlocking { + favoritesImportViewModel.handleLecturesInternal(SAMPLE_JSON) + } + + favoritesImportViewModel.selectAll(false) + val items = favoritesImportViewModel.items.value ?: emptyList() + + assertEquals(2, items.size) + items.map { value -> + assertEquals(false, value.selected) + } + } + + companion object { + private const val SAMPLE_JSON = "{\"conference\":\"ccc36c3\",\"lectures\":[" + + "{\"lectureId\":\"11223\"," + + "\"title\":\"Opening Ceremony\"," + + "\"subtitle\":\"\",\"day\":1," + + "\"room\":\"Ada\"," + + "\"slug\":\"36c3-11223-opening_ceremony\"," + + "\"url\":\"https://fahrplan.events.ccc.de/congress/2019/Fahrplan/events/11223.html\"," + + "\"speakers\":\"bleeptrack;blinry\"," + + "\"track\":\"CCC\"," + + "\"type\":\"de\"," + + "\"lang\":\"\"," + + "\"abstract\":\"Welcome!\"," + + "\"description\":\"\"," + + "\"links\":\"2019-12-27\"}," + + "{\"lectureId\":\"11224\"," + + "\"title\":\"Closing Ceremony\"," + + "\"subtitle\":\"\"," + + "\"day\":4," + + "\"room\":\"Ada\"," + + "\"slug\":\"36c3-11224-closing_ceremony\"," + + "\"url\":\"\"," + + "\"speakers\":\"bleeptrack;blinry\"," + + "\"track\":\"CCC\"," + + "\"type\":\"de\"," + + "\"lang\":\"\"," + + "\"abstract\":\"Welcome!\"," + + "\"description\":\"\"," + + "\"links\":\"2019-12-30\"}" + + "]}" + } +} diff --git a/touch/src/main/AndroidManifest.xml b/touch/src/main/AndroidManifest.xml index cdebf82e..c7c19967 100644 --- a/touch/src/main/AndroidManifest.xml +++ b/touch/src/main/AndroidManifest.xml @@ -52,6 +52,13 @@ android:parentActivityName=".browse.BrowseActivity"/> + + + + + + + diff --git a/touch/src/main/java/de/nicidienase/chaosflix/touch/browse/adapters/ImportItemAdapter.kt b/touch/src/main/java/de/nicidienase/chaosflix/touch/browse/adapters/ImportItemAdapter.kt new file mode 100644 index 00000000..2db0726f --- /dev/null +++ b/touch/src/main/java/de/nicidienase/chaosflix/touch/browse/adapters/ImportItemAdapter.kt @@ -0,0 +1,51 @@ +package de.nicidienase.chaosflix.touch.browse.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import de.nicidienase.chaosflix.common.ImportItem +import de.nicidienase.chaosflix.touch.databinding.ItemFavoritImportBinding +import java.lang.NumberFormatException + +class ImportItemAdapter(private val onListItemClick: ((ImportItem) -> Unit)? = null, private val onLectureClick: ((ImportItem) -> Unit)? = null) : ListAdapter( + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ImportItem, newItem: ImportItem) = oldItem === newItem + override fun areContentsTheSame(oldItem: ImportItem, newItem: ImportItem): Boolean { + return oldItem.selected == newItem.selected && + oldItem.lecture == newItem.lecture && + oldItem.event == newItem.event + } +}) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = ItemFavoritImportBinding.inflate(inflater, parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val item = getItem(position) + holder.binding.item = item + holder.binding.importItemEvent.setOnClickListener { + holder.binding.checkBox.apply { + isChecked = !isChecked + } + onListItemClick?.invoke(item) + } + holder.binding.importItemLecture.setOnClickListener { + onLectureClick?.invoke(item) + } + } + + override fun getItemId(position: Int): Long { + return try { + getItem(position).lecture.lectureId?.toLong() ?: 0 + } catch (ex: NumberFormatException) { + -1 + } + } + + class ViewHolder(val binding: ItemFavoritImportBinding) : RecyclerView.ViewHolder(binding.root) +} diff --git a/touch/src/main/java/de/nicidienase/chaosflix/touch/browse/adapters/ItemRecyclerViewAdapter.kt b/touch/src/main/java/de/nicidienase/chaosflix/touch/browse/adapters/ItemRecyclerViewAdapter.kt index 282ceb03..c7509f58 100644 --- a/touch/src/main/java/de/nicidienase/chaosflix/touch/browse/adapters/ItemRecyclerViewAdapter.kt +++ b/touch/src/main/java/de/nicidienase/chaosflix/touch/browse/adapters/ItemRecyclerViewAdapter.kt @@ -5,7 +5,7 @@ import android.widget.Filterable import androidx.recyclerview.widget.RecyclerView import java.util.Collections -abstract class ItemRecyclerViewAdapter() : +abstract class ItemRecyclerViewAdapter : RecyclerView.Adapter(), Filterable { abstract fun getComparator(): Comparator? diff --git a/touch/src/main/java/de/nicidienase/chaosflix/touch/favoritesimport/FavoritesImportActivity.kt b/touch/src/main/java/de/nicidienase/chaosflix/touch/favoritesimport/FavoritesImportActivity.kt new file mode 100644 index 00000000..2baddee5 --- /dev/null +++ b/touch/src/main/java/de/nicidienase/chaosflix/touch/favoritesimport/FavoritesImportActivity.kt @@ -0,0 +1,21 @@ +package de.nicidienase.chaosflix.touch.favoritesimport + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.Toolbar +import de.nicidienase.chaosflix.touch.R + +class FavoritesImportActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_favorites_import) + val toolbar = findViewById(R.id.toolbar) + setSupportActionBar(toolbar) + supportActionBar?.title = getString(R.string.import_activity_label) + } + + companion object { + private val TAG = FavoritesImportActivity::class.java.simpleName + } +} diff --git a/touch/src/main/java/de/nicidienase/chaosflix/touch/favoritesimport/FavoritesImportFragment.kt b/touch/src/main/java/de/nicidienase/chaosflix/touch/favoritesimport/FavoritesImportFragment.kt new file mode 100644 index 00000000..3a9930e6 --- /dev/null +++ b/touch/src/main/java/de/nicidienase/chaosflix/touch/favoritesimport/FavoritesImportFragment.kt @@ -0,0 +1,156 @@ +package de.nicidienase.chaosflix.touch.favoritesimport + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.snackbar.Snackbar +import de.nicidienase.chaosflix.common.ImportItem +import de.nicidienase.chaosflix.common.viewmodel.FavoritesImportViewModel +import de.nicidienase.chaosflix.common.viewmodel.ViewModelFactory +import de.nicidienase.chaosflix.touch.R +import de.nicidienase.chaosflix.touch.browse.adapters.ImportItemAdapter +import de.nicidienase.chaosflix.touch.databinding.FragmentFavoritesImportBinding + +class FavoritesImportFragment : Fragment() { + + private lateinit var viewModel: FavoritesImportViewModel + private lateinit var adapter: ImportItemAdapter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + setHasOptionsMenu(true) + val binding = FragmentFavoritesImportBinding.inflate(inflater, container, false) + binding.lifecycleOwner = this + viewModel = ViewModelProviders.of(requireActivity(), ViewModelFactory.getInstance(requireContext())).get(FavoritesImportViewModel::class.java) + binding.viewModel = viewModel + + binding.importList.layoutManager = LinearLayoutManager(context) + val onItemClick: (ImportItem) -> Unit = { + viewModel.itemChanged(it) + } + val onLectureClick: (ImportItem) -> Unit = { + viewModel.unavailableItemClicked(it) + } + adapter = ImportItemAdapter(onListItemClick = onItemClick, onLectureClick = onLectureClick) + adapter.setHasStableIds(true) + binding.importList.setHasFixedSize(true) + binding.importList.adapter = adapter + + viewModel.items.observe(viewLifecycleOwner, Observer { events -> + if (events != null) { + adapter.submitList(events.toList()) + } + }) + + viewModel.selectAll.observe(viewLifecycleOwner, Observer { + activity?.invalidateOptionsMenu() + }) + + viewModel.state.observe(viewLifecycleOwner, Observer { + when (it.state) { + FavoritesImportViewModel.State.IMPORT_DONE -> { + // TODO navigate to favorites + activity?.finish() + } + } + }) + + viewModel.working.observe(viewLifecycleOwner, Observer { working -> + binding.incOverlay.loadingOverlay.visibility = if (working) View.VISIBLE else View.GONE + }) + viewModel.processCount.observe(viewLifecycleOwner, Observer { pair -> + Log.i(TAG, "Progress ${pair.first}/${pair.second}") + binding.incOverlay.progressbar.apply { + if (pair.second != 0) { + visibility = View.VISIBLE + progress = (100 * pair.first / pair.second) + } + } + }) + + viewModel.errorMessage.observe(viewLifecycleOwner, Observer { errorMessage -> + if (errorMessage != null) { + Snackbar.make(binding.root, errorMessage, Snackbar.LENGTH_LONG).apply { + view.findViewById(com.google.android.material.R.id.snackbar_text).maxLines = 5 + setAction("OK", View.OnClickListener { + this.dismiss() + }).show() + } + viewModel.errorShown() + } + }) + + viewModel.importItemCount.observe(viewLifecycleOwner, Observer { count -> + if (count == 0) { + binding.buttonImport.hide() + } else { + binding.buttonImport.show() + } + }) + + val intent = activity?.intent + when { + intent?.action == Intent.ACTION_SEND -> { + when (intent.type) { + "text/json" -> handleJson(intent) + } + } + else -> { + // Handle other intents, such as being started from the home screen + } + } + + return binding.root + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.import_menu, menu) + if (viewModel.selectAll.value != false) { + menu.removeItem(R.id.menu_item_unselect_all) + } else { + menu.removeItem(R.id.menu_item_select_all) + } + super.onCreateOptionsMenu(menu, inflater) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.menu_item_select_all -> { + viewModel.selectAll() + viewModel.selectAll.value = false + true + } + R.id.menu_item_unselect_all -> { + viewModel.selectNone() + viewModel.selectAll.value = true + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private fun handleJson(intent: Intent) { + val extra = intent.getStringExtra(Intent.EXTRA_TEXT) + if (extra.isNotEmpty()) { + viewModel.handleLectures(extra) + } + } + + companion object { + private val TAG = FavoritesImportFragment::class.java.simpleName + } +} diff --git a/touch/src/main/java/de/nicidienase/chaosflix/touch/settings/SettingsFragment.kt b/touch/src/main/java/de/nicidienase/chaosflix/touch/settings/SettingsFragment.kt index 17c096e7..48c7977b 100644 --- a/touch/src/main/java/de/nicidienase/chaosflix/touch/settings/SettingsFragment.kt +++ b/touch/src/main/java/de/nicidienase/chaosflix/touch/settings/SettingsFragment.kt @@ -119,7 +119,7 @@ class SettingsFragment : PreferenceFragmentCompat() { if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) { importFavorites() } else { - Snackbar.make(listView, "Cannot import without Storage Permission.", Snackbar.LENGTH_SHORT).show() + Snackbar.make(listView, "Cannot importFavorites without Storage Permission.", Snackbar.LENGTH_SHORT).show() } } PERMISSION_REQUEST_EXPORT_FAVORITES -> { diff --git a/touch/src/main/res/layout/activity_favorites_import.xml b/touch/src/main/res/layout/activity_favorites_import.xml new file mode 100644 index 00000000..120cb5c9 --- /dev/null +++ b/touch/src/main/res/layout/activity_favorites_import.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + diff --git a/touch/src/main/res/layout/activity_splash.xml b/touch/src/main/res/layout/activity_splash.xml index 032dd513..a35bdbcb 100644 --- a/touch/src/main/res/layout/activity_splash.xml +++ b/touch/src/main/res/layout/activity_splash.xml @@ -3,7 +3,7 @@ android:layout_width="match_parent" android:layout_height="match_parent"> + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/touch/src/main/res/layout/fragment_tab_pager_layout.xml b/touch/src/main/res/layout/fragment_tab_pager_layout.xml index 1909c755..c3424513 100644 --- a/touch/src/main/res/layout/fragment_tab_pager_layout.xml +++ b/touch/src/main/res/layout/fragment_tab_pager_layout.xml @@ -1,6 +1,6 @@ + xmlns:app="http://schemas.android.com/apk/res-auto"> diff --git a/touch/src/main/res/layout/item_favorit_import.xml b/touch/src/main/res/layout/item_favorit_import.xml new file mode 100644 index 00000000..b8101015 --- /dev/null +++ b/touch/src/main/res/layout/item_favorit_import.xml @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/touch/src/main/res/layout/loading_overlay.xml b/touch/src/main/res/layout/loading_overlay.xml index eca2d8b1..6aa4ad64 100644 --- a/touch/src/main/res/layout/loading_overlay.xml +++ b/touch/src/main/res/layout/loading_overlay.xml @@ -3,19 +3,42 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"> - + app:duration="2000" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> - + + + + diff --git a/touch/src/main/res/layout/toolbar.xml b/touch/src/main/res/layout/toolbar.xml index ea2a46a4..424db058 100644 --- a/touch/src/main/res/layout/toolbar.xml +++ b/touch/src/main/res/layout/toolbar.xml @@ -7,7 +7,6 @@ + + + + \ No newline at end of file diff --git a/touch/src/main/res/values/strings.xml b/touch/src/main/res/values/strings.xml index 587ec4e8..e30a40f3 100644 --- a/touch/src/main/res/values/strings.xml +++ b/touch/src/main/res/values/strings.xml @@ -54,5 +54,9 @@ Play-Button List of Events Delete Item + Import Favorites + Fahrplan-Import + Select all + Bookmark \ No newline at end of file diff --git a/touch/src/main/res/values/styles.xml b/touch/src/main/res/values/styles.xml index d3f49573..107d9b33 100644 --- a/touch/src/main/res/values/styles.xml +++ b/touch/src/main/res/values/styles.xml @@ -6,24 +6,13 @@ false true true - @color/primary - @color/primary_dark - @color/accent - - - - - @style/SettingsStyle - - - - -