Merge branch 'develop' into feature/navigation

This commit is contained in:
Felix 2020-04-04 23:44:50 +02:00
commit 7bbb2100c6
34 changed files with 981 additions and 67 deletions

View file

@ -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:

View file

@ -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"
}

View file

@ -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
)

View file

@ -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 {

View file

@ -0,0 +1,6 @@
package de.nicidienase.chaosflix.common.eventimport
data class FahrplanExport(
val conference: String,
val lectures: List<FahrplanLecture>
)

View file

@ -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
)

View file

@ -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<LiveEvent<State, List<Conference>, String>> {
val updateState = SingleLiveEvent<LiveEvent<State, List<Conference>, 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<Long> = database.offlineEventDao().getAllDownloadReferences()
suspend fun saveOrUpdate(watchlistItem: WatchlistItem) {
watchlistItemDao.updateOrInsert(watchlistItem)
}
fun getReleatedEvents(event: Event, viewModelScope: CoroutineScope): LiveData<List<Event>> {
val data = MutableLiveData<List<Event>>()
viewModelScope.launch(Dispatchers.IO) {

View file

@ -61,6 +61,15 @@ abstract class EventDao : BaseDao<Event>() {
@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<Event?>
override suspend fun updateOrInsertInternal(item: Event) {
if (item.id != 0L) {
update(item)

View file

@ -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<List<WatchlistItem>>
abstract class WatchlistItemDao : BaseDao<WatchlistItem>() {
@Query("SELECT * from watchlist_item")
fun getAllSync(): List<WatchlistItem>
abstract fun getAll(): LiveData<List<WatchlistItem>>
@Query("SELECT * from watchlist_item")
abstract fun getAllSync(): List<WatchlistItem>
@Query("SELECT * from watchlist_item WHERE event_guid = :guid LIMIT 1")
fun getItemForEvent(guid: String): LiveData<WatchlistItem?>
abstract fun getItemForEvent(guid: String): LiveData<WatchlistItem?>
@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)
}
}
}
}

View file

@ -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<LiveEvent<State, List<ImportItem>, String>> = SingleLiveEvent()
private var lastImport: String = ""
private val _items = MutableLiveData<List<ImportItem>?>()
val items: LiveData<List<ImportItem>?>
get() = _items
private val _errorMessage = MutableLiveData<String?>()
val errorMessage: LiveData<String?>
get() = _errorMessage
private val _working = MutableLiveData<Boolean>(false)
val working: LiveData<Boolean>
get() = _working
private val _processCount = MutableLiveData<Pair<Int, Int>> (0 to 0)
val processCount: LiveData<Pair<Int, Int>>
get() = _processCount
val importItemCount: LiveData<Int> = Transformations.map(items) { items ->
val selectedItems = items?.filter { it.selected && it.event != null }
return@map selectedItems?.count() ?: 0
}
val selectAll = MutableLiveData<Boolean>(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<ImportItem>
_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<ImportItem> = _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<ImportItem>? = _items.value
val newList = items?.map { it.copy(selected = selected ?: (it.event != null)) }
_items.postValue(newList)
}
fun itemChanged(item: ImportItem) {
val items: MutableList<ImportItem>? = _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
}
}

View file

@ -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 <T : ViewModel?> create(modelClass: Class<T>): 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, Context>(::ViewModelFactory)
}
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M19,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.11,0 2,-0.9 2,-2L21,5c0,-1.1 -0.89,-2 -2,-2zM10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M19,5v14H5V5h14m0,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2z"/>
</vector>

View file

@ -46,7 +46,8 @@
<string name="setting_metered_networks">Allow downloads over metered networks</string>
<string name="settings_choose_recording">Automatically choose recording</string>
<string name="export_favorites">Export Favorites</string>
<string name="import_favorites">Import Favorites</string>
<string name="import_favorites">Import to Favorites</string>
<string name="error_event_not_found">Event not found</string>
<string name="privacy_policy">Chaosflix does not collect or transmit any personalized data.</string>
<string name="no_recording_found_for">No Recording found for</string>
</resources>

View file

@ -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)
}
}

View file

@ -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<String>()
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<String>()
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\"}" +
"]}"
}
}

View file

@ -52,6 +52,13 @@
android:parentActivityName=".browse.BrowseActivity"/>
<activity android:name="net.rdrei.android.dirchooser.DirectoryChooserActivity" />
<activity android:name=".favoritesimport.FavoritesImportActivity" >
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/json" />
</intent-filter>
</activity>
</application>

