Compare commits

..

10 Commits

Author SHA1 Message Date
Sebastian Seedorf
04f7e29b8a Added announcement list screen 2021-11-20 14:00:23 +01:00
Sebastian Seedorf
46e431b277 Added course detail screens 2021-11-20 02:18:46 +01:00
Sebastian Seedorf
348fdbf5d6 Extract components 2 2021-11-19 21:58:47 +01:00
Sebastian Seedorf
c07730587d Extract components 2021-11-19 21:46:04 +01:00
Sebastian Seedorf
adff1fea0c CourseDetailsScreen improvements 2021-11-19 21:06:34 +01:00
Sebastian Seedorf
cb905fc9a6 Add announcements to compose 2021-11-19 18:20:03 +01:00
Sebastian Seedorf
8302884fdc Cleanup 2021-11-14 22:58:36 +01:00
Sebastian Seedorf
def91f28a2 Migrate to Compose 2021-11-14 22:44:39 +01:00
Sebastian Seedorf
f9c2f9e6cf Lint 2021-11-11 18:55:58 +01:00
Sebastian Seedorf
f2133abea6 First Compose layout 2021-11-11 18:30:12 +01:00
60 changed files with 2086 additions and 827 deletions

View File

@@ -32,10 +32,12 @@ android {
buildFeatures { buildFeatures {
viewBinding true viewBinding true
compose true
} }
dataBinding { composeOptions {
enabled = true kotlinCompilerVersion kotlin_version
kotlinCompilerExtensionVersion compose_version
} }
// To inline the bytecode built with JVM target 1.8 into // To inline the bytecode built with JVM target 1.8 into
@@ -54,7 +56,6 @@ android {
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar']) implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.3.1' implementation 'androidx.appcompat:appcompat:1.3.1'
implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'androidx.legacy:legacy-support-v4:1.0.0'
@@ -77,4 +78,16 @@ dependencies {
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
// Compose
implementation "androidx.compose.runtime:runtime:1.0.5"
implementation "androidx.compose.runtime:runtime-livedata:1.0.5"
implementation "androidx.compose.ui:ui:1.0.5"
implementation "androidx.compose.ui:ui-tooling:1.0.5"
implementation "androidx.compose.foundation:foundation:1.0.5"
implementation "androidx.compose.foundation:foundation-layout:1.0.5"
implementation "androidx.compose.material:material:1.0.5"
implementation "androidx.navigation:navigation-compose:2.4.0-beta02"
implementation "com.google.android.material:compose-theme-adapter:1.1.0"
} }

View File

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

View File

@@ -43,7 +43,8 @@ class StartupActivity : AppCompatActivity() {
AppAccounts.RefreshResults.SUCCESS -> { AppAccounts.RefreshResults.SUCCESS -> {
AppAccounts.getInstance().setPeriodicSync() AppAccounts.getInstance().setPeriodicSync()
val intent = Intent(this, MainActivity::class.java) 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) if (it == AppAccounts.RefreshResults.NETWORK_ERROR)
intent.putExtra(MainActivity.EXTRA_NETWORK_ERROR, true) intent.putExtra(MainActivity.EXTRA_NETWORK_ERROR, true)
if (it == AppAccounts.RefreshResults.UNSPECIFIED_ERROR) if (it == AppAccounts.RefreshResults.UNSPECIFIED_ERROR)

View File

@@ -9,7 +9,6 @@ import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.beust.klaxon.JsonObject import com.beust.klaxon.JsonObject
import de.sebse.fuplanner2.R import de.sebse.fuplanner2.R
import de.sebse.fuplanner2.ui.notification.NotificationFragmentDirections
import de.sebse.fuplanner2.utils.* import de.sebse.fuplanner2.utils.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
@@ -82,7 +81,7 @@ data class Announcement (
?.let { AppDatabase.getInstance().courseDao().getCourseById2(it.courseId) } ?.let { AppDatabase.getInstance().courseDao().getCourseById2(it.courseId) }
?.let { ?.let {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
navController.navigate(NotificationFragmentDirections.actionNavNotificationsToCourseDetails(it.uid!!, it.title)) //navController.navigate(NotificationFragmentDirections.actionNavNotificationsToCourseDetails(it.uid!!, it.title))
} }
} }
} }

View File

@@ -1,20 +1,26 @@
package de.sebse.fuplanner2.database package de.sebse.fuplanner2.database
import androidx.lifecycle.LiveData
import androidx.paging.DataSource import androidx.paging.DataSource
import androidx.room.* import androidx.room.*
import de.sebse.fuplanner2.utils.console
@Dao @Dao
interface AnnouncementDao { interface AnnouncementDao {
@Query("SELECT * FROM announcement WHERE courseId = :courseId ORDER BY createdOn ASC") @Query("SELECT * FROM announcement WHERE courseId = :courseId ORDER BY createdOn DESC")
fun getAll1(courseId: Long): DataSource.Factory<Int, Announcement> fun getAll1(courseId: Long): DataSource.Factory<Int, Announcement>
@Query("SELECT * FROM announcement WHERE courseId = :courseId ORDER BY createdOn ASC") @Query("SELECT * FROM announcement WHERE courseId = :courseId ORDER BY createdOn DESC")
fun getAll2(courseId: Long): List<Announcement> fun getAll2(courseId: Long): List<Announcement>
@Query("SELECT * FROM announcement WHERE courseId = :courseId ORDER BY createdOn DESC")
fun getAll3(courseId: Long): LiveData<List<Announcement>>
@Query("SELECT * FROM announcement WHERE uid = :announcementId LIMIT 1") @Query("SELECT * FROM announcement WHERE uid = :announcementId LIMIT 1")
fun getAnnouncementById(announcementId: Long): Announcement fun getAnnouncementById(announcementId: Long): Announcement
@Query("SELECT * FROM announcement WHERE uid = :announcementId LIMIT 1")
fun getAnnouncementById2(announcementId: Long): LiveData<Announcement>
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(announcement: Announcement): Long fun insert(announcement: Announcement): Long

View File

@@ -9,7 +9,6 @@ import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.beust.klaxon.JsonObject import com.beust.klaxon.JsonObject
import de.sebse.fuplanner2.R import de.sebse.fuplanner2.R
import de.sebse.fuplanner2.ui.notification.NotificationFragmentDirections
import de.sebse.fuplanner2.utils.* import de.sebse.fuplanner2.utils.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
@@ -63,7 +62,7 @@ data class Course (
actCtx: Context, actCtx: Context,
type: Notifications.CourseUpdateType, type: Notifications.CourseUpdateType,
data: JsonObject data: JsonObject
): CharSequence? = when (type) { ): CharSequence = when (type) {
Notifications.CourseUpdateType.REMOVED -> actCtx.getHtmlSpannedString(R.string.not_course_update_course_removed, data.string("title")) Notifications.CourseUpdateType.REMOVED -> actCtx.getHtmlSpannedString(R.string.not_course_update_course_removed, data.string("title"))
Notifications.CourseUpdateType.UPDATED -> actCtx.getHtmlSpannedString(R.string.not_course_update_course_updated, data.string("title")) Notifications.CourseUpdateType.UPDATED -> actCtx.getHtmlSpannedString(R.string.not_course_update_course_updated, data.string("title"))
Notifications.CourseUpdateType.ADDED -> actCtx.getHtmlSpannedString(R.string.not_course_update_course_added, data.string("title")) Notifications.CourseUpdateType.ADDED -> actCtx.getHtmlSpannedString(R.string.not_course_update_course_added, data.string("title"))
@@ -73,7 +72,7 @@ data class Course (
actCtx: Context, actCtx: Context,
type: Notifications.CourseUpdateType, type: Notifications.CourseUpdateType,
data: JsonObject data: JsonObject
): CharSequence? = when (type) { ): CharSequence = when (type) {
Notifications.CourseUpdateType.REMOVED -> actCtx.getHtmlSpannedString(R.string.adapter_course_update_course_removed, data.string("title")) Notifications.CourseUpdateType.REMOVED -> actCtx.getHtmlSpannedString(R.string.adapter_course_update_course_removed, data.string("title"))
Notifications.CourseUpdateType.UPDATED -> actCtx.getHtmlSpannedString(R.string.adapter_course_update_course_updated, data.string("title")) Notifications.CourseUpdateType.UPDATED -> actCtx.getHtmlSpannedString(R.string.adapter_course_update_course_updated, data.string("title"))
Notifications.CourseUpdateType.ADDED -> actCtx.getHtmlSpannedString(R.string.adapter_course_update_course_added, data.string("title")) Notifications.CourseUpdateType.ADDED -> actCtx.getHtmlSpannedString(R.string.adapter_course_update_course_added, data.string("title"))
@@ -91,7 +90,7 @@ data class Course (
?.let { AppDatabase.getInstance().courseDao().getCourseById2(it) } ?.let { AppDatabase.getInstance().courseDao().getCourseById2(it) }
?.let { ?.let {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
navController.navigate(NotificationFragmentDirections.actionNavNotificationsToCourseDetails(it.uid!!, it.title)) //navController.navigate(NotificationFragmentDirections.actionNavNotificationsToCourseDetails(it.uid!!, it.title))
} }
} }
} }

View File

@@ -9,6 +9,7 @@ interface CourseDao {
fun getAll(): LiveData<List<Course>> fun getAll(): LiveData<List<Course>>
@Query("SELECT * FROM course INNER JOIN (SELECT year, CASE WHEN isSummerSemester THEN 1 ELSE 0 END AS semester FROM course GROUP BY year, isSummerSemester ORDER BY year DESC, semester ASC LIMIT 1) current WHERE current.year = course.year AND current.semester = CASE WHEN course.isSummerSemester THEN 1 ELSE 0 END ORDER BY title ASC") @Query("SELECT * FROM course INNER JOIN (SELECT year, CASE WHEN isSummerSemester THEN 1 ELSE 0 END AS semester FROM course GROUP BY year, isSummerSemester ORDER BY year DESC, semester ASC LIMIT 1) current WHERE current.year = course.year AND current.semester = CASE WHEN course.isSummerSemester THEN 1 ELSE 0 END ORDER BY title ASC")
@RewriteQueriesToDropUnusedColumns
fun getLatestSemester(): LiveData<List<Course>> fun getLatestSemester(): LiveData<List<Course>>
@Query("SELECT year, CASE WHEN isSummerSemester THEN 1 ELSE 0 END AS semester FROM course GROUP BY year, isSummerSemester ORDER BY year DESC, semester ASC LIMIT 1") @Query("SELECT year, CASE WHEN isSummerSemester THEN 1 ELSE 0 END AS semester FROM course GROUP BY year, isSummerSemester ORDER BY year DESC, semester ASC LIMIT 1")

View File

@@ -9,7 +9,6 @@ import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.beust.klaxon.JsonObject import com.beust.klaxon.JsonObject
import de.sebse.fuplanner2.R import de.sebse.fuplanner2.R
import de.sebse.fuplanner2.ui.notification.NotificationFragmentDirections
import de.sebse.fuplanner2.utils.* import de.sebse.fuplanner2.utils.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
@@ -81,7 +80,7 @@ data class Event (
?.let { AppDatabase.getInstance().courseDao().getCourseById2(it.courseId) } ?.let { AppDatabase.getInstance().courseDao().getCourseById2(it.courseId) }
?.let { ?.let {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
navController.navigate(NotificationFragmentDirections.actionNavNotificationsToCourseDetails(it.uid!!, it.title)) //navController.navigate(NotificationFragmentDirections.actionNavNotificationsToCourseDetails(it.uid!!, it.title))
} }
} }
} }

