From def91f28a226c3b4ef4e382779e57f30f5421912 Mon Sep 17 00:00:00 2001 From: Sebastian Seedorf Date: Sun, 14 Nov 2021 22:44:39 +0100 Subject: [PATCH] Migrate to Compose --- app/build.gradle | 2 + .../java/de/sebse/fuplanner2/MainActivity.kt | 84 ++++++---- .../de/sebse/fuplanner2/StartupActivity.kt | 3 +- .../java/de/sebse/fuplanner2/drawerLayout.kt | 156 ++++++++++++++++++ .../fuplanner2/mainActivityComponents.kt | 46 ++++++ .../fuplanner2/ui/courses/CoursesFragment.kt | 49 ------ .../fuplanner2/ui/courses/CoursesViewModel.kt | 4 +- .../sebse/fuplanner2/ui/courses/components.kt | 93 ++++++----- .../fuplanner2/ui/details/DetailsFragment.kt | 93 ++++++----- .../ui/details/components/courseDetails.kt | 118 +++++++++++++ .../ui/details/components/lecturer.kt | 114 +++++++++++++ .../fuplanner2/ui/details/previewProviders.kt | 62 +++++++ .../java/de/sebse/fuplanner2/utils/faking.kt | 123 ++++++++++++++ .../sebse/fuplanner2/utils/notifications.kt | 41 +++-- .../java/de/sebse/fuplanner2/utils/utils.kt | 79 +++++++-- app/src/main/res/layout/activity_main.xml | 26 --- app/src/main/res/layout/app_bar_main.xml | 25 --- app/src/main/res/layout/content_main.xml | 20 --- app/src/main/res/layout/fragment_recycler.xml | 11 +- .../res/layout/fragment_refresh_recycler.xml | 11 +- app/src/main/res/navigation/nav_graph.xml | 33 +--- app/src/main/res/values/strings.xml | 2 + .../de/sebse/fuplanner2/ExampleUnitTest.kt | 13 +- build.gradle | 2 +- 24 files changed, 894 insertions(+), 316 deletions(-) create mode 100644 app/src/main/java/de/sebse/fuplanner2/drawerLayout.kt create mode 100644 app/src/main/java/de/sebse/fuplanner2/mainActivityComponents.kt delete mode 100644 app/src/main/java/de/sebse/fuplanner2/ui/courses/CoursesFragment.kt create mode 100644 app/src/main/java/de/sebse/fuplanner2/ui/details/components/courseDetails.kt create mode 100644 app/src/main/java/de/sebse/fuplanner2/ui/details/components/lecturer.kt create mode 100644 app/src/main/java/de/sebse/fuplanner2/ui/details/previewProviders.kt create mode 100644 app/src/main/java/de/sebse/fuplanner2/utils/faking.kt delete mode 100644 app/src/main/res/layout/activity_main.xml delete mode 100644 app/src/main/res/layout/app_bar_main.xml delete mode 100644 app/src/main/res/layout/content_main.xml diff --git a/app/build.gradle b/app/build.gradle index 69807ce..3c0ff58 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" } diff --git a/app/src/main/java/de/sebse/fuplanner2/MainActivity.kt b/app/src/main/java/de/sebse/fuplanner2/MainActivity.kt index 4c2c09c..79dc3bb 100644 --- a/app/src/main/java/de/sebse/fuplanner2/MainActivity.kt +++ b/app/src/main/java/de/sebse/fuplanner2/MainActivity.kt @@ -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(R.id.nav_header_title).text = getString(R.string.full_name, it?.firstName, it?.lastName) + findViewById(R.id.nav_header_title).text = + getString(R.string.full_name, it?.firstName, it?.lastName) findViewById(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(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 = database.notificationDao().getUnreadRowCount() val latestSemester: LiveData> = database.courseDao().getLatestSemester() - @DelicateCoroutinesApi fun updateSelectedUser(account: Account) { GlobalScope.launch { user.postValue(database.userDao().findByUsername(account.name)) diff --git a/app/src/main/java/de/sebse/fuplanner2/StartupActivity.kt b/app/src/main/java/de/sebse/fuplanner2/StartupActivity.kt index 22870f6..dd29a99 100644 --- a/app/src/main/java/de/sebse/fuplanner2/StartupActivity.kt +++ b/app/src/main/java/de/sebse/fuplanner2/StartupActivity.kt @@ -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) diff --git a/app/src/main/java/de/sebse/fuplanner2/drawerLayout.kt b/app/src/main/java/de/sebse/fuplanner2/drawerLayout.kt new file mode 100644 index 0000000..a54bd3c --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/drawerLayout.kt @@ -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, + 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, 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, 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) { } + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/mainActivityComponents.kt b/app/src/main/java/de/sebse/fuplanner2/mainActivityComponents.kt new file mode 100644 index 0000000..ce1b656 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/mainActivityComponents.kt @@ -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) + } + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/courses/CoursesFragment.kt b/app/src/main/java/de/sebse/fuplanner2/ui/courses/CoursesFragment.kt deleted file mode 100644 index fe13607..0000000 --- a/app/src/main/java/de/sebse/fuplanner2/ui/courses/CoursesFragment.kt +++ /dev/null @@ -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)) - } - } } - } - } - } - } - } -} diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/courses/CoursesViewModel.kt b/app/src/main/java/de/sebse/fuplanner2/ui/courses/CoursesViewModel.kt index 9a4c0e9..89b9d50 100644 --- a/app/src/main/java/de/sebse/fuplanner2/ui/courses/CoursesViewModel.kt +++ b/app/src/main/java/de/sebse/fuplanner2/ui/courses/CoursesViewModel.kt @@ -6,5 +6,5 @@ import de.sebse.fuplanner2.database.AppDatabase import de.sebse.fuplanner2.database.Course class CoursesViewModel : ViewModel() { - val text: LiveData> = AppDatabase.getInstance().courseDao().getAll() -} \ No newline at end of file + val list: LiveData> = AppDatabase.getInstance().courseDao().getAll() +} diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/courses/components.kt b/app/src/main/java/de/sebse/fuplanner2/ui/courses/components.kt index 0fdff01..188a8f0 100644 --- a/app/src/main/java/de/sebse/fuplanner2/ui/courses/components.kt +++ b/app/src/main/java/de/sebse/fuplanner2/ui/courses/components.kt @@ -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>, 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, 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) {} } } - diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/details/DetailsFragment.kt b/app/src/main/java/de/sebse/fuplanner2/ui/details/DetailsFragment.kt index d9aae85..5f699ba 100644 --- a/app/src/main/java/de/sebse/fuplanner2/ui/details/DetailsFragment.kt +++ b/app/src/main/java/de/sebse/fuplanner2/ui/details/DetailsFragment.kt @@ -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(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 + ) + ) } } } diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/details/components/courseDetails.kt b/app/src/main/java/de/sebse/fuplanner2/ui/details/components/courseDetails.kt new file mode 100644 index 0000000..5de6a58 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/details/components/courseDetails.kt @@ -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) + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/details/components/lecturer.kt b/app/src/main/java/de/sebse/fuplanner2/ui/details/components/lecturer.kt new file mode 100644 index 0000000..abc979b --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/details/components/lecturer.kt @@ -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 + ) + ) +} diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/details/previewProviders.kt b/app/src/main/java/de/sebse/fuplanner2/ui/details/previewProviders.kt new file mode 100644 index 0000000..0d7c0e3 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/details/previewProviders.kt @@ -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 { + 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 { + 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() + ) + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/utils/faking.kt b/app/src/main/java/de/sebse/fuplanner2/utils/faking.kt new file mode 100644 index 0000000..a296fbd --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/utils/faking.kt @@ -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") + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/utils/notifications.kt b/app/src/main/java/de/sebse/fuplanner2/utils/notifications.kt index 22d25be..8a7e3c5 100644 --- a/app/src/main/java/de/sebse/fuplanner2/utils/notifications.kt +++ b/app/src/main/java/de/sebse/fuplanner2/utils/notifications.kt @@ -25,6 +25,7 @@ object Notifications { enum class CourseUpdateType { REMOVED, UPDATED, ADDED; + companion object { val values: List = values().toList() } @@ -32,13 +33,19 @@ object Notifications { enum class CourseUpdateEntity { COURSE, ANNOUNCEMENT, ASSIGNMENT, GRADE, RESOURCE, EVENT; + companion object { val values: List = 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 courseUpdates(updates: UpdateResult, database: AppDatabase, actCtx: Context) { + fun courseUpdates( + updates: UpdateResult, + 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( +data class UpdateResult( val removed: ArrayList = arrayListOf(), val added: ArrayList = arrayListOf(), val updated: ArrayList = arrayListOf(), @@ -137,12 +154,15 @@ data class UpdateResult( } val values: List - get() { - return this.added + this.updated + this.unmodified - } + get() { + return this.added + this.updated + this.unmodified + } } -fun mergeUpdatable(source: UpdateResult, plus: UpdateResult): UpdateResult { +fun mergeUpdatable( + source: UpdateResult, + plus: UpdateResult +): UpdateResult { 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 updateResultOf(old: List, new: List): UpdateResult { +fun updateResultOf(old: List, new: List): UpdateResult { val newIds = new .map { it.getIdentifier() to Pair(it.getHash(), it) } .toMap() @@ -200,4 +222,3 @@ fun updateResultOf(old: List, new: List): UpdateResult { } return UpdateResult(removed, added, updated, unmodified) } - diff --git a/app/src/main/java/de/sebse/fuplanner2/utils/utils.kt b/app/src/main/java/de/sebse/fuplanner2/utils/utils.kt index 618dc1b..97c46a5 100644 --- a/app/src/main/java/de/sebse/fuplanner2/utils/utils.kt +++ b/app/src/main/java/de/sebse/fuplanner2/utils/utils.kt @@ -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 enqueueOneTimeWork(appCtx: Context, workBuilder: (OneTimeWorkRequest.Builder) -> OneTimeWorkRequest.Builder): LiveData { +inline fun enqueueOneTimeWork( + appCtx: Context, + workBuilder: (OneTimeWorkRequest.Builder) -> OneTimeWorkRequest.Builder +): LiveData { val work = workBuilder(OneTimeWorkRequestBuilder()).build() val workManager = WorkManager.getInstance(appCtx) workManager.enqueue(work) return workManager.getWorkInfoByIdLiveData(work.id) } -fun List.pmap(f: suspend (A) -> B): List = runBlocking { +fun List.pmap(f: suspend (A) -> B): List = 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 cast(any: Any?) : T? = any as? T? +inline fun 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 } -} \ No newline at end of file +} + +class Either private constructor(val _a: A? = null, val _b: B? = null) { + @Composable + fun toValue(aTransform: @Composable (A) -> C, bTransform: @Composable (B) -> C): C { + return if (_a != null) + aTransform(_a) + else + bTransform(_b!!) + } + + companion object { + fun a(a: A): Either { + return Either(a, null) + } + + fun b(b: B): Either { + return Either(null, b) + } + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 19d21dc..0000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/app_bar_main.xml b/app/src/main/res/layout/app_bar_main.xml deleted file mode 100644 index 18a457e..0000000 --- a/app/src/main/res/layout/app_bar_main.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/content_main.xml b/app/src/main/res/layout/content_main.xml deleted file mode 100644 index 47fadaf..0000000 --- a/app/src/main/res/layout/content_main.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_recycler.xml b/app/src/main/res/layout/fragment_recycler.xml index f27235a..d919bb7 100644 --- a/app/src/main/res/layout/fragment_recycler.xml +++ b/app/src/main/res/layout/fragment_recycler.xml @@ -1,16 +1,13 @@ + android:layout_height="match_parent"> + android:layout_height="match_parent" + android:scrollbars="vertical" /> - \ No newline at end of file + diff --git a/app/src/main/res/layout/fragment_refresh_recycler.xml b/app/src/main/res/layout/fragment_refresh_recycler.xml index 65efc5e..c993537 100644 --- a/app/src/main/res/layout/fragment_refresh_recycler.xml +++ b/app/src/main/res/layout/fragment_refresh_recycler.xml @@ -1,16 +1,13 @@ + android:layout_height="match_parent"> + android:layout_height="match_parent" + android:scrollbars="vertical" /> - \ No newline at end of file + diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index 5d78a7d..21a803a 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -2,29 +2,7 @@ - - - - - + android:id="@+id/nav_graph"> + android:name="de.sebse.fuplanner2.ui.details.DetailsFragment"> + app:popExitAnim="@anim/slide_out_right" /> + app:popExitAnim="@anim/slide_out_right" /> @@ -122,4 +99,4 @@ app:destination="@id/course_details" app:popUpTo="@id/nav_courses" /> - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 77bc890..661f43f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -60,6 +60,8 @@ Time:
%1$s - %2$s]]> Course:

%1$s
]]> Course Type + Description + Resources One course message %1$d course messages diff --git a/app/src/test/java/de/sebse/fuplanner2/ExampleUnitTest.kt b/app/src/test/java/de/sebse/fuplanner2/ExampleUnitTest.kt index d093864..c52f383 100644 --- a/app/src/test/java/de/sebse/fuplanner2/ExampleUnitTest.kt +++ b/app/src/test/java/de/sebse/fuplanner2/ExampleUnitTest.kt @@ -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) } } diff --git a/build.gradle b/build.gradle index 6e3715c..75a8676 100644 --- a/build.gradle +++ b/build.gradle @@ -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