, P : MainFragment, VM : ViewModel> :
+ MainFragment() {
+ override val isInnerFragment = true
+
+ @Suppress("UNCHECKED_CAST")
+ protected val parent: P
+ get() = parentFragment as P
+
+ override fun provideSelfConfig() = liveDataOf(MainFragmentConfig())
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ viewModel = parent.viewModel
+ super.onCreate(savedInstanceState)
+ }
+
+
+ override fun performRefresh() = performRefreshWithParent(false)
+ fun performRefreshWithParent(fromParent: Boolean) {
+ refreshableImpl.isRefreshing = true
+ launch {
+ withContext(UI) {
+ refresh()
+ if (!fromParent)
+ parent.performRefreshWithChild(true)
+ }
+ withContext(UI) { refreshableImpl.isRefreshing = false }
+ }
+ }
+}
diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/main/MainActivity.kt b/app/src/main/java/org/schulcloud/mobile/controllers/main/MainActivity.kt
index 5c4736cb..83dfd1cd 100644
--- a/app/src/main/java/org/schulcloud/mobile/controllers/main/MainActivity.kt
+++ b/app/src/main/java/org/schulcloud/mobile/controllers/main/MainActivity.kt
@@ -15,6 +15,7 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.core.view.children
import androidx.core.view.forEach
+import androidx.core.view.iterator
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import androidx.navigation.NavController
@@ -22,17 +23,13 @@ import androidx.navigation.fragment.NavHostFragment.findNavController
import androidx.navigation.ui.NavigationUI
import com.getkeepsafe.taptargetview.TapTarget
import com.getkeepsafe.taptargetview.TapTargetView
-import com.google.android.material.bottomappbar.BottomAppBar
import kotlinx.android.synthetic.main.activity_main.*
import org.schulcloud.mobile.R
import org.schulcloud.mobile.controllers.base.BaseActivity
import org.schulcloud.mobile.controllers.login.LoginActivity
import org.schulcloud.mobile.models.user.UserRepository
import org.schulcloud.mobile.storages.Onboarding
-import org.schulcloud.mobile.utils.getTextColorForBackground
-import org.schulcloud.mobile.utils.getTextColorSecondaryForBackground
-import org.schulcloud.mobile.utils.setTintCompat
-import org.schulcloud.mobile.utils.visibilityBool
+import org.schulcloud.mobile.utils.*
import org.schulcloud.mobile.viewmodels.MainViewModel
import org.schulcloud.mobile.viewmodels.ToolbarColors
@@ -51,6 +48,8 @@ class MainActivity : BaseActivity() {
private var toolbarWrapper: ViewGroup? = null
private var optionsMenu: Menu? = null
+ private var lastConfig: MainFragmentConfig? = null
+
override fun onCreate(savedInstanceState: Bundle?) {
if (!UserRepository.isAuthorized) {
startActivity(Intent(this, LoginActivity::class.java))
@@ -63,26 +62,42 @@ class MainActivity : BaseActivity() {
setContentView(R.layout.activity_main)
viewModel.config.observe(this, Observer { config ->
- title = config.title.takeIf { config.showTitle }
- supportActionBar?.subtitle = config.subtitle
+ if (config == null || config == lastConfig) return@Observer
+
+ if (lastConfig?.title != config.title)
+ title = config.title.takeIf { config.showTitle }
+ if (lastConfig?.subtitle != config.subtitle)
+ supportActionBar?.subtitle = config.subtitle
recalculateToolbarColors()
bottomAppBar.apply {
- menu.clear()
- if (config.menuBottomRes != 0) {
- inflateMenu(config.menuBottomRes)
- for (id in config.menuBottomHiddenIds)
- if (id != 0)
- menu?.findItem(id)?.isVisible = false
+ val menuChanged = lastConfig?.menuBottomRes != config.menuBottomRes
+ if (menuChanged) {
+ menu.clear()
+ for (menuRes in config.menuBottomRes.filterNotNull())
+ inflateMenu(menuRes)
}
- }
- fab.visibilityBool = config.fabVisible && config.fabIconRes != 0
- bottomAppBar.fabAlignmentMode = when (config.fragmentType) {
- FragmentType.PRIMARY -> BottomAppBar.FAB_ALIGNMENT_MODE_CENTER
- FragmentType.SECONDARY -> BottomAppBar.FAB_ALIGNMENT_MODE_END
+ if (menuChanged
+ || lastConfig?.menuBottomHiddenIds != config.menuBottomHiddenIds) {
+ for (item in menu)
+ item.isVisible = !config.menuBottomHiddenIds.contains(item.itemId)
+ }
}
- fab.setImageResource(config.fabIconRes)
+
+ // Enables animation
+ val fabShouldBeVisible = config.fabVisible && config.fabIconRes != 0
+ if (fab.visibilityBool && !fabShouldBeVisible)
+ fab.hide()
+ else if (!fab.visibilityBool && fabShouldBeVisible)
+ fab.show()
+ if (lastConfig?.fabIconRes != config.fabIconRes)
+ fab.setImageResource(config.fabIconRes)
+
+ if (lastConfig?.innerFragmentIndex != config.innerFragmentIndex)
+ bottomAppBar.slideIntoView()
+
+ lastConfig = config
})
viewModel.toolbarColors.observe(this, Observer {
updateToolbarColors()
@@ -122,8 +137,7 @@ class MainActivity : BaseActivity() {
}
private fun showDrawer() {
- val drawer = NavigationDrawerFragment()
- drawer.show(supportFragmentManager, drawer.tag)
+ NavigationDrawerFragment().show(supportFragmentManager)
}
override fun setSupportActionBar(toolbar: Toolbar?) {
diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/main/MainFragment.kt b/app/src/main/java/org/schulcloud/mobile/controllers/main/MainFragment.kt
index 3fe194f7..41d96dce 100644
--- a/app/src/main/java/org/schulcloud/mobile/controllers/main/MainFragment.kt
+++ b/app/src/main/java/org/schulcloud/mobile/controllers/main/MainFragment.kt
@@ -1,49 +1,60 @@
package org.schulcloud.mobile.controllers.main
import android.os.Bundle
+import android.util.SparseBooleanArray
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
+import androidx.annotation.CallSuper
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
-import androidx.annotation.MenuRes
-import androidx.lifecycle.LiveData
-import androidx.lifecycle.Observer
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProviders
+import androidx.core.util.set
+import androidx.lifecycle.*
import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment.findNavController
+import kotlinx.coroutines.experimental.android.UI
+import kotlinx.coroutines.experimental.launch
+import kotlinx.coroutines.experimental.withContext
import org.schulcloud.mobile.R
import org.schulcloud.mobile.controllers.base.BaseFragment
import org.schulcloud.mobile.utils.*
import org.schulcloud.mobile.viewmodels.MainViewModel
-abstract class MainFragment(refreshableImpl: RefreshableImpl = RefreshableImpl()) : BaseFragment(),
+abstract class MainFragment, VM : ViewModel>(
+ protected val refreshableImpl: RefreshableImpl = RefreshableImpl()
+) :
+ BaseFragment(),
Refreshable by refreshableImpl {
protected val mainActivity: MainActivity get() = activity as MainActivity
protected val mainViewModel: MainViewModel
get() = ViewModelProviders.of(activity!!).get(MainViewModel::class.java)
+ protected open val isInnerFragment: Boolean = false
+ protected open val currentInnerFragment: LiveData = liveDataOf()
+ protected open val innerFragments: LiveData?>> = liveDataOf(emptyList())
+ private val innerFragmentsRefreshed = SparseBooleanArray()
private var isFirstInit: Boolean = true
+ val isInitialized: Boolean get() = !isFirstInit
+
protected val navController: NavController
get() = findNavController(this)
- protected lateinit var config: LiveData
+ lateinit var config: LiveData
private set
lateinit var viewModel: VM
protected set
- protected abstract fun provideConfig(): LiveData
open var url: String? = null
init {
- refreshableImpl.refresh = { refresh() }
+ refreshableImpl.refresh = { performRefresh() }
}
+ @CallSuper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -52,40 +63,51 @@ abstract class MainFragment(refreshableImpl: RefreshableImpl = R
})
mainViewModel.onFabClicked.observe(this, Observer { onFabClicked() })
+ config = provideConfig()
setHasOptionsMenu(true)
}
+ @CallSuper
override fun onResume() {
super.onResume()
- config = provideConfig()
- config.observe(this, Observer {
- activity?.invalidateOptionsMenu()
- it?.also { mainViewModel.config.value = it }
- })
-
+ // Config already provided in onCreate
+ if (!isFirstInit)
+ config = provideConfig()
swipeRefreshLayout = view?.findViewById(R.id.swipeRefresh)
- mainActivity.setSupportActionBar(view?.findViewById(R.id.toolbar))
- mainActivity.setToolbarWrapper(view?.findViewById(R.id.toolbarWrapper))
+ if (!isInnerFragment) {
+ config.observe(this, Observer { config ->
+ activity?.invalidateOptionsMenu()
+ config?.also { mainViewModel.config.value = config }
+ })
+
+ mainActivity.setSupportActionBar(view?.findViewById(R.id.toolbar))
+ mainActivity.setToolbarWrapper(view?.findViewById(R.id.toolbarWrapper))
- if (isFirstInit)
- performRefresh()
+ if (isFirstInit)
+ performRefresh()
+ }
isFirstInit = false
}
override fun onCreateOptionsMenu(menu: Menu?, inflater: MenuInflater?) {
- val menuTopRes = config.value?.menuTopRes
- if (menuTopRes != null && menuTopRes != 0)
- inflater?.inflate(menuTopRes, menu)
+ if (!isInnerFragment) {
+ config.value?.menuTopRes?.also {
+ for (menuRes in it.filterNotNull())
+ inflater?.inflate(menuRes, menu)
+ }
+
+ inflater?.inflate(R.menu.fragment_main_top, menu)
+ if (config.value?.supportsRefresh == false)
+ menu?.findItem(R.id.base_action_refresh)?.isVisible = false
- inflater?.inflate(R.menu.fragment_main_top, menu)
- if (config.value?.supportsRefresh == false)
- menu?.findItem(R.id.base_action_refresh)?.isVisible = false
- for (id in config.value?.menuTopHiddenIds.orEmpty())
- if (id != 0)
- menu?.findItem(id)?.isVisible = false
+ config.value?.menuTopHiddenIds?.also {
+ for (id in it.filterNotNull())
+ menu?.findItem(id)?.isVisible = false
+ }
+ }
super.onCreateOptionsMenu(menu, inflater)
}
@@ -99,14 +121,108 @@ abstract class MainFragment(refreshableImpl: RefreshableImpl = R
context?.shareLink(link, mainViewModel.config.value?.title)
}
R.id.base_action_refresh -> performRefresh()
- // TODO: Remove when deep linking is readded
+ // TODO: Remove when deep linking is readded
R.id.base_action_openInBrowser -> context?.openUrl(url.asUri())
- else -> return super.onOptionsItemSelected(item)
+ else -> {
+ val fragments = innerFragments.value.orEmpty().toMutableList()
+ // Currently visible parent takes precedence
+ currentInnerFragment.value?.also { fragments.move(it, 0) }
+ for (innerFragment in fragments)
+ if (innerFragment?.onOptionsItemSelected(item) == true)
+ return true
+ return super.onOptionsItemSelected(item)
+ }
}
return true
}
+ private fun provideConfig(): LiveData {
+ var lastFragment: InnerMainFragment<*, F, VM>? = null
+ var pos = 0
+
+ val (result, addFunc) = switch()
+ val observer = object : LifecycleObserver {
+ @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
+ fun onResume() {
+ if (currentInnerFragment.value ?: 0 != pos)
+ return
+
+ @Suppress("UNCHECKED_CAST")
+ addFunc(lastFragment?.config as? LiveData ?: liveDataOf())
+ }
+ }
+
+ return provideConfig(currentInnerFragment
+ .map { it ?: 0 }
+ .combineLatest(innerFragments)
+ .switchMapNullable { (position, fragments) ->
+ pos = position
+
+ val innerFragment = fragments.getOrNull(pos)
+ if (innerFragment != null) {
+ if (!innerFragmentsRefreshed[pos]) {
+ innerFragment.performRefreshWithParent(true)
+ innerFragmentsRefreshed[pos] = true
+ }
+
+ // Remove observer from old fragment
+ lastFragment?.let { lifecycle.removeObserver(observer) }
+
+ // Add to new fragment
+ lastFragment = innerFragment
+ // workaround as fragments are not fully initialized when tab is switched
+ innerFragment.getLifecycle().removeObserver(observer)
+ innerFragment.getLifecycle().addObserver(observer)
+ result
+ } else
+ null
+ })
+ }
+
+ protected open fun provideConfig(selectedTabConfig: LiveData)
+ : LiveData {
+ return provideSelfConfig()
+ .combineLatestNullable(selectedTabConfig)
+ .map { (self, tab) ->
+ MainFragmentConfig(
+ self.fragmentType,
+ currentInnerFragment.value,
+ tab?.title ?: self.title,
+ tab?.showTitle ?: self.showTitle,
+ tab?.subtitle ?: self.subtitle,
+ tab?.toolbarColor ?: self.toolbarColor,
+ (tab?.menuTopRes ?: emptyList()).union(self.menuTopRes),
+ (tab?.menuTopHiddenIds ?: emptyList()).union(self.menuTopHiddenIds),
+ (tab?.menuBottomRes ?: emptyList()).union(self.menuBottomRes),
+ (tab?.menuBottomHiddenIds ?: emptyList()).union(self.menuBottomHiddenIds),
+ tab?.supportsRefresh ?: self.supportsRefresh,
+ tab?.fabVisible ?: self.fabVisible,
+ tab?.fabIconRes ?: self.fabIconRes
+ )
+ }
+ }
+
+ protected abstract fun provideSelfConfig(): LiveData
+
+
+ override fun performRefresh() = performRefreshWithChild(false)
+ fun performRefreshWithChild(fromInner: Boolean) {
+ refreshableImpl.isRefreshing = true
+ launch {
+ withContext(UI) {
+ val innerFragment = currentInnerFragment.value?.let {
+ innerFragments.value?.getOrNull(it)
+ }
+ if (fromInner || innerFragment == null)
+ refresh()
+ else
+ innerFragment.performRefreshWithParent(true)
+ }
+ withContext(UI) { refreshableImpl.isRefreshing = false }
+ }
+ }
+
abstract suspend fun refresh()
open fun onFabClicked() {}
@@ -114,19 +230,18 @@ abstract class MainFragment(refreshableImpl: RefreshableImpl = R
data class MainFragmentConfig(
val fragmentType: FragmentType = FragmentType.SECONDARY,
+ val innerFragmentIndex: Int? = null,
- val title: String?,
+ val title: String? = null,
val showTitle: Boolean = true,
val subtitle: String? = null,
@ColorInt
val toolbarColor: Int? = null,
- @MenuRes
- val menuTopRes: Int = 0,
- val menuTopHiddenIds: List = emptyList(),
- @MenuRes
- val menuBottomRes: Int = 0,
- val menuBottomHiddenIds: List = emptyList(),
+ val menuTopRes: Iterable = emptyList(),
+ val menuTopHiddenIds: Iterable = emptyList(),
+ val menuBottomRes: Iterable = emptyList(),
+ val menuBottomHiddenIds: Iterable = emptyList(),
val supportsRefresh: Boolean = true,
val fabVisible: Boolean = true,
@@ -138,8 +253,3 @@ enum class FragmentType {
PRIMARY,
SECONDARY
}
-
-
-interface ParentFragment {
- suspend fun refreshWithChild(fromChild: Boolean)
-}
diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/main/MainPagerAdapter.kt b/app/src/main/java/org/schulcloud/mobile/controllers/main/MainPagerAdapter.kt
new file mode 100644
index 00000000..93c27452
--- /dev/null
+++ b/app/src/main/java/org/schulcloud/mobile/controllers/main/MainPagerAdapter.kt
@@ -0,0 +1,122 @@
+package org.schulcloud.mobile.controllers.main
+
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentPagerAdapter
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.Observer
+import androidx.lifecycle.ViewModel
+import org.schulcloud.mobile.utils.mutableLiveDataOf
+import kotlin.properties.Delegates
+
+
+sealed class MainPagerAdapter, VM : ViewModel>(fragment: P) :
+ FragmentPagerAdapter(fragment.childFragmentManager) {
+ open var parent: P? = fragment
+
+ abstract val tabs: List>
+
+ private val fragmentsToAdd: MutableList>> = mutableListOf()
+
+ private val _fragments: MutableLiveData?>> = mutableLiveDataOf(emptyList())
+ val fragments: LiveData?>>
+ get() = _fragments
+
+
+ override fun instantiateItem(container: ViewGroup, position: Int): Any {
+ @Suppress("UNCHECKED_CAST")
+ val childFragment = super.instantiateItem(container, position) as InnerMainFragment<*, P, VM>
+ fragmentsToAdd += position to childFragment
+ return childFragment
+ }
+
+ override fun destroyItem(container: ViewGroup, position: Int, obj: Any) {
+ super.destroyItem(container, position, obj)
+
+ _fragments.value = (_fragments.value?.toMutableList() ?: ArrayList(count))
+ .apply { this[position] = null }
+ }
+
+ override fun finishUpdate(container: ViewGroup) {
+ super.finishUpdate(container)
+
+ for ((pos, fragment) in fragmentsToAdd) {
+ _fragments.value = (_fragments.value?.toMutableList() ?: ArrayList(count))
+ .apply {
+ while (size < pos + 1) add(null)
+ this[pos] = fragment
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ val newParent = fragment.getParentFragment() as? P
+ if (parent != newParent) {
+ parent = newParent
+ onParentFragmentChanged()
+ }
+ }
+ fragmentsToAdd.clear()
+ }
+
+ open fun onParentFragmentChanged() {}
+}
+
+class SimplePagerAdapter, VM : ViewModel>(
+ fragment: P,
+ override val tabs: List>
+) : MainPagerAdapter(fragment) {
+
+ override fun getItem(position: Int): Fragment? {
+ return if (position in tabs.indices) tabs[position].fragment() else null
+ }
+
+ override fun getPageTitle(position: Int): CharSequence? {
+ return if (position in tabs.indices) tabs[position].title else null
+ }
+
+ override fun getCount() = tabs.size
+}
+
+fun
, VM : ViewModel> List>.toPagerAdapter(fragment: P): MainPagerAdapter {
+ return SimplePagerAdapter(fragment, this)
+}
+
+class LiveDataPagerAdapter
, VM : ViewModel>(
+ fragment: P,
+ private val tabsLiveData: LiveData>>
+) : MainPagerAdapter(fragment) {
+ override var parent by Delegates.observable
(null) { _, _, new ->
+ new ?: return@observable
+ tabsLiveData.observe(new, Observer {
+ tabs = it ?: emptyList()
+ notifyDataSetChanged()
+ })
+ }
+
+ override var tabs: List> = emptyList()
+
+ init {
+ parent = fragment
+ }
+
+
+ override fun getItem(position: Int): Fragment? {
+ return if (position in tabs.indices) tabs[position].fragment() else null
+ }
+
+ override fun getPageTitle(position: Int): CharSequence? {
+ return if (position in tabs.indices) tabs[position].title else null
+ }
+
+ override fun getCount() = tabs.size
+}
+
+fun , VM : ViewModel> LiveData>>.toPagerAdapter(fragment: P): MainPagerAdapter {
+ return LiveDataPagerAdapter(fragment, this)
+}
+
+
+data class Tab, P : MainFragment, VM : ViewModel>(
+ val title: String,
+ val fragment: () -> F
+)
diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/main/NavigationDrawerFragment.kt b/app/src/main/java/org/schulcloud/mobile/controllers/main/NavigationDrawerFragment.kt
index da960ba8..4f4b480d 100644
--- a/app/src/main/java/org/schulcloud/mobile/controllers/main/NavigationDrawerFragment.kt
+++ b/app/src/main/java/org/schulcloud/mobile/controllers/main/NavigationDrawerFragment.kt
@@ -8,15 +8,15 @@ import android.view.ViewGroup
import androidx.lifecycle.ViewModelProviders
import androidx.navigation.fragment.NavHostFragment.findNavController
import androidx.navigation.ui.NavigationUI
-import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kotlinx.android.synthetic.main.drawer_navigation.*
import kotlinx.coroutines.experimental.launch
+import org.schulcloud.mobile.controllers.base.BaseSheet
import org.schulcloud.mobile.controllers.login.LoginActivity
import org.schulcloud.mobile.databinding.DrawerNavigationBinding
import org.schulcloud.mobile.models.user.UserRepository
import org.schulcloud.mobile.viewmodels.NavigationDrawerViewModel
-class NavigationDrawerFragment : BottomSheetDialogFragment() {
+class NavigationDrawerFragment : BaseSheet() {
private lateinit var viewModel: NavigationDrawerViewModel
override fun onCreate(savedInstanceState: Bundle?) {
diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/main/Refreshable.kt b/app/src/main/java/org/schulcloud/mobile/controllers/main/Refreshable.kt
index 81830d71..74bbd326 100644
--- a/app/src/main/java/org/schulcloud/mobile/controllers/main/Refreshable.kt
+++ b/app/src/main/java/org/schulcloud/mobile/controllers/main/Refreshable.kt
@@ -26,7 +26,6 @@ class RefreshableImpl : Refreshable {
override var isRefreshing: Boolean by Delegates.observable(false) { _, _, new ->
swipeRefreshLayout?.isRefreshing = new
}
- private set
override fun performRefresh() {
diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/main/TabFragment.kt b/app/src/main/java/org/schulcloud/mobile/controllers/main/TabFragment.kt
deleted file mode 100644
index 0762ca2f..00000000
--- a/app/src/main/java/org/schulcloud/mobile/controllers/main/TabFragment.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package org.schulcloud.mobile.controllers.main
-
-import android.annotation.SuppressLint
-import android.os.Bundle
-import android.view.View
-import androidx.lifecycle.ViewModel
-import org.schulcloud.mobile.R
-import org.schulcloud.mobile.controllers.base.BaseFragment
-
-
-@SuppressLint("ValidFragment")
-abstract class TabFragment
, VM : ViewModel>(private val refreshableImpl: RefreshableImpl = RefreshableImpl()) :
- BaseFragment(),
- Refreshable by refreshableImpl {
- protected lateinit var viewModel: VM
-
- init {
- refreshableImpl.refresh = { refresh() }
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- @Suppress("UNCHECKED_CAST")
- viewModel = (parentFragment as P).viewModel
- super.onCreate(savedInstanceState)
- }
-
- override fun onStart() {
- super.onStart()
- performRefresh()
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- refreshableImpl.swipeRefreshLayout = view.findViewById(R.id.swipeRefresh)
- }
-
- abstract suspend fun refresh()
-}
diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/main/TabbedMainFragment.kt b/app/src/main/java/org/schulcloud/mobile/controllers/main/TabbedMainFragment.kt
new file mode 100644
index 00000000..c0d769aa
--- /dev/null
+++ b/app/src/main/java/org/schulcloud/mobile/controllers/main/TabbedMainFragment.kt
@@ -0,0 +1,56 @@
+package org.schulcloud.mobile.controllers.main
+
+import android.os.Bundle
+import android.view.View
+import androidx.annotation.CallSuper
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.Observer
+import androidx.lifecycle.ViewModel
+import com.google.android.material.tabs.TabLayout
+import org.schulcloud.mobile.R
+import org.schulcloud.mobile.utils.map
+import org.schulcloud.mobile.utils.zipLater
+import org.schulcloud.mobile.views.LiveViewPager
+
+
+abstract class TabbedMainFragment, VM : ViewModel> : MainFragment() {
+ companion object {
+ val TAG = TabbedMainFragment::class.simpleName
+ }
+
+ abstract val pagerAdapter: MainPagerAdapter
+ lateinit var pager: LiveViewPager
+ private set
+ var tabLayout: TabLayout? = null
+ private set
+
+ final override val currentInnerFragment: LiveData
+ private val currentPositionAddFunc: (LiveData) -> Unit
+ override val innerFragments
+ get() = pagerAdapter.fragments
+
+ init {
+ val (position, addFunc) = zipLater()
+ currentInnerFragment = position.map { it }
+ currentPositionAddFunc = addFunc
+ }
+
+
+ @CallSuper
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ pager = view.findViewById(R.id.viewPager)
+ // Subscribe currentInnerFragment to pager position
+ currentPositionAddFunc(pager.currentItemLiveData)
+ pager.adapter = pagerAdapter
+
+ tabLayout = view.findViewById(R.id.tabLayout)
+ tabLayout?.setupWithViewPager(pager)
+
+ mainViewModel.toolbarColors.observe(this, Observer {
+ tabLayout?.setTabTextColors(it.textColorSecondary, it.textColor)
+ tabLayout?.setSelectedTabIndicatorColor(it.textColor)
+ })
+ }
+}
diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/news/NewsFragment.kt b/app/src/main/java/org/schulcloud/mobile/controllers/news/NewsFragment.kt
index 06e02f9f..5761a2ea 100644
--- a/app/src/main/java/org/schulcloud/mobile/controllers/news/NewsFragment.kt
+++ b/app/src/main/java/org/schulcloud/mobile/controllers/news/NewsFragment.kt
@@ -14,16 +14,17 @@ import org.schulcloud.mobile.utils.map
import org.schulcloud.mobile.viewmodels.IdViewModelFactory
import org.schulcloud.mobile.viewmodels.NewsViewModel
-class NewsFragment : MainFragment() {
+
+class NewsFragment : MainFragment() {
companion object {
val TAG: String = NewsFragment::class.java.simpleName
}
override var url: String? = null
- get() = "news/${viewModel.news.value?.id}"
+ get() = "/news/${viewModel.news.value?.id}"
- override fun provideConfig() = viewModel.news
+ override fun provideSelfConfig() = viewModel.news
.map { news ->
MainFragmentConfig(
title = news?.title ?: getString(R.string.general_error_notFound)
diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/news/NewsListFragment.kt b/app/src/main/java/org/schulcloud/mobile/controllers/news/NewsListFragment.kt
index 9c1dc86f..f1fba5ce 100644
--- a/app/src/main/java/org/schulcloud/mobile/controllers/news/NewsListFragment.kt
+++ b/app/src/main/java/org/schulcloud/mobile/controllers/news/NewsListFragment.kt
@@ -17,7 +17,8 @@ import org.schulcloud.mobile.models.news.NewsRepository
import org.schulcloud.mobile.utils.asLiveData
import org.schulcloud.mobile.viewmodels.NewsListViewModel
-class NewsListFragment : MainFragment() {
+
+class NewsListFragment : MainFragment() {
companion object {
val TAG: String = NewsListFragment::class.java.simpleName
}
@@ -31,7 +32,7 @@ class NewsListFragment : MainFragment() {
override var url: String? = "/news"
- override fun provideConfig() = MainFragmentConfig(
+ override fun provideSelfConfig() = MainFragmentConfig(
fragmentType = FragmentType.PRIMARY,
title = getString(R.string.news_title)
).asLiveData()
diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/topic/TopicFragment.kt b/app/src/main/java/org/schulcloud/mobile/controllers/topic/TopicFragment.kt
index a9c36075..8890e5a8 100644
--- a/app/src/main/java/org/schulcloud/mobile/controllers/topic/TopicFragment.kt
+++ b/app/src/main/java/org/schulcloud/mobile/controllers/topic/TopicFragment.kt
@@ -15,15 +15,18 @@ import org.schulcloud.mobile.R
import org.schulcloud.mobile.controllers.main.MainFragment
import org.schulcloud.mobile.controllers.main.MainFragmentConfig
import org.schulcloud.mobile.databinding.FragmentTopicBinding
-import org.schulcloud.mobile.models.course.Course
import org.schulcloud.mobile.models.topic.TopicRepository
-import org.schulcloud.mobile.utils.*
+import org.schulcloud.mobile.utils.combineLatestBothNullable
+import org.schulcloud.mobile.utils.dpToPx
+import org.schulcloud.mobile.utils.map
+import org.schulcloud.mobile.utils.switchMapNullable
import org.schulcloud.mobile.viewmodels.IdViewModelFactory
import org.schulcloud.mobile.viewmodels.TopicViewModel
import org.schulcloud.mobile.views.DividerItemDecoration
import org.schulcloud.mobile.views.ItemOffsetDecoration
-class TopicFragment : MainFragment() {
+
+class TopicFragment : MainFragment() {
companion object {
val TAG: String = TopicFragment::class.java.simpleName
@@ -33,16 +36,17 @@ class TopicFragment : MainFragment() {
override var url: String? = null
get() = viewModel.topic.value?.url
- override fun provideConfig() = viewModel.topic
- .combineLatest(viewModel.topic.switchMap {
- it?.courseId?.let { viewModel.course(it) } ?: null.asLiveData()
- })
+ override fun provideSelfConfig() = viewModel.topic
+ .combineLatestBothNullable(
+ viewModel.topic.switchMapNullable { topic ->
+ topic?.courseId?.let { viewModel.course(it) }
+ })
.map { (topic, course) ->
MainFragmentConfig(
title = topic?.name ?: getString(R.string.general_error_notFound),
subtitle = course?.name,
toolbarColor = course?.color?.let { Color.parseColor(it) },
- menuBottomRes = R.menu.fragment_topic_bottom
+ menuBottomRes = listOf(R.menu.fragment_topic_bottom)
)
}
diff --git a/app/src/main/java/org/schulcloud/mobile/jobs/ListDirectoryContentsJob.kt b/app/src/main/java/org/schulcloud/mobile/jobs/ListDirectoryContentsJob.kt
index 4403b9ff..8ecf1f6d 100644
--- a/app/src/main/java/org/schulcloud/mobile/jobs/ListDirectoryContentsJob.kt
+++ b/app/src/main/java/org/schulcloud/mobile/jobs/ListDirectoryContentsJob.kt
@@ -17,13 +17,18 @@ class ListDirectoryContentsJob(private val path: String, callback: RequestJobCal
override suspend fun onRun() {
val response = ApiService.getInstance().listDirectoryContents(path).awaitResponse()
+ val body = response.body()
- if (response.isSuccessful) {
+ if (response.isSuccessful && body != null) {
if (BuildConfig.DEBUG) Log.i(TAG, "Contents for path $path received")
// Sync
- Sync.Data.with(File::class.java, response.body()!!.files!!).run()
- Sync.Data.with(Directory::class.java, response.body()!!.directories!!).run()
+ Sync.Data.with(File::class.java, body.files ?: emptyList()) {
+ equalTo("path", path)
+ }.run()
+ Sync.Data.with(Directory::class.java, body.directories ?: emptyList()) {
+ equalTo("path", path)
+ }.run()
} else {
if (BuildConfig.DEBUG) Log.e(TAG, "Error while fetching contents for path $path")
diff --git a/app/src/main/java/org/schulcloud/mobile/jobs/base/DataRequestJob.kt b/app/src/main/java/org/schulcloud/mobile/jobs/base/DataRequestJob.kt
deleted file mode 100644
index df4ddcd4..00000000
--- a/app/src/main/java/org/schulcloud/mobile/jobs/base/DataRequestJob.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package org.schulcloud.mobile.jobs.base
-
-import android.util.Log
-import io.realm.RealmModel
-import io.realm.RealmQuery
-import org.schulcloud.mobile.BuildConfig
-import org.schulcloud.mobile.models.Sync
-import org.schulcloud.mobile.models.base.HasId
-import org.schulcloud.mobile.network.ApiService
-import org.schulcloud.mobile.network.ApiServiceInterface
-import org.schulcloud.mobile.network.FeathersResponse
-import org.schulcloud.mobile.utils.it
-import retrofit2.Call
-import ru.gildor.coroutines.retrofit.awaitResponse
-
-
diff --git a/app/src/main/java/org/schulcloud/mobile/jobs/base/RequestJob.kt b/app/src/main/java/org/schulcloud/mobile/jobs/base/RequestJob.kt
index cb164c3b..731a7219 100644
--- a/app/src/main/java/org/schulcloud/mobile/jobs/base/RequestJob.kt
+++ b/app/src/main/java/org/schulcloud/mobile/jobs/base/RequestJob.kt
@@ -130,4 +130,43 @@ abstract class RequestJob(
}
}
}
+
+
+ @Suppress("SpreadOperator")
+ class UpdateSingleData(
+ private val clazz: Class,
+ private val item: T,
+ private val call: ApiServiceInterface.() -> Call,
+ callback: RequestJobCallback? = null,
+ preconditions: Array
+ ) : RequestJob(callback, *preconditions) where T : RealmModel, T : HasId {
+ companion object {
+ private val TAG: String = SingleData::class.java.simpleName
+
+ inline fun with(
+ item: T,
+ noinline call: ApiServiceInterface.() -> Call,
+ callback: RequestJobCallback? = null,
+ vararg preconditions: Precondition
+ ): UpdateSingleData where T : RealmModel, T : HasId {
+ return UpdateSingleData(T::class.java, item, call, callback, preconditions)
+ }
+ }
+
+ override suspend fun onRun() {
+ val response = call(ApiService.getInstance()).awaitResponse()
+
+ if (response.isSuccessful) {
+ if (BuildConfig.DEBUG)
+ Log.i(TAG, "${clazz.simpleName} ${item.id} updated")
+
+ // Sync
+ Sync.SingleData.with(clazz, response.body(), item.id).run()
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.e(TAG, "Error while updating ${clazz.simpleName}s")
+ callback?.error(RequestJobCallback.ErrorCode.ERROR)
+ }
+ }
+ }
}
diff --git a/app/src/main/java/org/schulcloud/mobile/models/base/RealmString.kt b/app/src/main/java/org/schulcloud/mobile/models/base/RealmString.kt
index 3c7dcf11..3df59368 100644
--- a/app/src/main/java/org/schulcloud/mobile/models/base/RealmString.kt
+++ b/app/src/main/java/org/schulcloud/mobile/models/base/RealmString.kt
@@ -6,6 +6,8 @@ import io.realm.annotations.RealmClass
@RealmClass
open class RealmString @JvmOverloads constructor(var value: String = "") : RealmModel {
+
+ override fun toString() = value
override fun equals(other: Any?): Boolean {
return when (other) {
this === other -> true
diff --git a/app/src/main/java/org/schulcloud/mobile/models/file/CreateFileRequest.kt b/app/src/main/java/org/schulcloud/mobile/models/file/CreateFileRequest.kt
new file mode 100644
index 00000000..2e7d7575
--- /dev/null
+++ b/app/src/main/java/org/schulcloud/mobile/models/file/CreateFileRequest.kt
@@ -0,0 +1,15 @@
+package org.schulcloud.mobile.models.file
+
+import io.realm.RealmObject
+
+
+open class CreateFileRequest : RealmObject() {
+ var key: String = ""
+ var path: String? = null
+ var name: String? = null
+
+ var type: String? = null
+ var size: Long? = null
+ var thumbnail: String? = null
+ var flatFileName: String? = null
+}
diff --git a/app/src/main/java/org/schulcloud/mobile/models/file/Directory.kt b/app/src/main/java/org/schulcloud/mobile/models/file/Directory.kt
index 9cd472b0..bb45eb94 100644
--- a/app/src/main/java/org/schulcloud/mobile/models/file/Directory.kt
+++ b/app/src/main/java/org/schulcloud/mobile/models/file/Directory.kt
@@ -1,14 +1,16 @@
package org.schulcloud.mobile.models.file
+import com.google.gson.annotations.SerializedName
import io.realm.RealmObject
import io.realm.annotations.PrimaryKey
import org.schulcloud.mobile.models.base.HasId
open class Directory : RealmObject(), HasId {
- override val id: String
- get() = key ?: ""
@PrimaryKey
+ @SerializedName("_id")
+ override var id: String = ""
+
var key: String? = null
var name: String? = null
var path: String? = null
diff --git a/app/src/main/java/org/schulcloud/mobile/models/file/File.kt b/app/src/main/java/org/schulcloud/mobile/models/file/File.kt
index e2c72a74..55e3ff10 100644
--- a/app/src/main/java/org/schulcloud/mobile/models/file/File.kt
+++ b/app/src/main/java/org/schulcloud/mobile/models/file/File.kt
@@ -1,20 +1,55 @@
package org.schulcloud.mobile.models.file
+import com.google.gson.annotations.SerializedName
+import io.realm.RealmList
import io.realm.RealmObject
import io.realm.annotations.PrimaryKey
import org.schulcloud.mobile.models.base.HasId
+import org.schulcloud.mobile.models.base.RealmString
+
open class File : RealmObject(), HasId {
- override val id: String
- get() = key
@PrimaryKey
+ @SerializedName("_id")
+ override var id: String = ""
+
var key: String = ""
- var name: String? = null
var path: String? = null
+ var name: String? = null
var type: String? = null
var size: Long? = null
var thumbnail: String? = null
var flatFileName: String? = null
+ var permissions: RealmList? = null
+
+
+ fun addPermissions(userIds: List, additionalPermissions: List) {
+ for (userId in userIds) {
+ val allPermissions = permissions
+ ?: RealmList().also { permissions = it }
+ val userPermissions = allPermissions.firstOrNull { it.userId == userId }
+ ?: FilePermissions().also {
+ it.userId = userId
+ allPermissions.add(it)
+ }
+ val permissions = userPermissions.permissions
+ ?: RealmList().also { userPermissions.permissions = it }
+
+ for (newPermission in additionalPermissions)
+ if (!permissions.any { it.value == newPermission })
+ permissions.add(RealmString(newPermission))
+ }
+ }
+}
+
+open class FilePermissions : RealmObject() {
+ companion object {
+ const val PERMISSION_READ = "can-read"
+ const val PERMISSION_WRITE = "can-write"
+ }
+
+ var userId: String = ""
+ var permissions: RealmList? = null
}
diff --git a/app/src/main/java/org/schulcloud/mobile/models/file/FileDao.kt b/app/src/main/java/org/schulcloud/mobile/models/file/FileDao.kt
index c9e254af..a1f42482 100644
--- a/app/src/main/java/org/schulcloud/mobile/models/file/FileDao.kt
+++ b/app/src/main/java/org/schulcloud/mobile/models/file/FileDao.kt
@@ -11,9 +11,22 @@ class FileDao(private val realm: Realm) {
.allAsLiveData()
}
+ fun files(ids: Array): LiveData> {
+ return realm.where(File::class.java)
+ .`in`("id", ids)
+ .allAsLiveData()
+ }
+
fun directories(path: String): LiveData> {
return realm.where(Directory::class.java)
.equalTo("path", path)
.allAsLiveData()
}
+
+
+ fun updateFile(value: File) {
+ realm.executeTransaction {
+ it.copyToRealmOrUpdate(value)
+ }
+ }
}
diff --git a/app/src/main/java/org/schulcloud/mobile/models/file/FileRepository.kt b/app/src/main/java/org/schulcloud/mobile/models/file/FileRepository.kt
index 4a5e78c3..a41edbd4 100644
--- a/app/src/main/java/org/schulcloud/mobile/models/file/FileRepository.kt
+++ b/app/src/main/java/org/schulcloud/mobile/models/file/FileRepository.kt
@@ -1,12 +1,26 @@
package org.schulcloud.mobile.models.file
+import android.net.Uri
+import android.util.Log
+import android.webkit.MimeTypeMap
import androidx.lifecycle.LiveData
import io.realm.Realm
+import okhttp3.MediaType
+import okhttp3.RequestBody
+import okio.BufferedSink
+import okio.Okio
+import org.schulcloud.mobile.BuildConfig
import org.schulcloud.mobile.jobs.ListDirectoryContentsJob
+import org.schulcloud.mobile.jobs.base.RequestJob
import org.schulcloud.mobile.models.user.UserRepository
+import org.schulcloud.mobile.network.ApiService
import org.schulcloud.mobile.utils.*
+import ru.gildor.coroutines.retrofit.awaitResponse
+import java.io.InputStream
+import java.io.File as JavaFile
object FileRepository {
+ private val TAG = FileRepository.javaClass.simpleName
const val CONTEXT_MY = "my"
const val CONTEXT_MY_API = "users"
const val CONTEXT_COURSES = "courses"
@@ -15,15 +29,29 @@ object FileRepository {
return realm.fileDao().files(path)
}
+ fun files(realm: Realm, ids: Array): LiveData> {
+ return realm.fileDao().files(ids)
+ }
+
fun directories(realm: Realm, path: String): LiveData> {
return realm.fileDao().directories(path)
}
+ suspend fun updateFile(realm: Realm, value: File) {
+ realm.fileDao().updateFile(value)
+ RequestJob.UpdateSingleData.with(value, { updateFile(value.id, value) }).run()
+ }
+
+
suspend fun syncDirectory(path: String) {
ListDirectoryContentsJob(path).run()
}
+ suspend fun syncFile(id: String) {
+ RequestJob.SingleData.with(id, { getFile(id) }).run()
+ }
+
fun fixPath(path: String): String {
var fixedPath = path.trimLeadingSlash().ensureTrailingSlash()
@@ -39,4 +67,67 @@ object FileRepository {
fun pathCourse(courseId: String, path: String? = null): String {
return combinePath(CONTEXT_COURSES, courseId, path)
}
+
+
+ /**
+ * @param path Path on server including the file name; defaults to private folder.
+ */
+ suspend fun upload(path: String, name: String, size: Long, streamGenerator: () -> InputStream?): File? {
+ val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(name.fileExtension)
+ val mediaType = mimeType?.let { MediaType.parse(it) }
+
+ // Generate URL for upload
+ val signedUrlReq = SignedUrlRequest().also {
+ it.action = SignedUrlRequest.ACTION_PUT
+ it.path = combinePath(path, name)
+ it.fileType = mimeType
+ }
+ val signedUrlRes = ApiService.getInstance().generateSignedUrl(signedUrlReq).awaitResponse()
+ val body = signedUrlRes.body()
+ val uploadUrl = body?.url
+ val header = body?.header
+ if (!signedUrlRes.isSuccessful || body == null || uploadUrl == null || header == null) {
+ if (BuildConfig.DEBUG)
+ Log.e(TAG, "upload $path: Error generating signed URL")
+ return null
+ }
+
+ // Actual upload
+ val uploadReq = object : RequestBody() {
+ override fun contentType() = mediaType
+ override fun contentLength() = size
+ override fun writeTo(sink: BufferedSink?) {
+ Okio.source(streamGenerator() ?: return).use {
+ sink?.writeAll(it)
+ }
+ }
+ }
+ val uploadRes = ApiService.getInstance().uploadFile(uploadUrl,
+ header.contentType, header.metaPath, header.metaName, header.metaFlatName, header.metaThumbnail,
+ uploadReq).awaitResponse()
+ if (!uploadRes.isSuccessful) {
+ if (BuildConfig.DEBUG)
+ Log.e(TAG, "upload $path: Error uploading file")
+ return null
+ }
+
+ // Notify SC server of new file
+ val newFile = CreateFileRequest().also {
+ it.key = combinePath(header.metaPath, Uri.encode(name))
+ it.path = header.metaPath?.ensureTrailingSlash()
+ it.name = name
+ it.type = mimeType
+ it.size = size
+ it.flatFileName = header.metaFlatName
+ it.thumbnail = header.metaThumbnail
+ }
+ val persistRes = ApiService.getInstance().persistFile(newFile).awaitResponse()
+ val file = persistRes.body()
+ if (!persistRes.isSuccessful || file == null) {
+ if (BuildConfig.DEBUG)
+ Log.e(TAG, "upload $path: Error persisting file")
+ return null
+ }
+ return file
+ }
}
diff --git a/app/src/main/java/org/schulcloud/mobile/models/file/SignedUrlRequest.kt b/app/src/main/java/org/schulcloud/mobile/models/file/SignedUrlRequest.kt
index 937e2a85..26f3a9b2 100644
--- a/app/src/main/java/org/schulcloud/mobile/models/file/SignedUrlRequest.kt
+++ b/app/src/main/java/org/schulcloud/mobile/models/file/SignedUrlRequest.kt
@@ -1,13 +1,10 @@
package org.schulcloud.mobile.models.file
-/**
- * Date: 7/5/2018
- */
class SignedUrlRequest {
companion object {
- val ACTION_GET = "getObject"
- val ACTION_PUT = "putObject"
+ const val ACTION_GET = "getObject"
+ const val ACTION_PUT = "putObject"
}
var action: String? = null
diff --git a/app/src/main/java/org/schulcloud/mobile/models/file/SignedUrlResponse.kt b/app/src/main/java/org/schulcloud/mobile/models/file/SignedUrlResponse.kt
index d45c7120..8a4d01d8 100644
--- a/app/src/main/java/org/schulcloud/mobile/models/file/SignedUrlResponse.kt
+++ b/app/src/main/java/org/schulcloud/mobile/models/file/SignedUrlResponse.kt
@@ -3,9 +3,6 @@ package org.schulcloud.mobile.models.file
import com.google.gson.annotations.SerializedName
-/**
- * Date: 7/5/2018
- */
class SignedUrlResponse {
var url: String? = null
var header: SignedUrlResponseHeader? = null
diff --git a/app/src/main/java/org/schulcloud/mobile/models/homework/HomeworkDao.kt b/app/src/main/java/org/schulcloud/mobile/models/homework/HomeworkDao.kt
index d93dcd0b..7ba4aaa5 100644
--- a/app/src/main/java/org/schulcloud/mobile/models/homework/HomeworkDao.kt
+++ b/app/src/main/java/org/schulcloud/mobile/models/homework/HomeworkDao.kt
@@ -42,4 +42,9 @@ class HomeworkDao(private val realm: Realm) {
.equalTo("id", id)
.firstAsLiveData()
}
+ fun homeworkBlocking(id: String): Homework? {
+ return realm.where(Homework::class.java)
+ .equalTo("id", id)
+ .findFirst()
+ }
}
diff --git a/app/src/main/java/org/schulcloud/mobile/models/homework/HomeworkRepository.kt b/app/src/main/java/org/schulcloud/mobile/models/homework/HomeworkRepository.kt
index f3fb1a2c..c6b1e3fe 100644
--- a/app/src/main/java/org/schulcloud/mobile/models/homework/HomeworkRepository.kt
+++ b/app/src/main/java/org/schulcloud/mobile/models/homework/HomeworkRepository.kt
@@ -18,6 +18,9 @@ object HomeworkRepository {
fun homework(realm: Realm, id: String): LiveData {
return realm.homeworkDao().homework(id)
}
+ fun homeworkBlocking(realm: Realm, id: String): Homework? {
+ return realm.homeworkDao().homeworkBlocking(id)
+ }
suspend fun syncHomeworkList() {
diff --git a/app/src/main/java/org/schulcloud/mobile/models/homework/submission/Submission.kt b/app/src/main/java/org/schulcloud/mobile/models/homework/submission/Submission.kt
index 9efae3e6..534a5158 100644
--- a/app/src/main/java/org/schulcloud/mobile/models/homework/submission/Submission.kt
+++ b/app/src/main/java/org/schulcloud/mobile/models/homework/submission/Submission.kt
@@ -17,6 +17,8 @@ open class Submission : RealmObject(), HasId {
var comment: String? = null
var createdAt: String? = null
+ var fileIds: RealmList? = null
+
var grade: Int? = null
var gradeComment: String? = null
diff --git a/app/src/main/java/org/schulcloud/mobile/models/homework/submission/SubmissionDao.kt b/app/src/main/java/org/schulcloud/mobile/models/homework/submission/SubmissionDao.kt
index bcaeab14..e7a34f01 100644
--- a/app/src/main/java/org/schulcloud/mobile/models/homework/submission/SubmissionDao.kt
+++ b/app/src/main/java/org/schulcloud/mobile/models/homework/submission/SubmissionDao.kt
@@ -25,4 +25,11 @@ class SubmissionDao(private val realm: Realm) {
.equalTo("studentId", studentId)
.firstAsLiveData()
}
+
+
+ fun updateSubmission(value: Submission) {
+ realm.executeTransaction {
+ it.copyToRealmOrUpdate(value)
+ }
+ }
}
diff --git a/app/src/main/java/org/schulcloud/mobile/models/homework/submission/SubmissionRepository.kt b/app/src/main/java/org/schulcloud/mobile/models/homework/submission/SubmissionRepository.kt
index 8f45f3c1..673c30f4 100644
--- a/app/src/main/java/org/schulcloud/mobile/models/homework/submission/SubmissionRepository.kt
+++ b/app/src/main/java/org/schulcloud/mobile/models/homework/submission/SubmissionRepository.kt
@@ -13,11 +13,18 @@ object SubmissionRepository {
fun submission(realm: Realm, id: String): LiveData {
return realm.submissionDao().submission(id)
}
+
fun submission(realm: Realm, homeworkId: String, studentId: String): LiveData {
return realm.submissionDao().submission(homeworkId, studentId)
}
+ suspend fun updateSubmission(realm: Realm, value: Submission) {
+ realm.submissionDao().updateSubmission(value)
+ RequestJob.UpdateSingleData.with(value, { updateSubmission(value.id, value) }).run()
+ }
+
+
suspend fun syncSubmissionsForHomework(homeworkId: String) {
RequestJob.Data.with({ listHomeworkSubmissions(homeworkId) },
{ equalTo("homeworkId", homeworkId) }).run()
diff --git a/app/src/main/java/org/schulcloud/mobile/models/user/User.kt b/app/src/main/java/org/schulcloud/mobile/models/user/User.kt
index 7ade1cb4..bc2b801b 100644
--- a/app/src/main/java/org/schulcloud/mobile/models/user/User.kt
+++ b/app/src/main/java/org/schulcloud/mobile/models/user/User.kt
@@ -1,9 +1,11 @@
package org.schulcloud.mobile.models.user
import com.google.gson.annotations.SerializedName
+import io.realm.RealmList
import io.realm.RealmObject
import io.realm.annotations.PrimaryKey
import org.schulcloud.mobile.models.base.HasId
+import org.schulcloud.mobile.models.base.RealmString
open class User : RealmObject(), HasId {
@@ -17,6 +19,19 @@ open class User : RealmObject(), HasId {
var schoolId: String? = null
var displayName: String? = null
+ var permissions: RealmList? = null
+
val name get() = "$firstName $lastName"
val shortName get() = "${firstName?.get(0)}. $lastName"
}
+
+
+enum class Permission(val string: String) {
+ FILE_CREATE("FILE_CREATE"),
+ SUBMISSIONS_EDIT("SUBMISSIONS_EDIT")
+}
+
+fun User?.hasPermission(permission: Permission): Boolean {
+ return this?.permissions
+ ?.any { it.value.equals(permission.string, true) } == true
+}
diff --git a/app/src/main/java/org/schulcloud/mobile/models/user/UserRepository.kt b/app/src/main/java/org/schulcloud/mobile/models/user/UserRepository.kt
index 80d52de4..54c7665e 100644
--- a/app/src/main/java/org/schulcloud/mobile/models/user/UserRepository.kt
+++ b/app/src/main/java/org/schulcloud/mobile/models/user/UserRepository.kt
@@ -19,7 +19,7 @@ import org.schulcloud.mobile.utils.userDao
object UserRepository {
- val TAG: String = UserRepository::class.java.simpleName
+ private val TAG: String = UserRepository::class.java.simpleName
@JvmStatic
val token: String?
diff --git a/app/src/main/java/org/schulcloud/mobile/network/ApiServiceInterface.kt b/app/src/main/java/org/schulcloud/mobile/network/ApiServiceInterface.kt
index 1d41ab1a..5b5f21cf 100644
--- a/app/src/main/java/org/schulcloud/mobile/network/ApiServiceInterface.kt
+++ b/app/src/main/java/org/schulcloud/mobile/network/ApiServiceInterface.kt
@@ -1,13 +1,12 @@
package org.schulcloud.mobile.network
+import okhttp3.RequestBody
import okhttp3.ResponseBody
import org.schulcloud.mobile.models.AccessToken
import org.schulcloud.mobile.models.Credentials
import org.schulcloud.mobile.models.course.Course
import org.schulcloud.mobile.models.event.Event
-import org.schulcloud.mobile.models.file.DirectoryResponse
-import org.schulcloud.mobile.models.file.SignedUrlRequest
-import org.schulcloud.mobile.models.file.SignedUrlResponse
+import org.schulcloud.mobile.models.file.*
import org.schulcloud.mobile.models.homework.Homework
import org.schulcloud.mobile.models.homework.submission.Submission
import org.schulcloud.mobile.models.news.News
@@ -17,7 +16,7 @@ import retrofit2.Call
import retrofit2.http.*
-@Suppress("TooManyFunctions")
+@Suppress("TooManyFunctions", "LongParameterList")
interface ApiServiceInterface {
// Login
@@ -51,7 +50,7 @@ interface ApiServiceInterface {
// Homework
@GET("homework?\$populate=courseId&\$sort=dueDate:-1")
- fun listUserHomework(): Call >>
+ fun listUserHomework(): Call>>
@GET("homework/{id}?\$populate=courseId&\$sort=dueDate:-1")
fun getHomework(@Path("id") homeworkId: String): Call
@@ -59,12 +58,32 @@ interface ApiServiceInterface {
fun listHomeworkSubmissions(@Query("homeworkId") homeworkId: String): Call>>
@GET("submissions/{id}")
fun getSubmission(@Path("id") submissionId: String): Call
+ @PATCH("submissions/{id}")
+ fun updateSubmission(@Path("id") submissionId: String, @Body submission: Submission): Call
// File
@GET("fileStorage")
fun listDirectoryContents(@Query("path") path: String): Call
+ @GET("files/{id}")
+ fun getFile(@Path("id") fileId: String): Call
+ @PATCH("files/{id}")
+ fun updateFile(@Path("id") fileId: String, @Body file: File): Call
+
@POST("fileStorage/signedUrl")
fun generateSignedUrl(@Body signedUrlRequest: SignedUrlRequest): Call
@GET
fun downloadFile(@Url fileUrl: String): Call
+ @PUT
+ fun uploadFile(
+ @Url fileUrl: String,
+ @Header("content-type") contentType: String?,
+ @Header("x-amz-meta-path") metaPath: String?,
+ @Header("x-amz-meta-name") metaName: String?,
+ @Header("x-amz-meta-flat-name") metaFlatName: String?,
+ @Header("x-amz-meta-thumbnail") metaThumbnail: String?,
+ @Body file: RequestBody
+ ): Call
+ @POST("files")
+ fun persistFile(@Body file: CreateFileRequest): Call
+
}
diff --git a/app/src/main/java/org/schulcloud/mobile/utils/AndroidUtils.kt b/app/src/main/java/org/schulcloud/mobile/utils/AndroidUtils.kt
index 1615f5aa..f6cc576f 100644
--- a/app/src/main/java/org/schulcloud/mobile/utils/AndroidUtils.kt
+++ b/app/src/main/java/org/schulcloud/mobile/utils/AndroidUtils.kt
@@ -4,6 +4,7 @@ package org.schulcloud.mobile.utils
import android.content.Context
import android.content.Intent
+import android.content.res.TypedArray
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
@@ -11,6 +12,8 @@ import android.graphics.drawable.Drawable
import android.os.Bundle
import androidx.annotation.ArrayRes
import androidx.annotation.ColorInt
+import androidx.annotation.Dimension
+import androidx.annotation.StyleableRes
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.text.TextUtilsCompat
@@ -61,6 +64,18 @@ fun Context.getColorArray(@ArrayRes id: Int, @ColorInt fallback: Int? = null): I
return colors
}
+
+@ColorInt
+fun TypedArray.getColorOrNull(@StyleableRes index: Int): Int? {
+ return if (hasValue(index)) getColor(index, 0) else null
+}
+
+@Dimension
+fun TypedArray.getDimensionOrNull(@StyleableRes index: Int): Float? {
+ return if (hasValue(index)) getDimension(index, 0f) else null
+}
+
+
fun Drawable.setTintCompat(@ColorInt tint: Int) {
DrawableCompat.setTint(this, tint)
}
diff --git a/app/src/main/java/org/schulcloud/mobile/utils/DialogUtils.kt b/app/src/main/java/org/schulcloud/mobile/utils/DialogUtils.kt
index e1214751..8bc6ccbe 100644
--- a/app/src/main/java/org/schulcloud/mobile/utils/DialogUtils.kt
+++ b/app/src/main/java/org/schulcloud/mobile/utils/DialogUtils.kt
@@ -2,35 +2,53 @@ package org.schulcloud.mobile.utils
import android.app.ProgressDialog
import android.content.Context
+import android.os.Looper
import android.widget.Toast
import androidx.annotation.StringRes
+import kotlinx.coroutines.experimental.android.UI
+import kotlinx.coroutines.experimental.launch
+import kotlinx.coroutines.experimental.withContext
import org.schulcloud.mobile.R
-fun Context.showGenericError(@StringRes messageRes: Int): Toast = showGenericError(getString(messageRes))
-fun Context.showGenericError(message: String): Toast {
- return Toast.makeText(this, getString(R.string.dialog_error_format, message), Toast.LENGTH_SHORT)
- .apply { show() }
+fun Context.showGenericError(@StringRes messageRes: Int) = showGenericError(getString(messageRes))
+fun Context.showGenericError(message: String) {
+ return showToast(getString(R.string.dialog_error_format, message), Toast.LENGTH_SHORT)
}
-fun Context.showGenericNeutral(@StringRes messageRes: Int): Toast = showGenericNeutral(getString(messageRes))
-fun Context.showGenericNeutral(message: String): Toast {
- return Toast.makeText(this, message, Toast.LENGTH_SHORT)
- .apply { show() }
+fun Context.showGenericNeutral(@StringRes messageRes: Int) = showGenericNeutral(getString(messageRes))
+fun Context.showGenericNeutral(message: String) {
+ return showToast(message, Toast.LENGTH_SHORT)
}
-fun Context.showGenericSuccess(@StringRes messageRes: Int): Toast = showGenericSuccess(getString(messageRes))
-fun Context.showGenericSuccess(message: String): Toast {
- return Toast.makeText(this, message, Toast.LENGTH_SHORT)
- .apply { show() }
+fun Context.showGenericSuccess(@StringRes messageRes: Int) = showGenericSuccess(getString(messageRes))
+fun Context.showGenericSuccess(message: String) {
+ return showToast(message, Toast.LENGTH_SHORT)
}
-suspend fun Context.withProgressDialog(@StringRes messageRes: Int, block: suspend () -> Unit) = withProgressDialog(getString(messageRes), block)
-suspend fun Context.withProgressDialog(message: String, block: suspend () -> Unit) {
+private fun Context.showToast(message: String, length: Int = Toast.LENGTH_LONG) {
+ fun showToastActual() {
+ Toast.makeText(this, message, Toast.LENGTH_SHORT)
+ .apply { show() }
+ }
+
+ if (Looper.myLooper() != Looper.getMainLooper())
+ launch(UI) { showToastActual() }
+ else showToastActual()
+}
+
+suspend fun Context.withProgressDialog(@StringRes messageRes: Int, block: suspend () -> T): T {
+ return withProgressDialog(getString(messageRes), block)
+}
+
+suspend fun Context.withProgressDialog(message: String, block: suspend () -> T): T {
val dialog = ProgressDialog(this).apply {
setMessage(message)
show()
}
- block()
+ val res = if (Looper.myLooper() != Looper.getMainLooper())
+ withContext(UI) { block() }
+ else block()
dialog.dismiss()
+ return res
}
diff --git a/app/src/main/java/org/schulcloud/mobile/utils/FileUtils.kt b/app/src/main/java/org/schulcloud/mobile/utils/FileUtils.kt
new file mode 100644
index 00000000..5082c2fe
--- /dev/null
+++ b/app/src/main/java/org/schulcloud/mobile/utils/FileUtils.kt
@@ -0,0 +1,202 @@
+@file:Suppress("TooManyFunctions")
+
+package org.schulcloud.mobile.utils
+
+import android.Manifest
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.provider.MediaStore
+import android.provider.OpenableColumns
+import android.util.Log
+import androidx.core.content.FileProvider
+import org.schulcloud.mobile.R
+import org.schulcloud.mobile.config.Config
+import org.schulcloud.mobile.controllers.base.ContextAware
+import org.schulcloud.mobile.models.file.File
+import org.schulcloud.mobile.models.file.FileRepository
+import org.schulcloud.mobile.models.file.SignedUrlRequest
+import org.schulcloud.mobile.network.ApiService
+import retrofit2.HttpException
+import ru.gildor.coroutines.retrofit.await
+import java.io.IOException
+import java.io.InputStream
+import java.io.File as JavaFile
+
+
+private const val TAG = "FileUtils"
+
+// General utilities
+fun JavaFile.create(): Boolean {
+ return try {
+ if (!parentFile.exists() && !parentFile.mkdirs())
+ return false
+ else if (exists() && !delete())
+ return false
+ else if (!createNewFile())
+ return false
+ else true
+ } catch (e: IOException) {
+ Log.e(TAG, e.toString())
+ false
+ }
+}
+
+fun JavaFile.saveDelete(): Boolean {
+ return try {
+ !exists() || delete()
+ true
+ } catch (e: IOException) {
+ false
+ }
+}
+
+
+// File picking
+suspend fun ContextAware.createFilePickerIntent(): Intent? {
+ if (!requestPermission(Manifest.permission.READ_EXTERNAL_STORAGE)) {
+ currentContext.showGenericError(R.string.file_pick_error_readPermissionDenied)
+ return null
+ }
+
+ return Intent(Intent.ACTION_GET_CONTENT).apply {
+ type = "*/*"
+ addCategory(Intent.CATEGORY_OPENABLE)
+ }
+}
+
+fun Context.createTakePhotoIntent(): TakePhotoInfo? {
+ // Create temp file used by camera app to store the photo
+ val tempPicture = java.io.File(filesDir, combinePath("temp", "photo.jpg"))
+ if (!tempPicture.create()) {
+ showGenericError(R.string.file_pick_error_createTempFile)
+ return null
+ }
+
+ val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
+ val uri = FileProvider.getUriForFile(this, Config.FILE_PROVIDER, tempPicture)
+ intent.putExtra(MediaStore.EXTRA_OUTPUT, uri)
+
+ // Test if apps can handle the intent, i.e. a camera app is installed
+ if (intent.resolveActivity(packageManager) == null) {
+ showGenericError(R.string.file_pick_error_noCamera)
+ return null
+ }
+
+ // Grant temporary write permission for the destination file to all resolved activities
+ val intentActivities = packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
+ for (resolveInfo in intentActivities)
+ grantUriPermission(resolveInfo.activityInfo.packageName, uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
+
+ return TakePhotoInfo(intent, tempPicture, uri)
+}
+
+data class TakePhotoInfo(
+ val intent: Intent,
+ val tempFile: JavaFile,
+ val tempFileUri: Uri
+)
+
+
+// Download
+@Suppress("ComplexMethod")
+suspend fun ContextAware.downloadFile(file: File, download: Boolean) {
+ try {
+ val response = ApiService.getInstance().generateSignedUrl(
+ SignedUrlRequest().apply {
+ action = SignedUrlRequest.ACTION_GET
+ path = Uri.decode(file.key)
+ fileType = file.type
+ }).await()
+
+ if (download) {
+ if (!requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+ currentContext.showGenericError(R.string.file_fileDownload_error_savePermissionDenied)
+ return
+ }
+
+ currentContext.withProgressDialog(R.string.file_fileDownload_progress) {
+ val result = ApiService.getInstance().downloadFile(response.url!!).await()
+ if (!result.writeToDisk(file.name.orEmpty())) {
+ currentContext.showGenericError(R.string.file_fileDownload_error_save)
+ return@withProgressDialog
+ }
+ currentContext.showGenericSuccess(
+ currentContext.getString(R.string.file_fileDownload_success, file.name))
+ }
+ } else {
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(Uri.parse(response.url), response.header?.contentType)
+ }
+ val packageManager = currentContext.packageManager
+ if (packageManager != null && intent.resolveActivity(packageManager) != null)
+ currentContext.startActivity(intent)
+ else
+ currentContext.showGenericError(currentContext.getString(R.string.file_fileOpen_error_cantResolve,
+ file.name?.fileExtension))
+ }
+ } catch (e: HttpException) {
+ @Suppress("MagicNumber")
+ when (e.code()) {
+ 404 -> currentContext.showGenericError(R.string.file_fileOpen_error_404)
+ else -> currentContext.showGenericError(R.string.file_fileOpen_error)
+ }
+ }
+}
+
+
+// Upload
+fun Context.prepareFileRead(uri: Uri): FileReadInfo? {
+ val (name, size) = uri.let {
+ @Suppress("Recycle")
+ contentResolver?.query(it, null, null, null, null)
+ }?.use { cursor ->
+ val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+ val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
+ cursor.moveToFirst()
+ return@use cursor.getString(nameIndex) to cursor.getLong(sizeIndex)
+ } ?: return null
+
+ return FileReadInfo(name, size) { contentResolver.openInputStream(uri) }
+}
+
+suspend fun Context.uploadFile(
+ uri: Uri?,
+ path: String = FileRepository.pathPersonal(),
+ name: String? = null,
+ addEnding: Boolean = true
+): File? {
+ val fileReadInfo = uri?.let { prepareFileRead(it) }
+ if (fileReadInfo == null) {
+ showGenericError(R.string.file_pick_error_read)
+ return null
+ }
+
+ return withProgressDialog(R.string.file_fileUpload_progress) {
+ val fileName = when {
+ name == null -> fileReadInfo.name
+ addEnding -> "$name.${fileReadInfo.name.fileExtension}"
+ else -> name
+ }
+ val file = FileRepository.upload(path, fileName, fileReadInfo.size) {
+ fileReadInfo.streamGenerator().also {
+ if (it == null)
+ showGenericError(R.string.file_pick_error_read)
+ }
+ }
+ if (file == null) {
+ showGenericError(R.string.file_fileUpload_error_upload)
+ return@withProgressDialog null
+ } else showGenericSuccess(R.string.file_fileUpload_success)
+
+ FileRepository.syncDirectory(path)
+ return@withProgressDialog file
+ }
+}
+
+data class FileReadInfo(
+ val name: String,
+ val size: Long,
+ val streamGenerator: () -> InputStream?
+)
diff --git a/app/src/main/java/org/schulcloud/mobile/utils/ListUtils.kt b/app/src/main/java/org/schulcloud/mobile/utils/ListUtils.kt
index 4868af51..800a8ffa 100644
--- a/app/src/main/java/org/schulcloud/mobile/utils/ListUtils.kt
+++ b/app/src/main/java/org/schulcloud/mobile/utils/ListUtils.kt
@@ -1,5 +1,8 @@
package org.schulcloud.mobile.utils
+import java.util.*
+
+
fun List.limit(limit: Int): List {
return subList(0, Math.min(limit, size))
}
@@ -11,3 +14,10 @@ fun Map.filterKeysNotNull(): Map {
result[key] = value
return result
}
+
+fun List.move(sourceIndex: Int, targetIndex: Int) {
+ if (sourceIndex <= targetIndex)
+ Collections.rotate(subList(sourceIndex, targetIndex + 1), -1)
+ else
+ Collections.rotate(subList(targetIndex, sourceIndex + 1), 1)
+}
diff --git a/app/src/main/java/org/schulcloud/mobile/utils/LiveDataUtils.kt b/app/src/main/java/org/schulcloud/mobile/utils/LiveDataUtils.kt
index 57f4f9a9..41127b5e 100644
--- a/app/src/main/java/org/schulcloud/mobile/utils/LiveDataUtils.kt
+++ b/app/src/main/java/org/schulcloud/mobile/utils/LiveDataUtils.kt
@@ -2,36 +2,86 @@
package org.schulcloud.mobile.utils
-import androidx.lifecycle.LiveData
-import androidx.lifecycle.MediatorLiveData
-import androidx.lifecycle.MutableLiveData
-import androidx.lifecycle.Transformations
+import androidx.lifecycle.*
-fun T?.asLiveData(): LiveData = MutableLiveData().also { it.value = this }
+// Construction
+fun T?.asLiveData(): LiveData = liveDataOf(this)
+fun liveDataOf(initialValue: T? = null): LiveData {
+ return MutableLiveData().apply { value = initialValue }
+}
+
+fun mutableLiveDataOf(initialValue: T? = null): MutableLiveData {
+ return MutableLiveData().apply { value = initialValue }
+}
+
+fun LiveData.toMutableLiveData(): MutableLiveData {
+ val result = MediatorLiveData()
+ result.addSource(this) { result.value = it }
+ return result
+}
+
+
+// Mapping
fun LiveData.map(func: (T) -> R): LiveData = Transformations.map(this, func)
-fun LiveData.switchMap(func: (T) -> LiveData): LiveData = Transformations.switchMap(this, func)
+fun LiveData.switchMap(func: (T) -> LiveData): LiveData {
+ return Transformations.switchMap(this, func)
+}
+
fun LiveData.switchMapNullable(func: (T) -> LiveData?): LiveData {
val result = MediatorLiveData()
var source: LiveData? = null
- result.addSource(this) {
- val newLiveData = func(it)
- ?: MutableLiveData().apply { value = null }
+ result.addSource(this) { value ->
+ val newLiveData = func(value) ?: liveDataOf()
if (source == newLiveData)
return@addSource
source?.also { result.removeSource(it) }
source = newLiveData
- source?.also {
- result.addSource(it) { result.value = it }
+ source?.also { source ->
+ result.addSource(source) { result.value = it }
}
}
return result
}
-inline fun LiveData.combineLatest(other: LiveData): LiveData> {
+
+// Combination
+fun LiveData.zip(other: LiveData): LiveData {
+ val result = MediatorLiveData()
+ val observer = Observer {
+ result.value = it
+ }
+ result.addSource(this, observer)
+ result.addSource(other, observer)
+ return result
+}
+
+fun zipLater(): Pair, (LiveData) -> Unit> {
+ val result = MediatorLiveData()
+ val addFunc: (LiveData) -> Unit = { source ->
+ result.addSource(source) { result.value = it }
+ }
+ return result to addFunc
+}
+
+fun switch(): Pair, (LiveData) -> Unit> {
+ val result = MediatorLiveData()
+ val sources = mutableListOf>()
+ val addFunc: (LiveData) -> Unit = { source ->
+ for (oldSource in sources)
+ result.removeSource(oldSource)
+ sources.clear()
+
+ result.addSource(source) { result.value = it }
+ sources += source
+ }
+ return result to addFunc
+}
+
+inline fun LiveData.combineLatest(other: LiveData): LiveData> {
val result = object : MediatorLiveData>() {
var v1: T1? = null
var v1Set = false
@@ -59,6 +109,61 @@ inline fun LiveData.combineLatest(other: LiveData LiveData.combineLatestNullable(other: LiveData): LiveData> {
+ val result = object : MediatorLiveData>() {
+ var v1: T1? = null
+ var v1Set = false
+ var v2: T2? = null
+ var v2Set = false
+
+ @Suppress("NAME_SHADOWING")
+ fun update() {
+ if (!v1Set || !v2Set)
+ return
+ value = v1 as T1 to v2
+ }
+ }
+
+ result.addSource(this) {
+ result.v1 = it
+ result.v1Set = true
+ result.update()
+ }
+ result.addSource(other) {
+ result.v2 = it
+ result.v2Set = true
+ result.update()
+ }
+ return result
+}
+
+inline fun LiveData.combineLatestBothNullable(other: LiveData): LiveData> {
+ val result = object : MediatorLiveData>() {
+ var v1: T1? = null
+ var v1Set = false
+ var v2: T2? = null
+ var v2Set = false
+
+ fun update() {
+ if (!v1Set || !v2Set)
+ return
+ value = v1 to v2
+ }
+ }
+
+ result.addSource(this) {
+ result.v1 = it
+ result.v1Set = true
+ result.update()
+ }
+ result.addSource(other) {
+ result.v2 = it
+ result.v2Set = true
+ result.update()
+ }
+ return result
+}
+
inline fun LiveData.combineLatest(
other1: LiveData,
other2: LiveData
@@ -97,6 +202,8 @@ inline fun LiveData.combineLatest(
return result
}
+
+// Filtering
fun LiveData.first(count: Int = 1): LiveData {
var iteration = 0
val result = MediatorLiveData()
@@ -105,6 +212,8 @@ fun LiveData.first(count: Int = 1): LiveData {
result.value = it
iteration++
}
+ if (iteration == count - 1)
+ result.removeSource(this)
}
return result
}
@@ -118,8 +227,11 @@ inline fun LiveData.filter(crossinline predicate: (T) -> Boolean)
return result
}
-fun LiveData.toMutableLiveData(count: Int = 1): MutableLiveData {
+inline fun LiveData.filterNotNull(): LiveData {
val result = MediatorLiveData()
- result.addSource(this) { result.value = it }
+ result.addSource(this) {
+ if (it != null)
+ result.value = it
+ }
return result
}
diff --git a/app/src/main/java/org/schulcloud/mobile/utils/PathUtils.kt b/app/src/main/java/org/schulcloud/mobile/utils/PathUtils.kt
index 1bdf575c..51fc31d8 100644
--- a/app/src/main/java/org/schulcloud/mobile/utils/PathUtils.kt
+++ b/app/src/main/java/org/schulcloud/mobile/utils/PathUtils.kt
@@ -37,7 +37,7 @@ fun List.combinePath(): String {
val String.parentDirectory: String
get() = trimTrailingSlash().substringBeforeLast(File.separatorChar).ensureTrailingSlash()
val String.fileExtension: String
- get() = substringAfterLast('.')
+ get() = substringAfterLast('.', "")
fun String.trimLeadingSlash(): String = if (length > 0 && this[0] == File.separatorChar) substring(1) else this
fun String.trimTrailingSlash(): String = if (length > 1 && this[length - 1] == File.separatorChar) substring(0, length - 1) else this
diff --git a/app/src/main/java/org/schulcloud/mobile/utils/ViewUtils.kt b/app/src/main/java/org/schulcloud/mobile/utils/ViewUtils.kt
index 4eb3c510..5c2e6087 100644
--- a/app/src/main/java/org/schulcloud/mobile/utils/ViewUtils.kt
+++ b/app/src/main/java/org/schulcloud/mobile/utils/ViewUtils.kt
@@ -2,6 +2,7 @@
package org.schulcloud.mobile.utils
+import android.animation.ObjectAnimator
import android.content.Context
import android.content.res.ColorStateList
import android.content.res.Resources
@@ -15,6 +16,7 @@ import androidx.core.content.ContextCompat
import androidx.databinding.BindingAdapter
import androidx.databinding.BindingConversion
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
+import com.google.android.material.bottomappbar.BottomAppBar
import org.schulcloud.mobile.R
private const val COLOR_BLACK_STRING = "#00000000"
@@ -86,3 +88,8 @@ fun Context.getTextColorSecondaryForBackground(color: Int): Int {
return ContextCompat.getColor(this, if (color.isLightColor) R.color.material_text_secondary_dark
else R.color.material_text_secondary_light)
}
+
+
+fun BottomAppBar.slideIntoView() {
+ ObjectAnimator.ofFloat(this, "translationY", 0f).start()
+}
diff --git a/app/src/main/java/org/schulcloud/mobile/viewmodels/AddAttachmentViewModel.kt b/app/src/main/java/org/schulcloud/mobile/viewmodels/AddAttachmentViewModel.kt
new file mode 100644
index 00000000..39eafe32
--- /dev/null
+++ b/app/src/main/java/org/schulcloud/mobile/viewmodels/AddAttachmentViewModel.kt
@@ -0,0 +1,34 @@
+package org.schulcloud.mobile.viewmodels
+
+import androidx.lifecycle.LiveData
+import io.realm.RealmList
+import kotlinx.coroutines.experimental.android.UI
+import kotlinx.coroutines.experimental.launch
+import org.schulcloud.mobile.models.file.File
+import org.schulcloud.mobile.models.file.FilePermissions
+import org.schulcloud.mobile.models.file.FileRepository
+import org.schulcloud.mobile.models.homework.HomeworkRepository
+import org.schulcloud.mobile.models.homework.submission.Submission
+import org.schulcloud.mobile.models.homework.submission.SubmissionRepository
+import org.schulcloud.mobile.viewmodels.base.BaseViewModel
+
+
+class AddAttachmentViewModel(id: String) : BaseViewModel() {
+ val submission: LiveData = SubmissionRepository.submission(realm, id)
+
+ suspend fun addFileToSubmission(submission: Submission, file: File) {
+ submission.fileIds = (submission.fileIds ?: RealmList()).apply { add(file.id) }
+
+ launch(UI) {
+ SubmissionRepository.updateSubmission(realm, submission)
+
+ val teacherId = submission.homeworkId
+ ?.let { HomeworkRepository.homeworkBlocking(realm, it) }?.teacherId
+ ?: return@launch
+
+ file.addPermissions(listOf(teacherId),
+ listOf(FilePermissions.PERMISSION_READ, FilePermissions.PERMISSION_WRITE))
+ FileRepository.updateFile(realm, file)
+ }
+ }
+}
diff --git a/app/src/main/java/org/schulcloud/mobile/viewmodels/BaseActivityViewModel.kt b/app/src/main/java/org/schulcloud/mobile/viewmodels/BaseActivityViewModel.kt
new file mode 100644
index 00000000..e99eb566
--- /dev/null
+++ b/app/src/main/java/org/schulcloud/mobile/viewmodels/BaseActivityViewModel.kt
@@ -0,0 +1,47 @@
+package org.schulcloud.mobile.viewmodels
+
+import android.app.Activity
+import android.content.Intent
+import android.content.pm.PackageManager
+import org.schulcloud.mobile.controllers.base.StartActivityResult
+import org.schulcloud.mobile.viewmodels.base.BaseViewModel
+import java.util.*
+import kotlin.coroutines.experimental.Continuation
+
+
+class BaseActivityViewModel : BaseViewModel() {
+ private val permissionRequests: MutableList