View File

@@ -5,7 +5,7 @@ import com.beust.klaxon.KlaxonException
data class Lecturer ( data class Lecturer (
val fistName: String, val firstName: String,
val lastName: String, val lastName: String,
val email: String, val email: String,
val isResponsible: Boolean val isResponsible: Boolean

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 de.sebse.fuplanner2.ui.theme.AppTheme
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() {
AppTheme {
TopBar(
title = "A title"
) { }
}
}
@Preview
@Composable
fun DrawerPreview() {
AppTheme {
Drawer(currentRoute = "courses", menu = screens) { }
}
}

View File

@@ -0,0 +1,78 @@
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.CourseDetailsScreen
import de.sebse.fuplanner2.ui.details_announcements.CourseAnnouncementScreen
import de.sebse.fuplanner2.ui.details_announcements.CourseAnnouncementsScreen
import de.sebse.fuplanner2.ui.details_description.CourseDescriptionScreen
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)
}
composable(
arguments = listOf(
navArgument("id") { type = NavType.LongType }
),
route = "${MenuItem.Courses.route}/{id}/description"
) {
val id = it.arguments!!.getLong("id")
CourseDescriptionScreen(tools, id)
}
composable(
arguments = listOf(
navArgument("id") { type = NavType.LongType }
),
route = "${MenuItem.Courses.route}/{id}/announcements"
) {
val id = it.arguments!!.getLong("id")
CourseAnnouncementsScreen(tools, id)
}
composable(
arguments = listOf(
navArgument("id") { type = NavType.LongType },
navArgument("announceId") { type = NavType.LongType }
),
route = "${MenuItem.Courses.route}/{id}/announcements/{announceId}"
) {
val id = it.arguments!!.getLong("id")
val announceId = it.arguments!!.getLong("announceId")
CourseAnnouncementScreen(tools, id, announceId)
}
}
}

View File

@@ -1,66 +0,0 @@
package de.sebse.fuplanner2.ui.courses
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import de.sebse.fuplanner2.R
import de.sebse.fuplanner2.database.Course
import de.sebse.fuplanner2.ui.CaptionHolder
import de.sebse.fuplanner2.ui.CustomHolder
import de.sebse.fuplanner2.ui.ListItemHolder
import de.sebse.fuplanner2.ui.ViewHolderGenerator
import de.sebse.fuplanner2.utils.cast
import java.util.*
class CoursesAdapter(private val onclick: (Course) -> Unit) : RecyclerView.Adapter<CustomHolder>() {
private val positionalData: ArrayList<Any> = arrayListOf()
var dataset: List<Course> = listOf()
set(value) {
field = value
positionalData.clear()
var last: Course? = null
dataset.forEach {
if (last?.let { last -> it < last } != false)
positionalData.add(Pair(it.isSummerSemester, it.year))
positionalData.add(it)
last = it
}
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomHolder {
return ViewHolderGenerator.getHolderByType(parent, viewType)
}
override fun onBindViewHolder(holder: CustomHolder, position: Int) {
// val viewType = getItemViewType(position)
val res = holder.itemView.resources
when (holder) {
is CaptionHolder -> cast<Pair<Boolean, Int?>>(positionalData[position])?.let { (isSummer, year) ->
holder.string.text = when {
year == null -> res.getString(R.string.projects)
isSummer -> res.getString(R.string.summer_semester, year)
else -> res.getString(R.string.winter_semester, year, year+1)
}
}
is ListItemHolder -> cast<Course>(positionalData[position])?.let {
holder.title.text = it.title
holder.subLeft.text = it.lecturers.filter { lecturer -> lecturer.isResponsible }.joinToString { lecturer ->
res.getString(R.string.full_name, lecturer.fistName.substring(0, 1)+".", lecturer.lastName)
}
holder.subRight.text = it.type
holder.itemView.setOnClickListener { _ -> this.onclick(it) }
}
}
}
override fun getItemViewType(position: Int): Int {
return when (positionalData[position]) {
is Course -> ViewHolderGenerator.HolderType.ITEM.ordinal
else -> ViewHolderGenerator.HolderType.HEADER.ordinal
}
}
// Return the size of your dataset (invoked by the layout manager)
override fun getItemCount() = positionalData.size
}

View File

@@ -1,73 +0,0 @@
package de.sebse.fuplanner2.ui.courses
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import de.sebse.fuplanner2.R
import de.sebse.fuplanner2.auth.AppAccounts
import de.sebse.fuplanner2.database.Course
import de.sebse.fuplanner2.databinding.ActivityMainBinding
import de.sebse.fuplanner2.databinding.FragmentRefreshRecyclerBinding
import de.sebse.fuplanner2.worker.AbstractAccountWorker.Companion.KEY_ACCOUNT_NAME
import de.sebse.fuplanner2.worker.CourseWorker
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? {
val viewManager = LinearLayoutManager(context)
val viewAdapter = CoursesAdapter(this::onClick)
coursesViewModel = ViewModelProvider(this).get(CoursesViewModel::class.java)
binding = FragmentRefreshRecyclerBinding.inflate(inflater, container, false)
return inflater.inflate(R.layout.fragment_refresh_recycler, container, false).apply {
binding.recyclerView.apply {
//setHasFixedSize(true)
layoutManager = viewManager
adapter = viewAdapter
}
binding.swipeRefreshLayout.setOnRefreshListener {
val work = OneTimeWorkRequestBuilder<CourseWorker>()
.setInputData(workDataOf(
KEY_ACCOUNT_NAME to AppAccounts.getInstance().selectedAccount?.name
))
.build()
WorkManager.getInstance(context.applicationContext)
.enqueue(work)
WorkManager.getInstance(context.applicationContext)
.getWorkInfoByIdLiveData(work.id)
.observe(viewLifecycleOwner, {
if (it.state.isFinished)
binding.swipeRefreshLayout.isRefreshing = false
})
}
coursesViewModel.text.observe(viewLifecycleOwner, Observer {
viewAdapter.dataset = it
})
navController = findNavController()
}
}
private fun onClick(course: Course) {
course.uid?.let {
navController.navigate(CoursesFragmentDirections.actionNavHomeToCourseDetails(it, course.title))
}
}
}

View File

