diff --git a/app/dependencies.gradle b/app/dependencies.gradle index a49b2d83..4a4c1716 100644 --- a/app/dependencies.gradle +++ b/app/dependencies.gradle @@ -39,25 +39,25 @@ dependencies { // AndroidX // architecture implementation "androidx.multidex:multidex:2.0.0" - implementation "androidx.appcompat:appcompat:1.0.0-rc01" + implementation "androidx.appcompat:appcompat:1.0.0-rc02" implementation "androidx.lifecycle:lifecycle-extensions:2.0.0-rc01" kapt "androidx.lifecycle:lifecycle-compiler:2.0.0-rc01" implementation 'android.arch.navigation:navigation-ui-ktx:1.0.0-alpha05' implementation 'android.arch.navigation:navigation-fragment-ktx:1.0.0-alpha05' - implementation "androidx.browser:browser:1.0.0-rc01" - implementation "androidx.annotation:annotation:1.0.0-rc01" + implementation "androidx.browser:browser:1.0.0-rc02" + implementation "androidx.annotation:annotation:1.0.0-rc02" // ui - implementation "com.google.android.material:material:1.0.0-rc01" - implementation "androidx.cardview:cardview:1.0.0-rc01" - implementation "androidx.recyclerview:recyclerview:1.0.0-rc01" - implementation "androidx.constraintlayout:constraintlayout:1.1.2" + implementation "com.google.android.material:material:1.0.0-rc02" + implementation "androidx.cardview:cardview:1.0.0-rc02" + implementation "androidx.recyclerview:recyclerview:1.0.0-rc02" + implementation "androidx.constraintlayout:constraintlayout:1.1.3" // other - implementation "androidx.core:core-ktx:1.0.0-rc01" + implementation "androidx.core:core-ktx:1.0.0-rc02" // unit tests testImplementation "junit:junit:4.12" // instrumented tests - androidTestImplementation "androidx.annotation:annotation:1.0.0-rc01" + androidTestImplementation "androidx.annotation:annotation:1.0.0-rc02" androidTestImplementation "androidx.test:runner:1.1.0-alpha4" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 112f2fb1..10ee20e6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -34,6 +34,17 @@ + + + + + diff --git a/app/src/main/java/org/schulcloud/mobile/config/Config.kt b/app/src/main/java/org/schulcloud/mobile/config/Config.kt index 202ca901..a81652cc 100644 --- a/app/src/main/java/org/schulcloud/mobile/config/Config.kt +++ b/app/src/main/java/org/schulcloud/mobile/config/Config.kt @@ -1,8 +1,12 @@ package org.schulcloud.mobile.config +import org.schulcloud.mobile.BuildConfig + object Config { val HEADER_AUTH = "Authorization" val HEADER_AUTH_VALUE_PREFIX = "Bearer " val REALM_SCHEMA_VERSION = 1L + + const val FILE_PROVIDER = "${BuildConfig.APPLICATION_ID}.fileprovider" } diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/base/BaseActivity.kt b/app/src/main/java/org/schulcloud/mobile/controllers/base/BaseActivity.kt index 02ac9feb..df372cd2 100644 --- a/app/src/main/java/org/schulcloud/mobile/controllers/base/BaseActivity.kt +++ b/app/src/main/java/org/schulcloud/mobile/controllers/base/BaseActivity.kt @@ -1,10 +1,14 @@ package org.schulcloud.mobile.controllers.base +import android.content.Context +import android.content.Intent import android.content.pm.PackageManager +import android.os.Bundle import android.view.MenuItem import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModelProviders import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import kotlinx.coroutines.experimental.android.UI import kotlinx.coroutines.experimental.launch @@ -14,39 +18,36 @@ import org.schulcloud.mobile.utils.asUri import org.schulcloud.mobile.utils.openUrl import org.schulcloud.mobile.utils.setup import org.schulcloud.mobile.utils.shareLink -import java.util.* -import kotlin.coroutines.experimental.Continuation +import org.schulcloud.mobile.viewmodels.BaseActivityViewModel import kotlin.coroutines.experimental.suspendCoroutine import kotlin.properties.Delegates -abstract class BaseActivity : AppCompatActivity() { +abstract class BaseActivity : AppCompatActivity(), ContextAware { + override val baseActivity: BaseActivity? get() = this + override val currentContext: Context get() = this + open var url: String? = null var swipeRefreshLayout by Delegates.observable(null) { _, _, new -> new?.setup() new?.setOnRefreshListener { performRefresh() } } - private val permissionRequests: MutableList> - by lazy { LinkedList>() } + private val viewModel: BaseActivityViewModel by lazy { + ViewModelProviders.of(this).get(BaseActivityViewModel::class.java) + } override fun onOptionsItemSelected(item: MenuItem?): Boolean { when (item?.itemId) { R.id.base_action_share -> shareLink(url!!, supportActionBar?.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 -> openUrl(url.asUri()) else -> return super.onOptionsItemSelected(item) } return true } - protected fun setupActionBar() { - supportActionBar?.apply { - setDisplayHomeAsUpEnabled(true) - } - } - protected open suspend fun refresh() {} protected fun performRefresh() { swipeRefreshLayout?.isRefreshing = true @@ -56,29 +57,36 @@ abstract class BaseActivity : AppCompatActivity() { } } - suspend fun requestPermission(permission: String): Boolean = suspendCoroutine { cont -> + + override suspend fun requestPermission(permission: String): Boolean = suspendCoroutine { cont -> if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) { cont.resume(true) return@suspendCoroutine } - permissionRequests.add(cont) - ActivityCompat.requestPermissions(this, arrayOf(permission), permissionRequests.size - 1) + ActivityCompat.requestPermissions(this, arrayOf(permission), viewModel.addPermissionRequest(cont)) } override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { // Request not from this class - if (requestCode >= permissionRequests.size) { + if (!viewModel.onPermissionResult(requestCode, permissions, grantResults)) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) return } + } - //The request was interrupted - if (permissions.isEmpty()) { - permissionRequests[requestCode].resume(false) - return + + override suspend fun startActivityForResult(intent: Intent, options: Bundle?): StartActivityResult { + return suspendCoroutine { cont -> + startActivityForResult(intent, viewModel.addActivityRequest(cont), options) } + } - permissionRequests[requestCode].resume(grantResults[0] == PackageManager.PERMISSION_GRANTED) + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + // Request not from this class + if (!viewModel.onActivityResult(requestCode, resultCode, data)) { + super.onActivityResult(requestCode, resultCode, data) + return + } } } diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/base/BaseFragment.kt b/app/src/main/java/org/schulcloud/mobile/controllers/base/BaseFragment.kt index ca151f5b..c1af6ca9 100644 --- a/app/src/main/java/org/schulcloud/mobile/controllers/base/BaseFragment.kt +++ b/app/src/main/java/org/schulcloud/mobile/controllers/base/BaseFragment.kt @@ -1,9 +1,20 @@ package org.schulcloud.mobile.controllers.base +import android.content.Context +import android.content.Intent +import android.os.Bundle import androidx.fragment.app.Fragment -abstract class BaseFragment : Fragment() { - val baseActivity: BaseActivity? get() = activity as? BaseActivity - suspend fun requestPermission(permission: String): Boolean = baseActivity?.requestPermission(permission) ?: false +abstract class BaseFragment : Fragment(), ContextAware { + override val baseActivity: BaseActivity? get() = activity as? BaseActivity + override val currentContext: Context get() = context!! + + override suspend fun requestPermission(permission: String): Boolean { + return baseActivity?.requestPermission(permission) ?: false + } + + override suspend fun startActivityForResult(intent: Intent, options: Bundle?): StartActivityResult { + return baseActivity?.startActivityForResult(intent, options) ?: StartActivityResult.error() + } } diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/base/BaseSheet.kt b/app/src/main/java/org/schulcloud/mobile/controllers/base/BaseSheet.kt new file mode 100644 index 00000000..876b3a60 --- /dev/null +++ b/app/src/main/java/org/schulcloud/mobile/controllers/base/BaseSheet.kt @@ -0,0 +1,24 @@ +package org.schulcloud.mobile.controllers.base + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.fragment.app.FragmentManager +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + + +abstract class BaseSheet : BottomSheetDialogFragment(), ContextAware { + override val baseActivity: BaseActivity? get() = activity as? BaseActivity + override val currentContext: Context get() = context!! + + override suspend fun requestPermission(permission: String): Boolean { + return baseActivity?.requestPermission(permission) ?: false + } + + override suspend fun startActivityForResult(intent: Intent, options: Bundle?): StartActivityResult { + return baseActivity?.startActivityForResult(intent, options) ?: StartActivityResult.error() + } + + + fun show(manager: FragmentManager?) = super.show(manager, tag) +} diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/base/ContextAware.kt b/app/src/main/java/org/schulcloud/mobile/controllers/base/ContextAware.kt new file mode 100644 index 00000000..a55f6454 --- /dev/null +++ b/app/src/main/java/org/schulcloud/mobile/controllers/base/ContextAware.kt @@ -0,0 +1,25 @@ +package org.schulcloud.mobile.controllers.base + +import android.content.Context +import android.content.Intent +import android.os.Bundle + +interface ContextAware { + + val baseActivity: BaseActivity? + val currentContext: Context + + suspend fun requestPermission(permission: String): Boolean + suspend fun startActivityForResult(intent: Intent, options: Bundle? = null): StartActivityResult + +} + +data class StartActivityResult( + val success: Boolean, + val data: Intent? +) { + companion object { + fun success(data: Intent?): StartActivityResult = StartActivityResult(true, data) + fun error(): StartActivityResult = StartActivityResult(false, null) + } +} diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/course/CourseFragment.kt b/app/src/main/java/org/schulcloud/mobile/controllers/course/CourseFragment.kt index ca8cbfc0..81748103 100644 --- a/app/src/main/java/org/schulcloud/mobile/controllers/course/CourseFragment.kt +++ b/app/src/main/java/org/schulcloud/mobile/controllers/course/CourseFragment.kt @@ -24,7 +24,7 @@ import org.schulcloud.mobile.viewmodels.CourseViewModel import org.schulcloud.mobile.viewmodels.IdViewModelFactory import org.schulcloud.mobile.views.DividerItemDecoration -class CourseFragment : MainFragment() { +class CourseFragment : MainFragment() { companion object { val TAG: String = CourseFragment::class.java.simpleName } @@ -40,11 +40,11 @@ class CourseFragment : MainFragment() { override var url: String? = null get() = "/courses/${viewModel.course.value?.id}" - override fun provideConfig() = viewModel.course.map { course -> + override fun provideSelfConfig() = viewModel.course.map { course -> MainFragmentConfig( title = course?.name ?: getString(R.string.general_error_notFound), toolbarColor = if (course != null) Color.parseColor(course.color) else null, - menuBottomRes = R.menu.fragment_course_bottom + menuBottomRes = listOf(R.menu.fragment_course_bottom) ) } diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/course/CourseListFragment.kt b/app/src/main/java/org/schulcloud/mobile/controllers/course/CourseListFragment.kt index b27d7154..83ce87a6 100644 --- a/app/src/main/java/org/schulcloud/mobile/controllers/course/CourseListFragment.kt +++ b/app/src/main/java/org/schulcloud/mobile/controllers/course/CourseListFragment.kt @@ -17,7 +17,8 @@ import org.schulcloud.mobile.utils.asLiveData import org.schulcloud.mobile.viewmodels.CourseListViewModel import org.schulcloud.mobile.views.ItemOffsetDecoration -class CourseListFragment : MainFragment() { + +class CourseListFragment : MainFragment() { companion object { val TAG: String = CourseListFragment::class.java.simpleName } @@ -31,7 +32,7 @@ class CourseListFragment : MainFragment() { override var url: String? = "/courses" - override fun provideConfig() = MainFragmentConfig( + override fun provideSelfConfig() = MainFragmentConfig( fragmentType = FragmentType.PRIMARY, title = getString(R.string.course_title) ).asLiveData() diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/dashboard/DashboardFragment.kt b/app/src/main/java/org/schulcloud/mobile/controllers/dashboard/DashboardFragment.kt index f2dd8b0d..30348441 100644 --- a/app/src/main/java/org/schulcloud/mobile/controllers/dashboard/DashboardFragment.kt +++ b/app/src/main/java/org/schulcloud/mobile/controllers/dashboard/DashboardFragment.kt @@ -11,7 +11,8 @@ import org.schulcloud.mobile.controllers.main.MainFragment import org.schulcloud.mobile.controllers.main.MainFragmentConfig import org.schulcloud.mobile.utils.asLiveData -class DashboardFragment : MainFragment() { + +class DashboardFragment : MainFragment() { companion object { val TAG: String = DashboardFragment::class.java.simpleName } @@ -20,7 +21,7 @@ class DashboardFragment : MainFragment() { override var url: String? = "/dashboard" - override fun provideConfig() = MainFragmentConfig( + override fun provideSelfConfig() = MainFragmentConfig( fragmentType = FragmentType.PRIMARY, title = getString(R.string.dashboard_title) ).asLiveData() diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/file/BreadcrumbsView.kt b/app/src/main/java/org/schulcloud/mobile/controllers/file/BreadcrumbsView.kt index 3bcd8417..a2156605 100644 --- a/app/src/main/java/org/schulcloud/mobile/controllers/file/BreadcrumbsView.kt +++ b/app/src/main/java/org/schulcloud/mobile/controllers/file/BreadcrumbsView.kt @@ -1,13 +1,13 @@ package org.schulcloud.mobile.controllers.file import android.content.Context +import android.graphics.Color import android.graphics.PorterDuff import android.os.Build import android.util.AttributeSet import android.util.TypedValue import android.widget.TextView import androidx.annotation.AttrRes -import androidx.annotation.ColorInt import androidx.appcompat.widget.LinearLayoutCompat import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.getDimensionOrThrow @@ -20,6 +20,7 @@ import org.schulcloud.mobile.utils.combinePath import org.schulcloud.mobile.utils.getPathParts import org.schulcloud.mobile.utils.limit import org.schulcloud.mobile.views.CompatTextView +import kotlin.properties.Delegates open class BreadcrumbsView @JvmOverloads constructor( context: Context, @@ -32,6 +33,12 @@ open class BreadcrumbsView @JvmOverloads constructor( var onPathSelected: ((String) -> Unit)? = null + var textColor: Int by Delegates.observable(Color.WHITE) { _, _, new -> + for (child in children) + (child as? TextView)?.setTextColor(new) + dividerDrawable.setColorFilter(new, PorterDuff.Mode.SRC_ATOP) + } + private var textSize: Float = 0f init { @@ -64,16 +71,11 @@ open class BreadcrumbsView @JvmOverloads constructor( addPartView(parts.limit(i + 1).combinePath(), parts[i]) } - fun setTextColor(@ColorInt color: Int) { - for (child in children) - (child as? TextView)?.setTextColor(color) - dividerDrawable.setColorFilter(color, PorterDuff.Mode.SRC_ATOP) - } - private fun addPartView(path: String, title: String) { addView(CompatTextView(context).also { - it.textSize = textSize + it.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize) it.text = title + it.setTextColor(textColor) it.setOnClickListener { onPathSelected?.invoke(path) } with(TypedValue()) { diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/file/FileFragment.kt b/app/src/main/java/org/schulcloud/mobile/controllers/file/FileFragment.kt index c979a973..ef7c2894 100644 --- a/app/src/main/java/org/schulcloud/mobile/controllers/file/FileFragment.kt +++ b/app/src/main/java/org/schulcloud/mobile/controllers/file/FileFragment.kt @@ -1,15 +1,12 @@ package org.schulcloud.mobile.controllers.file import android.Manifest -import android.content.Intent import android.graphics.Color -import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup -import androidx.core.content.ContextCompat.startActivity import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProviders import androidx.recyclerview.widget.DividerItemDecoration @@ -18,29 +15,22 @@ import kotlinx.android.synthetic.main.fragment_file.* import kotlinx.coroutines.experimental.android.UI import kotlinx.coroutines.experimental.launch import org.schulcloud.mobile.R -import org.schulcloud.mobile.R.id.* import org.schulcloud.mobile.controllers.course.CourseFragmentArgs import org.schulcloud.mobile.controllers.main.MainFragment import org.schulcloud.mobile.controllers.main.MainFragmentConfig import org.schulcloud.mobile.databinding.FragmentFileBinding -import org.schulcloud.mobile.models.course.Course import org.schulcloud.mobile.models.course.CourseRepository -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 org.schulcloud.mobile.models.user.Permission +import org.schulcloud.mobile.models.user.UserRepository +import org.schulcloud.mobile.models.user.hasPermission import org.schulcloud.mobile.utils.* import org.schulcloud.mobile.viewmodels.FileViewModel import org.schulcloud.mobile.viewmodels.IdViewModelFactory -import retrofit2.HttpException -import ru.gildor.coroutines.retrofit.await +import java.io.File as JavaFile -class FileFragment : MainFragment() { - companion object { - val TAG: String = FileFragment::class.java.simpleName - } - +class FileFragment : MainFragment() { private val args: FileFragmentArgs by lazy { FileFragmentArgs.fromBundle(arguments) } @@ -52,8 +42,8 @@ class FileFragment : MainFragment() { } } private val fileAdapter: FileAdapter by lazy { - FileAdapter({ loadFile(it, false) }, - { loadFile(it, true) }) + FileAdapter({ launch { downloadFile(it, false) } }, + { launch { downloadFile(it, true) } }) } @@ -70,10 +60,9 @@ class FileFragment : MainFragment() { } } - override fun provideConfig() = (getCourseFromFolder()?.let { - CourseRepository.course(viewModel.realm, it) - } ?: null.asLiveData()) - .map { course -> + override fun provideSelfConfig() = viewModel.course + .combineLatestBothNullable(viewModel.currentUser) + .map { (course, user) -> breadcrumbs.setPath(args.path, course) val parts = args.path.getPathParts() @@ -88,10 +77,12 @@ class FileFragment : MainFragment() { }, showTitle = false, toolbarColor = course?.color?.let { Color.parseColor(it) }, - menuBottomRes = R.menu.fragment_file_bottom, + menuBottomRes = listOf(R.menu.fragment_file_bottom), menuBottomHiddenIds = listOf( - if (course == null) R.id.file_action_gotoCourse else 0 - ) + R.id.file_action_gotoCourse.takeIf { course == null } + ), + fabIconRes = R.drawable.ic_file_upload_white_24dp, + fabVisible = user.hasPermission(Permission.FILE_CREATE) ) } @@ -146,7 +137,6 @@ class FileFragment : MainFragment() { mainActivity.setToolbarWrapper(toolbarWrapper) - breadcrumbs.setPath(args.path) breadcrumbs.onPathSelected = callback@{ path -> if (path == args.path) { performRefresh() @@ -157,13 +147,13 @@ class FileFragment : MainFragment() { FileFragmentArgs.Builder(path).build().toBundle()) } mainViewModel.toolbarColors.observe(this, Observer { - breadcrumbs.setTextColor(it.textColor) + breadcrumbs.textColor = it.textColor }) } override fun onOptionsItemSelected(item: MenuItem?): Boolean { when (item?.itemId) { - R.id.file_action_gotoCourse -> getCourseFromFolder()?.also { id -> + R.id.file_action_gotoCourse -> viewModel.courseId?.also { id -> navController.navigate(R.id.action_global_fragment_course, CourseFragmentArgs.Builder(id).build().toBundle()) } @@ -172,62 +162,25 @@ class FileFragment : MainFragment() { return true } - override suspend fun refresh() { - FileRepository.syncDirectory(viewModel.path) - getCourseFromFolder()?.also { - CourseRepository.syncCourse(it) - } - } - + override fun onFabClicked() { + launch(UI) { + if (!requestPermission(Manifest.permission.READ_EXTERNAL_STORAGE)) { + getContext()!!.showGenericError(R.string.file_pick_error_readPermissionDenied) + return@launch + } - private fun getCourseFromFolder(): String? { - if (!args.path.startsWith(FileRepository.CONTEXT_COURSES)) - return null + val res = startActivityForResult(createFilePickerIntent() ?: return@launch) + if (!res.success) return@launch - return args.path.getPathParts()[1] + getContext()!!.uploadFile(res.data?.data, viewModel.path) + } } - @Suppress("ComplexMethod") - private fun loadFile(file: File, download: Boolean) = launch(UI) { - try { - val response = ApiService.getInstance().generateSignedUrl( - SignedUrlRequest().apply { - action = SignedUrlRequest.ACTION_GET - path = file.key - fileType = file.type - }).await() - - if (download) { - if (!requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { - this@FileFragment.context?.showGenericError(R.string.file_fileDownload_error_savePermissionDenied) - return@launch - } - - this@FileFragment.context?.withProgressDialog(R.string.file_fileDownload_progress) { - val result = ApiService.getInstance().downloadFile(response.url!!).await() - if (!result.writeToDisk(file.name.orEmpty())) { - this@FileFragment.context?.showGenericError(R.string.file_fileDownload_error_save) - return@withProgressDialog - } - this@FileFragment.context?.showGenericSuccess( - 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 = activity?.packageManager - if (packageManager != null && intent.resolveActivity(packageManager) != null) - startActivity(intent) - else - this@FileFragment.context?.showGenericError( - getString(R.string.file_fileOpen_error_cantResolve, file.name?.fileExtension)) - } - } catch (e: HttpException) { - @Suppress("MagicNumber") - when (e.code()) { - 404 -> this@FileFragment.context?.showGenericError(R.string.file_fileOpen_error_404) - } + override suspend fun refresh() { + FileRepository.syncDirectory(viewModel.path) + viewModel.courseId?.also { + CourseRepository.syncCourse(it) } + UserRepository.syncCurrentUser() } } diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/file/FileOverviewFragment.kt b/app/src/main/java/org/schulcloud/mobile/controllers/file/FileOverviewFragment.kt index 30116269..751a651b 100644 --- a/app/src/main/java/org/schulcloud/mobile/controllers/file/FileOverviewFragment.kt +++ b/app/src/main/java/org/schulcloud/mobile/controllers/file/FileOverviewFragment.kt @@ -18,7 +18,7 @@ import org.schulcloud.mobile.models.file.FileRepository import org.schulcloud.mobile.utils.asLiveData import org.schulcloud.mobile.viewmodels.FileOverviewViewModel -class FileOverviewFragment : MainFragment() { +class FileOverviewFragment : MainFragment() { companion object { val TAG: String = FileOverviewFragment::class.java.simpleName } @@ -32,7 +32,7 @@ class FileOverviewFragment : MainFragment() { override var url: String? = "/files" - override fun provideConfig() = MainFragmentConfig( + override fun provideSelfConfig() = MainFragmentConfig( fragmentType = FragmentType.PRIMARY, title = getString(R.string.file_title) ).asLiveData() diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/homework/HomeworkListFragment.kt b/app/src/main/java/org/schulcloud/mobile/controllers/homework/HomeworkListFragment.kt index 469926cd..05feab3d 100644 --- a/app/src/main/java/org/schulcloud/mobile/controllers/homework/HomeworkListFragment.kt +++ b/app/src/main/java/org/schulcloud/mobile/controllers/homework/HomeworkListFragment.kt @@ -18,7 +18,8 @@ import org.schulcloud.mobile.models.homework.HomeworkRepository import org.schulcloud.mobile.utils.asLiveData import org.schulcloud.mobile.viewmodels.HomeworkListViewModel -class HomeworkListFragment : MainFragment() { + +class HomeworkListFragment : MainFragment() { companion object { val TAG: String = HomeworkListFragment::class.java.simpleName } @@ -37,7 +38,7 @@ class HomeworkListFragment : MainFragment() { override var url: String? = "/homework" - override fun provideConfig() = MainFragmentConfig( + override fun provideSelfConfig() = MainFragmentConfig( fragmentType = FragmentType.PRIMARY, title = getString(R.string.homework_title) ).asLiveData() diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/homework/attachment/AddAttachmentSheet.kt b/app/src/main/java/org/schulcloud/mobile/controllers/homework/attachment/AddAttachmentSheet.kt new file mode 100644 index 00000000..f63c2bd3 --- /dev/null +++ b/app/src/main/java/org/schulcloud/mobile/controllers/homework/attachment/AddAttachmentSheet.kt @@ -0,0 +1,88 @@ +package org.schulcloud.mobile.controllers.homework.attachment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import kotlinx.coroutines.experimental.android.UI +import kotlinx.coroutines.experimental.launch +import org.schulcloud.mobile.controllers.base.BaseSheet +import org.schulcloud.mobile.databinding.SheetHomeworkAttachmentAddBinding +import org.schulcloud.mobile.models.file.File +import org.schulcloud.mobile.utils.* +import org.schulcloud.mobile.viewmodels.AddAttachmentViewModel +import org.schulcloud.mobile.viewmodels.IdViewModelFactory +import java.text.SimpleDateFormat +import java.util.* + + +class AddAttachmentSheet : BaseSheet() { + companion object { + private const val ARGUMENT_SUBMISSION_ID = "ARGUMENT_SUBMISSION_ID" + + fun forSubmission(id: String): AddAttachmentSheet { + return AddAttachmentSheet().apply { + arguments = bundleOf(ARGUMENT_SUBMISSION_ID to id) + } + } + } + + private lateinit var viewModel: AddAttachmentViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val submissionId = arguments?.getString(ARGUMENT_SUBMISSION_ID)!! + viewModel = ViewModelProviders.of(this, IdViewModelFactory(submissionId)) + .get(AddAttachmentViewModel::class.java) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val binding = SheetHomeworkAttachmentAddBinding.inflate(layoutInflater).also { + it.setLifecycleOwner(this) + val context = context!! + + it.onAttachFile = { + launch(UI) { + val res = startActivityForResult(createFilePickerIntent() ?: return@launch) + if (!res.success) return@launch + + val file = context.uploadFile(res.data?.data) + if (file != null) + addToSubmission(file) + dismiss() + } + } + it.onAttachTakePhoto = { + launch(UI) { + val info = context.createTakePhotoIntent() ?: return@launch + val res = startActivityForResult(info.intent) + if (!res.success) return@launch + + val name = SimpleDateFormat.getDateTimeInstance().format(Date()) + val file = context.uploadFile(info.tempFileUri, name = name, addEnding = true) + if (file != null) + addToSubmission(file) + + info.tempFile.saveDelete() + dismiss() + } + } + } + return binding.root + } + + private suspend fun addToSubmission(file: File) { + viewModel.submission.first() + .observe(this, Observer { + it ?: return@Observer + + launch { + viewModel.addFileToSubmission(it, file) + } + }) + } +} diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/homework/detailed/HomeworkFragment.kt b/app/src/main/java/org/schulcloud/mobile/controllers/homework/detailed/HomeworkFragment.kt index dd307700..7d8e1add 100644 --- a/app/src/main/java/org/schulcloud/mobile/controllers/homework/detailed/HomeworkFragment.kt +++ b/app/src/main/java/org/schulcloud/mobile/controllers/homework/detailed/HomeworkFragment.kt @@ -6,15 +6,15 @@ import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup +import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProviders -import kotlinx.android.synthetic.main.fragment_homework.* import org.schulcloud.mobile.R import org.schulcloud.mobile.controllers.course.CourseFragmentArgs -import org.schulcloud.mobile.controllers.main.MainFragment import org.schulcloud.mobile.controllers.main.MainFragmentConfig -import org.schulcloud.mobile.controllers.main.ParentFragment -import org.schulcloud.mobile.controllers.main.TabFragment +import org.schulcloud.mobile.controllers.main.Tab +import org.schulcloud.mobile.controllers.main.TabbedMainFragment +import org.schulcloud.mobile.controllers.main.toPagerAdapter import org.schulcloud.mobile.databinding.FragmentHomeworkBinding import org.schulcloud.mobile.models.homework.HomeworkRepository import org.schulcloud.mobile.models.homework.submission.SubmissionRepository @@ -23,25 +23,39 @@ import org.schulcloud.mobile.utils.visibilityBool import org.schulcloud.mobile.viewmodels.HomeworkViewModel import org.schulcloud.mobile.viewmodels.IdViewModelFactory -class HomeworkFragment : MainFragment(), ParentFragment { + +class HomeworkFragment : TabbedMainFragment() { companion object { val TAG: String = HomeworkFragment::class.java.simpleName } - private val pagerAdapter by lazy { HomeworkPagerAdapter(context!!, childFragmentManager) } + override val pagerAdapter by lazy { + viewModel.homework + .map { homework -> + listOfNotNull( + Tab(getString(R.string.homework_overview)) { OverviewFragment() }, + if (homework?.canSeeSubmissions() == true) + Tab(getString(R.string.homework_submissions)) { SubmissionsFragment() } + else null + ) + } + .toPagerAdapter(this) + } override var url: String? = null - get() = "homework/${viewModel.homework.value?.id}" + get() = "/homework/${viewModel.homework.value?.id}" - override fun provideConfig() = viewModel.homework - .map { homework -> - MainFragmentConfig( - title = homework?.title ?: getString(R.string.general_error_notFound), - subtitle = homework?.course?.name, - toolbarColor = homework?.course?.color?.let { Color.parseColor(it) }, - menuBottomRes = R.menu.fragment_homework_bottom - ) - } + override fun provideSelfConfig(): LiveData { + return viewModel.homework + .map { homework -> + MainFragmentConfig( + title = homework?.title ?: getString(R.string.general_error_notFound), + subtitle = homework?.course?.name, + toolbarColor = homework?.course?.color?.let { Color.parseColor(it) }, + menuBottomRes = listOf(R.menu.fragment_homework_bottom) + ) + } + } override fun onCreate(savedInstanceState: Bundle?) { val args = HomeworkFragmentArgs.fromBundle(arguments) @@ -57,17 +71,11 @@ class HomeworkFragment : MainFragment(), ParentFragment { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - viewPager.adapter = pagerAdapter - tabLayout.setupWithViewPager(viewPager) - mainViewModel.toolbarColors.observe(this, Observer { - tabLayout.setTabTextColors(it.textColorSecondary, it.textColor) - tabLayout.setSelectedTabIndicatorColor(it.textColor) - }) + super.onViewCreated(view, savedInstanceState) viewModel.homework.observe(this, Observer { - pagerAdapter.homework = it // Hide tabs if only overview is visible - tabLayout.visibilityBool = it?.canSeeSubmissions() == true + tabLayout?.visibilityBool = it?.canSeeSubmissions() == true }) } @@ -83,14 +91,7 @@ class HomeworkFragment : MainFragment(), ParentFragment { } override suspend fun refresh() { - refreshWithChild(false) - } - - override suspend fun refreshWithChild(fromChild: Boolean) { - if (fromChild) { - HomeworkRepository.syncHomework(viewModel.id) - SubmissionRepository.syncSubmissionsForHomework(viewModel.id) - } else if (viewPager != null) - (pagerAdapter.getItem(viewPager.currentItem) as? TabFragment<*, *>)?.performRefresh() + HomeworkRepository.syncHomework(viewModel.id) + SubmissionRepository.syncSubmissionsForHomework(viewModel.id) } } diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/homework/detailed/HomeworkPagerAdapter.kt b/app/src/main/java/org/schulcloud/mobile/controllers/homework/detailed/HomeworkPagerAdapter.kt deleted file mode 100644 index 035a51d1..00000000 --- a/app/src/main/java/org/schulcloud/mobile/controllers/homework/detailed/HomeworkPagerAdapter.kt +++ /dev/null @@ -1,63 +0,0 @@ -package org.schulcloud.mobile.controllers.homework.detailed - -import android.content.Context -import androidx.annotation.IntDef -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.FragmentPagerAdapter -import org.schulcloud.mobile.R -import org.schulcloud.mobile.models.homework.Homework -import kotlin.properties.Delegates - - -class HomeworkPagerAdapter(private val context: Context, fm: FragmentManager) : FragmentPagerAdapter(fm) { - companion object { - private const val TAB_INVALID = 0 - private const val TAB_DETAILS = 1 - private const val TAB_SUBMISSIONS = 2 - - @Retention(AnnotationRetention.SOURCE) - @MustBeDocumented - @IntDef(TAB_INVALID, TAB_DETAILS, TAB_SUBMISSIONS) - annotation class Tab - } - - var homework by Delegates.observable(null) { _, _, _ -> - notifyDataSetChanged() - } - - override fun getItem(position: Int): Fragment? { - return when (getTabType(position)) { - TAB_DETAILS -> OverviewFragment() - TAB_SUBMISSIONS -> SubmissionsFragment() - - TAB_INVALID -> null - else -> null - } - } - - override fun getPageTitle(position: Int): CharSequence? { - val titleId = when (getTabType(position)) { - TAB_DETAILS -> R.string.homework_overview - TAB_SUBMISSIONS -> R.string.homework_submissions - - TAB_INVALID -> return null - else -> return null - } - return context.getString(titleId) - } - - override fun getCount(): Int { - return if (homework?.canSeeSubmissions() == true) 2 - else 1 - } - - @Tab - private fun getTabType(position: Int): Int { - return when { - position == 0 -> TAB_DETAILS - position == 1 && homework?.canSeeSubmissions() == true -> TAB_SUBMISSIONS - else -> TAB_INVALID - } - } -} diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/homework/detailed/OverviewFragment.kt b/app/src/main/java/org/schulcloud/mobile/controllers/homework/detailed/OverviewFragment.kt index b75fdde5..11d9f5a6 100644 --- a/app/src/main/java/org/schulcloud/mobile/controllers/homework/detailed/OverviewFragment.kt +++ b/app/src/main/java/org/schulcloud/mobile/controllers/homework/detailed/OverviewFragment.kt @@ -8,13 +8,12 @@ import androidx.navigation.fragment.NavHostFragment import kotlinx.android.synthetic.main.fragment_homework_overview.* import org.schulcloud.mobile.R import org.schulcloud.mobile.controllers.homework.submission.SubmissionFragmentArgs -import org.schulcloud.mobile.controllers.main.ParentFragment -import org.schulcloud.mobile.controllers.main.TabFragment +import org.schulcloud.mobile.controllers.main.InnerMainFragment import org.schulcloud.mobile.databinding.FragmentHomeworkOverviewBinding import org.schulcloud.mobile.viewmodels.HomeworkViewModel -class OverviewFragment : TabFragment() { +class OverviewFragment : InnerMainFragment() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return FragmentHomeworkOverviewBinding.inflate(layoutInflater).also { it.viewModel = viewModel @@ -34,7 +33,5 @@ class OverviewFragment : TabFragment() { } } - override suspend fun refresh() { - (parentFragment as? ParentFragment)?.refreshWithChild(true) - } + override suspend fun refresh() {} } diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/homework/detailed/SubmissionsFragment.kt b/app/src/main/java/org/schulcloud/mobile/controllers/homework/detailed/SubmissionsFragment.kt index e017b0a2..1a08d766 100644 --- a/app/src/main/java/org/schulcloud/mobile/controllers/homework/detailed/SubmissionsFragment.kt +++ b/app/src/main/java/org/schulcloud/mobile/controllers/homework/detailed/SubmissionsFragment.kt @@ -10,8 +10,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import kotlinx.android.synthetic.main.fragment_homework_submissions.* import org.schulcloud.mobile.R import org.schulcloud.mobile.controllers.homework.submission.SubmissionFragmentArgs -import org.schulcloud.mobile.controllers.main.ParentFragment -import org.schulcloud.mobile.controllers.main.TabFragment +import org.schulcloud.mobile.controllers.main.InnerMainFragment import org.schulcloud.mobile.databinding.FragmentHomeworkSubmissionsBinding import org.schulcloud.mobile.models.course.CourseRepository import org.schulcloud.mobile.utils.showGenericNeutral @@ -19,7 +18,7 @@ import org.schulcloud.mobile.viewmodels.HomeworkViewModel import org.schulcloud.mobile.views.DividerItemDecoration -class SubmissionsFragment : TabFragment() { +class SubmissionsFragment : InnerMainFragment() { private val submissionsAdapter by lazy { SubmissionsAdapter { if (it.isEmpty()) @@ -56,6 +55,5 @@ class SubmissionsFragment : TabFragment() { override suspend fun refresh() { viewModel.homework.value?.course?.id?.also { CourseRepository.syncCourse(it) } - (parentFragment as? ParentFragment)?.refreshWithChild(true) } } diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/homework/submission/FeedbackFragment.kt b/app/src/main/java/org/schulcloud/mobile/controllers/homework/submission/FeedbackFragment.kt index 1fc3ee31..258b440f 100644 --- a/app/src/main/java/org/schulcloud/mobile/controllers/homework/submission/FeedbackFragment.kt +++ b/app/src/main/java/org/schulcloud/mobile/controllers/homework/submission/FeedbackFragment.kt @@ -4,14 +4,16 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import org.schulcloud.mobile.controllers.main.ParentFragment -import org.schulcloud.mobile.controllers.main.TabFragment +import androidx.lifecycle.LiveData +import org.schulcloud.mobile.R +import org.schulcloud.mobile.controllers.main.InnerMainFragment +import org.schulcloud.mobile.controllers.main.MainFragmentConfig import org.schulcloud.mobile.databinding.FragmentHomeworkSubmissionFeedbackBinding +import org.schulcloud.mobile.utils.asLiveData import org.schulcloud.mobile.viewmodels.SubmissionViewModel -class FeedbackFragment : TabFragment() { - +class FeedbackFragment : InnerMainFragment() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return FragmentHomeworkSubmissionFeedbackBinding.inflate(layoutInflater).also { it.viewModel = viewModel @@ -19,7 +21,11 @@ class FeedbackFragment : TabFragment() }.root } - override suspend fun refresh() { - (parentFragment as? ParentFragment)?.refreshWithChild(true) + override fun provideSelfConfig(): LiveData { + return MainFragmentConfig( + menuBottomHiddenIds = listOf(R.id.submission_action_addAttachment) + ).asLiveData() } + + override suspend fun refresh() {} } diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/homework/submission/OverviewFragment.kt b/app/src/main/java/org/schulcloud/mobile/controllers/homework/submission/OverviewFragment.kt index f4a62608..6b887b27 100644 --- a/app/src/main/java/org/schulcloud/mobile/controllers/homework/submission/OverviewFragment.kt +++ b/app/src/main/java/org/schulcloud/mobile/controllers/homework/submission/OverviewFragment.kt @@ -2,15 +2,50 @@ package org.schulcloud.mobile.controllers.homework.submission import android.os.Bundle import android.view.LayoutInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup -import org.schulcloud.mobile.controllers.main.ParentFragment -import org.schulcloud.mobile.controllers.main.TabFragment +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import kotlinx.android.synthetic.main.fragment_homework_submission_overview.* +import kotlinx.coroutines.experimental.launch +import org.schulcloud.mobile.R +import org.schulcloud.mobile.controllers.file.FileAdapter +import org.schulcloud.mobile.controllers.homework.attachment.AddAttachmentSheet +import org.schulcloud.mobile.controllers.main.InnerMainFragment +import org.schulcloud.mobile.controllers.main.MainFragmentConfig import org.schulcloud.mobile.databinding.FragmentHomeworkSubmissionOverviewBinding +import org.schulcloud.mobile.models.file.FileRepository +import org.schulcloud.mobile.models.user.Permission +import org.schulcloud.mobile.models.user.UserRepository +import org.schulcloud.mobile.models.user.hasPermission +import org.schulcloud.mobile.utils.combineLatestBothNullable +import org.schulcloud.mobile.utils.downloadFile +import org.schulcloud.mobile.utils.map +import org.schulcloud.mobile.utils.visibilityBool import org.schulcloud.mobile.viewmodels.SubmissionViewModel -class OverviewFragment : TabFragment() { +class OverviewFragment : InnerMainFragment() { + private val attachmentsAdapter: FileAdapter by lazy { + FileAdapter({ launch { downloadFile(it, false) } }, + { launch { downloadFile(it, true) } }) + } + + override fun provideSelfConfig(): LiveData = viewModel.submission + .combineLatestBothNullable(viewModel.currentUser) + .map { (submission, user) -> + val canEdit = user?.hasPermission(Permission.SUBMISSIONS_EDIT) == true + && submission?.studentId == user.id + MainFragmentConfig( + menuBottomHiddenIds = listOf( + R.id.submission_action_addAttachment.takeUnless { canEdit } + ) + ) + } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return FragmentHomeworkSubmissionOverviewBinding.inflate(layoutInflater).also { it.viewModel = viewModel @@ -18,7 +53,34 @@ class OverviewFragment : TabFragment() }.root } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel.files.observe(this, Observer { + attachmentsAdapter.update(it ?: emptyList()) + attachments_header.visibilityBool = it.isNotEmpty() + }) + attachments_recyclerView.apply { + layoutManager = LinearLayoutManager(context) + adapter = attachmentsAdapter + addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) + } + } + + override fun onOptionsItemSelected(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.submission_action_addAttachment -> + AddAttachmentSheet.forSubmission(viewModel.id).show(fragmentManager) + else -> return super.onOptionsItemSelected(item) + } + return true + } + override suspend fun refresh() { - (parentFragment as? ParentFragment)?.refreshWithChild(true) + UserRepository.syncCurrentUser() + viewModel.submission.value?.fileIds?.let { + for (id in it) + FileRepository.syncFile(id) + } } } diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/homework/submission/SubmissionFragment.kt b/app/src/main/java/org/schulcloud/mobile/controllers/homework/submission/SubmissionFragment.kt index ef67089e..fec1bd3e 100644 --- a/app/src/main/java/org/schulcloud/mobile/controllers/homework/submission/SubmissionFragment.kt +++ b/app/src/main/java/org/schulcloud/mobile/controllers/homework/submission/SubmissionFragment.kt @@ -6,16 +6,17 @@ import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup +import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProviders import kotlinx.android.synthetic.main.fragment_homework_submission.* import org.schulcloud.mobile.R import org.schulcloud.mobile.controllers.course.CourseFragmentArgs import org.schulcloud.mobile.controllers.homework.detailed.HomeworkFragmentArgs -import org.schulcloud.mobile.controllers.main.MainFragment import org.schulcloud.mobile.controllers.main.MainFragmentConfig -import org.schulcloud.mobile.controllers.main.ParentFragment -import org.schulcloud.mobile.controllers.main.TabFragment +import org.schulcloud.mobile.controllers.main.Tab +import org.schulcloud.mobile.controllers.main.TabbedMainFragment +import org.schulcloud.mobile.controllers.main.toPagerAdapter import org.schulcloud.mobile.databinding.FragmentHomeworkSubmissionBinding import org.schulcloud.mobile.models.homework.HomeworkRepository import org.schulcloud.mobile.models.homework.submission.SubmissionRepository @@ -24,22 +25,28 @@ import org.schulcloud.mobile.viewmodels.IdViewModelFactory import org.schulcloud.mobile.viewmodels.SubmissionViewModel -class SubmissionFragment : MainFragment(), ParentFragment { - - private val pagerAdapter by lazy { SubmissionPagerAdapter(context!!, childFragmentManager) } +class SubmissionFragment : TabbedMainFragment() { + override val pagerAdapter by lazy { + listOf( + Tab(getString(R.string.homework_submission_overview)) { OverviewFragment() }, + Tab(getString(R.string.homework_submission_feedback)) { FeedbackFragment() } + ).toPagerAdapter(this) + } override var url: String? = null - get() = "homework/${viewModel.homework.value?.id}" + get() = "/homework/${viewModel.homework.value?.id}" - override fun provideConfig() = viewModel.homework - .map { homework -> - MainFragmentConfig( - title = homework?.title ?: getString(R.string.general_error_notFound), - subtitle = homework?.course?.name, - toolbarColor = homework?.course?.color?.let { Color.parseColor(it) }, - menuBottomRes = R.menu.fragment_homework_submission_bottom - ) - } + override fun provideSelfConfig(): LiveData { + return viewModel.homework + .map { homework -> + MainFragmentConfig( + title = homework?.title ?: getString(R.string.general_error_notFound), + subtitle = homework?.course?.name, + toolbarColor = homework?.course?.color?.let { Color.parseColor(it) }, + menuBottomRes = listOf(R.menu.fragment_homework_submission_bottom) + ) + } + } override fun onCreate(savedInstanceState: Bundle?) { val args = HomeworkFragmentArgs.fromBundle(arguments) @@ -56,12 +63,9 @@ class SubmissionFragment : MainFragment(), ParentFragment { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - viewPager.adapter = pagerAdapter - tabLayout.setupWithViewPager(viewPager) - mainViewModel.toolbarColors.observe(this, Observer { - tabLayout.setTabTextColors(it.textColorSecondary, it.textColor) - tabLayout.setSelectedTabIndicatorColor(it.textColor) + super.onViewCreated(view, savedInstanceState) + mainViewModel.toolbarColors.observe(this, Observer { selectedStudent.setTextColor(it.textColor) }) } @@ -82,14 +86,7 @@ class SubmissionFragment : MainFragment(), ParentFragment { } override suspend fun refresh() { - refreshWithChild(false) - } - - override suspend fun refreshWithChild(fromChild: Boolean) { - if (fromChild) { - SubmissionRepository.syncSubmission(viewModel.id) - viewModel.homework.value?.id?.also { HomeworkRepository.syncHomework(it) } - } else if (viewPager != null) - (pagerAdapter.getItem(viewPager.currentItem) as? TabFragment<*, *>)?.performRefresh() + SubmissionRepository.syncSubmission(viewModel.id) + viewModel.homework.value?.id?.also { HomeworkRepository.syncHomework(it) } } } diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/homework/submission/SubmissionPagerAdapter.kt b/app/src/main/java/org/schulcloud/mobile/controllers/homework/submission/SubmissionPagerAdapter.kt deleted file mode 100644 index 422f45bf..00000000 --- a/app/src/main/java/org/schulcloud/mobile/controllers/homework/submission/SubmissionPagerAdapter.kt +++ /dev/null @@ -1,54 +0,0 @@ -package org.schulcloud.mobile.controllers.homework.submission - -import android.content.Context -import androidx.annotation.IntDef -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.FragmentPagerAdapter -import org.schulcloud.mobile.R - - -class SubmissionPagerAdapter(private val context: Context, fm: FragmentManager) : FragmentPagerAdapter(fm) { - companion object { - private const val TAB_INVALID = 0 - private const val TAB_OVERVIEW = 1 - private const val TAB_FEEDBACK = 2 - - @Retention(AnnotationRetention.SOURCE) - @MustBeDocumented - @IntDef(TAB_INVALID, TAB_OVERVIEW, TAB_FEEDBACK) - annotation class Tab - } - - override fun getItem(position: Int): Fragment? { - return when (getTabType(position)) { - TAB_OVERVIEW -> OverviewFragment() - TAB_FEEDBACK -> FeedbackFragment() - - TAB_INVALID -> null - else -> null - } - } - - override fun getPageTitle(position: Int): CharSequence? { - val titleId = when (getTabType(position)) { - TAB_OVERVIEW -> R.string.homework_submission_overview - TAB_FEEDBACK -> R.string.homework_submission_feedback - - TAB_INVALID -> return null - else -> return null - } - return context.getString(titleId) - } - - override fun getCount() = 2 - - @Tab - private fun getTabType(position: Int): Int { - return when (position) { - 0 -> TAB_OVERVIEW - 1 -> TAB_FEEDBACK - else -> TAB_INVALID - } - } -} diff --git a/app/src/main/java/org/schulcloud/mobile/controllers/main/InnerMainFragment.kt b/app/src/main/java/org/schulcloud/mobile/controllers/main/InnerMainFragment.kt new file mode 100644 index 00000000..9153574c --- /dev/null +++ b/app/src/main/java/org/schulcloud/mobile/controllers/main/InnerMainFragment.kt @@ -0,0 +1,41 @@ +package org.schulcloud.mobile.controllers.main + +import android.annotation.SuppressLint +import android.os.Bundle +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.experimental.android.UI +import kotlinx.coroutines.experimental.launch +import kotlinx.coroutines.experimental.withContext +import org.schulcloud.mobile.utils.liveDataOf + + +@SuppressLint("ValidFragment") +abstract class InnerMainFragment, 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> + by lazy { LinkedList>() } + + private val activityRequests: MutableList> + by lazy { LinkedList>() } + + fun addPermissionRequest(request: Continuation): Int { + permissionRequests += request + return permissionRequests.size - 1 + } + + fun onPermissionResult(requestCode: Int, permissions: Array, grantResults: IntArray): Boolean { + if (requestCode >= permissionRequests.size) return false + + permissionRequests[requestCode].resume( + if (permissions.isEmpty()) false + else grantResults[0] == PackageManager.PERMISSION_GRANTED) + return true + } + + + fun addActivityRequest(request: Continuation): Int { + activityRequests += request + return activityRequests.size - 1 + } + + fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + if (requestCode >= activityRequests.size) return false + + activityRequests[requestCode].resume( + if (resultCode == Activity.RESULT_OK) StartActivityResult.success(data) + else StartActivityResult.error()) + return true + } +} diff --git a/app/src/main/java/org/schulcloud/mobile/viewmodels/CourseListViewModel.kt b/app/src/main/java/org/schulcloud/mobile/viewmodels/CourseListViewModel.kt index 3e679f38..27d1cf6d 100644 --- a/app/src/main/java/org/schulcloud/mobile/viewmodels/CourseListViewModel.kt +++ b/app/src/main/java/org/schulcloud/mobile/viewmodels/CourseListViewModel.kt @@ -1,16 +1,11 @@ package org.schulcloud.mobile.viewmodels import androidx.lifecycle.LiveData -import androidx.lifecycle.ViewModel -import io.realm.Realm import org.schulcloud.mobile.models.course.Course import org.schulcloud.mobile.models.course.CourseRepository +import org.schulcloud.mobile.viewmodels.base.BaseViewModel -class CourseListViewModel : ViewModel() { - - private val realm: Realm by lazy { - Realm.getDefaultInstance() - } +class CourseListViewModel : BaseViewModel() { val courses: LiveData> = CourseRepository.courses(realm) } diff --git a/app/src/main/java/org/schulcloud/mobile/viewmodels/CourseViewModel.kt b/app/src/main/java/org/schulcloud/mobile/viewmodels/CourseViewModel.kt index 9e5253ba..5023491e 100644 --- a/app/src/main/java/org/schulcloud/mobile/viewmodels/CourseViewModel.kt +++ b/app/src/main/java/org/schulcloud/mobile/viewmodels/CourseViewModel.kt @@ -1,19 +1,14 @@ package org.schulcloud.mobile.viewmodels import androidx.lifecycle.LiveData -import androidx.lifecycle.ViewModel -import io.realm.Realm import org.schulcloud.mobile.models.course.Course import org.schulcloud.mobile.models.course.CourseRepository import org.schulcloud.mobile.models.topic.Topic import org.schulcloud.mobile.models.topic.TopicRepository +import org.schulcloud.mobile.viewmodels.base.BaseViewModel -class CourseViewModel(val id: String) : ViewModel() { - - private val realm: Realm by lazy { - Realm.getDefaultInstance() - } +class CourseViewModel(val id: String) : BaseViewModel() { val course: LiveData = CourseRepository.course(realm, id) val topics: LiveData> = TopicRepository.topicsForCourse(realm, id) } diff --git a/app/src/main/java/org/schulcloud/mobile/viewmodels/EventListViewModel.kt b/app/src/main/java/org/schulcloud/mobile/viewmodels/EventListViewModel.kt index 1fa56417..615b9666 100644 --- a/app/src/main/java/org/schulcloud/mobile/viewmodels/EventListViewModel.kt +++ b/app/src/main/java/org/schulcloud/mobile/viewmodels/EventListViewModel.kt @@ -1,16 +1,11 @@ package org.schulcloud.mobile.viewmodels import androidx.lifecycle.LiveData -import androidx.lifecycle.ViewModel -import io.realm.Realm import org.schulcloud.mobile.models.event.Event import org.schulcloud.mobile.models.event.EventRepository +import org.schulcloud.mobile.viewmodels.base.BaseViewModel -class EventListViewModel : ViewModel() { - - private val realm: Realm by lazy { - Realm.getDefaultInstance() - } +class EventListViewModel : BaseViewModel() { val events: LiveData> = EventRepository.eventsForToday(realm) } diff --git a/app/src/main/java/org/schulcloud/mobile/viewmodels/FileOverviewViewModel.kt b/app/src/main/java/org/schulcloud/mobile/viewmodels/FileOverviewViewModel.kt index aa1b76e5..dce0127b 100644 --- a/app/src/main/java/org/schulcloud/mobile/viewmodels/FileOverviewViewModel.kt +++ b/app/src/main/java/org/schulcloud/mobile/viewmodels/FileOverviewViewModel.kt @@ -1,16 +1,11 @@ package org.schulcloud.mobile.viewmodels import androidx.lifecycle.LiveData -import androidx.lifecycle.ViewModel -import io.realm.Realm import org.schulcloud.mobile.models.course.Course import org.schulcloud.mobile.models.course.CourseRepository +import org.schulcloud.mobile.viewmodels.base.BaseViewModel -class FileOverviewViewModel : ViewModel() { - - private val realm: Realm by lazy { - Realm.getDefaultInstance() - } +class FileOverviewViewModel : BaseViewModel() { val courses: LiveData> = CourseRepository.courses(realm) } diff --git a/app/src/main/java/org/schulcloud/mobile/viewmodels/FileViewModel.kt b/app/src/main/java/org/schulcloud/mobile/viewmodels/FileViewModel.kt index fddb31a3..a510e688 100644 --- a/app/src/main/java/org/schulcloud/mobile/viewmodels/FileViewModel.kt +++ b/app/src/main/java/org/schulcloud/mobile/viewmodels/FileViewModel.kt @@ -1,19 +1,30 @@ package org.schulcloud.mobile.viewmodels import androidx.lifecycle.LiveData -import androidx.lifecycle.ViewModel -import io.realm.Realm +import org.schulcloud.mobile.models.course.Course +import org.schulcloud.mobile.models.course.CourseRepository import org.schulcloud.mobile.models.file.Directory import org.schulcloud.mobile.models.file.File import org.schulcloud.mobile.models.file.FileRepository +import org.schulcloud.mobile.models.user.User +import org.schulcloud.mobile.models.user.UserRepository +import org.schulcloud.mobile.utils.getPathParts +import org.schulcloud.mobile.utils.liveDataOf +import org.schulcloud.mobile.viewmodels.base.BaseViewModel -class FileViewModel(path_: String) : ViewModel() { - val path = FileRepository.fixPath(path_) - val realm: Realm by lazy { - Realm.getDefaultInstance() - } +class FileViewModel(path: String) : BaseViewModel() { + val path = FileRepository.fixPath(path) - val directories: LiveData> = FileRepository.directories(realm, path) - val files: LiveData> = FileRepository.files(realm, path) + val directories: LiveData> = FileRepository.directories(realm, this.path) + val files: LiveData> = FileRepository.files(realm, this.path) + + val courseId: String? = if (path.startsWith(FileRepository.CONTEXT_COURSES)) + path.getPathParts()[1] + else null + val course: LiveData = (courseId?.let { + CourseRepository.course(realm, it) + } ?: liveDataOf()) + + val currentUser: LiveData = UserRepository.currentUser(realm) } diff --git a/app/src/main/java/org/schulcloud/mobile/viewmodels/HomeworkListViewModel.kt b/app/src/main/java/org/schulcloud/mobile/viewmodels/HomeworkListViewModel.kt index c9723503..fb15cd67 100644 --- a/app/src/main/java/org/schulcloud/mobile/viewmodels/HomeworkListViewModel.kt +++ b/app/src/main/java/org/schulcloud/mobile/viewmodels/HomeworkListViewModel.kt @@ -1,16 +1,11 @@ package org.schulcloud.mobile.viewmodels import androidx.lifecycle.LiveData -import androidx.lifecycle.ViewModel -import io.realm.Realm import org.schulcloud.mobile.models.homework.Homework import org.schulcloud.mobile.models.homework.HomeworkRepository +import org.schulcloud.mobile.viewmodels.base.BaseViewModel -class HomeworkListViewModel : ViewModel() { - - private val realm: Realm by lazy { - Realm.getDefaultInstance() - } +class HomeworkListViewModel : BaseViewModel() { val homework: LiveData> = HomeworkRepository.homeworkList(realm) } diff --git a/app/src/main/java/org/schulcloud/mobile/viewmodels/HomeworkViewModel.kt b/app/src/main/java/org/schulcloud/mobile/viewmodels/HomeworkViewModel.kt index b5acf9fb..06025419 100644 --- a/app/src/main/java/org/schulcloud/mobile/viewmodels/HomeworkViewModel.kt +++ b/app/src/main/java/org/schulcloud/mobile/viewmodels/HomeworkViewModel.kt @@ -1,8 +1,6 @@ package org.schulcloud.mobile.viewmodels import androidx.lifecycle.LiveData -import androidx.lifecycle.ViewModel -import io.realm.Realm import org.schulcloud.mobile.models.course.Course import org.schulcloud.mobile.models.course.CourseRepository import org.schulcloud.mobile.models.homework.Homework @@ -11,21 +9,19 @@ import org.schulcloud.mobile.models.homework.submission.Submission import org.schulcloud.mobile.models.homework.submission.SubmissionRepository import org.schulcloud.mobile.models.user.User import org.schulcloud.mobile.models.user.UserRepository -import org.schulcloud.mobile.utils.combineLatest +import org.schulcloud.mobile.utils.combineLatestNullable import org.schulcloud.mobile.utils.map import org.schulcloud.mobile.utils.switchMapNullable +import org.schulcloud.mobile.viewmodels.base.BaseViewModel -class HomeworkViewModel(val id: String) : ViewModel() { - private val realm: Realm by lazy { - Realm.getDefaultInstance() - } +class HomeworkViewModel(val id: String) : BaseViewModel() { val homework: LiveData = HomeworkRepository.homework(realm, id) - val course: LiveData = homework.switchMapNullable { - it?.course?.id?.let { CourseRepository.course(realm, it) } + val course: LiveData = homework.switchMapNullable { homework -> + homework?.course?.id?.let { CourseRepository.course(realm, it) } } val submissions: LiveData>> = SubmissionRepository.submissionsForHomework(realm, id) - .combineLatest(course) + .combineLatestNullable(course) .map { (submissions, course) -> if (course == null) return@map emptyList>() diff --git a/app/src/main/java/org/schulcloud/mobile/viewmodels/HomeworkWidgetViewModel.kt b/app/src/main/java/org/schulcloud/mobile/viewmodels/HomeworkWidgetViewModel.kt index e86324f0..27b5b363 100644 --- a/app/src/main/java/org/schulcloud/mobile/viewmodels/HomeworkWidgetViewModel.kt +++ b/app/src/main/java/org/schulcloud/mobile/viewmodels/HomeworkWidgetViewModel.kt @@ -1,15 +1,11 @@ package org.schulcloud.mobile.viewmodels import androidx.lifecycle.LiveData -import androidx.lifecycle.ViewModel -import io.realm.Realm import org.schulcloud.mobile.models.homework.Homework import org.schulcloud.mobile.models.homework.HomeworkRepository +import org.schulcloud.mobile.viewmodels.base.BaseViewModel -class HomeworkWidgetViewModel : ViewModel() { - private val realm: Realm by lazy { - Realm.getDefaultInstance() - } +class HomeworkWidgetViewModel : BaseViewModel() { val homework: LiveData> = HomeworkRepository.openHomeworkForNextWeek(realm) } diff --git a/app/src/main/java/org/schulcloud/mobile/viewmodels/IdViewModelFactory.kt b/app/src/main/java/org/schulcloud/mobile/viewmodels/IdViewModelFactory.kt index c23047b6..bb7e65cf 100644 --- a/app/src/main/java/org/schulcloud/mobile/viewmodels/IdViewModelFactory.kt +++ b/app/src/main/java/org/schulcloud/mobile/viewmodels/IdViewModelFactory.kt @@ -13,6 +13,7 @@ class IdViewModelFactory(private val id: String) : ViewModelProvider.NewInstance TopicViewModel::class.java -> TopicViewModel(id) as T HomeworkViewModel::class.java -> HomeworkViewModel(id) as T SubmissionViewModel::class.java -> SubmissionViewModel(id) as T + AddAttachmentViewModel::class.java -> AddAttachmentViewModel(id) as T FileViewModel::class.java -> FileViewModel(id) as T else -> throw IllegalArgumentException("Can't instantiate view model of type $modelClass") } diff --git a/app/src/main/java/org/schulcloud/mobile/viewmodels/LoginViewModel.kt b/app/src/main/java/org/schulcloud/mobile/viewmodels/LoginViewModel.kt index 2358a069..92fed2a5 100644 --- a/app/src/main/java/org/schulcloud/mobile/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/org/schulcloud/mobile/viewmodels/LoginViewModel.kt @@ -1,13 +1,14 @@ package org.schulcloud.mobile.viewmodels import android.util.Patterns -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import org.schulcloud.mobile.jobs.base.RequestJobCallback import org.schulcloud.mobile.models.user.UserRepository +import org.schulcloud.mobile.utils.mutableLiveDataOf +import org.schulcloud.mobile.viewmodels.base.BaseViewModel -class LoginViewModel : ViewModel() { - val loginState = MutableLiveData() + +class LoginViewModel : BaseViewModel() { + val loginState = mutableLiveDataOf() fun login(email: String, password: String) { val invalidInputs = mutableListOf() diff --git a/app/src/main/java/org/schulcloud/mobile/viewmodels/MainViewModel.kt b/app/src/main/java/org/schulcloud/mobile/viewmodels/MainViewModel.kt index 0bfc15f1..47372f5a 100644 --- a/app/src/main/java/org/schulcloud/mobile/viewmodels/MainViewModel.kt +++ b/app/src/main/java/org/schulcloud/mobile/viewmodels/MainViewModel.kt @@ -3,12 +3,12 @@ package org.schulcloud.mobile.viewmodels import android.view.MenuItem import androidx.annotation.ColorInt import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import org.schulcloud.mobile.controllers.main.MainFragmentConfig import org.schulcloud.mobile.utils.SingleLiveEvent +import org.schulcloud.mobile.viewmodels.base.BaseViewModel -class MainViewModel : ViewModel() { +class MainViewModel : BaseViewModel() { val config: MutableLiveData = MutableLiveData() val title: MutableLiveData = MutableLiveData() val toolbarColors: MutableLiveData = MutableLiveData() diff --git a/app/src/main/java/org/schulcloud/mobile/viewmodels/NavigationDrawerViewModel.kt b/app/src/main/java/org/schulcloud/mobile/viewmodels/NavigationDrawerViewModel.kt index cd65ead8..36653333 100644 --- a/app/src/main/java/org/schulcloud/mobile/viewmodels/NavigationDrawerViewModel.kt +++ b/app/src/main/java/org/schulcloud/mobile/viewmodels/NavigationDrawerViewModel.kt @@ -1,15 +1,11 @@ package org.schulcloud.mobile.viewmodels import androidx.lifecycle.LiveData -import androidx.lifecycle.ViewModel -import io.realm.Realm import org.schulcloud.mobile.models.user.User import org.schulcloud.mobile.models.user.UserRepository +import org.schulcloud.mobile.viewmodels.base.BaseViewModel -class NavigationDrawerViewModel : ViewModel() { - private val realm: Realm by lazy { - Realm.getDefaultInstance() - } +class NavigationDrawerViewModel : BaseViewModel() { val user: LiveData = UserRepository.currentUser(realm) } diff --git a/app/src/main/java/org/schulcloud/mobile/viewmodels/NewsListViewModel.kt b/app/src/main/java/org/schulcloud/mobile/viewmodels/NewsListViewModel.kt index 83141629..7f11f9db 100644 --- a/app/src/main/java/org/schulcloud/mobile/viewmodels/NewsListViewModel.kt +++ b/app/src/main/java/org/schulcloud/mobile/viewmodels/NewsListViewModel.kt @@ -1,15 +1,11 @@ package org.schulcloud.mobile.viewmodels import androidx.lifecycle.LiveData -import androidx.lifecycle.ViewModel -import io.realm.Realm import org.schulcloud.mobile.models.news.News import org.schulcloud.mobile.models.news.NewsRepository +import org.schulcloud.mobile.viewmodels.base.BaseViewModel -class NewsListViewModel : ViewModel() { - private val realm: Realm by lazy { - Realm.getDefaultInstance() - } +class NewsListViewModel : BaseViewModel() { val news: LiveData> = NewsRepository.newsList(realm) } diff --git a/app/src/main/java/org/schulcloud/mobile/viewmodels/NewsViewModel.kt b/app/src/main/java/org/schulcloud/mobile/viewmodels/NewsViewModel.kt index b48f8df5..fc0bc943 100644 --- a/app/src/main/java/org/schulcloud/mobile/viewmodels/NewsViewModel.kt +++ b/app/src/main/java/org/schulcloud/mobile/viewmodels/NewsViewModel.kt @@ -1,15 +1,11 @@ package org.schulcloud.mobile.viewmodels import androidx.lifecycle.LiveData -import androidx.lifecycle.ViewModel -import io.realm.Realm import org.schulcloud.mobile.models.news.News import org.schulcloud.mobile.models.news.NewsRepository +import org.schulcloud.mobile.viewmodels.base.BaseViewModel -class NewsViewModel(val id: String) : ViewModel() { - private val realm: Realm by lazy { - Realm.getDefaultInstance() - } +class NewsViewModel(val id: String) : BaseViewModel() { val news: LiveData = NewsRepository.news(realm, id) } diff --git a/app/src/main/java/org/schulcloud/mobile/viewmodels/SubmissionViewModel.kt b/app/src/main/java/org/schulcloud/mobile/viewmodels/SubmissionViewModel.kt index 7dea8f12..88c0e62e 100644 --- a/app/src/main/java/org/schulcloud/mobile/viewmodels/SubmissionViewModel.kt +++ b/app/src/main/java/org/schulcloud/mobile/viewmodels/SubmissionViewModel.kt @@ -1,29 +1,37 @@ package org.schulcloud.mobile.viewmodels import androidx.lifecycle.LiveData -import androidx.lifecycle.ViewModel -import io.realm.Realm +import org.schulcloud.mobile.models.file.File +import org.schulcloud.mobile.models.file.FileRepository import org.schulcloud.mobile.models.homework.Homework 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.models.user.User import org.schulcloud.mobile.models.user.UserRepository +import org.schulcloud.mobile.utils.liveDataOf +import org.schulcloud.mobile.utils.switchMap import org.schulcloud.mobile.utils.switchMapNullable +import org.schulcloud.mobile.viewmodels.base.BaseViewModel -class SubmissionViewModel(val id: String) : ViewModel() { - private val realm: Realm by lazy { - Realm.getDefaultInstance() - } +class SubmissionViewModel(val id: String) : BaseViewModel() { val submission: LiveData = SubmissionRepository.submission(realm, id) val student: LiveData = submission .switchMapNullable { it?.studentId?.let { UserRepository.user(realm, it) } } + val files: LiveData> = submission + .switchMap { + it?.fileIds?.let { + FileRepository.files(realm, it.toTypedArray()) + } ?: liveDataOf(emptyList()) + } val homework: LiveData = submission .switchMapNullable { - it?.homeworkId?.let { HomeworkRepository.homework(realm, it) } - } + it?.homeworkId?.let { HomeworkRepository.homework(realm, it) } + } + + val currentUser: LiveData = UserRepository.currentUser(realm) } diff --git a/app/src/main/java/org/schulcloud/mobile/viewmodels/TopicViewModel.kt b/app/src/main/java/org/schulcloud/mobile/viewmodels/TopicViewModel.kt index d03e717c..b182febd 100644 --- a/app/src/main/java/org/schulcloud/mobile/viewmodels/TopicViewModel.kt +++ b/app/src/main/java/org/schulcloud/mobile/viewmodels/TopicViewModel.kt @@ -1,17 +1,13 @@ package org.schulcloud.mobile.viewmodels import androidx.lifecycle.LiveData -import androidx.lifecycle.ViewModel -import io.realm.Realm import org.schulcloud.mobile.models.course.CourseRepository import org.schulcloud.mobile.models.topic.Topic import org.schulcloud.mobile.models.topic.TopicRepository +import org.schulcloud.mobile.viewmodels.base.BaseViewModel -class TopicViewModel(val id: String) : ViewModel() { - private val realm: Realm by lazy { - Realm.getDefaultInstance() - } +class TopicViewModel(val id: String) : BaseViewModel() { val topic: LiveData = TopicRepository.topic(realm, id) fun course(id: String) = CourseRepository.course(realm, id) diff --git a/app/src/main/java/org/schulcloud/mobile/viewmodels/base/BaseViewModel.kt b/app/src/main/java/org/schulcloud/mobile/viewmodels/base/BaseViewModel.kt new file mode 100644 index 00000000..5e8a7be6 --- /dev/null +++ b/app/src/main/java/org/schulcloud/mobile/viewmodels/base/BaseViewModel.kt @@ -0,0 +1,25 @@ +package org.schulcloud.mobile.viewmodels.base + +import android.util.Log +import androidx.lifecycle.ViewModel +import io.realm.Realm +import java.io.IOException + +abstract class BaseViewModel : ViewModel() { + companion object { + private val TAG = BaseViewModel::class.simpleName + } + + protected val realm by lazy { Realm.getDefaultInstance() } + + + override fun onCleared() { + try { + realm.close() + } catch (e: IOException) { + Log.w(TAG, "Error closing realm instance") + } + + super.onCleared() + } +} diff --git a/app/src/main/java/org/schulcloud/mobile/views/CompatTextView.kt b/app/src/main/java/org/schulcloud/mobile/views/CompatTextView.kt index 77d38069..2f478bf9 100644 --- a/app/src/main/java/org/schulcloud/mobile/views/CompatTextView.kt +++ b/app/src/main/java/org/schulcloud/mobile/views/CompatTextView.kt @@ -5,14 +5,20 @@ import android.graphics.drawable.Drawable import android.os.Build import android.util.AttributeSet import androidx.annotation.AttrRes +import androidx.annotation.ColorInt import androidx.annotation.StyleableRes import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.widget.AppCompatTextView import androidx.core.content.withStyledAttributes +import androidx.core.graphics.drawable.DrawableCompat import org.schulcloud.mobile.R +import org.schulcloud.mobile.utils.getColorOrNull +import org.schulcloud.mobile.utils.getDimensionOrNull import org.schulcloud.mobile.utils.isLtr +import kotlin.math.min import kotlin.properties.Delegates + open class CompatTextView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @@ -39,20 +45,24 @@ open class CompatTextView @JvmOverloads constructor( var drawableTopVisible by updateDrawablesObservable(true) var drawableBottomVisible by updateDrawablesObservable(true) + @delegate:ColorInt + var drawableTintColor by updateDrawablesObservable(null) + + var drawableWidth by updateDrawablesObservable(null) + var drawableHeight by updateDrawablesObservable(null) + + init { context.withStyledAttributes(attrs, R.styleable.CompatTextView) { // To use vector drawables on pre-21 // https://stackoverflow.com/a/40250753/6220609 fun drawable(@StyleableRes attrId: Int): Drawable? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) getDrawable(attrId) - } else { - val id = getResourceId(attrId, 0) - if (id != 0) - AppCompatResources.getDrawable(context, id) - else - null - } + else + getResourceId(attrId, 0) + .takeIf { it != 0 } + ?.let { AppCompatResources.getDrawable(context, id) } } drawableStart = drawable(R.styleable.CompatTextView_drawableStart) @@ -65,13 +75,62 @@ open class CompatTextView @JvmOverloads constructor( drawableTopVisible = getBoolean(R.styleable.CompatTextView_drawableTopVisible, true) drawableBottomVisible = getBoolean(R.styleable.CompatTextView_drawableBottomVisible, true) + drawableTintColor = getColorOrNull(R.styleable.CompatTextView_drawableTint) + + drawableWidth = getDimensionOrNull(R.styleable.CompatTextView_drawableWidth)?.toInt() + drawableHeight = getDimensionOrNull(R.styleable.CompatTextView_drawableHeight)?.toInt() + isInitialized = true updateDrawables() } } private fun updateDrawables() { - fun drawableIfVisible(drawable: Drawable?, visible: Boolean) = if (visible) drawable else null + fun drawableIfVisible(drawable: Drawable?, visible: Boolean): Drawable? { + return if (drawable != null && visible) { + // Tint + val drawableTintColor = drawableTintColor + val result = if (drawableTintColor != null) + DrawableCompat.wrap(drawable).apply { + mutate() + DrawableCompat.setTint(this, drawableTintColor) + } + else drawable + + // Resize + val realBounds = result.bounds + val aspectRatio = realBounds.width().toFloat() / realBounds.height() + val width = realBounds.width() + val height = realBounds.height() + val drawableWidth = drawableWidth + val drawableHeight = drawableHeight + + val (newWidth, newHeight) = + // Too wide, scale down + if (drawableWidth != null && width > drawableWidth) + drawableWidth to (width / aspectRatio).toInt() + // Too high, scale down + else if (drawableHeight != null && height > drawableHeight) + (height * aspectRatio).toInt() to drawableHeight + // No intrinsic size, use setting + else if (drawableWidth != null && drawableHeight != null && + width == 0 && height == 0) + drawableWidth to drawableHeight + // Too small, scale up + else if (drawableWidth != null && drawableHeight != null + && width < drawableWidth && height < drawableHeight) { + val scale = min(drawableWidth / width, drawableHeight / height) + width * scale to height * scale + } else width to height + + realBounds.right = realBounds.left + newWidth + realBounds.bottom = realBounds.top + newHeight + drawable.bounds = realBounds + + result + } else null + } + when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1 -> setCompoundDrawablesRelativeWithIntrinsicBounds( @@ -91,7 +150,6 @@ open class CompatTextView @JvmOverloads constructor( drawableIfVisible(drawableTop, drawableTopVisible), drawableIfVisible(drawableStart, drawableStartVisible), drawableIfVisible(drawableBottom, drawableBottomVisible)) - } } } diff --git a/app/src/main/java/org/schulcloud/mobile/views/LiveViewPager.kt b/app/src/main/java/org/schulcloud/mobile/views/LiveViewPager.kt new file mode 100644 index 00000000..4eb0e1a2 --- /dev/null +++ b/app/src/main/java/org/schulcloud/mobile/views/LiveViewPager.kt @@ -0,0 +1,37 @@ +package org.schulcloud.mobile.views + +import android.content.Context +import android.util.AttributeSet +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.viewpager.widget.ViewPager +import org.schulcloud.mobile.utils.mutableLiveDataOf + +class LiveViewPager @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : ViewPager(context, attrs) { + companion object { + val TAG: String = LiveViewPager::class.java.simpleName + + private const val POS_OFFSET_THRESHOLD = 0.5f + } + + private val _currentItemLiveData: MutableLiveData = mutableLiveDataOf(0) + val currentItemLiveData: LiveData + get() = _currentItemLiveData + + init { + addOnPageChangeListener(object : ViewPager.OnPageChangeListener { + override fun onPageScrollStateChanged(state: Int) {} + + override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { + val pos = if (positionOffset > POS_OFFSET_THRESHOLD) position + 1 else position + if (_currentItemLiveData.value != pos) + _currentItemLiveData.value = pos + } + + override fun onPageSelected(position: Int) {} + }) + } +} diff --git a/app/src/main/res/drawable/ic_attachment_24dp.xml b/app/src/main/res/drawable/ic_attachment_24dp.xml new file mode 100644 index 00000000..4e554c39 --- /dev/null +++ b/app/src/main/res/drawable/ic_attachment_24dp.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_download.xml b/app/src/main/res/drawable/ic_file_download.xml similarity index 100% rename from app/src/main/res/drawable/ic_download.xml rename to app/src/main/res/drawable/ic_file_download.xml diff --git a/app/src/main/res/drawable/ic_file_upload_white_24dp.xml b/app/src/main/res/drawable/ic_file_upload_white_24dp.xml new file mode 100644 index 00000000..03a25016 --- /dev/null +++ b/app/src/main/res/drawable/ic_file_upload_white_24dp.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_gesture_white_24dp.xml b/app/src/main/res/drawable/ic_gesture_white_24dp.xml new file mode 100644 index 00000000..c29258c0 --- /dev/null +++ b/app/src/main/res/drawable/ic_gesture_white_24dp.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_photo_camera_white_24dp.xml b/app/src/main/res/drawable/ic_photo_camera_white_24dp.xml new file mode 100644 index 00000000..30a1967d --- /dev/null +++ b/app/src/main/res/drawable/ic_photo_camera_white_24dp.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/layout/drawer_navigation.xml b/app/src/main/res/layout/drawer_navigation.xml index 87170895..6f978226 100644 --- a/app/src/main/res/layout/drawer_navigation.xml +++ b/app/src/main/res/layout/drawer_navigation.xml @@ -15,9 +15,7 @@ + android:orientation="vertical"> - - - + android:orientation="vertical"> + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_homework_submissions.xml b/app/src/main/res/layout/fragment_homework_submissions.xml index dd26a4e7..417dbdb5 100644 --- a/app/src/main/res/layout/fragment_homework_submissions.xml +++ b/app/src/main/res/layout/fragment_homework_submissions.xml @@ -31,6 +31,8 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:clipToPadding="false" + android:paddingTop="16dp" + android:paddingBottom="16dp" tools:listitem="@layout/item_submission" /> diff --git a/app/src/main/res/layout/item_file.xml b/app/src/main/res/layout/item_file.xml index 385fd8fe..51248348 100644 --- a/app/src/main/res/layout/item_file.xml +++ b/app/src/main/res/layout/item_file.xml @@ -75,7 +75,7 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" - app:srcCompat="@drawable/ic_download" /> + app:srcCompat="@drawable/ic_file_download" /> + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_events.xml b/app/src/main/res/layout/widget_events.xml index 3214ba52..9e9bff52 100644 --- a/app/src/main/res/layout/widget_events.xml +++ b/app/src/main/res/layout/widget_events.xml @@ -13,8 +13,6 @@ + + - diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 2314e8c4..d6747940 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -106,14 +106,19 @@ No description available Submissions This course does not contain any students + This student has not submitted anything yet Submission + Add attachment Submission of %1$s - This student has not submitted anything yet No submission available + Attachments %1$d%% Feedback You solved %1$d%% correctly No review text available + Attach + File + Take photo Files @@ -128,11 +133,19 @@ This folder is empty Directories Files + The file couldn\'t be opened The file was not found No app is available for opening a file of type \'%1$s\' File is being downloaded… File \'%1$s\' was successfully saved in downloads! The file could not be saved The file cannot be saved without permission + Uploading file… + Upload completed! + The file couldn\'t be uploaded + No file can be selected for upload without permission + Couldn\'t open file + Error creating a temporary file + No camera was found diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index ff9ec6c5..9c1be3c6 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -16,6 +16,9 @@ + + + diff --git a/app/src/main/res/values/dimens_material.xml b/app/src/main/res/values/dimens_material.xml index 4c03054c..931046e5 100644 --- a/app/src/main/res/values/dimens_material.xml +++ b/app/src/main/res/values/dimens_material.xml @@ -17,6 +17,15 @@ 1dp 24dp + + + + 12dp + 24dp + 12dp + 48dp + 16sp + 16dp 8dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9c5f26f5..1f3eaa20 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -113,12 +113,17 @@ Der Kurs enthält keine Schüler/innen Es liegt noch keine Abgabe von diesem Schüler vor Abgabe + Anhang hinzufügen Abgabe von %1$s Keine Abgabe vorhanden + Anhänge %1$d%% Feedback Du hast %1$d%% richtig gelöst Kein Bewertungstext vorhanden + Anhängen + Datei + Foto aufnehmen Dateien @@ -133,11 +138,19 @@ Der Ordner ist leer Ordner Dateien + Die Datei konnte nicht geöffnet werden Die Datei wurde nicht gefunden Es ist keine App installiert, die den Dateityp \'%1$s\' öffnen kann Die Datei wird geladen… Datei \'%1$s\' wurde erfolgreich in Downloads gespeichert! Die Datei konnte nicht gespeichert werden Ohne die Berechtigung kann die Datei nicht gespeichert werden + Datei wird hochgeladen… + Die Datei wurde hochgeladen! + Die Datei konnte nicht hochgeladen werden + Ohne die Berechtigung kann keine Datei zum Upload ausgewählt werden + Die Datei konnte nicht geöffnet werden + Es konnte keine temporäre Datei erstellt werden + Es wurde keine Kamera gefunden diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index f3b4ff92..2ae29271 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -80,7 +80,7 @@ end + + + + +