View file

@ -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<ImportItem, ImportItemAdapter
.ViewHolder>(
object : DiffUtil.ItemCallback<ImportItem>() {
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)
}

View file

@ -5,7 +5,7 @@ import android.widget.Filterable
import androidx.recyclerview.widget.RecyclerView
import java.util.Collections
abstract class ItemRecyclerViewAdapter<T, VH : RecyclerView.ViewHolder?>() :
abstract class ItemRecyclerViewAdapter<T, VH : RecyclerView.ViewHolder?> :
RecyclerView.Adapter<VH>(), Filterable {
abstract fun getComparator(): Comparator<in T>?

View file

@ -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<Toolbar>(R.id.toolbar)
setSupportActionBar(toolbar)
supportActionBar?.title = getString(R.string.import_activity_label)
}
companion object {
private val TAG = FavoritesImportActivity::class.java.simpleName
}
}

View file

@ -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<TextView>(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
}
}

View file

@ -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 -> {

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/import_root"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:orientation="vertical">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<include
android:id="@+id/inc_toolbar"
layout="@layout/toolbar"/>
</com.google.android.material.appbar.AppBarLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<fragment
android:layout_width="match_parent"
android:layout_height="match_parent"
android:name="de.nicidienase.chaosflix.touch.favoritesimport.FavoritesImportFragment"/>
</LinearLayout>

View file

@ -3,7 +3,7 @@
android:layout_width="match_parent" android:layout_height="match_parent">
<ProgressBar
android:id="@+id/progressBar2"
android:id="@+id/icon_not_found"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"

View file

@ -23,7 +23,6 @@
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
style="@style/ToolbarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/transparent"

View file

@ -63,7 +63,6 @@
android:id="@+id/anim_toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
style="@style/ToolbarStyle"
app:titleTextColor="@color/white"
app:subtitleTextColor="@color/white"
app:layout_collapseMode="pin"

View file

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="viewModel"
type="de.nicidienase.chaosflix.common.viewmodel.FavoritesImportViewModel" />
</data>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/import_list"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:listitem="@layout/item_favorit_import" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/button_import"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:backgroundTint="@color/primary"
android:text="@string/import_label"
android:visibility="invisible"
android:onClick="@{()->viewModel.importFavorites()}"
app:icon="@drawable/ic_bookmark"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<include
android:id="@+id/inc_overlay"
layout="@layout/loading_overlay"/>
</FrameLayout>
</layout>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
@ -17,14 +17,14 @@
<com.google.android.material.tabs.TabLayout
android:id="@+id/sliding_tabs"
style="@style/ColoredToolbarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/primary"
app:layout_anchor="@id/toolbar"
app:layout_anchorGravity="bottom"
app:tabIndicatorColor="@color/white"
app:tabMode="scrollable"
app:tabSelectedTextColor="@color/white"
app:tabIndicatorColor="@color/white"
app:tabTextColor="@color/inactive_tab_color"/>
</com.google.android.material.appbar.AppBarLayout>

View file

@ -0,0 +1,148 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="item"
type="de.nicidienase.chaosflix.common.ImportItem" />
<import type="android.view.View"/>
</data>
<androidx.cardview.widget.CardView
app:cardElevation="0dp"
app:cardCornerRadius="2dp"
android:layout_margin="2dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:id="@+id/import_item_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="?attr/selectableItemBackground">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/import_item_event"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="@{item.event == null ? View.GONE : View.VISIBLE}"
tools:visibility="visible">
<androidx.appcompat.widget.AppCompatCheckBox
android:id="@+id/checkBox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:checked="@={item.selected}"
android:text=""
style="@android:style/Widget.Material.Light.CompoundButton.CheckBox"
tools:checked="false"
app:layout_constraintBottom_toBottomOf="@id/imageView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/imageView" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/imageView"
imageUrl="@{item.event.thumbUrl}"
android:layout_width="0dp"
android:layout_height="70dp"
android:layout_marginStart="8dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:adjustViewBounds="true"
android:contentDescription="@string/titleimage"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/checkBox"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0"
app:srcCompat="@drawable/unknown"
tools:srcCompat="@drawable/unknown" />
<TextView
android:id="@+id/event_title"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:text="@{item.event.title}"
tools:text="Energiespeicher von heute für die Energie von morgen"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageView"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/event_subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="16dp"
android:text="@{item.event.subtitle}"
tools:text="Wohin eigentlich mit all der erneuerbaren Energie?"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@+id/event_title"
app:layout_constraintTop_toBottomOf="@+id/event_title"
app:layout_constraintVertical_bias="0.0" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/import_item_lecture"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="@{item.event == null ? View.VISIBLE : View.GONE}"
tools:visibility="visible">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/icon_not_found"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:src="@drawable/ic_error"
android:tint="#E53935"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/not_found_title"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="0dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="16dp"
android:text="@{item.lecture.title}"
tools:text="Energiespeicher von heute für die Energie von morgen"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/icon_not_found"
app:layout_constraintTop_toBottomOf="@+id/not_found_description" />
<TextView
android:id="@+id/not_found_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/no_recording_found_for"
app:layout_constraintStart_toStartOf="@+id/not_found_title"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
</layout>

View file

@ -3,19 +3,42 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<FrameLayout
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/loading_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/inactive_tab_color"
tools:visibility="visible"
tools:showIn="@layout/fragment_tab_pager_layout">
<de.nicidienase.chaosflix.ChaosflixLoadingSpinner
android:id="@+id/chaosflixLoadingSpinner"
android:layout_width="120dp"
android:layout_height="120dp"
android:layout_gravity="center"
android:src="@drawable/vector_loading_icon"
app:duration="2000"/>
app:duration="2000"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</FrameLayout>
<ProgressBar
android:id="@+id/progressbar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal|bottom"
android:layout_marginTop="16dp"
android:indeterminate="false"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/chaosflixLoadingSpinner"
tools:max="10"
tools:progress="5"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -7,7 +7,6 @@
<androidx.appcompat.widget.Toolbar
android:visibility="gone"
android:id="@+id/toolbar"
style="@style/ColoredToolbarStyle"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:minHeight="?attr/actionBarSize"

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:id="@+id/menu_item_select_all"
android:title="@string/select_all"
android:icon="@drawable/ic_check_box"
app:showAsAction="ifRoom"/>
<item android:id="@+id/menu_item_unselect_all"
android:title="Select none"
android:icon="@drawable/ic_check_box_outline"
app:showAsAction="ifRoom"/>
</menu>

View file

@ -54,5 +54,9 @@
<string name="play_button">Play-Button</string>
<string name="list_of_events">List of Events</string>
<string name="delete_item">Delete Item</string>
<string name="import_favorites">Import Favorites</string>
<string name="import_activity_label">Fahrplan-Import</string>
<string name="select_all">Select all</string>
<string name="import_label">Bookmark</string>
<string name="empty" />
</resources>

View file

@ -6,24 +6,13 @@
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:windowContentTransitions">true</item>
<item name="android:colorPrimary">@color/primary</item>
<item name="android:colorPrimaryDark">@color/primary_dark</item>
<item name="android:colorAccent">@color/accent</item>
<!--<item name="android:textColorPrimary"></item>-->
<!--<item name="android:textColorSecondary"></item>-->
<!--<item name="android:actionBarStyle">@style/ToolbarStyle</item>-->
<!-- <item name="colorControlNormal">@color/white</item>-->
<item name="preferenceTheme">@style/SettingsStyle</item>
</style>
<style name="ToolbarStyle" parent="@style/ThemeOverlay.AppCompat.Dark.ActionBar">
<item name="colorPrimary">@color/primary</item>
<item name="android:colorPrimary">@color/primary</item>
<item name="colorPrimaryDark">@color/primary_dark</item>
<item name="android:colorPrimaryDark">@color/primary_dark</item>
<item name="colorAccent">@color/accent</item>
<item name="android:textColor">@color/white</item>
<item name="android:textColorPrimary">@color/white</item>
<item name="android:textColorSecondary">@color/white</item>
<item name="colorControlNormal">@color/white</item>
<item name="android:colorAccent">@color/accent</item>
<item name="preferenceTheme">@style/SettingsStyle</item>
</style>
<style name="SettingsStyle" parent="@style/PreferenceThemeOverlay.v14.Material">
@ -42,10 +31,6 @@
<item name="android:textColorSecondary">@color/white</item>
</style>
<style name="ColoredToolbarStyle" parent="ToolbarStyle">
<item name="android:background">@color/primary</item>
</style>
<style name="SplashTheme" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowBackground">@drawable/splash_screen</item>
</style>