@@ -0,0 +1,201 @@
package de.sebse.fuplanner2.ui.courses
import android.content.res.Configuration
import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
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.compose.ui.unit.dp
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
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.ui.theme.*
import de.sebse.fuplanner2.ui.tools.previews.CoursePreviewProvider
import de.sebse.fuplanner2.ui.tools.viewmodels.CoursesViewModel
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)
}
AppTheme {
GroupedCourseList(groups = groups) { course ->
course.uid?.let {
tools.navTo("${MenuItem.Courses.route}/$it")
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun GroupedCourseList(groups: Map<String, List<Course>>, onclick: (course: Course) -> Unit) {
val headerBg =
if (MaterialTheme.colors.isLight) md_theme_light_secondaryContainer
else md_theme_dark_secondaryContainer
val headerColor =
if (MaterialTheme.colors.isLight) md_theme_light_onSecondaryContainer
else md_theme_dark_onSecondaryContainer
LazyColumn {
for ((key, value) in groups.entries) {
stickyHeader(key = key) {
Text(
text = key,
color = headerColor,
modifier = Modifier
.fillMaxWidth()
.background(headerBg)
.padding()
)
}
items(value) { course ->
CourseItem(course) { onclick(course) }
}
}
}
}
@Composable
fun CourseList(courses: List<Course>, onclick: () -> Unit) {
LazyColumn {
items(courses) { course -> CourseItem(course, onclick) }
}
}
@Composable
fun CourseItem(course: Course, onclick: () -> Unit) {
@Suppress("SimplifiableCallChain")
CourseItem(
id = course.uid,
title = course.title,
lecturers = course.lecturers
.filter { lecturer -> lecturer.isResponsible }
.map { lecturer ->
stringResource(
R.string.full_name,
lecturer.firstName.substring(0, 1) + ".",
lecturer.lastName
)
}
.joinToString(),
type = course.type,
onclick = onclick
)
}
@Composable
fun CourseItem(id: Long?, title: String, lecturers: String, type: String, onclick: () -> Unit) {
val color = Color(getColor(LocalContext.current, id ?: 0, highContrast = true))
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onclick)
.padding(dimensionResource(R.dimen.card_view_margin)),
elevation = dimensionResource(R.dimen.card_view_elevation)
) {
Column(
modifier = Modifier
.height(1.5.dp)
.background(
Brush.horizontalGradient(
colors = listOf(color, MaterialTheme.colors.surface)
)
)
) {}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(dimensionResource(R.dimen.card_view_padding))
) {
Text(
text = title,
color = MaterialTheme.colors.primaryVariant,
style = MaterialTheme.typography.h6
)
CourseItemHint(
icon = Icons.Outlined.Person,
imageAltRes = R.string.lecturers,
text = lecturers
)
CourseItemHint(
icon = Icons.Outlined.Info,
imageAltRes = R.string.course_type,
text = type
)
}
}
}
@Composable
fun CourseItemHint(icon: ImageVector, @StringRes imageAltRes: Int, text: String) {
Row {
Icon(
icon,
contentDescription = stringResource(imageAltRes),
modifier = Modifier.padding(
start = dimensionResource(R.dimen.card_view_padding)
)
)
Text(
text = text,
style = MaterialTheme.typography.subtitle1,
modifier = Modifier.padding(
start = dimensionResource(R.dimen.card_view_padding)
)
)
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun CourseListPreview() {
AppTheme {
CourseList(
CoursePreviewProvider().values.take(3).toList()
) { }
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview
@Composable
fun CourseItemPreview(@PreviewParameter(CoursePreviewProvider::class, 1) course: Course) {
AppTheme {
CourseItem(course) {}
}
}

View File

@@ -0,0 +1,108 @@
package de.sebse.fuplanner2.ui.details
import android.content.res.Configuration
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.lifecycle.viewmodel.compose.viewModel
import de.sebse.fuplanner2.MenuItem
import de.sebse.fuplanner2.R
import de.sebse.fuplanner2.Tools
import de.sebse.fuplanner2.database.Announcement
import de.sebse.fuplanner2.database.Course
import de.sebse.fuplanner2.database.Event
import de.sebse.fuplanner2.database.Lecturer
import de.sebse.fuplanner2.ui.details.components.AnnouncementItem
import de.sebse.fuplanner2.ui.details.components.EventItem
import de.sebse.fuplanner2.ui.details.components.LecturerItem
import de.sebse.fuplanner2.ui.details.components.QuickLinks
import de.sebse.fuplanner2.ui.shared.Heading
import de.sebse.fuplanner2.ui.theme.AppTheme
import de.sebse.fuplanner2.ui.tools.previews.AnnouncementPreviewProvider
import de.sebse.fuplanner2.ui.tools.previews.CoursePreviewProvider
import de.sebse.fuplanner2.ui.tools.previews.EventPreviewProvider
import de.sebse.fuplanner2.ui.tools.viewmodels.DetailsViewModel
import de.sebse.fuplanner2.ui.tools.viewmodels.DetailsViewModelFactory
import kotlin.math.min
@Composable
fun CourseDetailsScreen(tools: Tools, id: Long) {
val coursesViewModel: DetailsViewModel = viewModel(factory = DetailsViewModelFactory(id))
val course by coursesViewModel.course.observeAsState()
val announcements by coursesViewModel.announcements.observeAsState()
val events by coursesViewModel.events.observeAsState()
val title = course?.title
val context = LocalContext.current
LaunchedEffect(title) {
title?.let { tools.setTitle(it) }
}
LaunchedEffect(true) {
coursesViewModel.refresh(context)
}
CourseDetailsScreen(
course?.lecturers ?: emptyList(),
announcements ?: emptyList(),
events ?: emptyList(),
id,
course?.title ?: ""
) {
tools.navTo(it)
}
}
@Composable
fun CourseDetailsScreen(
lecturers: List<Lecturer>,
announcements: List<Announcement>,
events: List<Event>,
id: Long,
title: String,
onClick: (String) -> Unit
) {
LazyColumn {
item {
QuickLinks(courseId = id, onClick)
if (lecturers.isNotEmpty()) Heading(stringResource(R.string.lecturers))
}
items(lecturers.sortedBy { (if (it.isResponsible) "AAAA" else "ZZZZ") + it.lastName }) {
LecturerItem(lecturer = it, courseTitle = title)
}
item {
if (announcements.isNotEmpty()) Heading(stringResource(R.string.announcements))
}
items(items = announcements.subList(0, min(announcements.size, 3)) ?: listOf()) {
AnnouncementItem(it) {
onClick("${MenuItem.Courses.route}/$id/announcements/${it.uid}")
}
}
item {
if (events.isNotEmpty()) Heading(stringResource(R.string.events))
}
items(items = events.subList(0, min(events.size, 3))) {
EventItem(it)
}
// TODO: Add current assignments, upcoming events
}
}
@Preview(showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Composable
fun CourseDetailsScreenPreview(@PreviewParameter(CoursePreviewProvider::class, 1) course: Course) {
AppTheme {
CourseDetailsScreen(
course.lecturers,
AnnouncementPreviewProvider().values.take(3).toList(),
EventPreviewProvider().values.take(3).toList(),
course.uid!!,
course.title
) { }
}
}

View File

@@ -1,84 +0,0 @@
package de.sebse.fuplanner2.ui.details
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import de.sebse.fuplanner2.R
import de.sebse.fuplanner2.database.Lecturer
import de.sebse.fuplanner2.ui.*
import de.sebse.fuplanner2.utils.cast
import java.util.*
class DetailsAdapter(private val onQuickLink: (ButtonTypes) -> Unit, private val onMailTo: (Lecturer) -> Unit) : RecyclerView.Adapter<CustomHolder>() {
enum class HeaderTypes {
BUTTONS, LECTURER, ANNOUNCEMENTS, ASSIGNMENTS, EVENTS;
}
enum class ButtonTypes {
DESCRIPTION, RESOURCES, GRADEBOOK, ANNOUNCEMENTS, ASSIGNMENTS, EVENTS;
}
private val positionalData: ArrayList<Any> = arrayListOf()
var lecturers: List<Lecturer> = listOf()
set(value) {
field = value
updatePositionalData()
}
private fun updatePositionalData() {
positionalData.clear()
positionalData.add(HeaderTypes.BUTTONS)
positionalData.add(ViewHolderGenerator.HolderType.BUTTONS)
positionalData.add(HeaderTypes.LECTURER)
positionalData.addAll(lecturers.sortedBy { it.lastName }.sortedBy { !it.isResponsible })
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomHolder {
return ViewHolderGenerator.getHolderByType(parent, viewType)
}
override fun onBindViewHolder(holder: CustomHolder, position: Int) {
// val viewType = getItemViewType(position)
val res = holder.itemView.resources
when (holder) {
is CaptionHolder -> cast<HeaderTypes>(positionalData[position])?.let { type ->
holder.string.text = when (type) {
HeaderTypes.BUTTONS -> res.getString(R.string.quick_links)
HeaderTypes.LECTURER -> res.getString(R.string.lecturers)
HeaderTypes.ANNOUNCEMENTS -> res.getString(R.string.announcements)
HeaderTypes.ASSIGNMENTS -> res.getString(R.string.assignments)
HeaderTypes.EVENTS -> res.getString(R.string.events)
}
}
is QuickLinksHolder -> {
holder.btnAnnouncements.setOnClickListener { this.onQuickLink(ButtonTypes.ANNOUNCEMENTS) }
holder.btnAssignments.setOnClickListener { this.onQuickLink(ButtonTypes.ASSIGNMENTS) }
holder.btnDescription.setOnClickListener { this.onQuickLink(ButtonTypes.DESCRIPTION) }
holder.btnEvents.setOnClickListener { this.onQuickLink(ButtonTypes.EVENTS) }
holder.btnGradebook.setOnClickListener { this.onQuickLink(ButtonTypes.GRADEBOOK) }
holder.btnResources.setOnClickListener { this.onQuickLink(ButtonTypes.RESOURCES) }
}
is MailHolder -> cast<Lecturer>(positionalData[position])?.let { type ->
holder.title.text = res.getString(R.string.full_name, type.fistName, type.lastName)
holder.subLeft.text = type.email
holder.itemView.setOnClickListener { this.onMailTo(type) }
}
is ListItemHolder -> when (positionalData[position]) {
}
}
}
override fun getItemViewType(position: Int): Int {
return when (positionalData[position]) {
is HeaderTypes -> ViewHolderGenerator.HolderType.HEADER.ordinal
ViewHolderGenerator.HolderType.BUTTONS -> ViewHolderGenerator.HolderType.BUTTONS.ordinal
is Lecturer -> ViewHolderGenerator.HolderType.MAIL.ordinal
else -> ViewHolderGenerator.HolderType.ITEM.ordinal
}
}
// Return the size of your dataset (invoked by the layout manager)
override fun getItemCount() = positionalData.size
}

View File

@@ -1,89 +0,0 @@
package de.sebse.fuplanner2.ui.details
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
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 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
}
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()
}
}
private fun sendMail(lecturer: Lecturer) {
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.fistName, lecturer.lastName))
startActivity(Intent.createChooser(intent, getString(R.string.send_email, lecturer.fistName, lecturer.lastName)))
}
private fun launchFragment(btnType: DetailsAdapter.ButtonTypes) {
when (btnType) {
DetailsAdapter.ButtonTypes.DESCRIPTION ->
this.navController.navigate(DetailsFragmentDirections.actionCourseDetailsToDescriptionFragment(args.courseId, title))
DetailsAdapter.ButtonTypes.RESOURCES -> TODO()
DetailsAdapter.ButtonTypes.GRADEBOOK -> TODO()
DetailsAdapter.ButtonTypes.ANNOUNCEMENTS ->
this.navController.navigate(DetailsFragmentDirections.actionCourseDetailsToCourseAnnouncements(args.courseId, title))
DetailsAdapter.ButtonTypes.ASSIGNMENTS -> TODO()
DetailsAdapter.ButtonTypes.EVENTS ->
this.navController.navigate(DetailsFragmentDirections.actionCourseDetailsToCourseEvents(args.courseId, title))
}
}
}

View File

@@ -1,16 +0,0 @@
package de.sebse.fuplanner2.ui.details
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import de.sebse.fuplanner2.database.AppDatabase
import de.sebse.fuplanner2.database.Course
class DetailsViewModelFactory(private val courseId: Long): ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel> create(modelClass: Class<T>): T = DetailsViewModel(courseId) as T
}
class DetailsViewModel(courseId: Long) : ViewModel() {
val course: LiveData<Course> = AppDatabase.getInstance().courseDao().getCourseById(courseId)
}

View File

@@ -0,0 +1,40 @@
package de.sebse.fuplanner2.ui.details.components
import androidx.compose.foundation.clickable
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import de.sebse.fuplanner2.database.Announcement
import de.sebse.fuplanner2.ui.shared.FuCardColumn
import de.sebse.fuplanner2.ui.theme.AppTheme
import de.sebse.fuplanner2.ui.tools.previews.AnnouncementPreviewProvider
import de.sebse.fuplanner2.utils.toDateTimeString
@Composable
fun AnnouncementItem(announcement: Announcement, click: () -> Unit) {
FuCardColumn(
modifier = Modifier
.clickable(true, onClick = click)
) {
Text(
text = announcement.title,
style = MaterialTheme.typography.h6
)
Text(
text = announcement.createdOn.toDateTimeString(LocalContext.current) ?: "",
style = MaterialTheme.typography.subtitle1
)
}
}
@Preview
@Composable
fun AnnouncementItemPreview(@PreviewParameter(AnnouncementPreviewProvider::class, 5) announcement: Announcement) {
AppTheme {
AnnouncementItem(announcement) { }
}
}

View File

@@ -0,0 +1,45 @@
package de.sebse.fuplanner2.ui.details.components
import androidx.compose.foundation.clickable
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import de.sebse.fuplanner2.database.Event
import de.sebse.fuplanner2.ui.shared.FuCardColumn
import de.sebse.fuplanner2.ui.theme.AppTheme
import de.sebse.fuplanner2.ui.tools.previews.EventPreviewProvider
import de.sebse.fuplanner2.utils.toDateTimeString
@Composable
fun EventItem(event: Event) {
EventItem(event) { }
}
@Composable
fun EventItem(event: Event, click: () -> Unit) {
FuCardColumn(
modifier = Modifier
.clickable(true, onClick = click)
) {
Text(
text = event.title,
style = MaterialTheme.typography.h6
)
Text(
text = event.startDateTime.toDateTimeString(LocalContext.current) ?: "",
style = MaterialTheme.typography.subtitle1
)
}
}
@Preview
@Composable
fun EventItemPreview(@PreviewParameter(EventPreviewProvider::class, 5) event: Event) {
AppTheme {
EventItem(event)
}
}

View File

