message: Add messages sorting

diff:
@@ -4,6 +4,8 @@ import android.graphics.Typeface
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.widget.CompoundButton
+import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_POSITION
@@ -11,60 +13,114 @@ import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Message
import io.github.wulkanowy.data.enums.MessageFolder
import io.github.wulkanowy.databinding.ItemMessageBinding
+import io.github.wulkanowy.databinding.ItemMessageChipsBinding
import io.github.wulkanowy.utils.toFormattedString
import javax.inject.Inject
class MessageTabAdapter @Inject constructor() :
- RecyclerView.Adapter<MessageTabAdapter.ItemViewHolder>() {
+ RecyclerView.Adapter<RecyclerView.ViewHolder>() {
- var onClickListener: (Message, position: Int) -> Unit = { _, _ -> }
+ enum class ViewType { HEADER, ITEM }
+
+ var onItemClickListener: (Message, position: Int) -> Unit = { _, _ -> }
+ var onHeaderClickListener: (chip: CompoundButton, isChecked: Boolean) -> Unit = { _, _ -> }
var onChangesDetectedListener = {}
- private var items = mutableListOf<Message>()
+ private var items = mutableListOf<MessageTabDataItem>()
+ private var onlyUnread: Boolean? = null
+ private var onlyWithAttachments = false
- fun setDataItems(data: List<Message>) {
+ fun setDataItems(
+ data: List<MessageTabDataItem>,
+ onlyUnread: Boolean?,
+ onlyWithAttachments: Boolean
+ ) {
if (items.size != data.size) onChangesDetectedListener()
val diffResult = DiffUtil.calculateDiff(MessageTabDiffUtil(items, data))
items = data.toMutableList()
+ this.onlyUnread = onlyUnread
+ this.onlyWithAttachments = onlyWithAttachments
diffResult.dispatchUpdatesTo(this)
}
+ override fun getItemViewType(position: Int): Int {
+ return when (position) {
+ 0 -> ViewType.HEADER.ordinal
+ else -> ViewType.ITEM.ordinal
+ }
+ }
+
override fun getItemCount() = items.size
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ItemViewHolder(
- ItemMessageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+ val inflater = LayoutInflater.from(parent.context)
+ return when (viewType) {
+ ViewType.ITEM.ordinal -> ItemViewHolder(
+ ItemMessageBinding.inflate(inflater, parent, false)
)
+ ViewType.HEADER.ordinal -> HeaderViewHolder(
+ ItemMessageChipsBinding.inflate(inflater, parent, false)
+ )
+ else -> throw IllegalStateException()
+ }
+ }
- override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
- val item = items[position]
+ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
+ when (holder) {
+ is ItemViewHolder -> {
+ val item = (items[position] as MessageTabDataItem.MessageItem).message
with(holder.binding) {
val style = if (item.unread) Typeface.BOLD else Typeface.NORMAL
messageItemAuthor.run {
- text = if (item.folderId == MessageFolder.SENT.id) item.recipient else item.sender
+ text =
+ if (item.folderId == MessageFolder.SENT.id) item.recipient else item.sender
setTypeface(null, style)
}
messageItemSubject.run {
- text = if (item.subject.isNotBlank()) item.subject else context.getString(R.string.message_no_subject)
+ text =
+ if (item.subject.isNotBlank()) item.subject else context.getString(R.string.message_no_subject)
setTypeface(null, style)
}
messageItemDate.run {
text = item.date.toFormattedString()
setTypeface(null, style)
}
- messageItemAttachmentIcon.visibility = if (item.hasAttachments) View.VISIBLE else View.GONE
+ messageItemAttachmentIcon.visibility =
+ if (item.hasAttachments) View.VISIBLE else View.GONE
root.setOnClickListener {
- holder.bindingAdapterPosition.let { if (it != NO_POSITION) onClickListener(item, it) }
+ holder.bindingAdapterPosition.let {
+ if (it != NO_POSITION) onItemClickListener(item, it)
+ }
+ }
+ }
+ }
+ is HeaderViewHolder -> {
+ with(holder.binding) {
+ if (onlyUnread == null) chipUnread.isVisible = false
+ else {
+ chipUnread.isVisible = true
+ chipUnread.isChecked = onlyUnread!!
+ chipUnread.setOnCheckedChangeListener(onHeaderClickListener)
+ }
+ chipAttachments.isChecked = onlyWithAttachments
+ chipAttachments.setOnCheckedChangeListener(onHeaderClickListener)
+ }
}
}
}
class ItemViewHolder(val binding: ItemMessageBinding) : RecyclerView.ViewHolder(binding.root)
+ class HeaderViewHolder(val binding: ItemMessageChipsBinding) :
+ RecyclerView.ViewHolder(binding.root)
- private class MessageTabDiffUtil(private val old: List<Message>, private val new: List<Message>) :
+ private class MessageTabDiffUtil(
+ private val old: List<MessageTabDataItem>,
+ private val new: List<MessageTabDataItem>
+ ) :
DiffUtil.Callback() {
override fun getOldListSize(): Int = old.size

diff:
+package io.github.wulkanowy.ui.modules.message.tab
+
+import io.github.wulkanowy.data.db.entities.Message
+
+sealed class MessageTabDataItem {
+ data class MessageItem(val message: Message) : MessageTabDataItem() {
+ override val id = message.id
+ }
+
+ object Header : MessageTabDataItem() {
+ override val id = Long.MIN_VALUE
+ }
+
+ abstract val id: Long
+}

diff:
@@ -7,6 +7,7 @@ import android.view.View
import android.view.View.GONE
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
+import android.widget.CompoundButton
import androidx.appcompat.widget.SearchView
import androidx.recyclerview.widget.LinearLayoutManager
import dagger.hilt.android.AndroidEntryPoint
@@ -48,6 +49,10 @@ class MessageTabFragment : BaseFragment<FragmentMessageTabBinding>(R.layout.frag
override val isViewEmpty
get() = tabAdapter.itemCount == 0
+ override var onlyUnread: Boolean? = false
+
+ override var onlyWithAttachments = false
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
@@ -58,26 +63,33 @@ class MessageTabFragment : BaseFragment<FragmentMessageTabBinding>(R.layout.frag
super.onViewCreated(view, savedInstanceState)
binding = FragmentMessageTabBinding.bind(view)
messageContainer = binding.messageTabRecycler
- presenter.onAttachView(this, MessageFolder.valueOf(
- (savedInstanceState ?: arguments)?.getString(MESSAGE_TAB_FOLDER_ID).orEmpty()
- ))
+
+ val folder = MessageFolder.valueOf(
+ (savedInstanceState ?: requireArguments()).getString(MESSAGE_TAB_FOLDER_ID).orEmpty()
+ )
+ presenter.onAttachView(this, folder)
}
override fun initView() {
with(tabAdapter) {
- onClickListener = presenter::onMessageItemSelected
+ onItemClickListener = presenter::onMessageItemSelected
+ onHeaderClickListener = ::onChipChecked
onChangesDetectedListener = ::resetListPosition
}
with(binding.messageTabRecycler) {
layoutManager = LinearLayoutManager(context)
adapter = tabAdapter
- addItemDecoration(DividerItemDecoration(context))
+ addItemDecoration(DividerItemDecoration(context, false))
}
with(binding) {
messageTabSwipe.setOnRefreshListener(presenter::onSwipeRefresh)
messageTabSwipe.setColorSchemeColors(requireContext().getThemeAttrColor(R.attr.colorPrimary))
- messageTabSwipe.setProgressBackgroundColorSchemeColor(requireContext().getThemeAttrColor(R.attr.colorSwipeRefresh))
+ messageTabSwipe.setProgressBackgroundColorSchemeColor(
+ requireContext().getThemeAttrColor(
+ R.attr.colorSwipeRefresh
+ )
+ )
messageTabErrorRetry.setOnClickListener { presenter.onRetry() }
messageTabErrorDetails.setOnClickListener { presenter.onDetailsClick() }
}
@@ -99,8 +111,9 @@ class MessageTabFragment : BaseFragment<FragmentMessageTabBinding>(R.layout.frag
})
}
- override fun updateData(data: List<Message>) {
- tabAdapter.setDataItems(data)
+ override fun updateData(data: List<MessageTabDataItem>, hide: Boolean) {
+ if (hide) onlyUnread = null
+ tabAdapter.setDataItems(data, onlyUnread, onlyWithAttachments)
}
override fun showProgress(show: Boolean) {
@@ -143,8 +156,19 @@ class MessageTabFragment : BaseFragment<FragmentMessageTabBinding>(R.layout.frag
(parentFragment as? MessageFragment)?.onChildFragmentLoaded()
}
- fun onParentLoadData(forceRefresh: Boolean) {
- presenter.onParentViewLoadData(forceRefresh)
+ fun onParentLoadData(
+ forceRefresh: Boolean,
+ onlyUnread: Boolean? = this.onlyUnread,
+ onlyWithAttachments: Boolean = this.onlyWithAttachments
+ ) {
+ presenter.onParentViewLoadData(forceRefresh, onlyUnread, onlyWithAttachments)
+ }
+
+ private fun onChipChecked(chip: CompoundButton, isChecked: Boolean) {
+ when (chip.id) {
+ R.id.chip_unread -> presenter.onUnreadFilterSelected(isChecked)
+ R.id.chip_attachments -> presenter.onAttachmentsFilterSelected(isChecked)
+ }
}
fun onParentDeleteMessage() {

diff:
@@ -55,15 +55,15 @@ class MessageTabPresenter @Inject constructor(
fun onSwipeRefresh() {
Timber.i("Force refreshing the $folder message")
- onParentViewLoadData(true)
+ view?.run { onParentViewLoadData(true, onlyUnread, onlyWithAttachments) }
}
fun onRetry() {
view?.run {
showErrorView(false)
showProgress(true)
+ loadData(true, onlyUnread == true, onlyWithAttachments)
}
- loadData(true)
}
fun onDetailsClick() {
@@ -71,11 +71,15 @@ class MessageTabPresenter @Inject constructor(
}
fun onDeleteMessage() {
- loadData(true)
+ view?.run { loadData(true, onlyUnread == true, onlyWithAttachments) }
}
- fun onParentViewLoadData(forceRefresh: Boolean) {
- loadData(forceRefresh)
+ fun onParentViewLoadData(
+ forceRefresh: Boolean,
+ onlyUnread: Boolean? = view?.onlyUnread,
+ onlyWithAttachments: Boolean = view?.onlyWithAttachments == true
+ ) {
+ loadData(forceRefresh, onlyUnread == true, onlyWithAttachments)
}
fun onMessageItemSelected(message: Message, position: Int) {
@@ -83,7 +87,25 @@ class MessageTabPresenter @Inject constructor(
view?.openMessage(message)
}
- private fun loadData(forceRefresh: Boolean) {
+ fun onUnreadFilterSelected(isChecked: Boolean) {
+ view?.run {
+ onlyUnread = isChecked
+ onParentViewLoadData(false, onlyUnread, onlyWithAttachments)
+ }
+ }
+
+ fun onAttachmentsFilterSelected(isChecked: Boolean) {
+ view?.run {
+ onlyWithAttachments = isChecked
+ onParentViewLoadData(false, onlyUnread, onlyWithAttachments)
+ }
+ }
+
+ private fun loadData(
+ forceRefresh: Boolean,
+ onlyUnread: Boolean,
+ onlyWithAttachments: Boolean
+ ) {
Timber.i("Loading $folder message data started")
flowWithResourceIn {
@@ -100,7 +122,15 @@ class MessageTabPresenter @Inject constructor(
showProgress(false)
showContent(true)
messages = it.data
- updateData(getFilteredData(lastSearchQuery))
+ val filteredData = getFilteredData(
+ lastSearchQuery,
+ onlyUnread,
+ onlyWithAttachments
+ )
+ val newItems = listOf(MessageTabDataItem.Header) + filteredData.map {
+ MessageTabDataItem.MessageItem(it)
+ }
+ updateData(newItems, folder.id == MessageFolder.SENT.id)
notifyParentDataLoaded()
}
}
@@ -108,7 +138,7 @@ class MessageTabPresenter @Inject constructor(
Status.SUCCESS -> {
Timber.i("Loading $folder message result: Success")
messages = it.data!!
- updateData(getFilteredData(lastSearchQuery))
+ updateData(getFilteredData(lastSearchQuery, onlyUnread, onlyWithAttachments))
analytics.logEvent(
"load_data",
"type" to "messages",
@@ -166,24 +196,42 @@ class MessageTabPresenter @Inject constructor(
}
}
- private fun getFilteredData(query: String): List<Message> {
- return if (query.trim().isEmpty()) {
- messages.sortedByDescending { it.date }
+ private fun getFilteredData(
+ query: String,
+ onlyUnread: Boolean = false,
+ onlyWithAttachments: Boolean = false
+ ): List<Message> {
+ if (query.trim().isEmpty()) {
+ val sortedMessages = messages.sortedByDescending { it.date }
+ return when {
+ onlyUnread && onlyWithAttachments -> sortedMessages.filter { it.unread == onlyUnread && it.hasAttachments == onlyWithAttachments }
+ onlyUnread -> sortedMessages.filter { it.unread == onlyUnread }
+ onlyWithAttachments -> sortedMessages.filter { it.hasAttachments == onlyWithAttachments }
+ else -> sortedMessages
+ }
} else {
- messages
+ val sortedMessages = messages
.map { it to calculateMatchRatio(it, query) }
- .sortedByDescending { it.second }
- .filter { it.second > 5000 }
+ .sortedWith(compareBy<Pair<Message, Int>> { -it.second }.thenByDescending { it.first.date })
+ .filter { it.second > 6000 }
.map { it.first }
+ return when {
+ onlyUnread && onlyWithAttachments -> sortedMessages.filter { it.unread == onlyUnread && it.hasAttachments == onlyWithAttachments }
+ onlyUnread -> sortedMessages.filter { it.unread == onlyUnread }
+ onlyWithAttachments -> sortedMessages.filter { it.hasAttachments == onlyWithAttachments }
+ else -> sortedMessages
+ }
}
}
private fun updateData(data: List<Message>) {
view?.run {
showEmpty(data.isEmpty())
- showContent(data.isNotEmpty())
+ showContent(true)
showErrorView(false)
- updateData(data)
+ val newItems =
+ listOf(MessageTabDataItem.Header) + data.map { MessageTabDataItem.MessageItem(it) }
+ updateData(newItems, folder.id == MessageFolder.SENT.id)
}
}
@@ -204,14 +252,6 @@ class MessageTabPresenter @Inject constructor(
FuzzySearch.ratio(
query.lowercase(),
message.date.toFormattedString("dd.MM.yyyy").lowercase()
- ),
- FuzzySearch.ratio(
- query.lowercase(),
- message.date.toFormattedString("d MMMM").lowercase()
- ),
- FuzzySearch.ratio(
- query.lowercase(),
- message.date.toFormattedString("d MMMM yyyy").lowercase()
)
).maxOrNull() ?: 0

diff:
@@ -7,11 +7,15 @@ interface MessageTabView : BaseView {
val isViewEmpty: Boolean
+ var onlyUnread: Boolean?
+
+ var onlyWithAttachments: Boolean
+
fun initView()
fun resetListPosition()
- fun updateData(data: List<Message>)
+ fun updateData(data: List<MessageTabDataItem>, hide: Boolean)
fun showProgress(show: Boolean)

diff:
@@ -5,7 +5,10 @@ import android.graphics.Canvas
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
-class DividerItemDecoration(context: Context) : DividerItemDecoration(context, VERTICAL) {
+class DividerItemDecoration(
+ context: Context,
+ private val showDividerWithFirstItem: Boolean = true
+) : DividerItemDecoration(context, VERTICAL) {
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
canvas.save()
@@ -13,6 +16,8 @@ class DividerItemDecoration(context: Context) : DividerItemDecoration(context, V
val dividerRight = parent.width - parent.paddingRight
for (i in 0..parent.childCount - 2) {
+ if (!showDividerWithFirstItem && i == 0) continue
+
val child = parent.getChildAt(i)
val params = child.layoutParams as RecyclerView.LayoutParams
val dividerTop = child.bottom + params.bottomMargin

diff:
+<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:id="@+id/messageChipsLayout"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingLeft="16dp"
+ android:paddingTop="10dp"
+ android:paddingRight="16dp"
+ tools:context=".ui.modules.message.tab.MessageTabAdapter">
+
+ <com.google.android.material.chip.ChipGroup
+ android:id="@+id/messageChipGroup"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent">
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_unread"
+ style="@style/Widget.MaterialComponents.Chip.Choice"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/message_chip_only_unread"
+ app:checkedIcon="@drawable/ic_mtrl_chip_checked_black"
+ app:checkedIconEnabled="true"
+ app:checkedIconTint="@color/mtrl_choice_chip_text_color" />
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_attachments"
+ style="@style/Widget.MaterialComponents.Chip.Choice"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/message_chip_only_with_attachments"
+ app:checkedIcon="@drawable/ic_mtrl_chip_checked_black"
+ app:checkedIconEnabled="true"
+ app:checkedIconTint="@color/mtrl_choice_chip_text_color" />
+ </com.google.android.material.chip.ChipGroup>
+</androidx.constraintlayout.widget.ConstraintLayout>

diff:
<string name="message_not_exists">Message does not exist</string>
<string name="message_required_recipients">You need to choose at least 1 recipient</string>
<string name="message_content_min_length">The message content must be at least 3 characters</string>
+ <string name="message_chip_only_unread">Only unread</string>
+ <string name="message_chip_only_with_attachments">Only with attachments</string>
<plurals name="message_number_item">
<item quantity="one">%d message</item>
<item quantity="other">%d messages</item>

diff:
<item name="android:textColor">?android:textColorPrimary</item>
</style>
+ <style name="Widget.Wulkanowy.Chip.Choice" parent="Widget.MaterialComponents.Chip.Choice">
+ ...
+ <item name="materialThemeOverlay">@style/ThemeOverlay.Wulkanowy.Chip.Choice</item>
+ </style>
+
+ <style name="ThemeOverlay.Wulkanowy.Chip.Choice" parent="">
+ <item name="elevationOverlayEnabled">false</item>
+ </style>
+
<style name="WulkanowyTheme.TextAppearanceBottomNavigation">
<item name="android:textSize">11sp</item>
</style>

original_message: Add messages sorting (#1262)


message: Add last sync date in sync settings

change_type: MODIFY

diff:
@@ -13,9 +13,12 @@ import io.github.wulkanowy.R
import io.github.wulkanowy.ui.modules.dashboard.DashboardItem
import io.github.wulkanowy.ui.modules.grade.GradeAverageMode
import io.github.wulkanowy.ui.modules.grade.GradeSortingMode
+import io.github.wulkanowy.utils.toLocalDateTime
+import io.github.wulkanowy.utils.toTimestamp
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
+import java.time.LocalDateTime
import javax.inject.Inject
import javax.inject.Singleton
@@ -168,6 +171,13 @@ class PreferencesRepository @Inject constructor(
R.bool.pref_default_optional_arithmetic_average
)
+ var lasSyncDate: LocalDateTime
+ get() = getLong(
+ R.string.pref_key_last_sync_date,
+ R.string.pref_default_last_sync_date
+ ).toLocalDateTime()
+ set(value) = sharedPref.edit().putLong("last_sync_date", value.toTimestamp()).apply()
+
var dashboardItemsPosition: Map<DashboardItem.Type, Int>?
get() {
val json = sharedPref.getString(PREF_KEY_DASHBOARD_ITEMS_POSITION, null) ?: return null
@@ -211,6 +221,11 @@ class PreferencesRepository @Inject constructor(
return flowSharedPref.getStringSet(prefKey, defaultSet)
}
+ private fun getLong(id: Int, default: Int) = getLong(context.getString(id), default)
+
+ private fun getLong(id: String, default: Int) =
+ sharedPref.getLong(id, context.resources.getString(default).toLong())
+
private fun getString(id: Int, default: Int) = getString(context.getString(id), default)
private fun getString(id: String, default: Int) =

diff:
@@ -22,6 +22,8 @@ import io.github.wulkanowy.services.sync.works.Work
import io.github.wulkanowy.utils.getCompatColor
import kotlinx.coroutines.coroutineScope
import timber.log.Timber
+import java.time.LocalDateTime
+import java.time.ZoneId
import kotlin.random.Random
@HiltWorker
@@ -48,6 +50,7 @@ class SyncWorker @AssistedInject constructor(
Timber.i("${work::class.java.simpleName} is starting")
work.doWork(student, semester)
Timber.i("${work::class.java.simpleName} result: Success")
+ preferencesRepository.lasSyncDate = LocalDateTime.now(ZoneId.systemDefault())
null
} catch (e: Throwable) {
Timber.w("${work::class.java.simpleName} result: An exception ${e.message} occurred")

diff:
@@ -39,6 +39,12 @@ class SyncFragment : PreferenceFragmentCompat(),
}
}
+ override fun setLastSyncDate(lastSyncDate: String) {
+ findPreference<Preference>(getString(R.string.pref_key_services_force_sync))?.run {
+ summary = getString(R.string.pref_services_last_full_sync_date, lastSyncDate)
+ }
+ }
+
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
presenter.onAttachView(this)

diff:
@@ -8,6 +8,7 @@ import io.github.wulkanowy.ui.base.BasePresenter
import io.github.wulkanowy.ui.base.ErrorHandler
import io.github.wulkanowy.utils.AnalyticsHelper
import io.github.wulkanowy.utils.isHolidays
+import io.github.wulkanowy.utils.toFormattedString
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
@@ -27,6 +28,7 @@ class SyncPresenter @Inject constructor(
Timber.i("Settings sync view was initialized")
view.setServicesSuspended(preferencesRepository.serviceEnableKey, now().isHolidays)
view.initView()
+ setSyncDateInView()
}
fun onSharedPreferenceChanged(key: String) {
@@ -63,10 +65,21 @@ class SyncPresenter @Inject constructor(
}
else -> Timber.d("Sync now state: ${workInfo.state}")
}
- if (workInfo.state.isFinished) setSyncInProgress(false)
+ if (workInfo.state.isFinished) {
+ setSyncInProgress(false)
+ setSyncDateInView()
+ }
}.catch {
Timber.e(it, "Sync now failed")
}.launch("sync")
}
}
+
+ private fun setSyncDateInView() {
+ val lastSyncDate = preferencesRepository.lasSyncDate
+
+ if (lastSyncDate.year == 1970) return
+
+ view?.setLastSyncDate(lastSyncDate.toFormattedString("dd.MM.yyyy HH:mm:ss"))
+ }
}

diff:
@@ -10,6 +10,8 @@ interface SyncView : BaseView {
fun initView()
+ fun setLastSyncDate(lastSyncDate: String)
+
fun setServicesSuspended(serviceEnablesKey: String, isHolidays: Boolean)
fun setSyncInProgress(inProgress: Boolean)

diff:
<bool name="pref_default_homework_fullscreen">false</bool>
<bool name="pref_default_subjects_without_grades">false</bool>
<bool name="pref_default_optional_arithmetic_average">false</bool>
+ <string name="pref_default_last_sync_date">0</string>
<string-array name="pref_default_dashboard_tiles">
<item>LUCKY_NUMBER</item>
<item>MESSAGES</item>

diff:
<string name="pref_key_optional_arithmetic_average">optional_arithmetic_average</string>
<string name="pref_key_message_send_is_draft">message_send_is_draft</string>
<string name="pref_key_message_send_draft">message_send_recipients</string>
+ <string name="pref_key_last_sync_date">last_sync_date</string>
</resources>

diff:
<string name="pref_services_message_sync_success">Synced!</string>
<string name="pref_services_message_sync_failed">Sync failed</string>
<string name="pref_services_sync_in_progress">Sync in progress</string>
+ <string name="pref_services_last_full_sync_date">Last full sync: %s</string>
<string name="pref_other_grade_modifier_plus">Value of the plus</string>
<string name="pref_other_grade_modifier_minus">Value of the minus</string>

license: Apache License 2.0

repo: wulkanowy/wulkanowy

original_message: Add last sync date in sync settings (#1436)


diff:
@@ -5,6 +5,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
+import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.GradeSummary
import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.databinding.ItemGradeSummaryBinding
@@ -35,8 +36,12 @@ class GradeSummaryAdapter @Inject constructor(
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
- ViewType.HEADER.id -> HeaderViewHolder(ScrollableHeaderGradeSummaryBinding.inflate(inflater, parent, false))
- ViewType.ITEM.id -> ItemViewHolder(ItemGradeSummaryBinding.inflate(inflater, parent, false))
+ ViewType.HEADER.id -> HeaderViewHolder(
+ ScrollableHeaderGradeSummaryBinding.inflate(inflater, parent, false)
+ )
+ ViewType.ITEM.id -> ItemViewHolder(
+ ItemGradeSummaryBinding.inflate(inflater, parent, false)
+ )
else -> throw IllegalStateException()
}
}
@@ -51,13 +56,27 @@ class GradeSummaryAdapter @Inject constructor(
private fun bindHeaderViewHolder(binding: ScrollableHeaderGradeSummaryBinding) {
if (items.isEmpty()) return
- with(binding) {
- gradeSummaryScrollableHeaderFinal.text = formatAverage(items.calcAverage(preferencesRepository.gradePlusModifier, preferencesRepository.gradeMinusModifier))
- gradeSummaryScrollableHeaderCalculated.text = formatAverage(items
- .filter { value -> value.average != 0.0 }
+ val context = binding.root.context
+ val finalItemsCount = items.count { it.finalGrade.matches("[0-6][+-]?".toRegex()) }
+ val calculatedItemsCount = items.count { value -> value.average != 0.0 }
+ val finalAverage = items.calcAverage(
+ preferencesRepository.gradePlusModifier,
+ preferencesRepository.gradeMinusModifier
+ )
+ val calculatedAverage = items.filter { value -> value.average != 0.0 }
.map { values -> values.average }
.reversed() // fix average precision
.average()
+
+ with(binding) {
+ gradeSummaryScrollableHeaderFinal.text = formatAverage(finalAverage)
+ gradeSummaryScrollableHeaderCalculated.text = formatAverage(calculatedAverage)
+ gradeSummaryScrollableHeaderFinalSubjectCount.text =
+ context.getString(R.string.grade_summary_from_subjects, finalItemsCount, items.size)
+ gradeSummaryScrollableHeaderCalculatedSubjectCount.text = context.getString(
+ R.string.grade_summary_from_subjects,
+ calculatedItemsCount,
+ items.size
)
}
}
@@ -71,7 +90,8 @@ class GradeSummaryAdapter @Inject constructor(
gradeSummaryItemPredicted.text = "${item.predictedGrade} ${item.proposedPoints}".trim()
gradeSummaryItemFinal.text = "${item.finalGrade} ${item.finalPoints}".trim()
- gradeSummaryItemPointsContainer.visibility = if (item.pointsSum.isBlank()) View.GONE else View.VISIBLE
+ gradeSummaryItemPointsContainer.visibility =
+ if (item.pointsSum.isBlank()) View.GONE else View.VISIBLE
}
}

diff:
android:layout_height="wrap_content"
android:gravity="center"
android:minLines="2"
+ android:textStyle="bold"
android:text="@string/grade_summary_calculated_average"
android:textSize="16sp" />
android:gravity="center_horizontal"
android:textSize="21sp"
tools:text="6,00" />
+
+ <TextView
+ android:id="@+id/gradeSummaryScrollableHeaderCalculatedSubjectCount"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="5dp"
+ android:gravity="center_horizontal"
+ android:textSize="13sp"
+ tools:text="from 8 subjects" />
</LinearLayout>
<LinearLayout
android:layout_height="wrap_content"
android:gravity="center"
android:minLines="2"
+ android:textStyle="bold"
android:text="@string/grade_summary_final_average"
android:textSize="16sp" />
android:gravity="center_horizontal"
android:textSize="21sp"
tools:text="6,00" />
+
+ <TextView
+ android:id="@+id/gradeSummaryScrollableHeaderFinalSubjectCount"
+ android:layout_width="match_parent"
+ android:layout_marginTop="5dp"
+ android:layout_height="wrap_content"
+ android:gravity="center_horizontal"
+ android:textSize="13sp"
+ tools:text="from 5 subjects" />
</LinearLayout>
</LinearLayout>

diff:
<string name="grade_summary_predicted_grade">Predicted grade</string>
<string name="grade_summary_calculated_average">Calculated average</string>
<string name="grade_summary_final_average">Final average</string>
+ <string name="grade_summary_from_subjects">From %d of %d subjects</string>
<string name="grade_menu_summary">Summary</string>
<string name="grade_menu_statistics">Class</string>
<string name="grade_menu_read">Mark as read</string>

original_message: Add captions for averages from how many items have been counted (#1437)


diff:
@@ -20,14 +20,12 @@ import io.github.wulkanowy.data.pojos.MessageDraftJsonAdapter
import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.sdk.pojo.Folder
import io.github.wulkanowy.sdk.pojo.SentMessage
-import io.github.wulkanowy.ui.modules.message.send.RecipientChipItem
import io.github.wulkanowy.utils.AutoRefreshHelper
import io.github.wulkanowy.utils.getRefreshKey
import io.github.wulkanowy.utils.init
import io.github.wulkanowy.utils.networkBoundResource
import io.github.wulkanowy.utils.uniqueSubtract
import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import timber.log.Timber
import java.time.LocalDateTime.now
@@ -79,8 +77,9 @@ class MessageRepository @Inject constructor(
},
saveFetchResult = { old, (downloadedMessage, attachments) ->
checkNotNull(old, { "Fetched message no longer exist!" })
- messagesDb.updateAll(listOf(old.message.copy(unread = !markAsRead).apply {
+ messagesDb.updateAll(listOf(old.message.apply {
id = old.message.id
+ unread = !markAsRead
content = content.ifBlank { downloadedMessage }
}))
messageAttachmentDao.insertAttachments(attachments)

diff:
@@ -33,7 +33,8 @@ class MessagePreviewAdapter @Inject constructor() :
private var attachments: List<MessageAttachment> = emptyList()
- override fun getItemCount() = if (messageWithAttachment == null) 0 else attachments.size + 1 + if (attachments.isNotEmpty()) 1 else 0
+ override fun getItemCount() =
+ if (messageWithAttachment == null) 0 else attachments.size + 1 + if (attachments.isNotEmpty()) 1 else 0
override fun getItemViewType(position: Int) = when (position) {
0 -> ViewType.MESSAGE.id
@@ -45,25 +46,60 @@ class MessagePreviewAdapter @Inject constructor() :
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
- ViewType.MESSAGE.id -> MessageViewHolder(ItemMessagePreviewBinding.inflate(inflater, parent, false))
- ViewType.DIVIDER.id -> DividerViewHolder(ItemMessageDividerBinding.inflate(inflater, parent, false))
- ViewType.ATTACHMENT.id -> AttachmentViewHolder(ItemMessageAttachmentBinding.inflate(inflater, parent, false))
+ ViewType.MESSAGE.id -> MessageViewHolder(
+ ItemMessagePreviewBinding.inflate(inflater, parent, false)
+ )
+ ViewType.DIVIDER.id -> DividerViewHolder(
+ ItemMessageDividerBinding.inflate(inflater, parent, false)
+ )
+ ViewType.ATTACHMENT.id -> AttachmentViewHolder(
+ ItemMessageAttachmentBinding.inflate(inflater, parent, false)
+ )
else -> throw IllegalStateException()
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
- is MessageViewHolder -> bindMessage(holder, requireNotNull(messageWithAttachment).message)
- is AttachmentViewHolder -> bindAttachment(holder, requireNotNull(messageWithAttachment).attachments[position - 2])
+ is MessageViewHolder -> bindMessage(
+ holder,
+ requireNotNull(messageWithAttachment).message
+ )
+ is AttachmentViewHolder -> bindAttachment(
+ holder,
+ requireNotNull(messageWithAttachment).attachments[position - 2]
+ )
}
}
@SuppressLint("SetTextI18n")
private fun bindMessage(holder: MessageViewHolder, message: Message) {
+ val context = holder.binding.root.context
+ val recipientCount = message.unreadBy + message.readBy
+
+ val readText = when {
+ recipientCount > 1 -> {
+ context.resources.getQuantityString(
+ R.plurals.message_read_by,
+ message.readBy,
+ message.readBy,
+ recipientCount
+ )
+ }
+ message.readBy == 1 -> {
+ context.getString(R.string.message_read, context.getString(R.string.all_yes))
+ }
+ else -> context.getString(R.string.message_read, context.getString(R.string.all_no))
+ }
+
with(holder.binding) {
- messagePreviewSubject.text = message.subject.ifBlank { root.context.getString(R.string.message_no_subject) }
- messagePreviewDate.text = root.context.getString(R.string.message_date, message.date.toFormattedString("yyyy-MM-dd HH:mm:ss"))
+ messagePreviewSubject.text =
+ message.subject.ifBlank { root.context.getString(R.string.message_no_subject) }
+ messagePreviewDate.text = root.context.getString(
+ R.string.message_date,
+ message.date.toFormattedString("yyyy-MM-dd HH:mm:ss")
+ )
+ messagePreviewRead.text = readText
messagePreviewContent.text = message.content
messagePreviewFromSender.text = message.sender
messagePreviewToRecipient.text = message.recipient

diff:
app:layout_constraintTop_toBottomOf="@id/messagePreviewToLabel"
tools:text="@tools:sample/date/ddmmyy" />
+ <TextView
+ android:id="@+id/messagePreviewRead"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="4dp"
+ android:textColor="?android:textColorSecondary"
+ android:textSize="15sp"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/messagePreviewDate"
+ tools:text="Read: Yes" />
+
<TextView
android:id="@+id/messagePreviewContent"
android:layout_width="0dp"
android:textIsSelectable="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/messagePreviewDate"
+ app:layout_constraintTop_toBottomOf="@+id/messagePreviewRead"
tools:text="@tools:sample/lorem/random" />
</androidx.constraintlayout.widget.ConstraintLayout>

diff:
<string name="grade_summary_predicted_grade">Predicted grade</string>
<string name="grade_summary_calculated_average">Calculated average</string>
<string name="grade_summary_final_average">Final average</string>
- <string name="grade_summary_from_subjects">From %d of %d subjects</string>
+ <string name="grade_summary_from_subjects">From %1$d of %2$d subjects</string>
<string name="grade_menu_summary">Summary</string>
<string name="grade_menu_statistics">Class</string>
<string name="grade_menu_read">Mark as read</string>
<string name="message_content_min_length">The message content must be at least 3 characters</string>
<string name="message_chip_only_unread">Only unread</string>
<string name="message_chip_only_with_attachments">Only with attachments</string>
+ <string name="message_read">Read: %s</string>
+ <plurals name="message_read_by">
+ <item quantity="one">Read by: %1$d of %2$d people</item>
+ <item quantity="other">Read by: %1$d of %2$d people</item>
+ </plurals>
<plurals name="message_number_item">
<item quantity="one">%d message</item>
<item quantity="other">%d messages</item>

original_message: Show information when the recipient has read the message (#1430)


message: Add in app review

diff:
@@ -215,6 +215,7 @@ dependencies {
playImplementation 'com.google.firebase:firebase-analytics-ktx'
playImplementation 'com.google.firebase:firebase-messaging:'
playImplementation 'com.google.firebase:firebase-crashlytics:'
+ playImplementation 'com.google.android.play:core:1.10.0'
playImplementation 'com.google.android.play:core-ktx:1.8.1'
hmsImplementation 'com.huawei.hms:hianalytics:6.1.1.300'

change_type: ADD

diff:
+package io.github.wulkanowy.utils
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import io.github.wulkanowy.ui.modules.main.MainActivity
+import javax.inject.Singleton
+import javax.inject.Inject
+
+@Singleton
+class InAppReviewHelper @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+
+ fun showInAppReview(activity: MainActivity) {
+ // do nothing
+ }
+}
\ No newline at end of file

diff:
+package io.github.wulkanowy.utils
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import io.github.wulkanowy.ui.modules.main.MainActivity
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class InAppReviewHelper @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+
+ fun showInAppReview(activity: MainActivity) {
+ // do nothing
+ }
+}
\ No newline at end of file

diff:
@@ -10,14 +10,17 @@ import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
+import io.github.wulkanowy.sdk.toLocalDate
import io.github.wulkanowy.ui.modules.dashboard.DashboardItem
import io.github.wulkanowy.ui.modules.grade.GradeAverageMode
import io.github.wulkanowy.ui.modules.grade.GradeSortingMode
+import io.github.wulkanowy.utils.toTimestamp
import io.github.wulkanowy.utils.toLocalDateTime
import io.github.wulkanowy.utils.toTimestamp
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
+import java.time.LocalDate
import java.time.LocalDateTime
import javax.inject.Inject
import javax.inject.Singleton
@@ -222,6 +225,18 @@ class PreferencesRepository @Inject constructor(
return flowSharedPref.getStringSet(prefKey, defaultSet)
}
+ var inAppReviewCount: Int
+ get() = sharedPref.getInt(PREF_KEY_IN_APP_REVIEW_COUNT, 0)
+ set(value) = sharedPref.edit().putInt(PREF_KEY_IN_APP_REVIEW_COUNT, value).apply()
+
+ var inAppReviewDate: LocalDate?
+ get() = sharedPref.getLong(PREF_KEY_IN_APP_REVIEW_DATE, 0).takeIf { it != 0L }?.toLocalDate()
+ set(value) = sharedPref.edit().putLong(PREF_KEY_IN_APP_REVIEW_DATE, value!!.toTimestamp()).apply()
+
+ var isAppReviewDone: Boolean
+ get() = sharedPref.getBoolean(PREF_KEY_IN_APP_REVIEW_DONE, false)
+ set(value) = sharedPref.edit().putBoolean(PREF_KEY_IN_APP_REVIEW_DONE, value).apply()
+
private fun getLong(id: Int, default: Int) = getLong(context.getString(id), default)
private fun getLong(id: String, default: Int) =
@@ -240,5 +255,11 @@ class PreferencesRepository @Inject constructor(
private companion object {
private const val PREF_KEY_DASHBOARD_ITEMS_POSITION = "dashboard_items_position"
+
+ private const val PREF_KEY_IN_APP_REVIEW_COUNT = "in_app_review_count"
+
+ private const val PREF_KEY_IN_APP_REVIEW_DATE = "in_app_review_date"
+
+ private const val PREF_KEY_IN_APP_REVIEW_DONE = "in_app_review_done"
}
}

diff:
@@ -45,6 +45,7 @@ import io.github.wulkanowy.ui.modules.schoolannouncement.SchoolAnnouncementFragm
import io.github.wulkanowy.ui.modules.timetable.TimetableFragment
import io.github.wulkanowy.utils.AnalyticsHelper
import io.github.wulkanowy.utils.AppInfo
+import io.github.wulkanowy.utils.InAppReviewHelper
import io.github.wulkanowy.utils.UpdateHelper
import io.github.wulkanowy.utils.createNameInitialsDrawable
import io.github.wulkanowy.utils.dpToPx
@@ -68,6 +69,9 @@ class MainActivity : BaseActivity<MainPresenter, ActivityMainBinding>(), MainVie
@Inject
lateinit var updateHelper: UpdateHelper
+ @Inject
+ lateinit var inAppReviewHelper: InAppReviewHelper
+
@Inject
lateinit var appInfo: AppInfo
@@ -362,6 +366,10 @@ class MainActivity : BaseActivity<MainPresenter, ActivityMainBinding>(), MainVie
}
}
+ override fun showInAppReview() {
+ inAppReviewHelper.showInAppReview(this)
+ }
+
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
navController.onSaveInstanceState(outState)

diff:
@@ -14,6 +14,7 @@ import io.github.wulkanowy.utils.AnalyticsHelper
import io.github.wulkanowy.utils.flowWithResource
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
+import java.time.LocalDate
import javax.inject.Inject
class MainPresenter @Inject constructor(
@@ -106,11 +107,27 @@ class MainPresenter @Inject constructor(
} else {
notifyMenuViewChanged()
switchMenuView(index)
+ checkInAppReview()
true
}
} == true
}
+ private fun checkInAppReview() {
+ prefRepository.inAppReviewCount++
+
+ if (prefRepository.inAppReviewDate == null) {
+ prefRepository.inAppReviewDate = LocalDate.now()
+ }
+
+ if (!prefRepository.isAppReviewDone && prefRepository.inAppReviewCount >= 50 &&
+ LocalDate.now().minusDays(14).isAfter(prefRepository.inAppReviewDate)
+ ) {
+ view?.showInAppReview()
+ prefRepository.isAppReviewDone = true
+ }
+ }
+
private fun showCurrentStudentAvatar() {
val currentStudent =
studentsWitSemesters?.singleOrNull { it.student.isCurrent }?.student ?: return

diff:
@@ -40,6 +40,8 @@ interface MainView : BaseView {
fun showStudentAvatar(student: Student)
+ fun showInAppReview()
+
interface MainChildView {
fun onFragmentReselected()

diff:
+package io.github.wulkanowy.utils
+
+import android.content.Context
+import com.google.android.play.core.review.ReviewManagerFactory
+import dagger.hilt.android.qualifiers.ApplicationContext
+import io.github.wulkanowy.ui.modules.main.MainActivity
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class InAppReviewHelper @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+
+ fun showInAppReview(activity: MainActivity) {
+ val manager = ReviewManagerFactory.create(context)
+ val request = manager.requestReviewFlow()
+ request.addOnCompleteListener { task ->
+ if (task.isSuccessful) {
+ val reviewInfo = task.result
+ manager.launchReviewFlow(activity, reviewInfo)
+ } else {
+ Timber.e(task.exception)
+ }
+ }
+ }
+}
\ No newline at end of file

original_message: Add in app review (#1435)


diff:
@@ -10,6 +10,7 @@ import io.github.wulkanowy.data.db.entities.Attendance
import io.github.wulkanowy.data.enums.SentExcuseStatus
import io.github.wulkanowy.databinding.ItemAttendanceBinding
import io.github.wulkanowy.utils.description
+import io.github.wulkanowy.utils.isExcusableOrNotExcused
import javax.inject.Inject
class AttendanceAdapter @Inject constructor() :
@@ -56,7 +57,7 @@ class AttendanceAdapter @Inject constructor() :
attendanceItemExcuseInfo.visibility = View.VISIBLE
}
else -> {
- if (item.excusable && excuseActionMode) {
+ if (item.isExcusableOrNotExcused && excuseActionMode) {
attendanceItemNumber.visibility = View.GONE
attendanceItemExcuseCheckbox.visibility = View.VISIBLE
} else {

diff:
@@ -24,6 +24,7 @@ import io.github.wulkanowy.ui.base.BaseFragment
import io.github.wulkanowy.ui.modules.attendance.summary.AttendanceSummaryFragment
import io.github.wulkanowy.ui.modules.main.MainActivity
import io.github.wulkanowy.ui.modules.main.MainView
+import io.github.wulkanowy.ui.modules.message.send.SendMessageActivity
import io.github.wulkanowy.ui.widgets.DividerItemDecoration
import io.github.wulkanowy.utils.SchoolDaysValidator
import io.github.wulkanowy.utils.dpToPx
@@ -120,7 +121,9 @@ class AttendanceFragment : BaseFragment<FragmentAttendanceBinding>(R.layout.frag
attendanceSwipe.setOnRefreshListener(presenter::onSwipeRefresh)
attendanceSwipe.setColorSchemeColors(requireContext().getThemeAttrColor(R.attr.colorPrimary))
attendanceSwipe.setProgressBackgroundColorSchemeColor(
- requireContext().getThemeAttrColor(R.attr.colorSwipeRefresh)
+ requireContext().getThemeAttrColor(
+ R.attr.colorSwipeRefresh
+ )
)
attendanceErrorRetry.setOnClickListener { presenter.onRetry() }
attendanceErrorDetails.setOnClickListener { presenter.onDetailsClick() }
@@ -253,8 +256,13 @@ class AttendanceFragment : BaseFragment<FragmentAttendanceBinding>(R.layout.frag
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.create()
.apply {
- setButton(BUTTON_POSITIVE, getString(R.string.attendance_excuse_dialog_submit)) { _, _ ->
- presenter.onExcuseDialogSubmit(dialogBinding.excuseReason.text?.toString().orEmpty())
+ setButton(
+ BUTTON_POSITIVE,
+ getString(R.string.attendance_excuse_dialog_submit)
+ ) { _, _ ->
+ presenter.onExcuseDialogSubmit(
+ dialogBinding.excuseReason.text?.toString().orEmpty()
+ )
}
}.show()
}
@@ -267,6 +275,17 @@ class AttendanceFragment : BaseFragment<FragmentAttendanceBinding>(R.layout.frag
actionMode = (activity as MainActivity?)?.startSupportActionMode(actionModeCallback)
}
+ override fun startSendMessageIntent(date: LocalDate, numbers: String, reason: String) {
+ val reasonFullText = getString(
+ R.string.attendance_excuse_formula,
+ date,
+ numbers,
+ if (reason.isNotBlank()) " ${getString(R.string.attendance_excuse_reason)} " else "",
+ reason.ifBlank { "" }
+ )
+ startActivity(SendMessageActivity.getStartIntent(requireContext(), reasonFullText))
+ }
+
override fun showExcuseCheckboxes(show: Boolean) {
attendanceAdapter.apply {
excuseActionMode = show

diff:
@@ -15,6 +15,7 @@ import io.github.wulkanowy.utils.capitalise
import io.github.wulkanowy.utils.flowWithResource
import io.github.wulkanowy.utils.flowWithResourceIn
import io.github.wulkanowy.utils.getLastSchoolDayIfHoliday
+import io.github.wulkanowy.utils.isExcusableOrNotExcused
import io.github.wulkanowy.utils.isHolidays
import io.github.wulkanowy.utils.nextSchoolDay
import io.github.wulkanowy.utils.previousOrSameSchoolDay
@@ -47,6 +48,8 @@ class AttendancePresenter @Inject constructor(
private val attendanceToExcuseList = mutableListOf<Attendance>()
+ private var isVulcanExcusedFunctionEnabled = false
+
fun onAttachView(view: AttendanceView, date: Long?) {
super.onAttachView(view)
view.initView()
@@ -148,7 +151,21 @@ class AttendancePresenter @Inject constructor(
fun onExcuseDialogSubmit(reason: String) {
view?.finishActionMode()
- excuseAbsence(if (reason != "") reason else null, attendanceToExcuseList.toList())
+
+ if (isVulcanExcusedFunctionEnabled) {
+ excuseAbsence(
+ reason = reason.takeIf { it.isNotBlank() },
+ toExcuseList = attendanceToExcuseList.toList()
+ )
+ } else {
+ val attendanceToExcuseNumbers = attendanceToExcuseList.map { it.number }
+
+ view?.startSendMessageIntent(
+ date = attendanceToExcuseList[0].date,
+ numbers = attendanceToExcuseNumbers.joinToString(", "),
+ reason = reason
+ )
+ }
}
fun onPrepareActionMode(): Boolean {
@@ -188,8 +205,12 @@ class AttendancePresenter @Inject constructor(
private fun loadData(forceRefresh: Boolean = false) {
Timber.i("Loading attendance data started")
+ var isParent = false
+
flowWithResourceIn {
val student = studentRepository.getCurrentStudent()
+ isParent = student.isParent
+
val semester = semesterRepository.getCurrentSemester(student)
attendanceRepository.getAttendance(
student,
@@ -227,12 +248,17 @@ class AttendancePresenter @Inject constructor(
it.data?.filter { item -> !item.presence }.orEmpty()
}
+ isVulcanExcusedFunctionEnabled =
+ filteredAttendance.any { item -> item.excusable }
+
view?.apply {
updateData(filteredAttendance.sortedBy { item -> item.number })
showEmpty(filteredAttendance.isEmpty())
showErrorView(false)
showContent(filteredAttendance.isNotEmpty())
- showExcuseButton(filteredAttendance.any { item -> item.excusable })
+ showExcuseButton(filteredAttendance.any { item ->
+ (!isParent && isVulcanExcusedFunctionEnabled) || (isParent && item.isExcusableOrNotExcused)
+ })
}
analytics.logEvent(
"load_data",

diff:
@@ -54,6 +54,8 @@ interface AttendanceView : BaseView {
fun openSummaryView()
+ fun startSendMessageIntent(date: LocalDate, numbers: String, reason: String)
+
fun startActionMode()
fun showExcuseCheckboxes(show: Boolean)

diff:
@@ -35,6 +35,8 @@ class SendMessageActivity : BaseActivity<SendMessagePresenter, ActivitySendMessa
companion object {
private const val EXTRA_MESSAGE = "EXTRA_MESSAGE"
+ private const val EXTRA_REASON = "EXTRA_REASON"
+
private const val EXTRA_REPLY = "EXTRA_REPLY"
fun getStartIntent(context: Context) = Intent(context, SendMessageActivity::class.java)
@@ -44,6 +46,11 @@ class SendMessageActivity : BaseActivity<SendMessagePresenter, ActivitySendMessa
.putExtra(EXTRA_MESSAGE, message)
.putExtra(EXTRA_REPLY, reply)
}
+
+ fun getStartIntent(context: Context, reason: String): Intent {
+ return getStartIntent(context)
+ .putExtra(EXTRA_REASON, reason)
+ }
}
override val isDropdownListVisible: Boolean
@@ -79,6 +86,7 @@ class SendMessageActivity : BaseActivity<SendMessagePresenter, ActivitySendMessa
presenter.onAttachView(
view = this,
+ reason = intent.getSerializableExtra(EXTRA_REASON) as? String,
message = intent.getSerializableExtra(EXTRA_MESSAGE) as? Message,
reply = intent.getSerializableExtra(EXTRA_REPLY) as? Boolean
)

diff:
@@ -40,7 +40,7 @@ class SendMessagePresenter @Inject constructor(
private val messageUpdateChannel = Channel<Unit>()
- fun onAttachView(view: SendMessageView, message: Message?, reply: Boolean?) {
+ fun onAttachView(view: SendMessageView, reason: String?, message: Message?, reply: Boolean?) {
super.onAttachView(view)
view.initView()
initializeSubjectStream()
@@ -50,6 +50,10 @@ class SendMessagePresenter @Inject constructor(
if (messageRepository.draftMessage != null && reply == null) {
view.showMessageBackupDialog()
}
+ reason?.let {
+ setSubject("Usprawiedliwenie")
+ setContent(it)
+ }
message?.let {
setSubject(when (reply) {
true -> "RE: "

diff:
@@ -16,6 +16,9 @@ private inline val AttendanceSummary.allPresences: Double
private inline val AttendanceSummary.allAbsences: Double
get() = absence.toDouble() + absenceExcused
+inline val Attendance.isExcusableOrNotExcused: Boolean
+ get() = excusable || ((absence || lateness) && !excused)
+
fun AttendanceSummary.calculatePercentage() = calculatePercentage(allPresences, allAbsences)
fun List<AttendanceSummary>.calculatePercentage(): Double {

original_message: Add excuse function to "not excusable" account (#1429)


diff:
@@ -166,7 +166,7 @@ ext {
}
dependencies {
- implementation "io.github.wulkanowy:sdk:8f3721f1f9"
+ implementation "io.github.wulkanowy:sdk:230d2075df"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'

diff:
@@ -360,7 +360,7 @@ class TimetableAdapter @Inject constructor() : RecyclerView.Adapter<RecyclerView
private fun updateTeacherColor(teacherTextView: TextView, lesson: Timetable) {
teacherTextView.setTextColor(
teacherTextView.context.getThemeAttrColor(
- if (lesson.teacherOld.isNotBlank() && lesson.teacherOld != lesson.teacher) R.attr.colorTimetableChange
+ if (lesson.teacherOld.isNotBlank()) R.attr.colorTimetableChange
else android.R.attr.textColorSecondary
)
)

diff:
@@ -89,14 +89,22 @@ class TimetableDialog : DialogFragment() {
R.attr.colorPrimary
)
)
- timetableDialogChangesValue.setTextColor(requireContext().getThemeAttrColor(R.attr.colorPrimary))
+ timetableDialogChangesValue.setTextColor(
+ requireContext().getThemeAttrColor(
+ R.attr.colorPrimary
+ )
+ )
} else {
timetableDialogChangesTitle.setTextColor(
requireContext().getThemeAttrColor(
R.attr.colorTimetableChange
)
)
- timetableDialogChangesValue.setTextColor(requireContext().getThemeAttrColor(R.attr.colorTimetableChange))
+ timetableDialogChangesValue.setTextColor(
+ requireContext().getThemeAttrColor(
+ R.attr.colorTimetableChange
+ )
+ )
}
timetableDialogChangesValue.text = when {
@@ -128,6 +136,15 @@ class TimetableDialog : DialogFragment() {
}
}
}
+ teacherOld.isNotBlank() && teacherOld == teacher -> {
+ timetableDialogTeacherValue.run {
+ visibility = GONE
+ }
+ timetableDialogTeacherNewValue.run {
+ visibility = VISIBLE
+ text = teacher
+ }
+ }
teacher.isNotBlank() -> timetableDialogTeacherValue.text = teacher
else -> {
timetableDialogTeacherTitle.visibility = GONE

diff:
@@ -173,7 +173,7 @@ class TimetableWidgetFactory(
updateNotCanceledLessonNumberColor(this, lesson)
updateNotCanceledSubjectColor(this, lesson)
- val teacherChange = lesson.teacherOld.isNotBlank() && lesson.teacher != lesson.teacherOld
+ val teacherChange = lesson.teacherOld.isNotBlank()
updateNotCanceledRoom(this, lesson, teacherChange)
updateNotCanceledTeacher(this, lesson, teacherChange)
}

original_message: Mark teacher with yellow when new and old are the same (#1529)


message: Add transparency to lucky number widget

diff:
<?xml version="1.0" encoding="UTF-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
- <solid android:color="@android:color/white" />
- <corners android:radius="5dp" />
+ <solid android:color="#bbffffff" />
+ <corners android:radius="8dp" />
</shape>

diff:
<?xml version="1.0" encoding="UTF-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
- <solid android:color="@color/colorWidgetBackground" />
- <corners android:radius="5dp" />
+ <solid android:color="#BB191919" />
+ <corners android:radius="8dp" />
</shape>

original_message: Add transparency to lucky number widget (#1530)


diff:
@@ -107,6 +107,14 @@ class PreferencesRepository @Inject constructor(
R.bool.pref_default_notification_upcoming_lessons_enable
)
+ val isUpcomingLessonsNotificationsPersistentKey =
+ context.getString(R.string.pref_key_notifications_upcoming_lessons_persistent)
+ val isUpcomingLessonsNotificationsPersistent: Boolean
+ get() = getBoolean(
+ isUpcomingLessonsNotificationsPersistentKey,
+ R.bool.pref_default_notification_upcoming_lessons_persistent
+ )
+
val isNotificationPiggybackEnabledKey =
context.getString(R.string.pref_key_notifications_piggyback)
val isNotificationPiggybackEnabled: Boolean

diff:
@@ -11,6 +11,7 @@ import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R
import io.github.wulkanowy.data.Status
+import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.data.repositories.StudentRepository
import io.github.wulkanowy.services.HiltBroadcastReceiver
import io.github.wulkanowy.services.sync.channels.UpcomingLessonsChannel.Companion.CHANNEL_ID
@@ -32,6 +33,9 @@ class TimetableNotificationReceiver : HiltBroadcastReceiver() {
@Inject
lateinit var studentRepository: StudentRepository
+ @Inject
+ lateinit var preferencesRepository: PreferencesRepository
+
companion object {
const val NOTIFICATION_TYPE_CURRENT = 1
const val NOTIFICATION_TYPE_UPCOMING = 2
@@ -68,6 +72,7 @@ class TimetableNotificationReceiver : HiltBroadcastReceiver() {
private fun prepareNotification(context: Context, intent: Intent) {
val type = intent.getIntExtra(LESSON_TYPE, 0)
val notificationId = intent.getIntExtra(NOTIFICATION_ID, MainView.Section.TIMETABLE.id)
+ val isPersistent = preferencesRepository.isUpcomingLessonsNotificationsPersistent
if (type == NOTIFICATION_TYPE_LAST_LESSON_CANCELLATION) {
return NotificationManagerCompat.from(context).cancel(notificationId)
@@ -87,20 +92,38 @@ class TimetableNotificationReceiver : HiltBroadcastReceiver() {
Timber.d("TimetableNotification receive: type: $type, subject: $subject, start: ${start.toLocalDateTime()}, student: $studentId")
- showNotification(context, notificationId, studentName,
+ showNotification(context, notificationId, isPersistent, studentName,
if (type == NOTIFICATION_TYPE_CURRENT) end else start, end - start,
- context.getString(if (type == NOTIFICATION_TYPE_CURRENT) R.string.timetable_now else R.string.timetable_next, "($room) $subject".removePrefix("()")),
- nextSubject?.let { context.getString(R.string.timetable_later, "($nextRoom) $nextSubject".removePrefix("()")) }
+ context.getString(
+ if (type == NOTIFICATION_TYPE_CURRENT) R.string.timetable_now else R.string.timetable_next,
+ "($room) $subject".removePrefix("()")
+ ),
+ nextSubject?.let {
+ context.getString(
+ R.string.timetable_later,
+ "($nextRoom) $nextSubject".removePrefix("()")
+ )
+ }
)
}
- private fun showNotification(context: Context, notificationId: Int, studentName: String?, countDown: Long, timeout: Long, title: String, next: String?) {
- NotificationManagerCompat.from(context).notify(notificationId, NotificationCompat.Builder(context, CHANNEL_ID)
+ private fun showNotification(
+ context: Context,
+ notificationId: Int,
+ isPersistent: Boolean,
+ studentName: String?,
+ countDown: Long,
+ timeout: Long,
+ title: String,
+ next: String?
+ ) {
+ NotificationManagerCompat.from(context)
+ .notify(notificationId, NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(title)
.setContentText(next)
.setAutoCancel(false)
- .setOngoing(true)
.setWhen(countDown)
+ .setOngoing(isPersistent)
.apply {
if (Build.VERSION.SDK_INT >= N) setUsesChronometer(true)
}
@@ -111,8 +134,14 @@ class TimetableNotificationReceiver : HiltBroadcastReceiver() {
it.setSummaryText(studentName)
it.addLine(next)
})
- .setContentIntent(PendingIntent.getActivity(context, MainView.Section.TIMETABLE.id,
- MainActivity.getStartIntent(context, MainView.Section.TIMETABLE, true), FLAG_UPDATE_CURRENT))
+ .setContentIntent(
+ PendingIntent.getActivity(
+ context,
+ MainView.Section.TIMETABLE.id,
+ MainActivity.getStartIntent(context, MainView.Section.TIMETABLE, true),
+ FLAG_UPDATE_CURRENT
+ )
+ )
.build()
)
}

diff:
@@ -42,7 +42,7 @@ class NotificationsPresenter @Inject constructor(
preferencesRepository.apply {
when (key) {
- isUpcomingLessonsNotificationsEnableKey -> {
+ isUpcomingLessonsNotificationsEnableKey, isUpcomingLessonsNotificationsPersistentKey -> {
if (!isUpcomingLessonsNotificationsEnable) {
timetableNotificationHelper.cancelNotification()
}

diff:
<bool name="pref_default_services_wifi_only">false</bool>
<bool name="pref_default_notifications_enable">true</bool>
<bool name="pref_default_notification_upcoming_lessons_enable">false</bool>
+ <bool name="pref_default_notification_upcoming_lessons_persistent">true</bool>
<bool name="pref_default_notification_debug">false</bool>
<string name="pref_default_grade_modifier_plus">0.33</string>
<string name="pref_default_grade_modifier_minus">0.33</string>

diff:
<string name="pref_notify_header">Notifications</string>
<string name="pref_notify_switch">Show notifications</string>
<string name="pref_notify_upcoming_lessons_switch">Show upcoming lesson notifications</string>
+ <string name="pref_notify_upcoming_lessons_persistent_switch">Make upcoming lesson notification persistent</string>
+ <string name="pref_notify_upcoming_lessons_persistent_summary">Turn off when notification is not showing in your watch/band</string>
<string name="pref_notify_open_system_settings">Open system notification settings</string>
<string name="pref_notify_fix_sync_issues">Fix synchronization & notifications issues</string>
<string name="pref_notify_fix_sync_issues_message">Your device may have data synchronization issues and with notifications.\n\nTo fix them, you need to add Wulkanowy to the autostart and turn off battery optimization/saving in the phone settings.</string>

diff:
app:key="@string/pref_key_notifications_upcoming_lessons_enable"
app:singleLineTitle="false"
app:title="@string/pref_notify_upcoming_lessons_switch" />
+ <SwitchPreferenceCompat
+ app:defaultValue="@bool/pref_default_notification_upcoming_lessons_persistent"
+ app:iconSpaceReserved="false"
+ app:key="@string/pref_key_notifications_upcoming_lessons_persistent"
+ app:dependency="@string/pref_key_notifications_upcoming_lessons_enable"
+ app:singleLineTitle="false"
+ app:summary="@string/pref_notify_upcoming_lessons_persistent_summary"
+ app:title="@string/pref_notify_upcoming_lessons_persistent_switch" />
<SwitchPreferenceCompat
app:defaultValue="@bool/pref_default_notification_piggyback"
app:iconSpaceReserved="false"

original_message: Add option to make upcoming lesson notification not persistent (#1537)


message: Separate calculated average settings

diff:
<string name="pref_attendance_appearance_view">Attendance</string>
<string name="pref_timetable_appearance_view">Timetable</string>
<string name="pref_grades_advanced_header">Grades</string>
+ <string name="pref_counted_average_advanced_header">Calculated average</string>
<string name="pref_messages_advanced_header">Messages</string>
<string name="pref_appearance_category">Appearance & Behavior</string>

diff:
app:key="@string/pref_key_grade_modifier_minus"
app:title="@string/pref_other_grade_modifier_minus"
app:useSimpleSummaryProvider="true" />
+ </PreferenceCategory>
+ <PreferenceCategory
+ app:iconSpaceReserved="false"
+ app:title="@string/pref_counted_average_advanced_header">
<SwitchPreferenceCompat
app:defaultValue="@bool/pref_default_grade_average_force_calc"
app:iconSpaceReserved="false"
app:key="@string/pref_key_grade_average_force_calc"
app:singleLineTitle="false"
app:title="@string/pref_view_grade_average_force_calc" />
- <SwitchPreferenceCompat
- app:defaultValue="@bool/pref_default_optional_arithmetic_average"
- app:iconSpaceReserved="false"
- app:key="@string/pref_key_optional_arithmetic_average"
- app:singleLineTitle="false"
- app:title="@string/pref_other_optional_arithmetic_average" />
<ListPreference
app:defaultValue="@string/pref_default_grade_average_mode"
app:entries="@array/grade_average_mode_entries"
app:key="@string/pref_key_grade_average_mode"
app:title="@string/pref_view_grade_average_mode"
app:useSimpleSummaryProvider="true" />
+ <SwitchPreferenceCompat
+ app:defaultValue="@bool/pref_default_optional_arithmetic_average"
+ app:iconSpaceReserved="false"
+ app:key="@string/pref_key_optional_arithmetic_average"
+ app:singleLineTitle="false"
+ app:title="@string/pref_other_optional_arithmetic_average" />
</PreferenceCategory>
<PreferenceCategory
app:iconSpaceReserved="false"

original_message: Separate calculated average settings (#1558)


diff:
@@ -301,26 +301,31 @@ class DashboardAdapter @Inject constructor() : RecyclerView.Adapter<RecyclerView
dateToNavigate = currentDate
updateLessonView(item, currentTimetable, binding)
binding.dashboardLessonsItemTitleTomorrow.isVisible = false
+ binding.dashboardLessonsItemTitleTodayAndTomorrow.isVisible = false
}
tomorrowTimetable.isNotEmpty() -> {
dateToNavigate = tomorrowDate
updateLessonView(item, tomorrowTimetable, binding)
binding.dashboardLessonsItemTitleTomorrow.isVisible = true
+ binding.dashboardLessonsItemTitleTodayAndTomorrow.isVisible = false
}
currentDayHeader != null && currentDayHeader.content.isNotBlank() -> {
dateToNavigate = currentDate
updateLessonView(item, emptyList(), binding, currentDayHeader)
binding.dashboardLessonsItemTitleTomorrow.isVisible = false
+ binding.dashboardLessonsItemTitleTodayAndTomorrow.isVisible = false
}
tomorrowDayHeader != null && tomorrowDayHeader.content.isNotBlank() -> {
dateToNavigate = tomorrowDate
updateLessonView(item, emptyList(), binding, tomorrowDayHeader)
binding.dashboardLessonsItemTitleTomorrow.isVisible = true
+ binding.dashboardLessonsItemTitleTodayAndTomorrow.isVisible = false
}
else -> {
- dateToNavigate = tomorrowDate
+ dateToNavigate = currentDate
updateLessonView(item, emptyList(), binding)
- binding.dashboardLessonsItemTitleTomorrow.isVisible =
+ binding.dashboardLessonsItemTitleTomorrow.isVisible = false
+ binding.dashboardLessonsItemTitleTodayAndTomorrow.isVisible =
!(item.isLoading && item.error == null)
}
}

diff:
app:layout_constraintBottom_toBottomOf="@id/dashboard_lessons_item_title"
app:layout_constraintStart_toEndOf="@id/dashboard_lessons_item_title" />
+ <TextView
+ android:id="@+id/dashboard_lessons_item_title_today_and_tomorrow"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="4dp"
+ android:text="@string/dashboard_timetable_title_today_and_tomorrow"
+ android:textColor="?colorOnSurface"
+ android:textSize="14sp"
+ tools:visibility="invisible"
+ app:layout_constraintBaseline_toBaselineOf="@id/dashboard_lessons_item_title"
+ app:layout_constraintBottom_toBottomOf="@id/dashboard_lessons_item_title"
+ app:layout_constraintStart_toEndOf="@id/dashboard_lessons_item_title" />
+
<TextView
android:id="@+id/dashboard_lessons_item_first_title"
android:layout_width="wrap_content"

diff:
<!--Dashboard-->
<string name="dashboard_timetable_title">Lessons</string>
<string name="dashboard_timetable_title_tomorrow">(Tomorrow)</string>
+ <string name="dashboard_timetable_title_today_and_tomorrow">(Today and tomorrow)</string>
<string name="dashboard_timetable_first_lesson_title_moment">In a moment:</string>
<string name="dashboard_timetable_first_lesson_title_soon">Soon:</string>
<string name="dashboard_timetable_first_lesson_title_first">First:</string>

original_message: Change text when there are no lessons today and tomorrow in dashboard (#1571)


diff:
@@ -11,6 +11,7 @@ import android.widget.Toast
import android.widget.Toast.LENGTH_LONG
import androidx.appcompat.app.AlertDialog
import androidx.core.content.getSystemService
+import androidx.core.view.isGone
import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R
import io.github.wulkanowy.databinding.DialogErrorBinding
@@ -24,8 +25,6 @@ import io.github.wulkanowy.utils.openEmailClient
import io.github.wulkanowy.utils.openInternetBrowser
import okhttp3.internal.http2.StreamResetException
import java.io.InterruptedIOException
-import java.io.PrintWriter
-import java.io.StringWriter
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
@@ -64,26 +63,26 @@ class ErrorDialog : BaseDialogFragment<DialogErrorBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- val stringWriter = StringWriter().apply {
- error.printStackTrace(PrintWriter(this))
- }
+ val errorStacktrace = error.stackTraceToString()
with(binding) {
- errorDialogContent.text = stringWriter.toString()
+ errorDialogContent.text = errorStacktrace.replace(": ${error.localizedMessage}", "")
with(errorDialogHorizontalScroll) {
post { fullScroll(HorizontalScrollView.FOCUS_LEFT) }
}
errorDialogCopy.setOnClickListener {
- val clip = ClipData.newPlainText("wulkanowy", stringWriter.toString())
+ val clip = ClipData.newPlainText("Error details", errorStacktrace)
activity?.getSystemService<ClipboardManager>()?.setPrimaryClip(clip)
Toast.makeText(context, R.string.all_copied, LENGTH_LONG).show()
}
errorDialogCancel.setOnClickListener { dismiss() }
errorDialogReport.setOnClickListener {
- openConfirmDialog { openEmailClient(stringWriter.toString()) }
+ openConfirmDialog { openEmailClient(errorStacktrace) }
}
- errorDialogMessage.text = resources.getString(error)
+ errorDialogHumanizedMessage.text = resources.getString(error)
+ errorDialogErrorMessage.text = error.localizedMessage
+ errorDialogErrorMessage.isGone = error.localizedMessage.isNullOrBlank()
errorDialogReport.isEnabled = when (error) {
is UnknownHostException,
is InterruptedIOException,

diff:
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minWidth="300dp"
- android:orientation="vertical">
+ android:orientation="vertical"
+ tools:context=".ui.base.ErrorDialog">
<TextView
android:layout_width="match_parent"
android:textStyle="bold" />
<TextView
- android:id="@+id/errorDialogMessage"
+ android:id="@+id/errorDialogHumanizedMessage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="20dp"
android:textSize="21sp"
tools:text="@tools:sample/lorem" />
+ <TextView
+ android:id="@+id/errorDialogErrorMessage"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="5dp"
+ android:paddingHorizontal="20dp"
+ android:paddingTop="10dp"
+ android:textColor="?android:textColorSecondary"
+ tools:text="@tools:sample/lorem" />
+
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="start"
+ android:textColor="?android:textColorTertiary"
android:textIsSelectable="true"
android:textSize="12sp"
tools:text="@tools:sample/lorem/random" />

original_message: Error messages content wrap in error dialog (#1577)


diff:
<!--Preferences-->
- <string name="pref_view_header">App appearance & behavior</string>
+ <string name="pref_view_header">App</string>
<string name="pref_view_list">Default view</string>
<string name="pref_view_grade_average_mode">Calculated average options</string>
<string name="pref_view_grade_average_force_calc">Force average calculation by app</string>
<string name="pref_view_present">Show presence</string>
<string name="pref_view_app_theme">Theme</string>
- <string name="pref_view_expand_grade">Expand grades</string>
+ <string name="pref_view_expand_grade">Grades expanding</string>
<string name="pref_view_timetable_show_timers">Mark current lesson</string>
<string name="pref_view_timetable_show_groups">Show groups next to subjects</string>
<string name="pref_view_grade_statistics_list">Show chart list in class grades</string>
<string name="pref_view_app_language">Language</string>
<string name="pref_notify_header">Notifications</string>
+ <string name="pref_notify_header_other">Other</string>
<string name="pref_notify_switch">Show notifications</string>
<string name="pref_notify_upcoming_lessons_switch">Show upcoming lesson notifications</string>
<string name="pref_notify_upcoming_lessons_persistent_switch">Make upcoming lesson notification persistent</string>
<string name="pref_sync_category_summary">Automatic update, synchronization interval</string>
<string name="pref_advanced_category_summary">Plus and minus values, average calculation</string>
<string name="pref_advanced_category">Advanced</string>
- <string name="pref_about_category_summary">App version, contributors, social portals, licenses</string>
+ <string name="pref_about_category_summary">App version, contributors, social portals</string>
<string name="pref_ads_category_summary">Displaying advertisements, project support</string>

diff:
app:singleLineTitle="false"
app:summary="@string/pref_notify_upcoming_lessons_persistent_summary"
app:title="@string/pref_notify_upcoming_lessons_persistent_switch" />
- <SwitchPreferenceCompat
- app:defaultValue="@bool/pref_default_notification_piggyback"
- app:iconSpaceReserved="false"
- app:key="@string/pref_key_notifications_piggyback"
- app:singleLineTitle="false"
- app:title="@string/pref_notify_notifications_piggyback" />
<SwitchPreferenceCompat
app:defaultValue="@bool/pref_default_notification_debug"
app:iconSpaceReserved="false"
app:key="@string/pref_key_notification_debug"
app:singleLineTitle="false"
app:title="@string/pref_notify_debug_switch" />
+ </PreferenceCategory>
+ <PreferenceCategory
+ app:iconSpaceReserved="false"
+ app:title="@string/pref_notify_header_other">
+ <SwitchPreferenceCompat
+ app:defaultValue="@bool/pref_default_notification_piggyback"
+ app:iconSpaceReserved="false"
+ app:key="@string/pref_key_notifications_piggyback"
+ app:singleLineTitle="false"
+ app:title="@string/pref_notify_notifications_piggyback" />
<Preference
app:iconSpaceReserved="false"
app:key="@string/pref_key_notifications_system_settings"

original_message: Notifications settings reorganize and strings update (#1616)


diff:
<code_scheme name="Project" version="173">
<option name="LINE_SEPARATOR" value="
" />
<JetCodeStyleSettings>
- <option name="PACKAGES_TO_USE_STAR_IMPORTS">
- <value>
- <package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
- </value>
- </option>
- <option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
- <option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
- <option name="WRAP_ELVIS_EXPRESSIONS" value="0" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
- <option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="1" />
- <option name="KEEP_BLANK_LINES_IN_CODE" value="1" />
- <option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="0" />
- <option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
- <indentOptions>
- <option name="CONTINUATION_INDENT_SIZE" value="4" />
- </indentOptions>
</codeStyleSettings>
</code_scheme>
</component>
\ No newline at end of file

diff:
@@ -22,12 +22,14 @@ import io.github.wulkanowy.data.repositories.TimetableRepository
import io.github.wulkanowy.ui.modules.timetablewidget.TimetableWidgetProvider.Companion.getCurrentThemeWidgetKey
import io.github.wulkanowy.ui.modules.timetablewidget.TimetableWidgetProvider.Companion.getDateWidgetKey
import io.github.wulkanowy.ui.modules.timetablewidget.TimetableWidgetProvider.Companion.getStudentWidgetKey
+import io.github.wulkanowy.ui.modules.timetablewidget.TimetableWidgetProvider.Companion.getTodayLastLessonEndDateTimeWidgetKey
import io.github.wulkanowy.utils.getCompatColor
import io.github.wulkanowy.utils.toFirstResult
import io.github.wulkanowy.utils.toFormattedString
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import java.time.LocalDate
+import java.time.ZoneOffset
class TimetableWidgetFactory(
private val timetableRepository: TimetableRepository,
@@ -70,6 +72,16 @@ class TimetableWidgetFactory(
updateTheme(appWidgetId)
lessons = getLessons(date, studentId)
+
+ if (date == LocalDate.now()) {
+ val todayLastLessonEndTimestamp =
+ lessons.maxOf { it.end }.toEpochSecond(ZoneOffset.UTC)
+ sharedPref.putLong(
+ getTodayLastLessonEndDateTimeWidgetKey(appWidgetId),
+ todayLastLessonEndTimestamp,
+ true
+ )
+ }
}
}

diff:
@@ -3,10 +3,7 @@ package io.github.wulkanowy.ui.modules.timetablewidget
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
-import android.appwidget.AppWidgetManager.ACTION_APPWIDGET_DELETED
-import android.appwidget.AppWidgetManager.ACTION_APPWIDGET_UPDATE
-import android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_ID
-import android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_IDS
+import android.appwidget.AppWidgetManager.*
import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
@@ -25,22 +22,14 @@ import io.github.wulkanowy.services.HiltBroadcastReceiver
import io.github.wulkanowy.services.widgets.TimetableWidgetService
import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
-import io.github.wulkanowy.utils.AnalyticsHelper
-import io.github.wulkanowy.utils.PendingIntentCompat
-import io.github.wulkanowy.utils.capitalise
-import io.github.wulkanowy.utils.createNameInitialsDrawable
-import io.github.wulkanowy.utils.getCompatColor
-import io.github.wulkanowy.utils.nextOrSameSchoolDay
-import io.github.wulkanowy.utils.nextSchoolDay
-import io.github.wulkanowy.utils.nickOrName
-import io.github.wulkanowy.utils.previousSchoolDay
-import io.github.wulkanowy.utils.toFormattedString
+import io.github.wulkanowy.utils.*
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.time.LocalDate
-import java.time.LocalDate.now
+import java.time.LocalDateTime
+import java.time.ZoneOffset
import javax.inject.Inject
@AndroidEntryPoint
@@ -76,6 +65,9 @@ class TimetableWidgetProvider : HiltBroadcastReceiver() {
fun getDateWidgetKey(appWidgetId: Int) = "timetable_widget_date_$appWidgetId"
+ fun getTodayLastLessonEndDateTimeWidgetKey(appWidgetId: Int) =
+ "timetable_widget_today_last_lesson_end_date_time_$appWidgetId"
+
fun getStudentWidgetKey(appWidgetId: Int) = "timetable_widget_student_$appWidgetId"
fun getThemeWidgetKey(appWidgetId: Int) = "timetable_widget_theme_$appWidgetId"
@@ -100,7 +92,8 @@ class TimetableWidgetProvider : HiltBroadcastReceiver() {
intent.getIntArrayExtra(EXTRA_APPWIDGET_IDS)?.forEach { appWidgetId ->
val student =
getStudent(sharedPref.getLong(getStudentWidgetKey(appWidgetId), 0), appWidgetId)
- updateWidget(context, appWidgetId, now().nextOrSameSchoolDay, student)
+
+ updateWidget(context, appWidgetId, getWidgetDateToLoad(appWidgetId), student)
}
} else {
val buttonType = intent.getStringExtra(EXTRA_BUTTON_TYPE)
@@ -112,15 +105,17 @@ class TimetableWidgetProvider : HiltBroadcastReceiver() {
val savedDate =
LocalDate.ofEpochDay(sharedPref.getLong(getDateWidgetKey(toggledWidgetId), 0))
val date = when (buttonType) {
- BUTTON_RESET -> now().nextOrSameSchoolDay
+ BUTTON_RESET -> getWidgetDateToLoad(toggledWidgetId)
BUTTON_NEXT -> savedDate.nextSchoolDay
BUTTON_PREV -> savedDate.previousSchoolDay
- else -> now().nextOrSameSchoolDay
+ else -> getWidgetDateToLoad(toggledWidgetId)
}
- if (!buttonType.isNullOrBlank()) analytics.logEvent(
+ if (!buttonType.isNullOrBlank()) {
+ analytics.logEvent(
"changed_timetable_widget_day",
"button" to buttonType
)
+ }
updateWidget(context, toggledWidgetId, date, student)
}
}
@@ -278,4 +273,21 @@ class TimetableWidgetProvider : HiltBroadcastReceiver() {
avatarDrawable.draw(canvas)
return avatarBitmap
}
+
+ private fun getWidgetDateToLoad(appWidgetId: Int): LocalDate {
+ val lastLessonEndTimestamp =
+ sharedPref.getLong(getTodayLastLessonEndDateTimeWidgetKey(appWidgetId), 0)
+ val lastLessonEndDateTime =
+ LocalDateTime.ofEpochSecond(lastLessonEndTimestamp, 0, ZoneOffset.UTC)
+
+ val todayDate = LocalDate.now()
+ val isLastLessonEndDateNow = lastLessonEndDateTime.toLocalDate() == todayDate
+ val isLastLessonEndDateAfterNowTime = LocalDateTime.now() > lastLessonEndDateTime
+
+ return if (isLastLessonEndDateNow && isLastLessonEndDateAfterNowTime) {
+ todayDate.nextSchoolDay
+ } else {
+ todayDate.nextOrSameSchoolDay
+ }
+ }
}

original_message: Update timetable to next day if there is no more lessons today (#1551)


message: Add Crowdin badges to README

diff:
[![Discord](https://img.shields.io/discord/390889354199040011.svg?style=flat-square)](https://discord.gg/vccAQBr)
[![F-Droid](https://img.shields.io/f-droid/v/io.github.wulkanowy.svg?style=flat-square)](https://f-droid.org/packages/io.github.wulkanowy/)
[![Last release](https://img.shields.io/github/release/wulkanowy/wulkanowy.svg?logo=github&style=flat-square)](https://github.com/wulkanowy/wulkanowy/releases)
+[![Crowdin](https://badges.crowdin.net/wulkanowy2/localized.svg)](https://translate.wulkanowy.net.pl)
Unofficial android VULCAN UONET+ register client for both students and their parents

original_message: Add Crowdin badges to README (#2025)


message: Add info about optional ads in README

diff:
@@ -34,7 +34,7 @@ Unofficial android VULCAN UONET+ register client for both students and their par
* support for multiple accounts with the ability to rename students
* dark and black (AMOLED) theme
* offline mode
-* no ads
+* optional ads which allow to support the project
## Download

original_message: Add info about optional ads in README (#2054)


message: fix the image

diff:
@@ -230,7 +230,7 @@ def maybe_download_and_extract():
def main(_):
maybe_download_and_extract()
- image = (FLAGS.image_file if FLAGS.image_file)
+ image = FLAGS.image_file
run_inference_on_image(image)

repo: apache/tika

original_message: fix the image


message: inceptionapi.py file added for REST API feature

diff:
@@ -29,6 +29,11 @@ Please see the tutorial and website for a detailed description of how
to use this script to perform image recognition.
https://tensorflow.org/tutorials/image_recognition/
+
+Requirements:
+ tensorflow
+ tensorflow models (for TF-slim)
+
"""
from __future__ import absolute_import
@@ -157,7 +162,7 @@ def run_inference_on_image(image):
with tf.Session() as sess:
init_fn(sess)
- np_image, probabilities = sess.run([image, probabilities])
+ _, probabilities = sess.run([image, probabilities])
probabilities = probabilities[0, 0:]
sorted_inds = [i[0] for i in sorted(enumerate(-probabilities), key=lambda x:x[1])]
@@ -167,7 +172,6 @@ def run_inference_on_image(image):
index = sorted_inds[i]
print('%s (score = %.5f)' % (names[index], probabilities[index]))
-
def util_download(url, dest_directory):
"""Downloads the file.
@@ -211,7 +215,6 @@ def util_download_tar(url, dest_directory):
print('Successfully downloaded', filename, statinfo.st_size, 'bytes.')
tarfile.open(filepath, 'r:gz').extractall(dest_directory)
-
def maybe_download_and_extract():
"""Download and extract model tar file."""
dest_directory = FLAGS.model_dir

diff:
@@ -41,11 +41,19 @@ import tarfile
import numpy as np
from six.moves import urllib
import tensorflow as tf
+from datasets import imagenet, dataset_utils
+from nets import inception
+from preprocessing import inception_preprocessing
+
+slim = tf.contrib.slim
+
import requests
import json
json.encoder.FLOAT_REPR = lambda o: format(o, '.2f') # JSON serialization of floats
from time import time
+
+
import flask
@@ -59,109 +67,125 @@ FLAGS = tf.app.flags.FLAGS
# Text representation of a protocol buffer mapping a label to synset ID.
tf.app.flags.DEFINE_string(
'model_dir', '/tmp/imagenet',
- """Path to classify_image_graph_def.pb, """
- """imagenet_synset_to_human_label_map.txt, and """
- """imagenet_2012_challenge_label_map_proto.pbtxt.""")
+ """Path to inception_v4.ckpt, """
+ """imagenet_lsvrc_2015_synsets.txt, and """
+ """imagenet_metadata.txt.""")
tf.app.flags.DEFINE_integer('port', '8764', """Server PORT, default:8764""")
tf.app.flags.DEFINE_string('log', 'inception.log', """Log file name, default: inception.log""")
# pylint: disable=line-too-long
-DATA_URL = 'http://download.tensorflow.org/models/image/imagenet/inception-2015-12-05.tgz'
+DATA_URL = 'http://download.tensorflow.org/models/inception_v4_2016_09_09.tar.gz'
# pylint: enable=line-too-long
-class NodeLookup(object):
- """Converts integer node ID's to human readable labels."""
+def create_readable_names_for_imagenet_labels():
+ """Create a dict mapping label id to human readable string.
+
+ Returns:
+ labels_to_names: dictionary where keys are integers from to 1000
+ and values are human-readable names.
+
+ We retrieve a synset file, which contains a list of valid synset labels used
+ by ILSVRC competition. There is one synset one per line, eg.
+ # n01440764
+ # n01443537
+ We also retrieve a synset_to_human_file, which contains a mapping from synsets
+ to human-readable names for every synset in Imagenet. These are stored in a
+ tsv format, as follows:
+ # n02119247 black fox
+ # n02119359 silver fox
+ We assign each synset (in alphabetical order) an integer, starting from 1
+ (since 0 is reserved for the background class).
+
+ Code is based on
+ https://github.com/tensorflow/models/blob/master/inception/inception/data/build_imagenet_data.py#L463
+ """
+
+ # pylint: disable=line-too-long
+
+ dest_directory = FLAGS.model_dir
- def __init__(self,
- label_lookup_path=None,
- uid_lookup_path=None):
- if not label_lookup_path:
- label_lookup_path = os.path.join(
- FLAGS.model_dir, 'imagenet_2012_challenge_label_map_proto.pbtxt')
- if not uid_lookup_path:
- uid_lookup_path = os.path.join(
- FLAGS.model_dir, 'imagenet_synset_to_human_label_map.txt')
- self.node_lookup = self.load(label_lookup_path, uid_lookup_path)
+ synset_list = [s.strip() for s in open(os.path.join(dest_directory, 'imagenet_lsvrc_2015_synsets.txt')).readlines()]
+ num_synsets_in_ilsvrc = len(synset_list)
+ assert num_synsets_in_ilsvrc == 1000
- def load(self, label_lookup_path, uid_lookup_path):
- """Loads a human readable English name for each softmax node.
+ synset_to_human_list = open(os.path.join(dest_directory, 'imagenet_metadata.txt')).readlines()
+ num_synsets_in_all_imagenet = len(synset_to_human_list)
+ assert num_synsets_in_all_imagenet == 21842
- Args:
- label_lookup_path: string UID to integer node ID.
- uid_lookup_path: string UID to human-readable string.
+ synset_to_human = {}
+ for s in synset_to_human_list:
+ parts = s.strip().split('\t')
+ assert len(parts) == 2
+ synset = parts[0]
+ human = parts[1]
+ synset_to_human[synset] = human
+ label_index = 1
+ labels_to_names = {0: 'background'}
+ for synset in synset_list:
+ name = synset_to_human[synset]
+ labels_to_names[label_index] = name
+ label_index += 1
+
+ return labels_to_names
+
+def util_download(url, dest_directory):
+ """Downloads the file.
+
+ Args:
+ url: URL to download the file from.
+ dest_directory: Destination directory
Returns:
- dict from integer node ID to human-readable string.
+ Nothing
"""
- if not tf.gfile.Exists(uid_lookup_path):
- tf.logging.fatal('File does not exist %s', uid_lookup_path)
- if not tf.gfile.Exists(label_lookup_path):
- tf.logging.fatal('File does not exist %s', label_lookup_path)
-
- # Loads mapping from string UID to human-readable string
- proto_as_ascii_lines = tf.gfile.GFile(uid_lookup_path).readlines()
- uid_to_human = {}
- p = re.compile(r'[n\d]*[ \S,]*')
- for line in proto_as_ascii_lines:
- parsed_items = p.findall(line)
- uid = parsed_items[0]
- human_string = parsed_items[2]
- uid_to_human[uid] = human_string
-
- # Loads mapping from string UID to integer node ID.
- node_id_to_uid = {}
- proto_as_ascii = tf.gfile.GFile(label_lookup_path).readlines()
- for line in proto_as_ascii:
- if line.startswith(' target_class:'):
- target_class = int(line.split(': ')[1])
- if line.startswith(' target_class_string:'):
- target_class_string = line.split(': ')[1]
- node_id_to_uid[target_class] = target_class_string[1:-2]
-
- # Loads the final mapping of integer node ID to human-readable string
- node_id_to_name = {}
- for key, val in node_id_to_uid.items():
- if val not in uid_to_human:
- tf.logging.fatal('Failed to locate: %s', val)
- name = uid_to_human[val]
- node_id_to_name[key] = name
-
- return node_id_to_name
-
- def id_to_string(self, node_id):
- if node_id not in self.node_lookup:
- return ''
- return self.node_lookup[node_id]
-
-def create_graph():
- """Creates a graph from saved GraphDef file and returns a saver."""
- # Creates graph from saved graph_def.pb.
- with tf.gfile.FastGFile(os.path.join(
- FLAGS.model_dir, 'classify_image_graph_def.pb'), 'rb') as f:
- graph_def = tf.GraphDef()
- graph_def.ParseFromString(f.read())
- _ = tf.import_graph_def(graph_def, name='')
+ filename = url.split('/')[-1]
+ filepath = os.path.join(dest_directory, filename)
+
+ def _progress(count, block_size, total_size):
+ sys.stdout.write('\r>> Downloading %s %.1f%%' % (
+ filename, float(count * block_size) / float(total_size) * 100.0))
+ sys.stdout.flush()
+ filepath, _ = urllib.request.urlretrieve(url, filepath, _progress)
+ print()
+ statinfo = os.stat(filepath)
+ print('Successfully downloaded', filename, statinfo.st_size, 'bytes.')
+def util_download_tar(url, dest_directory):
+ """Downloads a file and extracts it.
-def maybe_download_and_extract():
- """Download and extract model tar file."""
- dest_directory = FLAGS.model_dir
- if not os.path.exists(dest_directory):
- os.makedirs(dest_directory)
- filename = DATA_URL.split('/')[-1]
+ Args:
+ url: URL to download the file from.
+ dest_directory: Destination directory
+ Returns:
+ Nothing
+ """
+ filename = url.split('/')[-1]
filepath = os.path.join(dest_directory, filename)
- if not os.path.exists(filepath):
+
def _progress(count, block_size, total_size):
sys.stdout.write('\r>> Downloading %s %.1f%%' % (
filename, float(count * block_size) / float(total_size) * 100.0))
sys.stdout.flush()
-
- filepath, _ = urllib.request.urlretrieve(DATA_URL, filepath, _progress)
+ filepath, _ = urllib.request.urlretrieve(url, filepath, _progress)
print()
statinfo = os.stat(filepath)
- print('Succesfully downloaded', filename, statinfo.st_size, 'bytes.')
+ print('Successfully downloaded', filename, statinfo.st_size, 'bytes.')
tarfile.open(filepath, 'r:gz').extractall(dest_directory)
+def maybe_download_and_extract():
+ """Download and extract model tar file."""
+ dest_directory = FLAGS.model_dir
+ if not tf.gfile.Exists(dest_directory):
+ tf.gfile.MakeDirs(dest_directory)
+ if not tf.gfile.Exists(os.path.join(dest_directory, 'inception_v4.ckpt')):
+ util_download_tar(DATA_URL, dest_directory)
+ # pylint: disable=line-too-long
+ if not tf.gfile.Exists(os.path.join(dest_directory, 'imagenet_lsvrc_2015_synsets.txt')):
+ util_download('https://raw.githubusercontent.com/tensorflow/models/master/inception/inception/data/imagenet_lsvrc_2015_synsets.txt', dest_directory)
+ if not tf.gfile.Exists(os.path.join(dest_directory, 'imagenet_metadata.txt')):
+ util_download('https://raw.githubusercontent.com/tensorflow/models/master/inception/inception/data/imagenet_metadata.txt', dest_directory)
+ # pylint: enable=line-too-long
+
def current_time():
"""
Returns current time in milli seconds
@@ -175,10 +199,12 @@ class Classifier(flask.Flask):
def __init__(self, name):
super(Classifier, self).__init__(name)
maybe_download_and_extract()
- create_graph()
+ init_fn = slim.assign_from_checkpoint_fn(
+ os.path.join(checkpoints_dir, 'inception_v4.ckpt'),
+ slim.get_model_variables('InceptionV4'))
self.sess = tf.Session()
- self.softmax_tensor = self.sess.graph.get_tensor_by_name('softmax:0')
- self.node_lookup = NodeLookup()
+ init_fn(self.sess)
+
print("Logs are directed to %s" % FLAGS.log)
import logging
from logging.handlers import RotatingFileHandler
@@ -188,16 +214,31 @@ class Classifier(flask.Flask):
file_handler.setFormatter(formatter)
self.logger.addHandler(file_handler)
- def classify(self, image_data, topk):
- predictions = self.sess.run(self.softmax_tensor,
- {'DecodeJpeg/contents:0': image_data})
- predictions = np.squeeze(predictions)
- top_k = predictions.argsort()[-topk:][::-1]
+ def classify(self, image_string, topk):
+
+ image_size = inception.inception_v4.default_image_size
+ image = tf.image.decode_jpeg(image_string, channels=3)
+ processed_image = inception_preprocessing.preprocess_image(image, image_size, image_size, is_training=False)
+ processed_images = tf.expand_dims(processed_image, 0)
+
+ # Create the model, use the default arg scope to configure the batch norm parameters.
+ with slim.arg_scope(inception.inception_v4_arg_scope()):
+ logits, _ = inception.inception_v4(processed_images, num_classes=1001, is_training=False)
+ probabilities = tf.nn.softmax(logits)
+
+
+
+ _, probabilities = self.sess.run([image, probabilities])
+ probabilities = probabilities[0, 0:]
+ sorted_inds = [i[0] for i in sorted(enumerate(-probabilities), key=lambda x:x[1])]
+
+
+ names = imagenet.create_readable_names_for_imagenet_labels()
res = []
- for node_id in top_k:
- class_name = self.node_lookup.id_to_string(node_id)
- score = float(predictions[node_id])
- res.append((node_id, class_name, score))
+ for i in range(topk):
+ index = sorted_inds[i]
+ score = float(probabilities[index])
+ res.append((index, names[index], score))
return res


message: fix the destination directory

diff:
@@ -197,10 +197,11 @@ class Classifier(flask.Flask):
Classifier Service class
'''
def __init__(self, name):
+ dest_directory = FLAGS.model_dir
super(Classifier, self).__init__(name)
maybe_download_and_extract()
init_fn = slim.assign_from_checkpoint_fn(
- os.path.join(checkpoints_dir, 'inception_v4.ckpt'),
+ os.path.join(dest_directory, 'inception_v4.ckpt'),
slim.get_model_variables('InceptionV4'))
self.sess = tf.Session()
init_fn(self.sess)

original_message: fix the destination directory


message: fix no variables to save

diff:
@@ -200,11 +200,7 @@ class Classifier(flask.Flask):
dest_directory = FLAGS.model_dir
super(Classifier, self).__init__(name)
maybe_download_and_extract()
- init_fn = slim.assign_from_checkpoint_fn(
- os.path.join(dest_directory, 'inception_v4.ckpt'),
- slim.get_model_variables('InceptionV4'))
self.sess = tf.Session()
- init_fn(self.sess)
print("Logs are directed to %s" % FLAGS.log)
import logging
@@ -217,6 +213,11 @@ class Classifier(flask.Flask):
def classify(self, image_string, topk):
+ init_fn = slim.assign_from_checkpoint_fn(
+ os.path.join(dest_directory, 'inception_v4.ckpt'),
+ slim.get_model_variables('InceptionV4'))
+ init_fn(self.sess)
+
image_size = inception.inception_v4.default_image_size
image = tf.image.decode_jpeg(image_string, channels=3)
processed_image = inception_preprocessing.preprocess_image(image, image_size, image_size, is_training=False)

original_message: fix no variables to save


message: unexpected argument

diff:
@@ -333,7 +333,7 @@ def classify_image():
read_time = current_time() - st
st = current_time() # reset start time
try:
- classes = app.classify(image_data=image_data, topk=topk)
+ classes = app.classify(image_string=image_data, topk=topk)
except Exception as e:
app.logger.error(e)
return Response(status=400, response=str(e))

original_message: unexpected argument


message: Working inceptionapi.py without comments

diff:
@@ -199,16 +199,20 @@ class Classifier(flask.Flask):
def __init__(self, name):
super(Classifier, self).__init__(name)
maybe_download_and_extract()
- self.sess = tf.Session()
+ import logging
+ from logging.handlers import RotatingFileHandler
+ file_handler = RotatingFileHandler(FLAGS.log, maxBytes=1024 * 1024 * 100, backupCount=20)
+ file_handler.setLevel(logging.INFO)
+ formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
+ file_handler.setFormatter(formatter)
+ self.logger.addHandler(file_handler)
+
def classify(self, image_string, topk):
dest_directory = FLAGS.model_dir
- init_fn = slim.assign_from_checkpoint_fn(
- os.path.join(dest_directory, 'inception_v4.ckpt'),
- slim.get_model_variables('InceptionV4'))
- init_fn(self.sess)
image_size = inception.inception_v4.default_image_size
+ with tf.Graph().as_default():
image = tf.image.decode_jpeg(image_string, channels=3)
processed_image = inception_preprocessing.preprocess_image(image, image_size, image_size, is_training=False)
processed_images = tf.expand_dims(processed_image, 0)
@@ -218,13 +222,16 @@ class Classifier(flask.Flask):
logits, _ = inception.inception_v4(processed_images, num_classes=1001, is_training=False)
probabilities = tf.nn.softmax(logits)
+ init_fn = slim.assign_from_checkpoint_fn(
+ os.path.join(dest_directory, 'inception_v4.ckpt'),
+ slim.get_model_variables('InceptionV4'))
-
- _, probabilities = self.sess.run([image, probabilities])
+ with tf.Session() as sess:
+ init_fn(sess)
+ _, probabilities = sess.run([image, probabilities])
probabilities = probabilities[0, 0:]
sorted_inds = [i[0] for i in sorted(enumerate(-probabilities), key=lambda x:x[1])]
-
names = imagenet.create_readable_names_for_imagenet_labels()
res = []
for i in range(topk):
@@ -246,6 +253,7 @@ def get_remotefile(url, success=200, timeout=10):
app.logger.info("GET: %s" % url)
auth = None
res = requests.get(url, stream=True, timeout=timeout, auth=auth)
+ print(res)
if res.status_code == success:
return res.headers.get('Content-Type', 'application/octet-stream'), res.raw.data
except:
@@ -262,14 +270,11 @@ def index():

 Inception REST API

 The following API end points are valid

-
 Inception V3

-
 /inception/v3/classes  -

-  Description :  This API gets all classes/object types known to the current model
-

-
 /inception/v3/ping  -

+
 Inception V4

+
 /inception/v4/ping  -

 Description :  checks availability of the service. returns "pong" with status 200 when it is available

-
 /inception/v3/classify -

+
 /inception/v4/classify -

 Description 	 This is a classifier service that can classify images

	 Query Params :

@@ -279,11 +284,11 @@ def index():

 How to supply Image Content

 With HTTP GET :
Include a query parameter url  which is an http url of JPEG image

- Example:  curl "localhost:8764/inception/v3/classify?url=http://xyz.com/example.jpg"
+ Example:  curl "localhost:8764/inception/v4/classify?url=http://xyz.com/example.jpg"

 With HTTP POST :
POST JPEG image content as binary data in request body.

- Example:  curl -X POST "localhost:8764/inception/v3/classify?topk=10&human=false" --data-binary @example.jpg
+ Example:  curl -X POST "localhost:8764/inception/v4/classify?topk=10&human=false" --data-binary @example.jpg

@@ -291,19 +296,14 @@ def index():

"""
-@app.route("/inception/v3/classes", methods=["GET"])
-def get_classes():
- """API to list all known classes
- """
- return jsonify(app.node_lookup.node_lookup)
-@app.route("/inception/v3/ping", methods=["GET"])
+@app.route("/inception/v4/ping", methods=["GET"])
def ping_pong():
"""API to do health check. If this says status code 200, then healthy
"""
return "pong"
-@app.route("/inception/v3/classify", methods=["GET", "POST"])
+@app.route("/inception/v4/classify", methods=["GET", "POST"])
def classify_image():
"""
API to classify images


message: Changed models repo to a forked repo for future compatibility

diff:
@@ -28,7 +28,8 @@ RUN \
pip install flask requests
# Get the TF-slim dependencies
-RUN git clone https://github.com/tensorflow/models/
+# Forked for future compatibility
+RUN git clone https://github.com/KranthiGV/models
# TODO: Change the URL to Apache/Tika Repo when this PR gets merged
RUN \