Migrate to Compose

This commit is contained in:
Sebastian Seedorf
2021-11-14 22:44:39 +01:00
parent f9c2f9e6cf
commit def91f28a2
24 changed files with 894 additions and 316 deletions

View File

@@ -87,5 +87,7 @@ dependencies {
implementation "androidx.compose.foundation:foundation:$compose_version"
implementation "androidx.compose.foundation:foundation-layout:$compose_version"
implementation "androidx.compose.material:material:$compose_version"
implementation "androidx.navigation:navigation-compose:2.4.0-beta02"
implementation "com.google.android.material:compose-theme-adapter:$compose_version"
}

View File

@@ -3,40 +3,35 @@ package de.sebse.fuplanner2
import android.accounts.Account
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.TextView
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.lifecycle.*
import androidx.navigation.NavOptions
import androidx.navigation.findNavController
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.navigateUp
import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.composethemeadapter.MdcTheme
import de.sebse.fuplanner2.auth.AppAccounts
import de.sebse.fuplanner2.database.AppDatabase
import de.sebse.fuplanner2.database.Course
import de.sebse.fuplanner2.database.User
import de.sebse.fuplanner2.databinding.ActivityMainBinding
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class MainActivity() : AppCompatActivity() {
private lateinit var appBarConfiguration: AppBarConfiguration
private lateinit var activityViewModel: MainActivityViewModel
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
activityViewModel = ViewModelProvider(this)[MainActivityViewModel::class.java]
setContent {
MdcTheme {
MainActivityComposable()
}
}
/*setContentView(R.layout.activity_main)
val navHostFragment =
supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
val navController = navHostFragment.navController
binding = ActivityMainBinding.inflate(layoutInflater)
setSupportActionBar(binding.appBarMain.toolbar)
@@ -45,7 +40,8 @@ class MainActivity() : AppCompatActivity() {
activityViewModel.user.observe(this) {
binding.navView.getHeaderView(0).run {
findViewById<TextView>(R.id.nav_header_title).text = getString(R.string.full_name, it?.firstName, it?.lastName)
findViewById<TextView>(R.id.nav_header_title).text =
getString(R.string.full_name, it?.firstName, it?.lastName)
findViewById<TextView>(R.id.nav_header_subtitle).text = it?.email
}
}
@@ -57,7 +53,11 @@ class MainActivity() : AppCompatActivity() {
)
actionView = if (it == 0)
null
else layoutInflater.inflate(R.layout.nav_action_view_counter, binding.navView, false).apply {
else layoutInflater.inflate(
R.layout.nav_action_view_counter,
binding.navView,
false
).apply {
findViewById<TextView>(R.id.counterText).text = when (it) {
in 0..99 -> it.toString()
else -> "99+"
@@ -68,7 +68,7 @@ class MainActivity() : AppCompatActivity() {
activityViewModel.latestSemester.observe(this) {
val courseOrder = binding.navView.menu.findItem(R.id.nav_courses).order
var i = binding.navView.menu.size() - 1
while(i >= 0) {
while (i >= 0) {
val menuItem: MenuItem = binding.navView.menu.getItem(i--)
if (menuItem.order / 100 == courseOrder / 100 && menuItem.order != courseOrder) {
binding.navView.menu.removeItem(menuItem.itemId)
@@ -76,20 +76,26 @@ class MainActivity() : AppCompatActivity() {
}
it.mapIndexed { index, course ->
val itemOrder = courseOrder / 100 * 100 + index + 2
binding.navView.menu.add(0, itemOrder, itemOrder, course.title).setOnMenuItemClickListener {
navController.navigate(
R.id.course_details,
bundleOf("courseId" to course.uid, "title" to course.title),
NavOptions.Builder().setPopUpTo(R.id.nav_courses, false).build()
)
binding.drawerLayout.closeDrawers()
false
}
binding.navView.menu.add(0, itemOrder, itemOrder, course.title)
.setOnMenuItemClickListener {
navController.navigate(
R.id.course_details,
bundleOf("courseId" to course.uid, "title" to course.title),
NavOptions.Builder().setPopUpTo(R.id.nav_courses, false).build()
)
binding.drawerLayout.closeDrawers()
false
}
}
}
appBarConfiguration = AppBarConfiguration(
setOf(R.id.nav_courses, R.id.nav_canteen, R.id.nav_schedule, R.id.nav_notifications),
setOf(
R.id.nav_courses,
R.id.nav_canteen,
R.id.nav_schedule,
R.id.nav_notifications
),
binding.drawerLayout
)
setupActionBarWithNavController(navController, appBarConfiguration)
@@ -98,7 +104,7 @@ class MainActivity() : AppCompatActivity() {
if (intent.getBooleanExtra(EXTRA_OPEN_NOTIFICATIONS, false)) {
navController.navigate(R.id.nav_notifications)
intent.putExtra(EXTRA_OPEN_NOTIFICATIONS, false)
}
}*/
}
override fun onStart() {
@@ -115,7 +121,7 @@ class MainActivity() : AppCompatActivity() {
activityViewModel.updateSelectedUser(selectedAccount)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
/*override fun onCreateOptionsMenu(menu: Menu): Boolean {
// Inflate the menu; this adds items to the action bar if it is present.
//menuInflater.inflate(R.menu.main, menu)
return true
@@ -126,6 +132,13 @@ class MainActivity() : AppCompatActivity() {
return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return onNavDestinationSelected(
item,
findNavController(R.id.nav_host_fragment)
) || super.onOptionsItemSelected(item)
}*/
companion object {
const val EXTRA_OPEN_NOTIFICATIONS: String = "EXTRA_OPEN_NOTIFICATIONS"
const val EXTRA_NETWORK_ERROR = "EXTRA_NETWORK_ERROR"
@@ -141,7 +154,6 @@ class MainActivityViewModel : ViewModel() {
val notificationCnt: LiveData<Int> = database.notificationDao().getUnreadRowCount()
val latestSemester: LiveData<List<Course>> = database.courseDao().getLatestSemester()
@DelicateCoroutinesApi
fun updateSelectedUser(account: Account) {
GlobalScope.launch {
user.postValue(database.userDao().findByUsername(account.name))

View File

@@ -43,7 +43,8 @@ class StartupActivity : AppCompatActivity() {
AppAccounts.RefreshResults.SUCCESS -> {
AppAccounts.getInstance().setPeriodicSync()
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_TASK_ON_HOME
intent.flags =
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_TASK_ON_HOME
if (it == AppAccounts.RefreshResults.NETWORK_ERROR)
intent.putExtra(MainActivity.EXTRA_NETWORK_ERROR, true)
if (it == AppAccounts.RefreshResults.UNSPECIFIED_ERROR)

View File

@@ -0,0 +1,156 @@
package de.sebse.fuplanner2
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.selection.selectable
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavOptions
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import com.google.android.material.composethemeadapter.MdcTheme
import kotlinx.coroutines.launch
abstract class NavLinks(
@StringRes val title: Int,
@DrawableRes val drawable: Int,
val route: String
)
@Composable
fun DrawerLayout(
appName: String,
menu: List<NavLinks>,
startDestination: String,
builder: NavGraphBuilder.(Tools) -> Unit
) {
val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed))
val scope = rememberCoroutineScope()
val navController = rememberNavController()
val (title, setTitle) = remember {
mutableStateOf(appName)
}
val tools = Tools(setTitle = { setTitle(it) }, navController)
Scaffold(
scaffoldState = scaffoldState,
topBar = {
TopBar(title = title) {
scope.launch {
scaffoldState.drawerState.open()
}
}
},
drawerContent = {
Drawer(currentRoute = navController.currentDestination?.route, menu = menu) {
scope.launch { scaffoldState.drawerState.close() }
navController.navigate(it)
}
},
) {
NavHost(navController, startDestination = startDestination) {
this.apply { builder(tools) }
}
}
}
class Tools(val setTitle: (String) -> Unit, private val navController: NavHostController) {
private val navOptions = NavOptions.Builder()
.setEnterAnim(R.anim.slide_in_right)
.setExitAnim(R.anim.slide_out_left)
.setPopEnterAnim(R.anim.slide_in_left)
.setPopExitAnim(R.anim.slide_out_right)
.build();
fun navBack() {
this.navController.popBackStack()
}
fun navTo(route: String) {
this.navController.navigate(route, navOptions = navOptions)
}
}
@Composable
fun TopBar(title: String = "", onOpenClick: () -> Unit) {
TopAppBar(
title = {
Text(
text = title
)
},
navigationIcon = {
IconButton(onClick = onOpenClick) {
Icon(Icons.Filled.Menu, "")
}
}
)
}
@Composable
fun Drawer(currentRoute: String?, menu: List<NavLinks>, onItemClick: (route: String) -> Unit) {
Column(
Modifier
.widthIn(min = 300.dp, max = 500.dp)
.fillMaxHeight()
) {
Icon(
painter = painterResource(R.drawable.ic_logo_mono),
contentDescription = "App icon"
)
DrawerItems(currentRoute, menu = menu, onItemClick)
}
}
@Composable
fun DrawerItems(currentRoute: String?, menu: List<NavLinks>, onItemClick: (route: String) -> Unit) {
menu.forEach { screen ->
val isSelected = currentRoute == screen.route
val modifier = Modifier
.padding(top = 24.dp)
.selectable(isSelected) { onItemClick(screen.route) }
Row(
modifier = modifier
) {
Icon(
painter = painterResource(screen.drawable),
contentDescription = stringResource(screen.title)
)
Text(
text = stringResource(screen.title),
style = MaterialTheme.typography.h6
)
}
}
}
@Preview
@Composable
fun TopBarPreview() {
MdcTheme {
TopBar(
title = "A title"
) { }
}
}
@Preview
@Composable
fun DrawerPreview() {
MdcTheme {
Drawer(currentRoute = "courses", menu = screens) { }
}
}

View File

@@ -0,0 +1,46 @@
package de.sebse.fuplanner2
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import de.sebse.fuplanner2.ui.courses.CoursesScreen
import de.sebse.fuplanner2.ui.details.components.CourseDetailsScreen
sealed class MenuItem {
object Courses : NavLinks(R.string.menu_courses, R.drawable.ic_menu_courses, "courses")
object Canteen : NavLinks(R.string.menu_canteen, R.drawable.ic_menu_canteen, "canteen")
object Schedule : NavLinks(R.string.menu_schedule, R.drawable.ic_menu_event, "schedule")
object Notifications :
NavLinks(R.string.menu_notifications, R.drawable.ic_menu_notifications, "navigation")
}
val screens = listOf(
MenuItem.Courses,
MenuItem.Canteen,
MenuItem.Schedule,
MenuItem.Notifications
)
@Composable
fun MainActivityComposable() {
DrawerLayout(
appName = stringResource(R.string.app_name),
menu = screens,
startDestination = MenuItem.Courses.route
) { tools ->
composable(route = MenuItem.Courses.route) {
CoursesScreen(tools)
}
composable(
arguments = listOf(
navArgument("id") { type = NavType.LongType }
),
route = "${MenuItem.Courses.route}/{id}"
) {
val id = it.arguments!!.getLong("id")
CourseDetailsScreen(tools, id)
}
}
}

View File

@@ -1,49 +0,0 @@
package de.sebse.fuplanner2.ui.courses
import android.os.Bundle
import android.provider.Settings.Global.getString
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.platform.ComposeView
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.google.android.material.composethemeadapter.MdcTheme
import de.sebse.fuplanner2.databinding.FragmentRefreshRecyclerBinding
class CoursesFragment : Fragment() {
private lateinit var coursesViewModel: CoursesViewModel
private lateinit var navController: NavController
private lateinit var binding: FragmentRefreshRecyclerBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
navController = findNavController()
coursesViewModel = ViewModelProvider(this).get(CoursesViewModel::class.java)
binding = FragmentRefreshRecyclerBinding.inflate(inflater, container, false)
return ComposeView(requireContext()).apply {
setContent {
MdcTheme {
val courses = coursesViewModel.text.observeAsState(listOf())
LazyColumn {
items(courses.value) { course -> CourseItem(course) {
course.uid?.let {
navController.navigate(CoursesFragmentDirections.actionNavHomeToCourseDetails(it, course.title))
}
} }
}
}
}
}
}
}

View File

@@ -6,5 +6,5 @@ import de.sebse.fuplanner2.database.AppDatabase
import de.sebse.fuplanner2.database.Course
class CoursesViewModel : ViewModel() {
val text: LiveData<List<Course>> = AppDatabase.getInstance().courseDao().getAll()
}
val list: LiveData<List<Course>> = AppDatabase.getInstance().courseDao().getAll()
}

View File

@@ -1,7 +1,9 @@
package de.sebse.fuplanner2.ui.courses
import android.content.res.Configuration
import android.util.Log
import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -17,6 +19,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Person
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
@@ -24,12 +28,56 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import com.google.android.material.composethemeadapter.MdcTheme
import de.sebse.fuplanner2.MenuItem
import de.sebse.fuplanner2.R
import de.sebse.fuplanner2.Tools
import de.sebse.fuplanner2.database.Course
import de.sebse.fuplanner2.database.Lecturer
import de.sebse.fuplanner2.ui.details.CoursePreviewProvider
import de.sebse.fuplanner2.utils.color.getColor
@Composable
fun CoursesScreen(tools: Tools) {
val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
}
val coursesViewModel = ViewModelProvider(viewModelStoreOwner)[CoursesViewModel::class.java]
val courses = coursesViewModel.list.observeAsState(listOf()).value
val groups =
courses.groupBy { (if (it.isSummerSemester) "+" else "-") + (it.year ?: 0) }
val title = stringResource(R.string.menu_courses)
LaunchedEffect(title) {
tools.setTitle(title)
}
MdcTheme {
GroupedCourseList(groups = groups) { course ->
course.uid?.let {
Log.d("WHERE TO GO", "${MenuItem.Courses.route}/$it")
tools.navTo("${MenuItem.Courses.route}/$it")
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun GroupedCourseList(groups: Map<String, List<Course>>, onclick: (course: Course) -> Unit) {
LazyColumn {
for ((key, value) in groups.entries) {
stickyHeader(key = key) {
Text(text = key)
}
items(value) { course ->
CourseItem(course) { onclick(course) }
}
}
}
}
@Composable
fun CourseList(courses: List<Course>, onclick: () -> Unit) {
LazyColumn {
@@ -64,7 +112,8 @@ fun CourseItem(id: Long?, title: String, lecturers: String, type: String, onclic
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onclick)
.padding(dimensionResource(R.dimen.card_view_margin))
.padding(dimensionResource(R.dimen.card_view_margin)),
elevation = dimensionResource(R.dimen.card_view_elevation)
) {
Column(
modifier = Modifier
@@ -113,37 +162,9 @@ fun CourseItemHint(icon: ImageVector, @StringRes imageAltRes: Int, text: String)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun CourseListPreview() {
val course = Course(
userId = 1,
lastRefreshed = 1,
lvNumber = hashSetOf("1000"),
title = "Kurs mit tollem Namen",
description = "Beschreibung",
internalId = "165464635",
isSummerSemester = false,
year = 2020,
lecturers = listOf(
Lecturer(
firstName = "Max",
lastName = "Maurer",
email = "some@mail.com",
isResponsible = false
),
Lecturer(
firstName = "Peter",
lastName = "Engelbert",
email = "coolio@example.com",
isResponsible = false
)
),
moduleType = 1236,
type = "Type"
)
MdcTheme {
CourseList(
listOf(
course, course, course
)
CoursePreviewProvider().values.take(3).toList()
) { }
}
}
@@ -151,14 +172,8 @@ fun CourseListPreview() {
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview
@Composable
fun CourseItemPreview() {
fun CourseItemPreview(@PreviewParameter(CoursePreviewProvider::class, 1) course: Course) {
MdcTheme {
CourseItem(
12411111111,
"Höhere Algorithmik",
"M. Berta, P. Parker",
"Vorlesung"
) {}
CourseItem(course) {}
}
}

View File

@@ -6,61 +6,47 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.res.stringResource
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.work.workDataOf
import com.google.android.material.composethemeadapter.MdcTheme
import de.sebse.fuplanner2.R
import de.sebse.fuplanner2.auth.AppAccounts
import de.sebse.fuplanner2.database.Lecturer
import de.sebse.fuplanner2.databinding.FragmentRefreshRecyclerBinding
import de.sebse.fuplanner2.utils.enqueueOneTimeWork
import de.sebse.fuplanner2.worker.AbstractAccountWorker.Companion.KEY_ACCOUNT_NAME
import de.sebse.fuplanner2.worker.AbstractAccountWorker.Companion.KEY_COURSE_ID
import de.sebse.fuplanner2.worker.CourseWorker
class DetailsFragment : Fragment() {
private var title: String = ""
private val args: DetailsFragmentArgs by navArgs()
private val detailsViewModel: DetailsViewModel by viewModels { DetailsViewModelFactory(args.courseId) }
private lateinit var navController: NavController
private lateinit var binding: FragmentRefreshRecyclerBinding
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val viewManager = LinearLayoutManager(context)
val viewAdapter = DetailsAdapter(this::launchFragment, this::sendMail)
return inflater.inflate(R.layout.fragment_refresh_recycler, container, false).apply {
binding.recyclerView.apply {
setHasFixedSize(true)
layoutManager = viewManager
adapter = viewAdapter
): View {
navController = findNavController()
return ComposeView(requireContext()).apply {
setContent {
val course = detailsViewModel.course.observeAsState(null).value
(activity as? AppCompatActivity)?.supportActionBar?.title = args.title
MdcTheme {
Column {
Text(
text = stringResource(R.string.description),
style = MaterialTheme.typography.h5
)
}
}
}
binding.swipeRefreshLayout.setOnRefreshListener {
enqueueOneTimeWork<CourseWorker>(context.applicationContext) {
it.setInputData(workDataOf(
KEY_ACCOUNT_NAME to AppAccounts.getInstance().selectedAccount?.name,
KEY_COURSE_ID to args.courseId
))
}.observe(viewLifecycleOwner, Observer {
if (it.state.isFinished)
binding.swipeRefreshLayout.isRefreshing = false
})
}
detailsViewModel.course.observe(viewLifecycleOwner, Observer {
viewAdapter.lecturers = it.lecturers
this@DetailsFragment.title = it.title
})
navController = findNavController()
}
}
@@ -68,22 +54,45 @@ class DetailsFragment : Fragment() {
val intent = Intent(Intent.ACTION_SENDTO)
intent.type = "text/html"
intent.data = Uri.fromParts("mailto", lecturer.email, null)
intent.putExtra(Intent.EXTRA_SUBJECT, this.title)
intent.putExtra(Intent.EXTRA_TEXT, getString(R.string.email_preview, lecturer.firstName, lecturer.lastName))
startActivity(Intent.createChooser(intent, getString(R.string.send_email, lecturer.firstName, lecturer.lastName)))
intent.putExtra(Intent.EXTRA_SUBJECT, args.title)
intent.putExtra(
Intent.EXTRA_TEXT,
getString(R.string.email_preview, lecturer.firstName, lecturer.lastName)
)
startActivity(
Intent.createChooser(
intent,
getString(R.string.send_email, lecturer.firstName, lecturer.lastName)
)
)
}
private fun launchFragment(btnType: DetailsAdapter.ButtonTypes) {
when (btnType) {
DetailsAdapter.ButtonTypes.DESCRIPTION ->
this.navController.navigate(DetailsFragmentDirections.actionCourseDetailsToDescriptionFragment(args.courseId, title))
this.navController.navigate(
DetailsFragmentDirections.actionCourseDetailsToDescriptionFragment(
args.courseId,
args.title
)
)
DetailsAdapter.ButtonTypes.RESOURCES -> TODO()
DetailsAdapter.ButtonTypes.GRADEBOOK -> TODO()
DetailsAdapter.ButtonTypes.ANNOUNCEMENTS ->
this.navController.navigate(DetailsFragmentDirections.actionCourseDetailsToCourseAnnouncements(args.courseId, title))
this.navController.navigate(
DetailsFragmentDirections.actionCourseDetailsToCourseAnnouncements(
args.courseId,
args.title
)
)
DetailsAdapter.ButtonTypes.ASSIGNMENTS -> TODO()
DetailsAdapter.ButtonTypes.EVENTS ->
this.navController.navigate(DetailsFragmentDirections.actionCourseDetailsToCourseEvents(args.courseId, title))
this.navController.navigate(
DetailsFragmentDirections.actionCourseDetailsToCourseEvents(
args.courseId,
args.title
)
)
}
}
}

View File

@@ -0,0 +1,118 @@
package de.sebse.fuplanner2.ui.details.components
import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.GridCells
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyVerticalGrid
import androidx.compose.foundation.lazy.items
import androidx.compose.material.Card
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.android.material.composethemeadapter.MdcTheme
import de.sebse.fuplanner2.R
import de.sebse.fuplanner2.Tools
import de.sebse.fuplanner2.database.Course
import de.sebse.fuplanner2.ui.details.CoursePreviewProvider
import de.sebse.fuplanner2.ui.details.DetailsViewModel
import de.sebse.fuplanner2.ui.details.DetailsViewModelFactory
import de.sebse.fuplanner2.ui.details.LecturerItem
@Composable
fun CourseDetailsScreen(tools: Tools, id: Long) {
val coursesViewModel: DetailsViewModel = viewModel(factory = DetailsViewModelFactory(id))
val state = coursesViewModel.course.observeAsState()
val title = state.value?.title
LaunchedEffect(title) {
title?.let { tools.setTitle(it) }
}
CourseDetailsScreen(state.value, id)
}
@Composable
fun CourseDetailsScreen(course: Course?, id: Long) {
Column {
QuickLinks(courseId = id)
Text(
text = stringResource(R.string.lecturers),
style = MaterialTheme.typography.h5
)
LazyColumn {
items(course?.lecturers ?: listOf()) {
LecturerItem(lecturer = it, courseTitle = course?.title ?: "")
}
}
}
}
data class QuickLinkProps(@StringRes val name: Int, val route: String)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun QuickLinks(courseId: Long) {
val list = listOf(
QuickLinkProps(R.string.description, "courses/$courseId/description"),
QuickLinkProps(R.string.resources, "courses/$courseId/resources"),
QuickLinkProps(R.string.assignments, "courses/$courseId/assignments"),
QuickLinkProps(R.string.announcements, "courses/$courseId/announcements"),
QuickLinkProps(R.string.events, "courses/$courseId/events")
)
LazyVerticalGrid(
cells = GridCells.Adaptive(150.dp),
contentPadding = PaddingValues(
horizontal = 12.dp,
vertical = 16.dp
),
) {
items(list.size) { index ->
Card(
backgroundColor = MaterialTheme.colors.secondary,
modifier = Modifier
.padding(dimensionResource(R.dimen.card_view_margin))
.fillMaxWidth(),
elevation = dimensionResource(R.dimen.card_view_elevation),
) {
Text(
text = stringResource(list[index].name),
color = MaterialTheme.colors.onSecondary,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.h6,
modifier = Modifier
.padding(dimensionResource(R.dimen.card_view_padding))
)
}
}
}
}
@Preview
@Composable
fun CourseDetailsScreenPreview(@PreviewParameter(CoursePreviewProvider::class, 1) course: Course) {
MdcTheme {
CourseDetailsScreen(course, course.uid!!)
}
}
@Preview
@Composable
fun QuickLinksPreview() {
MdcTheme {
QuickLinks(3)
}
}

View File

@@ -0,0 +1,114 @@
package de.sebse.fuplanner2.ui.details
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import com.google.android.material.composethemeadapter.MdcTheme
import de.sebse.fuplanner2.R
import de.sebse.fuplanner2.database.Lecturer
@Composable
fun LecturerItem(lecturer: Lecturer, courseTitle: String) {
val mailTemplate = stringResource(R.string.email_preview, lecturer.firstName, lecturer.lastName)
val fullName = stringResource(R.string.send_email, lecturer.firstName, lecturer.lastName)
val ctx = LocalContext.current
LecturerItem(lecturer) {
sendMail(ctx, mailTemplate, fullName, lecturer.email, courseTitle)
}
}
@Composable
fun LecturerItem(lecturer: Lecturer, click: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(dimensionResource(R.dimen.card_view_margin)),
elevation = dimensionResource(R.dimen.card_view_elevation)
) {
Row(
modifier = Modifier
.clickable(true, onClick = click)
.padding(dimensionResource(R.dimen.card_view_padding)),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
) {
val textDecor = if (lecturer.isResponsible) TextDecoration.Underline else null
Text(
text = stringResource(
R.string.full_name,
lecturer.firstName,
lecturer.lastName
),
textDecoration = textDecor,
style = MaterialTheme.typography.h6
)
Text(
text = lecturer.email,
style = MaterialTheme.typography.subtitle1
)
}
Column {
Icon(
Icons.Filled.Email,
contentDescription = stringResource(R.string.mail_icon),
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.height(70.dp)
.aspectRatio(1f, true)
)
}
}
}
}
@Preview
@Composable
fun LecturerItemPreview(@PreviewParameter(LecturerPreviewProvider::class, 2) lecturer: Lecturer) {
MdcTheme {
LecturerItem(lecturer, "Course Name")
}
}
private fun sendMail(
ctx: Context,
mailTemplate: String,
fullName: String,
mail: String,
courseTitle: String
) {
val intent = Intent(Intent.ACTION_SENDTO)
intent.type = "text/html"
intent.data = Uri.fromParts("mailto", mail, null)
intent.putExtra(Intent.EXTRA_SUBJECT, courseTitle)
intent.putExtra(
Intent.EXTRA_TEXT,
mailTemplate
)
ctx.startActivity(
Intent.createChooser(
intent,
fullName
)
)
}

View File

@@ -0,0 +1,62 @@
package de.sebse.fuplanner2.ui.details
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import de.sebse.fuplanner2.database.Course
import de.sebse.fuplanner2.database.Lecturer
import de.sebse.fuplanner2.utils.Faker
import de.sebse.fuplanner2.utils.getFaker
class LecturerPreviewProvider(private val faker: Faker = getFaker()) : PreviewParameterProvider<Lecturer> {
private val resp = faker.primitive.int(1, 2)
override val values = (0..10).map {
getLecturer(it < resp)
}.asSequence()
private fun getLecturer(isResponsible: Boolean): Lecturer {
val firstName = faker.name.firstName()
val lastName = faker.name.lastName()
return Lecturer(
firstName,
lastName,
faker.internet.email("$firstName $lastName"),
isResponsible
)
}
}
class CoursePreviewProvider(private val faker: Faker = getFaker()) : PreviewParameterProvider<Course> {
var isSummer = false
var year = 21
override val values = (0..10).map {
val res = getCourse()
val reduce = faker.primitive.bool(.3f)
year = if (reduce && isSummer) year-1 else year
isSummer = if (reduce) !isSummer else isSummer
res
}.asSequence()
private fun getCourse(): Course {
val diff = 1000L*60*60*24*5
val title =
"${faker.strings.title()} ${if (isSummer) "S" else "W"} ${if (isSummer) year else "$year/${year + 1}"}"
return Course(
faker.primitive.long(0, 100),
10,
faker.primitive.long(System.currentTimeMillis()-diff, System.currentTimeMillis()+diff),
isSummer,
year,
(0..10)
.map { faker.primitive.int(10000, 20000) }
.map { toString() }
.toHashSet(),
title,
faker.strings.courseTypes(),
faker.strings.lorem(100, 1000),
faker.strings.uuid(title),
faker.primitive.int(1, 2),
LecturerPreviewProvider(faker).values
.take(faker.primitive.int(1, 4))
.toList()
)
}
}

View File

@@ -0,0 +1,123 @@
package de.sebse.fuplanner2.utils
import java.util.*
import kotlin.math.ceil
import kotlin.random.Random
fun getFaker(): Faker {
return Faker(42)
}
class Faker(randomSeed: Int) {
private val random = Random(randomSeed)
val name: FakerName
get() = FakerName(random)
val internet: FakerInternet
get() = FakerInternet(random)
val primitive: FakerPrimitive
get() = FakerPrimitive(random)
val strings: FakerStrings
get() = FakerStrings(random)
}
class FakerName(private val random: Random) {
fun lastName(): String {
return LASTNAMES.random(random)
}
fun firstName(): String {
return FIRSTNAMES.random(random)
}
companion object {
private val LASTNAMES = listOf("Müller", "Schmidt", "Schneider", "Fischer", "Weber",
"Meyer", "Wagner", "Becker", "Schulz", "Hoffmann", "Schäfer", "Koch", "Bauer",
"Richter", "Klein", "Schröder", "Wolf", "Neumann", "Schwarz", "Zimmermann", "Braun",
"Hofmann", "Hartmann", "Schmitt", "Krüger", "Schmitz", "Lange", "Werner", "Krause",
"Meier")
private val FIRSTNAMES = listOf("Daisie", "Elisabeth", "Dyane", "Valry", "Terrye",
"Susette", "Karen", "Cecile", "Hesther", "Faunie", "Cissy", "Babb", "Cristionna",
"Paola", "Claribel", "Eveline", "June", "Ange", "Ebba", "Willow", "Aggi",
"Anne-Corinne", "Merl", "Di", "Kit", "Dreddy", "Fayette", "Griselda", "Emmey",
"Timothea", "Lucia", "Jacky", "Aline", "Rikki", "Sandye", "Aura", "Cordula", "Linell",
"Adeline", "Jessalyn", "Mariya", "Ema", "Hermine", "Annalee", "Agatha", "Wrennie",
"Florinda", "Malynda", "Bess", "Binni")
}
}
class FakerInternet(private val random: Random) {
fun email(name: String?): String {
val salt = name ?: FakerName(random).let {
"${it.firstName()} ${it.lastName()}"
}
val user = salt
.lowercase()
.replace(Regex("[ -]"), ".")
.replace(Regex("[^a-z0-9.]"), "")
return "$user@fu-berlin.de"
}
}
class FakerPrimitive(private val random: Random) {
fun int(min: Int, max: Int): Int {
return (min..max).random(random)
}
fun long(min: Long, max: Long): Long {
return (min..max).random(random)
}
fun bool(trueProbability: Float): Boolean {
return random.nextFloat() < trueProbability
}
}
class FakerStrings(private val random: Random) {
fun lorem(min: Int, max: Int): String {
val length = (min..max).random(random)
val loremArray = LOREM.split(" ").size
val n = ceil(length.toFloat() / loremArray).toInt()
val repeatedArray = ("$LOREM ")
.repeat(n)
.trim()
.split(" ")
return repeatedArray.run {
if (size-length < 1) throw Error("$n + $size<$length")
val from = (0 until size-length).random()
subList(from, from+length)
}.joinToString(" ")
}
fun title(): String {
return lorem(3, 7)
.split(" ")
.joinToString(" ") { word ->
word.replaceFirstChar { it.uppercase() }
}
}
fun uuid(namespace: String): String {
return UUID.nameUUIDFromBytes(namespace.encodeToByteArray()).toString();
}
fun courseTypes(): String {
return COURSE_TYPES.random(random)
}
companion object {
val LOREM = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy "+
"eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam "+
"voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita "+
"kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem "+
"ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor "+
"invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos "+
"et accusam et justo duo dolores et ea rebum."
val COURSE_TYPES = listOf("Vorlesung + Übung", "Vorlesung + Seminar am PC", "Praktikum",
"Vorlesung/Übung")
}
}

View File

@@ -25,6 +25,7 @@ object Notifications {
enum class CourseUpdateType {
REMOVED, UPDATED, ADDED;
companion object {
val values: List<CourseUpdateType> = values().toList()
}
@@ -32,13 +33,19 @@ object Notifications {
enum class CourseUpdateEntity {
COURSE, ANNOUNCEMENT, ASSIGNMENT, GRADE, RESOURCE, EVENT;
companion object {
val values: List<CourseUpdateEntity> = values().toList()
}
}
fun init(actCtx: Context) {
createNotificationChannel(actCtx, COURSE_UPDATE_CHANNEL_ID, R.string.channel_name, R.string.channel_description)
createNotificationChannel(
actCtx,
COURSE_UPDATE_CHANNEL_ID,
R.string.channel_name,
R.string.channel_description
)
}
private fun createNotificationChannel(
@@ -62,7 +69,11 @@ object Notifications {
}
}
fun <T: Updatable> courseUpdates(updates: UpdateResult<T>, database: AppDatabase, actCtx: Context) {
fun <T : Updatable> courseUpdates(
updates: UpdateResult<T>,
database: AppDatabase,
actCtx: Context
) {
val newNotifications = listOf(
CourseUpdateType.REMOVED to updates.removed,
CourseUpdateType.ADDED to updates.added,
@@ -95,7 +106,13 @@ object Notifications {
val notification = NotificationCompat.Builder(actCtx, COURSE_UPDATE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_logo_mono)
.setContentTitle(actCtx.getString(R.string.not_course_update_title))
.setContentText(actCtx.getQuantityString(R.plurals.not_course_update_text, unread.size, unread.size))
.setContentText(
actCtx.getQuantityString(
R.plurals.not_course_update_text,
unread.size,
unread.size
)
)
.setStyle(NotificationCompat.InboxStyle().also { inboxStyle ->
unread.forEach { not ->
not.getJsonData()?.let { json ->
@@ -122,7 +139,7 @@ object Notifications {
}
}
data class UpdateResult<T: Updatable>(
data class UpdateResult<T : Updatable>(
val removed: ArrayList<T> = arrayListOf(),
val added: ArrayList<T> = arrayListOf(),
val updated: ArrayList<T> = arrayListOf(),
@@ -137,12 +154,15 @@ data class UpdateResult<T: Updatable>(
}
val values: List<T>
get() {
return this.added + this.updated + this.unmodified
}
get() {
return this.added + this.updated + this.unmodified
}
}
fun <S: Updatable> mergeUpdatable(source: UpdateResult<Updatable>, plus: UpdateResult<S>): UpdateResult<Updatable> {
fun <S : Updatable> mergeUpdatable(
source: UpdateResult<Updatable>,
plus: UpdateResult<S>
): UpdateResult<Updatable> {
source.removed.addAll(plus.removed)
source.added.addAll(plus.added)
source.updated.addAll(plus.updated)
@@ -163,11 +183,13 @@ interface UpdatableCompanion {
type: Notifications.CourseUpdateType,
data: JsonObject
): CharSequence?
fun adapterText(
actCtx: Context,
type: Notifications.CourseUpdateType,
data: JsonObject
): CharSequence?
fun adapterCallback(
actCtx: Context,
type: Notifications.CourseUpdateType,
@@ -176,7 +198,7 @@ interface UpdatableCompanion {
): () -> Unit
}
fun <T: Updatable> updateResultOf(old: List<T>, new: List<T>): UpdateResult<T> {
fun <T : Updatable> updateResultOf(old: List<T>, new: List<T>): UpdateResult<T> {
val newIds = new
.map { it.getIdentifier() to Pair(it.getHash(), it) }
.toMap()
@@ -200,4 +222,3 @@ fun <T: Updatable> updateResultOf(old: List<T>, new: List<T>): UpdateResult<T> {
}
return UpdateResult(removed, added, updated, unmodified)
}

View File

@@ -14,6 +14,7 @@ import android.util.Log
import androidx.annotation.ColorInt
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.lifecycle.LiveData
import androidx.work.*
import de.sebse.fuplanner2.R
@@ -56,7 +57,7 @@ object console {
object xml {
fun decode(xml: String): String {
return if (Build.VERSION.SDK_INT >= 24) {
Html.fromHtml(xml , Html.FROM_HTML_MODE_LEGACY).toString()
Html.fromHtml(xml, Html.FROM_HTML_MODE_LEGACY).toString()
} else {
@Suppress("DEPRECATION")
Html.fromHtml(xml).toString()
@@ -76,7 +77,8 @@ object color {
// range for more beautiful colors
h = h / 30 * 30
val isNightMode = actCtx.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
val isNightMode =
actCtx.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
val doDarkColors = if (highContrast) !isNightMode else isNightMode
val (s, v) =
if (doDarkColors)
@@ -93,7 +95,7 @@ object color {
g: Double,
b: Double
): Int {
return 0xFF000000.toInt() + (r*0xFF).roundToInt() * 0x10000 + (g*0xFF).roundToInt() * 0x100 + (b*0xFF).roundToInt()
return 0xFF000000.toInt() + (r * 0xFF).roundToInt() * 0x10000 + (g * 0xFF).roundToInt() * 0x100 + (b * 0xFF).roundToInt()
}
private fun hsvToRgb(
@@ -118,24 +120,27 @@ object color {
}
}
inline fun <reified T: ListenableWorker> enqueueOneTimeWork(appCtx: Context, workBuilder: (OneTimeWorkRequest.Builder) -> OneTimeWorkRequest.Builder): LiveData<WorkInfo> {
inline fun <reified T : ListenableWorker> enqueueOneTimeWork(
appCtx: Context,
workBuilder: (OneTimeWorkRequest.Builder) -> OneTimeWorkRequest.Builder
): LiveData<WorkInfo> {
val work = workBuilder(OneTimeWorkRequestBuilder<T>()).build()
val workManager = WorkManager.getInstance(appCtx)
workManager.enqueue(work)
return workManager.getWorkInfoByIdLiveData(work.id)
}
fun <A, B>List<A>.pmap(f: suspend (A) -> B): List<B> = runBlocking {
fun <A, B> List<A>.pmap(f: suspend (A) -> B): List<B> = runBlocking {
map { async { f(it) } }.map { it.await() }
}
fun hashCodeOf(vararg elements: Any?): Int {
var code = 0
elements.forEach { code = code*31 + (it?.hashCode() ?: 0) }
elements.forEach { code = code * 31 + (it?.hashCode() ?: 0) }
return code
}
inline fun <reified T> cast(any: Any?) : T? = any as? T?
inline fun <reified T> cast(any: Any?): T? = any as? T?
fun String.toHtmlSpan(): Spanned = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
@@ -147,17 +152,23 @@ fun String.toHtmlSpan(): Spanned = if (Build.VERSION.SDK_INT >= Build.VERSION_CO
fun Context.getHtmlSpannedString(@StringRes id: Int): Spanned = getString(id).toHtmlSpan()
fun Context.getHtmlSpannedString(@StringRes id: Int, vararg formatArgs: Any?): Spanned = getString(id, *formatArgs).toHtmlSpan()
fun Context.getHtmlSpannedString(@StringRes id: Int, vararg formatArgs: Any?): Spanned =
getString(id, *formatArgs).toHtmlSpan()
fun Context.getQuantityString(@PluralsRes id: Int, quantity: Int): String = resources.getQuantityString(id, quantity)
fun Context.getQuantityString(@PluralsRes id: Int, quantity: Int): String =
resources.getQuantityString(id, quantity)
fun Context.getQuantityString(@PluralsRes id: Int, quantity: Int, vararg formatArgs: Any?): String = resources.getQuantityString(id, quantity, *formatArgs)
fun Context.getQuantityHtmlSpannedString(@PluralsRes id: Int, quantity: Int): Spanned = getQuantityString(id, quantity).toHtmlSpan()
fun Context.getQuantityHtmlSpannedString(@PluralsRes id: Int, quantity: Int, vararg formatArgs: Any?): Spanned = getQuantityString(id, quantity, *formatArgs).toHtmlSpan()
fun Context.getQuantityString(@PluralsRes id: Int, quantity: Int, vararg formatArgs: Any?): String =
resources.getQuantityString(id, quantity, *formatArgs)
fun Context.getQuantityHtmlSpannedString(@PluralsRes id: Int, quantity: Int): Spanned =
getQuantityString(id, quantity).toHtmlSpan()
fun Context.getQuantityHtmlSpannedString(
@PluralsRes id: Int,
quantity: Int,
vararg formatArgs: Any?
): Spanned = getQuantityString(id, quantity, *formatArgs).toHtmlSpan()
fun Long.timeAgoString(actCtx: Context): String {
@@ -173,9 +184,21 @@ fun Long.timeAgoString(actCtx: Context): String {
val diff = now - this
return when {
diff < MINUTE_MILLIS -> actCtx.getString(R.string.time_just_now)
diff < 60 * MINUTE_MILLIS -> actCtx.getQuantityString(R.plurals.time_minutes_ago, (diff / MINUTE_MILLIS).toInt(), diff / MINUTE_MILLIS)
diff < 24 * HOUR_MILLIS -> actCtx.getQuantityString(R.plurals.time_hours_ago, (diff / HOUR_MILLIS).toInt(), diff / HOUR_MILLIS)
else -> actCtx.getQuantityString(R.plurals.time_days_ago, (diff / DAY_MILLIS).toInt(), diff / DAY_MILLIS)
diff < 60 * MINUTE_MILLIS -> actCtx.getQuantityString(
R.plurals.time_minutes_ago,
(diff / MINUTE_MILLIS).toInt(),
diff / MINUTE_MILLIS
)
diff < 24 * HOUR_MILLIS -> actCtx.getQuantityString(
R.plurals.time_hours_ago,
(diff / HOUR_MILLIS).toInt(),
diff / HOUR_MILLIS
)
else -> actCtx.getQuantityString(
R.plurals.time_days_ago,
(diff / DAY_MILLIS).toInt(),
diff / DAY_MILLIS
)
}
}
@@ -236,4 +259,24 @@ fun String.dateStringToLong(format: String, locale: Locale): Long? {
e.printStackTrace()
null
}
}
}
class Either<A, B> private constructor(val _a: A? = null, val _b: B? = null) {
@Composable
fun <C> toValue(aTransform: @Composable (A) -> C, bTransform: @Composable (B) -> C): C {
return if (_a != null)
aTransform(_a)
else
bTransform(_b!!)
}
companion object {
fun <A, B> a(a: A): Either<A, B> {
return Either(a, null)
}
fun <A, B> b(b: B): Either<A, B> {
return Either(null, b)
}
}
}

View File

@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:openDrawer="start">
<include
android:id="@+id/app_bar_main"
layout="@layout/app_bar_main"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<com.google.android.material.navigation.NavigationView
android:id="@+id/nav_view"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true"
app:headerLayout="@layout/nav_header_main"
app:menu="@menu/activity_main_drawer" />
</androidx.drawerlayout.widget.DrawerLayout>

View File

@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="@style/FUTheme.AppBarOverlay">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
app:popupTheme="@style/FUTheme.PopupOverlay" />
</com.google.android.material.appbar.AppBarLayout>
<include layout="@layout/content_main" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:showIn="@layout/app_bar_main">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navGraph="@navigation/nav_graph" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -1,16 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/linear_layout"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:showIn="@layout/activity_main"
tools:context=".ui.courses.CoursesFragment">
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:scrollbars="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
android:layout_height="match_parent"
android:scrollbars="vertical" />
</LinearLayout>
</LinearLayout>

View File

@@ -1,16 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/swipe_refresh_layout"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:showIn="@layout/activity_main"
tools:context=".ui.courses.CoursesFragment">
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:scrollbars="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
android:layout_height="match_parent"
android:scrollbars="vertical" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@@ -2,29 +2,7 @@
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph"
app:startDestination="@+id/nav_courses">
<fragment
android:id="@+id/nav_courses"
android:name="de.sebse.fuplanner2.ui.courses.CoursesFragment"
android:label="@string/menu_courses"
tools:layout="@layout/fragment_refresh_recycler">
<action
android:id="@+id/action_nav_home_to_course_details"
app:destination="@id/course_details"
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right" />
<action
android:id="@+id/action_nav_courses_to_notificationFragment"
app:destination="@id/nav_notifications"
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right" />
</fragment>
android:id="@+id/nav_graph">
<fragment
android:id="@+id/nav_canteen"
@@ -47,8 +25,7 @@
</fragment>
<fragment
android:id="@+id/course_details"
android:name="de.sebse.fuplanner2.ui.details.DetailsFragment"
android:label="{title}">
android:name="de.sebse.fuplanner2.ui.details.DetailsFragment">
<action
android:id="@+id/action_course_details_to_descriptionFragment"
app:destination="@id/course_description"
@@ -62,14 +39,14 @@
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right" />
app:popExitAnim="@anim/slide_out_right" />
<action
android:id="@+id/action_course_details_to_course_announcements"
app:destination="@id/course_announcements"
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right" />
app:popExitAnim="@anim/slide_out_right" />
<argument
android:name="courseId"
app:argType="long" />
@@ -122,4 +99,4 @@
app:destination="@id/course_details"
app:popUpTo="@id/nav_courses" />
</fragment>
</navigation>
</navigation>

View File

@@ -60,6 +60,8 @@
<string name="dialog_time"><![CDATA[<b>Time:</b><br>%1$s - %2$s]]></string>
<string name="dialog_course_br"><![CDATA[<b>Course:</b><br>%1$s<br>]]></string>
<string name="course_type">Course Type</string>
<string name="description">Description</string>
<string name="resources">Resources</string>
<plurals name="not_course_update_text">
<item quantity="one">One course message</item>
<item quantity="other">%1$d course messages</item>

View File

@@ -1,17 +1,20 @@
package de.sebse.fuplanner2
import de.sebse.fuplanner2.utils.getFaker
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
class FakerTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
fun lorem() {
getFaker().strings.lorem(100, 1000)
getFaker().strings.lorem(100, 1000)
getFaker().strings.lorem(100, 1000)
getFaker().strings.lorem(100, 1000)
getFaker().strings.lorem(100, 1000)
}
}

View File

@@ -11,7 +11,7 @@ buildscript {
dependencies {
classpath 'com.android.tools.build:gradle:7.0.3'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'androidx.navigation:navigation-safe-args-gradle-plugin:2.3.5'
classpath 'androidx.navigation:navigation-safe-args-gradle-plugin:2.4.0-beta02'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files