@@ -0,0 +1,104 @@
package de.sebse.fuplanner2.ui.details.components
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.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.Modifier
import androidx.compose.ui.platform.LocalContext
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 de.sebse.fuplanner2.R
import de.sebse.fuplanner2.database.Lecturer
import de.sebse.fuplanner2.ui.shared.FuCardRow
import de.sebse.fuplanner2.ui.theme.AppTheme
import de.sebse.fuplanner2.ui.tools.previews.LecturerPreviewProvider
@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) {
FuCardRow(
modifier = Modifier
.clickable(true, onClick = click)
.height(IntrinsicSize.Min)
) {
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
.fillMaxHeight()
.aspectRatio(1f, true)
)
}
}
}
@Preview
@Composable
fun LecturerItemPreview(@PreviewParameter(LecturerPreviewProvider::class, 3) lecturer: Lecturer) {
AppTheme {
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,68 @@
package de.sebse.fuplanner2.ui.details.components
import android.content.res.Configuration
import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.GridCells
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults.buttonColors
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
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.unit.dp
import de.sebse.fuplanner2.R
import de.sebse.fuplanner2.ui.shared.VerticalGrid
import de.sebse.fuplanner2.ui.theme.AppTheme
data class QuickLinkProps(@StringRes val name: Int, val route: String)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun QuickLinks(courseId: Long, onClick: (String) -> Unit) {
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")
)
VerticalGrid(
cells = GridCells.Adaptive(150.dp)
) {
list.forEach {
Button(
colors = buttonColors(
backgroundColor = MaterialTheme.colors.secondary,
contentColor = MaterialTheme.colors.onSecondary,
),
modifier = Modifier
.padding(dimensionResource(R.dimen.card_view_margin))
.fillMaxWidth(),
onClick = { onClick(it.route) }
) {
Text(
text = stringResource(it.name),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.h6
)
}
}
}
}
@Preview(showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Composable
fun QuickLinksPreview() {
AppTheme {
QuickLinks(3) { }
}
}

View File

@@ -1,40 +0,0 @@
package de.sebse.fuplanner2.ui.details_announcements
import android.view.ViewGroup
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil
import de.sebse.fuplanner2.database.Announcement
import de.sebse.fuplanner2.ui.ListItemHolder
import de.sebse.fuplanner2.utils.toDateTimeString
class AnnouncementsAdapter() : PagedListAdapter<Announcement, ListItemHolder>(EventDiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListItemHolder {
return ListItemHolder.invoke(parent)
}
override fun onBindViewHolder(holder: ListItemHolder, position: Int) {
val event = getItem(position)
val actCtx = holder.itemView.context
event?.let {
holder.title.text = it.title
holder.subLeft.text = it.createdOn.toDateTimeString(actCtx)
holder.subRight.text = it.createdBy
} ?: run {
holder.clear()
holder.itemView.setOnClickListener(null)
}
}
}
class EventDiffCallback : DiffUtil.ItemCallback<Announcement>() {
override fun areItemsTheSame(oldItem: Announcement, newItem: Announcement): Boolean {
return oldItem.uid == newItem.uid
}
override fun areContentsTheSame(oldItem: Announcement, newItem: Announcement): Boolean {
return oldItem.getHash() == newItem.getHash()
}
}

View File

@@ -1,64 +0,0 @@
package de.sebse.fuplanner2.ui.details_announcements
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
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 de.sebse.fuplanner2.R
import de.sebse.fuplanner2.auth.AppAccounts
import de.sebse.fuplanner2.databinding.FragmentRefreshRecyclerBinding
import de.sebse.fuplanner2.utils.console
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.AnnouncementWorker
class AnnouncementsFragment : Fragment() {
private var title: String = ""
private val args: AnnouncementsFragmentArgs by navArgs()
private val announcementsViewModel: AnnouncementsViewModel by viewModels { AnnouncementsViewModelFactory(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 = AnnouncementsAdapter()
return inflater.inflate(R.layout.fragment_refresh_recycler, container, false).apply {
console.log(findViewById(R.id.recycler_view), binding.recyclerView)
binding.recyclerView.apply {
setHasFixedSize(true)
layoutManager = viewManager
adapter = viewAdapter
}
binding.swipeRefreshLayout.setOnRefreshListener {
enqueueOneTimeWork<AnnouncementWorker>(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
})
}
announcementsViewModel.events.observe(viewLifecycleOwner, Observer {
viewAdapter.submitList(it)
this@AnnouncementsFragment.title = args.title
})
navController = findNavController()
}
}
}

View File

@@ -1,18 +0,0 @@
package de.sebse.fuplanner2.ui.details_announcements
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.paging.LivePagedListBuilder
import androidx.paging.PagedList
import de.sebse.fuplanner2.database.Announcement
import de.sebse.fuplanner2.database.AppDatabase
class AnnouncementsViewModelFactory(private val courseId: Long): ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel> create(modelClass: Class<T>): T = AnnouncementsViewModel(courseId) as T
}
class AnnouncementsViewModel(courseId: Long) : ViewModel() {
private val factory = AppDatabase.getInstance().announcementDao().getAll1(courseId)
val events: LiveData<PagedList<Announcement>> = LivePagedListBuilder(factory, 50).build()
}

View File

@@ -0,0 +1,63 @@
package de.sebse.fuplanner2.ui.details_announcements
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.lifecycle.viewmodel.compose.viewModel
import de.sebse.fuplanner2.R
import de.sebse.fuplanner2.Tools
import de.sebse.fuplanner2.database.Announcement
import de.sebse.fuplanner2.ui.shared.Heading
import de.sebse.fuplanner2.ui.shared.HtmlText
import de.sebse.fuplanner2.ui.theme.AppTheme
import de.sebse.fuplanner2.ui.tools.previews.AnnouncementPreviewProvider
import de.sebse.fuplanner2.ui.tools.viewmodels.AnnouncementViewModel
import de.sebse.fuplanner2.ui.tools.viewmodels.AnnouncementViewModelFactory
import de.sebse.fuplanner2.ui.tools.viewmodels.DetailsViewModel
import de.sebse.fuplanner2.ui.tools.viewmodels.DetailsViewModelFactory
@Composable
fun CourseAnnouncementScreen(tools: Tools, id: Long, announcementId: Long) {
val coursesViewModel: DetailsViewModel =
viewModel(factory = DetailsViewModelFactory(id))
val announcementViewModel: AnnouncementViewModel =
viewModel(factory = AnnouncementViewModelFactory(announcementId))
val course by coursesViewModel.course.observeAsState()
val announcement by announcementViewModel.observeAsState()
val title = course?.title ?: stringResource(id = R.string.description)
LaunchedEffect(title) {
tools.setTitle(title)
}
announcement?.let { CourseAnnouncementScreen(it) }
}
@Composable
fun CourseAnnouncementScreen(announcement: Announcement) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier.verticalScroll(scrollState)
) {
Heading(text = announcement.title)
HtmlText(
html = announcement.body,
modifier = Modifier.fillMaxWidth()
)
}
}
@Preview
@Composable
fun CourseAnnouncementScreenPreview(@PreviewParameter(AnnouncementPreviewProvider::class, 2) announcement: Announcement) {
AppTheme {
CourseAnnouncementScreen(announcement)
}
}

View File

@@ -0,0 +1,56 @@
package de.sebse.fuplanner2.ui.details_announcements
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel
import de.sebse.fuplanner2.MenuItem
import de.sebse.fuplanner2.R
import de.sebse.fuplanner2.Tools
import de.sebse.fuplanner2.database.Announcement
import de.sebse.fuplanner2.ui.details.components.AnnouncementItem
import de.sebse.fuplanner2.ui.theme.AppTheme
import de.sebse.fuplanner2.ui.tools.previews.AnnouncementPreviewProvider
import de.sebse.fuplanner2.ui.tools.viewmodels.DetailsViewModel
import de.sebse.fuplanner2.ui.tools.viewmodels.DetailsViewModelFactory
@Composable
fun CourseAnnouncementsScreen(tools: Tools, id: Long) {
val coursesViewModel: DetailsViewModel =
viewModel(factory = DetailsViewModelFactory(id))
val course by coursesViewModel.course.observeAsState()
val announcements by coursesViewModel.announcements.observeAsState()
val title = course?.title ?: stringResource(id = R.string.description)
LaunchedEffect(title) {
tools.setTitle(title)
}
announcements?.let { CourseAnnouncementsScreen(it) { uid ->
tools.navTo("${MenuItem.Courses.route}/$id/announcements/${uid}")
} }
}
@Composable
fun CourseAnnouncementsScreen(announcements: List<Announcement>, onClick: (Long) -> Unit) {
LazyColumn {
items(announcements) {
AnnouncementItem(it) {
it.uid?.let { uid -> onClick(uid) }
}
}
}
}
@Preview
@Composable
fun CourseAnnouncementsScreenPreview() {
AppTheme {
CourseAnnouncementsScreen(
AnnouncementPreviewProvider().values.take(10).toList()
) { }
}
}

View File

@@ -0,0 +1,59 @@
package de.sebse.fuplanner2.ui.details_description
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.lifecycle.viewmodel.compose.viewModel
import de.sebse.fuplanner2.R
import de.sebse.fuplanner2.Tools
import de.sebse.fuplanner2.database.Course
import de.sebse.fuplanner2.ui.shared.Heading
import de.sebse.fuplanner2.ui.shared.HtmlText
import de.sebse.fuplanner2.ui.theme.AppTheme
import de.sebse.fuplanner2.ui.tools.previews.CoursePreviewProvider
import de.sebse.fuplanner2.ui.tools.viewmodels.DetailsViewModel
import de.sebse.fuplanner2.ui.tools.viewmodels.DetailsViewModelFactory
@Composable
fun CourseDescriptionScreen(tools: Tools, id: Long) {
val coursesViewModel: DetailsViewModel = viewModel(factory = DetailsViewModelFactory(id))
val course by coursesViewModel.course.observeAsState()
val title = course?.title ?: stringResource(id = R.string.description)
LaunchedEffect(title) {
tools.setTitle(title)
}
CourseDescriptionScreen(course?.description ?: "")
}
@Composable
fun CourseDescriptionScreen(description: String) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier.verticalScroll(scrollState)
) {
Heading(text = stringResource(id = R.string.description))
HtmlText(
html = description,
modifier = Modifier.fillMaxWidth()
)
}
}
@Preview
@Composable
fun CourseDescriptionScreenPreview(@PreviewParameter(CoursePreviewProvider::class, 1) course: Course) {
AppTheme {
CourseDescriptionScreen(
"scrip<b>fsdfsfg</b><br><a href='example.com'>ti</a>on"
)
}
}

View File

@@ -1,45 +0,0 @@
package de.sebse.fuplanner2.ui.details_description
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.WebSettings
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 de.sebse.fuplanner2.R
import de.sebse.fuplanner2.databinding.FragmentDescriptionBinding
import de.sebse.fuplanner2.ui.details.DetailsViewModel
import de.sebse.fuplanner2.ui.details.DetailsViewModelFactory
class DescriptionFragment : Fragment() {
private var title: String = ""
private val args: DescriptionFragmentArgs by navArgs()
private val detailsViewModel: DetailsViewModel by viewModels { DetailsViewModelFactory(args.courseId) }
private lateinit var navController: NavController
private lateinit var binding: FragmentDescriptionBinding
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_description, container, false).apply {
val nightModeFlags = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
if (nightModeFlags == Configuration.UI_MODE_NIGHT_YES && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
binding.description.settings.forceDark = WebSettings.FORCE_DARK_ON
}
detailsViewModel.course.observe(viewLifecycleOwner, Observer {
binding.description.loadDataWithBaseURL("", it.description, "text/html", "UTF-8", "")
})
navController = findNavController()
}
}
}

View File

@@ -1,24 +1,21 @@
package de.sebse.fuplanner2.ui.schedule package de.sebse.fuplanner2.ui.schedule
import android.content.Context import android.content.Context
import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.view.* import android.view.*
import androidx.annotation.ColorInt
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import com.alamkanak.weekview.WeekView import com.alamkanak.weekview.WeekView
import com.alamkanak.weekview.WeekViewDisplayable
import com.alamkanak.weekview.WeekViewEntity import com.alamkanak.weekview.WeekViewEntity
import com.alamkanak.weekview.WeekViewEvent
import de.sebse.fuplanner2.R import de.sebse.fuplanner2.R
import de.sebse.fuplanner2.database.AppDatabase import de.sebse.fuplanner2.database.AppDatabase
import de.sebse.fuplanner2.database.Course import de.sebse.fuplanner2.database.Course
import de.sebse.fuplanner2.database.Event import de.sebse.fuplanner2.database.Event
import de.sebse.fuplanner2.ui.schedule.MyCustomPagingAdapter.LoadMoreHandler import de.sebse.fuplanner2.ui.schedule.MyCustomPagingAdapter.LoadMoreHandler
import de.sebse.fuplanner2.utils.color.getColor
import de.sebse.fuplanner2.utils.getHtmlSpannedString import de.sebse.fuplanner2.utils.getHtmlSpannedString
import de.sebse.fuplanner2.utils.toTimeString import de.sebse.fuplanner2.utils.toTimeString
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -26,8 +23,6 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.* import java.util.*
import kotlin.math.roundToInt
import kotlin.random.Random
class ScheduleFragment : Fragment() { class ScheduleFragment : Fragment() {
@@ -58,7 +53,7 @@ class ScheduleFragment : Fragment() {
} }
.setPositiveButton(R.string.view_course) { _, _ -> .setPositiveButton(R.string.view_course) { _, _ ->
alertCourse?.let { course -> alertCourse?.let { course ->
navController.navigate(ScheduleFragmentDirections.actionNavScheduleToCourseDetails(course.uid!!, course.title)) //navController.navigate(ScheduleFragmentDirections.actionNavScheduleToCourseDetails(course.uid!!, course.title))
} }
} }
.create() .create()
@@ -164,56 +159,6 @@ data class ContextEvent(val actCtx: Context, val event: Event) {
.setStyle(style) .setStyle(style)
.build() .build()
} }
@ColorInt
fun getColor(actCtx: Context, seed: Long): Int {
var h = Random(seed).nextInt(0xFFFF)
h = h * 360 / 0xffff
//int s = 0xff & encodedHash[2];
//s = s * 100 / 0xffff;
//int v = 0xff & encodedHash[3];
//v = v * 100 / 0xffff;
// range for more beautiful colors
h = h / 30 * 30
val (s, v) =
if (actCtx.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES)
Pair(100, 20)//Pair(100, 80)
else
Pair(60, 100)
val (r, g, b) = hsvToRgb(h / 360.0, s / 100.0, v / 100.0)
return rgbToColorInt(r, g, b)
}
private fun hsvToRgb(
hue: Double,
saturation: Double,
value: Double
): Triple<Double, Double, Double> {
val h = (hue * 6).toInt()
val f = hue * 6 - h
val p = value * (1 - saturation)
val q = value * (1 - f * saturation)
val t = value * (1 - (1 - f) * saturation)
return when (h) {
0 -> Triple(value, t, p)
1 -> Triple(q, value, p)
2 -> Triple(p, value, t)
3 -> Triple(p, q, value)
4 -> Triple(t, p, value)
5 -> Triple(value, p, q)
else -> Triple(0.0, 0.0, 0.0)
}
}
@ColorInt
private fun rgbToColorInt(
r: Double,
g: Double,
b: Double
): Int {
return 0xFF000000.toInt() + (r*0xFF).roundToInt() * 0x10000 + (g*0xFF).roundToInt() * 0x100 + (b*0xFF).roundToInt()
}
} }
class MyCustomPagingAdapter( class MyCustomPagingAdapter(

View File

@@ -0,0 +1,46 @@
package de.sebse.fuplanner2.ui.shared
import androidx.compose.foundation.layout.*
import androidx.compose.material.Card
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import de.sebse.fuplanner2.R
@Composable
fun FuCardColumn(
modifier: Modifier,
content :@Composable (ColumnScope.() -> Unit)
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(dimensionResource(R.dimen.card_view_margin)),
elevation = dimensionResource(R.dimen.card_view_elevation)
) {
Column(
modifier = modifier
.padding(dimensionResource(R.dimen.card_view_padding)),
content = content
)
}
}
@Composable
fun FuCardRow(
modifier: Modifier,
content :@Composable (RowScope.() -> Unit)
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(dimensionResource(R.dimen.card_view_margin)),
elevation = dimensionResource(R.dimen.card_view_elevation)
) {
Row (
modifier = modifier
.padding(dimensionResource(R.dimen.card_view_padding)),
content = content
)
}
}

View File

@@ -0,0 +1,49 @@
package de.sebse.fuplanner2.ui.shared
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import de.sebse.fuplanner2.R
import de.sebse.fuplanner2.ui.theme.AppTheme
@Composable
fun Heading(text: String, onClick: (() -> Unit)? = null) {
Box(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = text,
style = MaterialTheme.typography.h5,
color = MaterialTheme.colors.onSurface,
modifier = Modifier
.padding(top = dimensionResource(id = R.dimen.header_padding))
)
if (onClick != null) Text(
text = "More >>",
style = MaterialTheme.typography.subtitle2,
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.primary,
modifier = Modifier
.padding(top = dimensionResource(id = R.dimen.header_padding))
.align(Alignment.BottomEnd)
.clickable(onClick = onClick)
)
}
}
@Preview
@Composable
fun HeadingPreview() {
AppTheme {
Heading("Super cool") { }
}
}

View File

@@ -0,0 +1,21 @@
package de.sebse.fuplanner2.ui.shared
import android.text.method.LinkMovementMethod
import android.widget.TextView
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.text.HtmlCompat
@Composable
fun HtmlText(html: String, modifier: Modifier = Modifier) {
AndroidView(
modifier = modifier,
factory = { context -> TextView(context) },
update = {
it.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT)
it.movementMethod = LinkMovementMethod.getInstance()
it.setTextIsSelectable(true)
}
)
}

View File

@@ -0,0 +1,64 @@
package de.sebse.fuplanner2.ui.shared
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.lazy.GridCells
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun VerticalGrid(
cells: GridCells,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
val columns: Int = when (cells) {
is GridCells.Fixed -> {
cells.count
}
is GridCells.Adaptive -> {
val width = LocalContext.current.resources.displayMetrics.widthPixels
val columnWidthPx = with(LocalDensity.current) { cells.minSize.toPx() }
((width / columnWidthPx).toInt()).coerceAtLeast(1)
}
}
Layout(
content = content,
modifier = modifier
) { measurables, constraints ->
val itemWidth = constraints.maxWidth / columns
// Keep given height constraints, but set an exact width
val itemConstraints = constraints.copy(
minWidth = itemWidth,
maxWidth = itemWidth
)
// Measure each item with these constraints
val placeables = measurables.map { measurable ->
measurable.measure(itemConstraints)
}
// Track each columns height so we can calculate the overall height
val columnHeights = Array(columns) { 0 }
placeables.forEachIndexed { index, placeable ->
val column = index % columns
columnHeights[column] += placeable.height
}
val height = (columnHeights.maxOrNull() ?: constraints.minHeight)
.coerceAtMost(constraints.maxHeight)
layout(constraints.maxWidth, height) {
// Track the Y co-ord per column we have placed up to
val columnY = Array(columns) { 0 }
placeables.forEachIndexed { index, placeable ->
val column = index % columns
placeable.place(
x = column * itemWidth,
y = columnY[column]
)
columnY[column] += placeable.height
}
}
}
}

View File

@@ -0,0 +1,58 @@
package de.sebse.fuplanner2.ui.theme
import androidx.compose.ui.graphics.Color
val md_theme_light_primary = Color(0xFF245fa7)
val md_theme_light_onPrimary = Color(0xFFffffff)
val md_theme_light_primaryContainer = Color(0xFFd4e3ff)
val md_theme_light_onPrimaryContainer = Color(0xFF001b3d)
val md_theme_light_secondary = Color(0xFF4a6800)
val md_theme_light_onSecondary = Color(0xFFffffff)
val md_theme_light_secondaryContainer = Color(0xFFbef43c)
val md_theme_light_onSecondaryContainer = Color(0xFF131f00)
val md_theme_light_tertiary = Color(0xFF8b5000)
val md_theme_light_onTertiary = Color(0xFFffffff)
val md_theme_light_tertiaryContainer = Color(0xFFffdcba)
val md_theme_light_onTertiaryContainer = Color(0xFF2d1600)
val md_theme_light_error = Color(0xFFba1b1b)
val md_theme_light_errorContainer = Color(0xFFffdad4)
val md_theme_light_onError = Color(0xFFffffff)
val md_theme_light_onErrorContainer = Color(0xFF410001)
val md_theme_light_background = Color(0xFFfdfbff)
val md_theme_light_onBackground = Color(0xFF1b1b1d)
val md_theme_light_surface = Color(0xFFfdfbff)
val md_theme_light_onSurface = Color(0xFF1b1b1d)
val md_theme_light_surfaceVariant = Color(0xFFe0e2eb)
val md_theme_light_onSurfaceVariant = Color(0xFF44474f)
val md_theme_light_outline = Color(0xFF74777f)
val md_theme_light_inverseOnSurface = Color(0xFFf1f0f4)
val md_theme_light_inverseSurface = Color(0xFF2f3033)
val md_theme_dark_primary = Color(0xFFa6c8ff)
val md_theme_dark_onPrimary = Color(0xFF003063)
val md_theme_dark_primaryContainer = Color(0xFF00468b)
val md_theme_dark_onPrimaryContainer = Color(0xFFd4e3ff)
val md_theme_dark_secondary = Color(0xFFa3d719)
val md_theme_dark_onSecondary = Color(0xFF253600)
val md_theme_dark_secondaryContainer = Color(0xFF374e00)
val md_theme_dark_onSecondaryContainer = Color(0xFFbef43c)
val md_theme_dark_tertiary = Color(0xFFffb86b)
val md_theme_dark_onTertiary = Color(0xFF4a2800)
val md_theme_dark_tertiaryContainer = Color(0xFF6a3c00)
val md_theme_dark_onTertiaryContainer = Color(0xFFffdcba)
val md_theme_dark_error = Color(0xFFffb4a9)
val md_theme_dark_errorContainer = Color(0xFF930006)
val md_theme_dark_onError = Color(0xFF680003)
val md_theme_dark_onErrorContainer = Color(0xFFffdad4)
val md_theme_dark_background = Color(0xFF1b1b1d)
val md_theme_dark_onBackground = Color(0xFFe3e2e6)
val md_theme_dark_surface = Color(0xFF1b1b1d)
val md_theme_dark_onSurface = Color(0xFFe3e2e6)
val md_theme_dark_surfaceVariant = Color(0xFF44474f)
val md_theme_dark_onSurfaceVariant = Color(0xFFc3c6cf)
val md_theme_dark_outline = Color(0xFF8e919a)
val md_theme_dark_inverseOnSurface = Color(0xFF1b1b1d)
val md_theme_dark_inverseSurface = Color(0xFFe3e2e6)
val seed = Color(0xFF003366)
val error = Color(0xFFba1b1b)

View File

@@ -0,0 +1,102 @@
package de.sebse.fuplanner2.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.MaterialTheme
import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
// Comments are changes in Material 3 (Material You)
//private val LightThemeColors = lightColorScheme(
private val LightThemeColors = lightColors(
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
//primaryContainer = md_theme_light_primaryContainer,
//onPrimaryContainer = md_theme_light_onPrimaryContainer,
primaryVariant = md_theme_light_onPrimaryContainer,
secondary = md_theme_light_secondary,
onSecondary = md_theme_light_onSecondary,
//secondaryContainer = md_theme_light_secondaryContainer,
//onSecondaryContainer = md_theme_light_onSecondaryContainer,
secondaryVariant = md_theme_light_onSecondaryContainer,
//tertiary = md_theme_light_tertiary,
//onTertiary = md_theme_light_onTertiary,
//tertiaryContainer = md_theme_light_tertiaryContainer,
//onTertiaryContainer = md_theme_light_onTertiaryContainer,
error = md_theme_light_error,
onError = md_theme_light_onError,
//errorContainer = md_theme_light_errorContainer,
//onErrorContainer = md_theme_light_onErrorContainer,
background = md_theme_light_background,
onBackground = md_theme_light_onBackground,
surface = md_theme_light_surface,
onSurface = md_theme_light_onSurface,
//surfaceVariant = md_theme_light_surfaceVariant,
//onSurfaceVariant = md_theme_light_onSurfaceVariant,
//outline = md_theme_light_outline,
//inverseOnSurface = md_theme_light_inverseOnSurface,
//inverseSurface = md_theme_light_inverseSurface,
)
//private val DarkThemeColors = darkColorScheme(
private val DarkThemeColors = darkColors(
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
//primaryContainer = md_theme_dark_primaryContainer,
//onPrimaryContainer = md_theme_dark_onPrimaryContainer,
primaryVariant = md_theme_dark_onPrimaryContainer,
secondary = md_theme_dark_secondary,
onSecondary = md_theme_dark_onSecondary,
//secondaryContainer = md_theme_dark_secondaryContainer,
//onSecondaryContainer = md_theme_dark_onSecondaryContainer,
secondaryVariant = md_theme_dark_onSecondaryContainer,
//tertiary = md_theme_dark_tertiary,
//onTertiary = md_theme_dark_onTertiary,
//tertiaryContainer = md_theme_dark_tertiaryContainer,
//onTertiaryContainer = md_theme_dark_onTertiaryContainer,
error = md_theme_dark_error,
onError = md_theme_dark_onError,
//errorContainer = md_theme_dark_errorContainer,
//onErrorContainer = md_theme_dark_onErrorContainer,
background = md_theme_dark_background,
onBackground = md_theme_dark_onBackground,
surface = md_theme_dark_surface,
onSurface = md_theme_dark_onSurface,
//surfaceVariant = md_theme_dark_surfaceVariant,
//onSurfaceVariant = md_theme_dark_onSurfaceVariant,
//outline = md_theme_dark_outline,
//inverseOnSurface = md_theme_dark_inverseOnSurface,
//inverseSurface = md_theme_dark_inverseSurface,
)
@Composable
fun AppTheme(
useDarkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable() () -> Unit
) {
val colors = if (!useDarkTheme) {
LightThemeColors
} else {
DarkThemeColors
}
MaterialTheme(
//colorScheme = colors,
colors = colors,
// typography = AppTypography,
content = content
)
}

View File

@@ -0,0 +1,117 @@
package de.sebse.fuplanner2.ui.theme
// Comments are changes in Material 3 (Material You)
// Use default instead
import androidx.compose.ui.text.font.FontFamily
//Replace with your font locations
val Roboto = FontFamily.Default
/*val AppTypography = Typography(
displayLarge = TextStyle(
fontFamily = Roboto,
fontWeight = FontWeight.W400,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = -0.25.sp,
),
displayMedium = TextStyle(
fontFamily = Roboto,
fontWeight = FontWeight.W400,
fontSize = 45.sp,
lineHeight = 52.sp,
letterSpacing = 0.sp,
),
displaySmall = TextStyle(
fontFamily = Roboto,
fontWeight = FontWeight.W400,
fontSize = 36.sp,
lineHeight = 44.sp,
letterSpacing = 0.sp,
),
headlineLarge = TextStyle(
fontFamily = Roboto,
fontWeight = FontWeight.W400,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp,
),
headlineMedium = TextStyle(
fontFamily = Roboto,
fontWeight = FontWeight.W400,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp,
),
headlineSmall = TextStyle(
fontFamily = Roboto,
fontWeight = FontWeight.W400,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp,
),
titleLarge = TextStyle(
fontFamily = Roboto,
fontWeight = FontWeight.W400,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp,
),
titleMedium = TextStyle(
fontFamily = Roboto,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.1.sp,
),
titleSmall = TextStyle(
fontFamily = Roboto,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
labelLarge = TextStyle(
fontFamily = Roboto,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
bodyLarge = TextStyle(
fontFamily = Roboto,
fontWeight = FontWeight.W400,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
),
bodyMedium = TextStyle(
fontFamily = Roboto,
fontWeight = FontWeight.W400,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp,
),
bodySmall = TextStyle(
fontFamily = Roboto,
fontWeight = FontWeight.W400,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp,
),
labelMedium = TextStyle(
fontFamily = Roboto,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
labelSmall = TextStyle(
fontFamily = Roboto,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
)*/

View File

@@ -0,0 +1,40 @@
package de.sebse.fuplanner2.ui.tools.previews
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import de.sebse.fuplanner2.database.Announcement
import de.sebse.fuplanner2.database.Attachment
import de.sebse.fuplanner2.utils.Faker
import de.sebse.fuplanner2.utils.getFaker
class AnnouncementPreviewProvider(private val faker: Faker = getFaker()) : PreviewParameterProvider<Announcement> {
override val values = generateSequence { getItem() }
private fun getItem(): Announcement {
val title = faker.strings.title()
return Announcement(
faker.primitive.long(0, 100),
faker.primitive.long(0, 100),
faker.other.lastRefreshed(),
faker.strings.uuid(title),
title,
faker.strings.lorem(100, 1000),
faker.other.date(-20, -2),
"${faker.name.firstName()} ${faker.name.lastName()}",
AttachmentPreviewProvider(faker).values
.take(faker.primitive.int(1, 4))
.toList()
)
}
}
class AttachmentPreviewProvider(private val faker: Faker = getFaker()) : PreviewParameterProvider<Attachment> {
override val values = generateSequence { getItem() }
private fun getItem(): Attachment {
return Attachment(
faker.internet.url(),
faker.strings.title(),
faker.internet.mime()
)
}
}

View File

@@ -0,0 +1,62 @@
package de.sebse.fuplanner2.ui.tools.previews
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> {
override val values = sequence {
yield(getItem(true))
yield(getItem(faker.primitive.bool(.5f)))
yieldAll(generateSequence { getItem(false) })
}
private fun getItem(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> {
private var isSummer = false
private var year = 21
override val values = sequence {
yield(getItem())
val reduce = faker.primitive.bool(.3f)
year = if (reduce && isSummer) year-1 else year
isSummer = if (reduce) !isSummer else isSummer
}
private fun getItem(): 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,34 @@
package de.sebse.fuplanner2.ui.tools.previews
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import de.sebse.fuplanner2.database.Event
import de.sebse.fuplanner2.utils.Faker
import de.sebse.fuplanner2.utils.getFaker
import java.util.*
class EventPreviewProvider(private val faker: Faker = getFaker()) : PreviewParameterProvider<Event> {
val title = faker.strings.title()
override val values = generateSequence(0) { it + 1 }
.map { getItem(it) }
private fun getItem(num: Int): Event {
val isExam = num == 20
val cal = Calendar.getInstance()
cal.add(Calendar.DATE, num / 2 * 7 + num % 2 * 3)
cal.set(Calendar.HOUR, 14 + (num % 2) * 2)
cal.set(Calendar.MINUTE, 30)
cal.set(Calendar.SECOND, 0)
cal.set(Calendar.MILLISECOND, 0)
return Event(
faker.primitive.long(0, 100),
faker.primitive.long(0, 100),
faker.other.lastRefreshed(),
title,
90*60*1000 + (if (isExam) 1L else 0),
cal.timeInMillis,
"Hörsaal T9",
if (isExam) "Klausur" else "Vorlesung",
)
}
}

View File

@@ -0,0 +1,25 @@
package de.sebse.fuplanner2.ui.tools.viewmodels
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.livedata.observeAsState
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import de.sebse.fuplanner2.database.Announcement
import de.sebse.fuplanner2.database.AppDatabase
class AnnouncementViewModelFactory(private val announcementId: Long): ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel> create(modelClass: Class<T>): T = AnnouncementViewModel(announcementId) as T
}
class AnnouncementViewModel(private val announcementId: Long) : ViewModel() {
private val announcement: LiveData<Announcement> =
AppDatabase.getInstance().announcementDao().getAnnouncementById2(announcementId)
@Composable
fun observeAsState(): State<Announcement?> {
return announcement.observeAsState()
}
}

View File

@@ -1,4 +1,4 @@
package de.sebse.fuplanner2.ui.courses package de.sebse.fuplanner2.ui.tools.viewmodels
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@@ -6,5 +6,5 @@ import de.sebse.fuplanner2.database.AppDatabase
import de.sebse.fuplanner2.database.Course import de.sebse.fuplanner2.database.Course
class CoursesViewModel : ViewModel() { class CoursesViewModel : ViewModel() {
val text: LiveData<List<Course>> = AppDatabase.getInstance().courseDao().getAll() val list: LiveData<List<Course>> = AppDatabase.getInstance().courseDao().getAll()
} }

View File

@@ -0,0 +1,45 @@
package de.sebse.fuplanner2.ui.tools.viewmodels
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.paging.LivePagedListBuilder
import androidx.paging.PagedList
import androidx.work.workDataOf
import de.sebse.fuplanner2.auth.AppAccounts
import de.sebse.fuplanner2.database.Announcement
import de.sebse.fuplanner2.database.AppDatabase
import de.sebse.fuplanner2.database.Course
import de.sebse.fuplanner2.database.Event
import de.sebse.fuplanner2.utils.enqueueOneTimeWork
import de.sebse.fuplanner2.worker.AbstractAccountWorker
import de.sebse.fuplanner2.worker.CourseWorker
class DetailsViewModelFactory(private val courseId: Long): ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel> create(modelClass: Class<T>): T = DetailsViewModel(courseId) as T
}
class DetailsViewModel(private val courseId: Long) : ViewModel() {
fun refresh(ctx: Context) {
enqueueOneTimeWork<CourseWorker>(ctx) {
it.setInputData(workDataOf(
AbstractAccountWorker.KEY_ACCOUNT_NAME to AppAccounts.getInstance().selectedAccount?.name,
AbstractAccountWorker.KEY_COURSE_ID to courseId,
AbstractAccountWorker.KEY_FORCE_FETCH to true
)
)
}
}
val course: LiveData<Course> = AppDatabase.getInstance().courseDao().getCourseById(courseId)
val announcements: LiveData<PagedList<Announcement>> = LivePagedListBuilder(
AppDatabase.getInstance().announcementDao().getAll1(courseId),
50
).build()
val events: LiveData<PagedList<Event>> = LivePagedListBuilder(
AppDatabase.getInstance().eventDao().getAll1(courseId),
50
).build()
}

View File

@@ -0,0 +1,160 @@
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 other: FakerOther
get() = FakerOther(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"
}
fun mime(): String {
return MIME_TYPES.random(random)
}
fun url(): String {
return "https://example.de/file"
}
companion object {
private val MIME_TYPES = listOf("application/pdf", "text/plain", "text/csv",
"application/msword")
}
}
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 FakerOther(private val random: Random) {
fun lastRefreshed(): Long {
return FakerPrimitive(random).long(
System.currentTimeMillis() - DIFF,
System.currentTimeMillis() + DIFF
)
}
fun date(startDay: Int, endDay: Int): Long {
return FakerPrimitive(random).long(
System.currentTimeMillis() + DAY * startDay,
System.currentTimeMillis() + DAY * endDay
)
}
companion object {
const val DAY = 1000L*60*60*24
const val DIFF = DAY*5
}
}
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() + 1
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 {
const 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 { enum class CourseUpdateType {
REMOVED, UPDATED, ADDED; REMOVED, UPDATED, ADDED;
companion object { companion object {
val values: List<CourseUpdateType> = values().toList() val values: List<CourseUpdateType> = values().toList()
} }
@@ -32,13 +33,19 @@ object Notifications {
enum class CourseUpdateEntity { enum class CourseUpdateEntity {
COURSE, ANNOUNCEMENT, ASSIGNMENT, GRADE, RESOURCE, EVENT; COURSE, ANNOUNCEMENT, ASSIGNMENT, GRADE, RESOURCE, EVENT;
companion object { companion object {
val values: List<CourseUpdateEntity> = values().toList() val values: List<CourseUpdateEntity> = values().toList()
} }
} }
fun init(actCtx: Context) { 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( 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( val newNotifications = listOf(
CourseUpdateType.REMOVED to updates.removed, CourseUpdateType.REMOVED to updates.removed,
CourseUpdateType.ADDED to updates.added, CourseUpdateType.ADDED to updates.added,
@@ -95,7 +106,13 @@ object Notifications {
val notification = NotificationCompat.Builder(actCtx, COURSE_UPDATE_CHANNEL_ID) val notification = NotificationCompat.Builder(actCtx, COURSE_UPDATE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_logo_mono) .setSmallIcon(R.drawable.ic_logo_mono)
.setContentTitle(actCtx.getString(R.string.not_course_update_title)) .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 -> .setStyle(NotificationCompat.InboxStyle().also { inboxStyle ->
unread.forEach { not -> unread.forEach { not ->
not.getJsonData()?.let { json -> 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 removed: ArrayList<T> = arrayListOf(),
val added: ArrayList<T> = arrayListOf(), val added: ArrayList<T> = arrayListOf(),
val updated: ArrayList<T> = arrayListOf(), val updated: ArrayList<T> = arrayListOf(),
@@ -142,7 +159,10 @@ data class UpdateResult<T: Updatable>(
} }
} }
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.removed.addAll(plus.removed)
source.added.addAll(plus.added) source.added.addAll(plus.added)
source.updated.addAll(plus.updated) source.updated.addAll(plus.updated)
@@ -163,11 +183,13 @@ interface UpdatableCompanion {
type: Notifications.CourseUpdateType, type: Notifications.CourseUpdateType,
data: JsonObject data: JsonObject
): CharSequence? ): CharSequence?
fun adapterText( fun adapterText(
actCtx: Context, actCtx: Context,
type: Notifications.CourseUpdateType, type: Notifications.CourseUpdateType,
data: JsonObject data: JsonObject
): CharSequence? ): CharSequence?
fun adapterCallback( fun adapterCallback(
actCtx: Context, actCtx: Context,
type: Notifications.CourseUpdateType, type: Notifications.CourseUpdateType,
@@ -176,7 +198,7 @@ interface UpdatableCompanion {
): () -> Unit ): () -> 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 val newIds = new
.map { it.getIdentifier() to Pair(it.getHash(), it) } .map { it.getIdentifier() to Pair(it.getHash(), it) }
.toMap() .toMap()
@@ -200,4 +222,3 @@ fun <T: Updatable> updateResultOf(old: List<T>, new: List<T>): UpdateResult<T> {
} }
return UpdateResult(removed, added, updated, unmodified) return UpdateResult(removed, added, updated, unmodified)
} }

View File

@@ -5,13 +5,16 @@ package de.sebse.fuplanner2.utils
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.annotation.TargetApi import android.annotation.TargetApi
import android.content.Context import android.content.Context
import android.content.res.Configuration
import android.os.Build import android.os.Build
import android.text.Html import android.text.Html
import android.text.Spanned import android.text.Spanned
import android.text.format.DateFormat import android.text.format.DateFormat
import android.util.Log import android.util.Log
import androidx.annotation.ColorInt
import androidx.annotation.PluralsRes import androidx.annotation.PluralsRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.work.* import androidx.work.*
import de.sebse.fuplanner2.R import de.sebse.fuplanner2.R
@@ -20,6 +23,8 @@ import kotlinx.coroutines.runBlocking
import java.text.ParseException import java.text.ParseException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlin.math.roundToInt
import kotlin.random.Random
object console { object console {
@@ -52,7 +57,7 @@ object console {
object xml { object xml {
fun decode(xml: String): String { fun decode(xml: String): String {
return if (Build.VERSION.SDK_INT >= 24) { 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 { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
Html.fromHtml(xml).toString() Html.fromHtml(xml).toString()
@@ -60,24 +65,82 @@ object xml {
} }
} }
inline fun <reified T: ListenableWorker> enqueueOneTimeWork(appCtx: Context, workBuilder: (OneTimeWorkRequest.Builder) -> OneTimeWorkRequest.Builder): LiveData<WorkInfo> { object color {
@ColorInt
fun getColor(actCtx: Context, seed: Long, highContrast: Boolean = false): Int {
var h = Random(seed).nextInt(0xFFFF)
h = h * 360 / 0xffff
//int s = 0xff & encodedHash[2];
//s = s * 100 / 0xffff;
//int v = 0xff & encodedHash[3];
//v = v * 100 / 0xffff;
// 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 doDarkColors = if (highContrast) !isNightMode else isNightMode
val (s, v) =
if (doDarkColors)
Pair(100, 50)//Pair(100, 80)
else
Pair(100, 70)
val (r, g, b) = hsvToRgb(h / 360.0, s / 100.0, v / 100.0)
return rgbToColorInt(r, g, b)
}
@ColorInt
private fun rgbToColorInt(
r: Double,
g: Double,
b: Double
): Int {
return 0xFF000000.toInt() + (r * 0xFF).roundToInt() * 0x10000 + (g * 0xFF).roundToInt() * 0x100 + (b * 0xFF).roundToInt()
}
private fun hsvToRgb(
hue: Double,
saturation: Double,
value: Double
): Triple<Double, Double, Double> {
val h = (hue * 6).toInt()
val f = hue * 6 - h
val p = value * (1 - saturation)
val q = value * (1 - f * saturation)
val t = value * (1 - (1 - f) * saturation)
return when (h) {
0 -> Triple(value, t, p)
1 -> Triple(q, value, p)
2 -> Triple(p, value, t)
3 -> Triple(p, q, value)
4 -> Triple(t, p, value)
5 -> Triple(value, p, q)
else -> Triple(0.0, 0.0, 0.0)
}
}
}
inline fun <reified T : ListenableWorker> enqueueOneTimeWork(
appCtx: Context,
workBuilder: (OneTimeWorkRequest.Builder) -> OneTimeWorkRequest.Builder
): LiveData<WorkInfo> {
val work = workBuilder(OneTimeWorkRequestBuilder<T>()).build() val work = workBuilder(OneTimeWorkRequestBuilder<T>()).build()
val workManager = WorkManager.getInstance(appCtx) val workManager = WorkManager.getInstance(appCtx)
workManager.enqueue(work) workManager.enqueue(work)
return workManager.getWorkInfoByIdLiveData(work.id) 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() } map { async { f(it) } }.map { it.await() }
} }
fun hashCodeOf(vararg elements: Any?): Int { fun hashCodeOf(vararg elements: Any?): Int {
var code = 0 var code = 0
elements.forEach { code = code*31 + (it?.hashCode() ?: 0) } elements.forEach { code = code * 31 + (it?.hashCode() ?: 0) }
return code 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) { fun String.toHtmlSpan(): Spanned = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
@@ -89,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): 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.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.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 { fun Long.timeAgoString(actCtx: Context): String {
@@ -115,9 +184,21 @@ fun Long.timeAgoString(actCtx: Context): String {
val diff = now - this val diff = now - this
return when { return when {
diff < MINUTE_MILLIS -> actCtx.getString(R.string.time_just_now) 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 < 60 * MINUTE_MILLIS -> actCtx.getQuantityString(
diff < 24 * HOUR_MILLIS -> actCtx.getQuantityString(R.plurals.time_hours_ago, (diff / HOUR_MILLIS).toInt(), diff / HOUR_MILLIS) R.plurals.time_minutes_ago,
else -> actCtx.getQuantityString(R.plurals.time_days_ago, (diff / DAY_MILLIS).toInt(), diff / DAY_MILLIS) (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
)
} }
} }
@@ -179,3 +260,23 @@ fun String.dateStringToLong(format: String, locale: Locale): Long? {
null 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

@@ -16,7 +16,8 @@ abstract class AbstractAccountWorker(context: Context, params: WorkerParameters)
companion object { companion object {
const val KEY_ACCOUNT_NAME = AccountManager.KEY_ACCOUNT_NAME const val KEY_ACCOUNT_NAME = AccountManager.KEY_ACCOUNT_NAME
const val KEY_COURSE_ID = "KEY_COURSE_ID" const val KEY_COURSE_ID = "KEY_COURSE_ID"
const val OUT_ERROR_CODE = "OUT_CODE" const val OUT_ERROR_CODE = "OUT_ERROR_CODE"
const val KEY_FORCE_FETCH = "KEY_FORCE_FETCH"
} }
enum class ErrorCodes { enum class ErrorCodes {

View File

@@ -12,7 +12,6 @@ import de.sebse.fuplanner2.database.Course
import de.sebse.fuplanner2.database.User import de.sebse.fuplanner2.database.User
import de.sebse.fuplanner2.utils.Notifications import de.sebse.fuplanner2.utils.Notifications
import de.sebse.fuplanner2.utils.UpdateResult import de.sebse.fuplanner2.utils.UpdateResult
import de.sebse.fuplanner2.utils.console
import de.sebse.fuplanner2.whiteboard.Whiteboard import de.sebse.fuplanner2.whiteboard.Whiteboard
import de.sebse.fuplanner2.whiteboard.getCourse import de.sebse.fuplanner2.whiteboard.getCourse
import de.sebse.fuplanner2.whiteboard.getCourses import de.sebse.fuplanner2.whiteboard.getCourses
@@ -34,11 +33,19 @@ class CourseWorker(context: Context, params: WorkerParameters) : AbstractAccount
override suspend fun doActualWork(database: AppDatabase, account: Account, user: User): Data { override suspend fun doActualWork(database: AppDatabase, account: Account, user: User): Data {
val courseId = inputData.getLong(KEY_COURSE_ID, -1) val courseId = inputData.getLong(KEY_COURSE_ID, -1)
.let { if (it == -1L) null else it } .let { if (it == -1L) null else it }
val isForceFetch = inputData.getBoolean(KEY_FORCE_FETCH, false)
val updates = work(applicationContext, database, user, courseId) val updates = work(applicationContext, database, user, courseId)
val latestSemester = database.courseDao().getLatestSemesterName() val latestSemester = database.courseDao().getLatestSemesterName()
updates.added.forEach {
if (it.isSummerSemester == latestSemester.semester && it.year == latestSemester.year) { val detailsToFetch =
if (isForceFetch) updates.added + updates.updated
else updates.added
detailsToFetch.forEach {
val isLatestSemester = it.isSummerSemester == latestSemester.semester && it.year == latestSemester.year
if (isForceFetch || isLatestSemester) {
EventWorker.work(applicationContext, database, user, it) EventWorker.work(applicationContext, database, user, it)
AnnouncementWorker.work(applicationContext, database, user, it)
} }
} }
Notifications.courseUpdates(updates, database, applicationContext) Notifications.courseUpdates(updates, database, applicationContext)

View File

@@ -7,7 +7,6 @@ import de.sebse.fuplanner2.auth.AppAccounts
import de.sebse.fuplanner2.database.AppDatabase import de.sebse.fuplanner2.database.AppDatabase
import de.sebse.fuplanner2.database.Course import de.sebse.fuplanner2.database.Course
import de.sebse.fuplanner2.utils.Notifications import de.sebse.fuplanner2.utils.Notifications
import de.sebse.fuplanner2.utils.Updatable
import de.sebse.fuplanner2.utils.UpdateResult import de.sebse.fuplanner2.utils.UpdateResult
import de.sebse.fuplanner2.utils.mergeUpdatable import de.sebse.fuplanner2.utils.mergeUpdatable
@@ -52,6 +51,7 @@ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(c
courseCreations.forEach { course -> courseCreations.forEach { course ->
if (course.isSummerSemester == latestSemester.semester && course.year == latestSemester.year) { if (course.isSummerSemester == latestSemester.semester && course.year == latestSemester.year) {
EventWorker.work(applicationContext, database, it, course) EventWorker.work(applicationContext, database, it, course)
AnnouncementWorker.work(applicationContext, database, it, course)
} }
} }
Notifications.courseUpdates(notifications, database, applicationContext) Notifications.courseUpdates(notifications, database, applicationContext)

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"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/linear_layout" android:id="@+id/linear_layout"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent">
tools:showIn="@layout/activity_main"
tools:context=".ui.courses.CoursesFragment">
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view" android:id="@+id/recycler_view"
android:scrollbars="vertical"
android:layout_width="match_parent" 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"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/swipe_refresh_layout" android:id="@+id/swipe_refresh_layout"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent">
tools:showIn="@layout/activity_main"
tools:context=".ui.courses.CoursesFragment">
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view" android:id="@+id/recycler_view"
android:scrollbars="vertical"
android:layout_width="match_parent" 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" <navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph" 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>
<fragment <fragment
android:id="@+id/nav_canteen" android:id="@+id/nav_canteen"
@@ -36,47 +14,7 @@
android:id="@+id/nav_schedule" android:id="@+id/nav_schedule"
android:name="de.sebse.fuplanner2.ui.schedule.ScheduleFragment" android:name="de.sebse.fuplanner2.ui.schedule.ScheduleFragment"
android:label="@string/menu_schedule" android:label="@string/menu_schedule"
tools:layout="@layout/fragment_schedule"> tools:layout="@layout/fragment_schedule"/>
<action
android:id="@+id/action_nav_schedule_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" />
</fragment>
<fragment
android:id="@+id/course_details"
android:name="de.sebse.fuplanner2.ui.details.DetailsFragment"
android:label="{title}">
<action
android:id="@+id/action_course_details_to_descriptionFragment"
app:destination="@id/course_description"
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_course_details_to_course_events"
app:destination="@id/course_events"
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_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" />
<argument
android:name="courseId"
app:argType="long" />
<argument
android:name="title"
app:argType="string" />
</fragment>
<fragment <fragment
android:id="@+id/course_events" android:id="@+id/course_events"
android:name="de.sebse.fuplanner2.ui.details_events.EventsFragment" android:name="de.sebse.fuplanner2.ui.details_events.EventsFragment"
@@ -89,37 +27,9 @@
android:name="title" android:name="title"
app:argType="string" /> app:argType="string" />
</fragment> </fragment>
<fragment
android:id="@+id/course_announcements"
android:name="de.sebse.fuplanner2.ui.details_announcements.AnnouncementsFragment"
android:label="{title}"
tools:layout="@layout/fragment_refresh_recycler">
<argument
android:name="courseId"
app:argType="long" />
<argument
android:name="title"
app:argType="string" />
</fragment>
<fragment
android:id="@+id/course_description"
android:name="de.sebse.fuplanner2.ui.details_description.DescriptionFragment"
android:label="{title}">
<argument
android:name="courseId"
app:argType="long" />
<argument
android:name="title"
app:argType="string" />
</fragment>
<fragment <fragment
android:id="@+id/nav_notifications" android:id="@+id/nav_notifications"
android:name="de.sebse.fuplanner2.ui.notification.NotificationFragment" android:name="de.sebse.fuplanner2.ui.notification.NotificationFragment"
android:label="@string/menu_notifications" android:label="@string/menu_notifications"
tools:layout="@layout/notification_fragment"> tools:layout="@layout/notification_fragment"/>
<action
android:id="@+id/action_nav_notifications_to_course_details"
app:destination="@id/course_details"
app:popUpTo="@id/nav_courses" />
</fragment>
</navigation> </navigation>

View File

@@ -9,4 +9,5 @@
<dimen name="card_view_margin">4dp</dimen> <dimen name="card_view_margin">4dp</dimen>
<dimen name="card_view_padding">5dp</dimen> <dimen name="card_view_padding">5dp</dimen>
<dimen name="card_view_elevation">4dp</dimen> <dimen name="card_view_elevation">4dp</dimen>
<dimen name="header_padding">16dp</dimen>
</resources> </resources>

View File

@@ -59,6 +59,10 @@
<string name="dialog_location_br"><![CDATA[<b>Location:</b><br>%1$s<br>]]></string> <string name="dialog_location_br"><![CDATA[<b>Location:</b><br>%1$s<br>]]></string>
<string name="dialog_time"><![CDATA[<b>Time:</b><br>%1$s - %2$s]]></string> <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="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>
<string name="announcement">Announcement</string>
<plurals name="not_course_update_text"> <plurals name="not_course_update_text">
<item quantity="one">One course message</item> <item quantity="one">One course message</item>
<item quantity="other">%1$d course messages</item> <item quantity="other">%1$d course messages</item>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Base application theme. --> <!-- Base application theme. -->
<style name="FUTheme" parent="Theme.AppCompat.DayNight.DarkActionBar"> <style name="FUTheme" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<item name="colorPrimary">@color/colorPrimary</item> <item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item> <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item> <item name="colorAccent">@color/colorAccent</item>

View File

@@ -1,17 +1,26 @@
package de.sebse.fuplanner2 package de.sebse.fuplanner2
import de.sebse.fuplanner2.ui.tools.previews.AnnouncementPreviewProvider
import de.sebse.fuplanner2.utils.getFaker
import org.junit.Test import org.junit.Test
import org.junit.Assert.*
/** /**
* Example local unit test, which will execute on the development machine (host). * Example local unit test, which will execute on the development machine (host).
* *
* See [testing documentation](http://d.android.com/tools/testing). * See [testing documentation](http://d.android.com/tools/testing).
*/ */
class ExampleUnitTest { class FakerTest {
@Test @Test
fun addition_isCorrect() { fun lorem() {
assertEquals(4, 2 + 2) 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)
}
@Test
fun announcement() {
AnnouncementPreviewProvider().values.take(10)
} }
} }

View File

@@ -2,6 +2,7 @@
buildscript { buildscript {
ext.kotlin_version = '1.5.31' ext.kotlin_version = '1.5.31'
ext.compose_version = '1.0.5'
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
@@ -10,7 +11,7 @@ buildscript {
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:7.0.3' classpath 'com.android.tools.build:gradle:7.0.3'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 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 // NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files // in the individual module build.gradle files