Services refactor (#168)

This commit is contained in:
Mikołaj Pich 2018-11-01 19:27:02 +01:00 committed by Rafał Borcz
parent ab71dd3fde
commit 70879945f2
52 changed files with 899 additions and 188 deletions

View File

@ -38,7 +38,7 @@ jobs:
command: ./gradlew build -x test -x lint -x fabricGenerateResourcesRelease -x packageRelease --no-daemon --stacktrace --console=plain -PdisablePreDex command: ./gradlew build -x test -x lint -x fabricGenerateResourcesRelease -x packageRelease --no-daemon --stacktrace --console=plain -PdisablePreDex
- run: - run:
name: Run FOSSA name: Run FOSSA
command: fossa --no-ansi command: fossa --no-ansi || true
- persist_to_workspace: - persist_to_workspace:
root: *workspace_root root: *workspace_root
paths: paths:
@ -113,7 +113,7 @@ jobs:
adb shell input keyevent 82 adb shell input keyevent 82
- run: - run:
name: Run instrumented tests name: Run instrumented tests
command: ./gradlew clean createDebugCoverageReport jacocoTestReport --no-daemon --stacktrace --console=plain -PdisablePreDex command: ./gradlew clean createDebugCoverageReport jacocoTestReport --no-daemon --stacktrace --console=plain -PdisablePreDex -PdisableCrashlytics
- run: - run:
name: Collect logs from emulator name: Collect logs from emulator
command: adb logcat -d > ./app/build/reports/logcat_emulator.txt command: adb logcat -d > ./app/build/reports/logcat_emulator.txt

3
.gitignore vendored
View File

@ -33,7 +33,6 @@ local.properties
.idea/vcs.xml .idea/vcs.xml
.idea/workspace.xml .idea/workspace.xml
.idea/caches/ .idea/caches/
.idea/codeStyles/
*.iml *.iml
# OS-specific files # OS-specific files
@ -44,7 +43,7 @@ local.properties
.Trashes .Trashes
ehthumbs.db ehthumbs.db
Thumbs.db Thumbs.db
.idea/codeStyles/
.idea/caches/ .idea/caches/
./app/key.p12 ./app/key.p12
./app/upload-key.jks ./app/upload-key.jks
*.log

View File

@ -0,0 +1,174 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<option name="LINE_SEPARATOR" value="&#10;" />
<AndroidXmlCodeStyleSettings>
<option name="USE_CUSTOM_SETTINGS" value="true" />
</AndroidXmlCodeStyleSettings>
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value>
<package name="kotlinx.android.synthetic" withSubpackages="true" static="false" />
</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="CONTINUATION_INDENT_IN_PARAMETER_LISTS" value="false" />
<option name="CONTINUATION_INDENT_IN_ARGUMENT_LISTS" value="false" />
<option name="CONTINUATION_INDENT_FOR_EXPRESSION_BODIES" value="false" />
<option name="CONTINUATION_INDENT_FOR_CHAINED_CALLS" value="false" />
<option name="CONTINUATION_INDENT_IN_SUPERTYPE_LISTS" value="false" />
<option name="CONTINUATION_INDENT_IN_IF_CONDITIONS" value="false" />
<option name="CONTINUATION_INDENT_IN_ELVIS" value="false" />
<option name="WRAP_EXPRESSION_BODY_FUNCTIONS" value="1" />
</JetCodeStyleSettings>
<Objective-C-extensions>
<file>
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Import" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Macro" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Typedef" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Enum" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Constant" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Global" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Struct" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="FunctionPredecl" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Function" />
</file>
<class>
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Property" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Synthesize" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="InitMethod" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="StaticMethod" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="InstanceMethod" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="DeallocMethod" />
</class>
<extensions>
<pair source="cpp" header="h" fileNamingConvention="NONE" />
<pair source="c" header="h" fileNamingConvention="NONE" />
</extensions>
</Objective-C-extensions>
<XML>
<option name="XML_KEEP_LINE_BREAKS" value="false" />
<option name="XML_ALIGN_ATTRIBUTES" value="false" />
<option name="XML_SPACE_INSIDE_EMPTY_TAG" value="true" />
</XML>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<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" />
<option name="METHOD_PARAMETERS_WRAP" value="5" />
<option name="METHOD_PARAMETERS_LPAREN_ON_NEXT_LINE" value="true" />
<option name="METHOD_PARAMETERS_RPAREN_ON_NEXT_LINE" value="true" />
<option name="EXTENDS_LIST_WRAP" value="1" />
<option name="METHOD_CALL_CHAIN_WRAP" value="1" />
<option name="ASSIGNMENT_WRAP" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

View File

@ -7,6 +7,8 @@ apply plugin: 'com.github.triplet.play'
apply from: 'jacoco.gradle' apply from: 'jacoco.gradle'
apply from: 'sonarqube.gradle' apply from: 'sonarqube.gradle'
def fabricApiKey = System.getenv("FABRIC_API_KEY") ?: "null"
android { android {
compileSdkVersion 28 compileSdkVersion 28
buildToolsVersion '28.0.3' buildToolsVersion '28.0.3'
@ -29,9 +31,7 @@ android {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
playAccountConfig = playAccountConfigs.defaultAccountConfig playAccountConfig = playAccountConfigs.defaultAccountConfig
manifestPlaceholders = [ manifestPlaceholders = [ fabricApiKey: fabricApiKey ]
fabricApiKey: System.getenv("FABRIC_API_KEY") ?: "null"
]
} }
signingConfigs { signingConfigs {
@ -45,18 +45,24 @@ android {
buildTypes { buildTypes {
release { release {
buildConfigField "boolean", "FABRIC_ENABLED", "true"
minifyEnabled false minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release signingConfig signingConfigs.release
} }
debug { debug {
buildConfigField "boolean", "FABRIC_ENABLED", fabricApiKey == "null" ? "false" : "true"
applicationIdSuffix ".dev" applicationIdSuffix ".dev"
versionNameSuffix "-dev" versionNameSuffix "-dev"
testCoverageEnabled = true testCoverageEnabled = true
ext.enableCrashlytics = false ext.enableCrashlytics = fabricApiKey != "null" && !project.hasProperty("disableCrashlytics")
multiDexKeepProguard file('proguard-multidex-rules.pro') multiDexKeepProguard file('proguard-multidex-rules.pro')
} }
} }
lintOptions {
disable 'HardwareIds'
}
} }
androidExtensions { androidExtensions {
@ -72,7 +78,7 @@ ext.androidx_version = "1.0.0"
dependencies { dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation('com.github.wulkanowy:api:1400211f0d') { exclude module: "threetenbp" } implementation('com.github.wulkanowy:api:e829b094de') { exclude module: "threetenbp" }
implementation "androidx.legacy:legacy-support-v4:$androidx_version" implementation "androidx.legacy:legacy-support-v4:$androidx_version"
implementation "androidx.appcompat:appcompat:$androidx_version" implementation "androidx.appcompat:appcompat:$androidx_version"
@ -89,12 +95,13 @@ dependencies {
kapt "com.google.dagger:dagger-compiler:2.16" kapt "com.google.dagger:dagger-compiler:2.16"
kapt "com.google.dagger:dagger-android-processor:2.16" kapt "com.google.dagger:dagger-android-processor:2.16"
implementation "androidx.room:room-runtime:2.1.0-alpha01" implementation "androidx.room:room-runtime:2.1.0-alpha02"
implementation "androidx.room:room-rxjava2:2.1.0-alpha01" implementation "androidx.room:room-rxjava2:2.1.0-alpha02"
kapt "androidx.room:room-compiler:2.1.0-alpha01" kapt "androidx.room:room-compiler:2.1.0-alpha02"
implementation "eu.davidea:flexible-adapter:5.1.0" implementation "eu.davidea:flexible-adapter:5.1.0"
implementation "eu.davidea:flexible-adapter-ui:1.0.0" implementation "eu.davidea:flexible-adapter-ui:1.0.0"
implementation "com.aurelhubert:ahbottomnavigation:2.2.0" implementation "com.aurelhubert:ahbottomnavigation:2.2.0"
implementation 'com.ncapdevi:frag-nav:3.0.0-RC3' implementation 'com.ncapdevi:frag-nav:3.0.0-RC3'
@ -120,9 +127,10 @@ dependencies {
testImplementation "org.mockito:mockito-inline:2.23.0" testImplementation "org.mockito:mockito-inline:2.23.0"
testImplementation 'org.threeten:threetenbp:1.3.7' testImplementation 'org.threeten:threetenbp:1.3.7'
androidTestImplementation 'androidx.test:core:1.0.0-beta02' androidTestImplementation 'androidx.test:core:1.0.0'
androidTestImplementation 'androidx.test:runner:1.1.0-beta02' androidTestImplementation 'androidx.test:runner:1.1.0'
androidTestImplementation 'androidx.test.ext:junit:1.0.0-beta02' androidTestImplementation 'androidx.test.ext:junit:1.0.0'
androidTestImplementation "org.mockito:mockito-android:2.23.0" androidTestImplementation "org.mockito:mockito-android:2.23.0"
androidTestImplementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version" androidTestImplementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version"
} }

View File

@ -40,6 +40,14 @@
android:launchMode="singleTop" android:launchMode="singleTop"
android:theme="@style/WulkanowyTheme.NoActionBar" /> android:theme="@style/WulkanowyTheme.NoActionBar" />
<service
android:name=".services.job.SyncWorker"
android:exported="false">
<intent-filter>
<action android:name="com.firebase.jobdispatcher.ACTION_EXECUTE" />
</intent-filter>
</service>
<meta-data <meta-data
android:name="io.fabric.ApiKey" android:name="io.fabric.ApiKey"
android:value="${fabricApiKey}" /> android:value="${fabricApiKey}" />

View File

@ -39,7 +39,7 @@ class WulkanowyApp : DaggerApplication() {
private fun initializeFabric() { private fun initializeFabric() {
Fabric.with(Fabric.Builder(this) Fabric.with(Fabric.Builder(this)
.kits(Crashlytics.Builder() .kits(Crashlytics.Builder()
.core(CrashlyticsCore.Builder().disabled(BuildConfig.DEBUG).build()) .core(CrashlyticsCore.Builder().disabled(BuildConfig.DEBUG || !BuildConfig.FABRIC_ENABLED).build())
.build(), .build(),
Answers()) Answers())
.debuggable(BuildConfig.DEBUG) .debuggable(BuildConfig.DEBUG)

View File

@ -14,7 +14,7 @@ open class ErrorHandler @Inject constructor(private val resources: Resources) {
var showErrorMessage: (String) -> Unit = {} var showErrorMessage: (String) -> Unit = {}
open fun proceed(error: Throwable) { open fun proceed(error: Throwable) {
Timber.i(error, "An exception occurred while the Wulkanowy was running") Timber.e(error, "An exception occurred while the Wulkanowy was running")
showErrorMessage((when (error) { showErrorMessage((when (error) {
is UnknownHostException -> resources.getString(R.string.all_no_internet) is UnknownHostException -> resources.getString(R.string.all_no_internet)

View File

@ -14,4 +14,12 @@ class SharedPrefHelper @Inject constructor(private val sharedPref: SharedPrefere
fun getLong(key: String, defaultValue: Long): Long { fun getLong(key: String, defaultValue: Long): Long {
return sharedPref.getLong(key, defaultValue) return sharedPref.getLong(key, defaultValue)
} }
}
fun getBoolean(key: String, defaultValue: Boolean): Boolean {
return sharedPref.getBoolean(key, defaultValue)
}
fun getString(key: String, defaultValue: String): String {
return sharedPref.getString(key, defaultValue) ?: defaultValue
}
}

View File

@ -13,9 +13,15 @@ interface GradeDao {
@Update @Update
fun update(grade: Grade) fun update(grade: Grade)
@Update
fun updateAll(grade: List<Grade>)
@Delete @Delete
fun deleteAll(grades: List<Grade>) fun deleteAll(grades: List<Grade>)
@Query("SELECT * FROM Grades WHERE semester_id = :semesterId AND student_id = :studentId") @Query("SELECT * FROM Grades WHERE semester_id = :semesterId AND student_id = :studentId")
fun getGrades(semesterId: Int, studentId: Int): Maybe<List<Grade>> fun getGrades(semesterId: Int, studentId: Int): Maybe<List<Grade>>
@Query("SELECT * FROM Grades WHERE is_read = 0 AND semester_id = :semesterId AND student_id = :studentId")
fun getNewGrades(semesterId: Int, studentId: Int): Maybe<List<Grade>>
} }

View File

@ -11,7 +11,7 @@ import io.reactivex.Maybe
@Dao @Dao
interface GradeSummaryDao { interface GradeSummaryDao {
@Insert(onConflict = REPLACE) @Insert
fun insertAll(gradesSummary: List<GradeSummary>) fun insertAll(gradesSummary: List<GradeSummary>)
@Delete @Delete

View File

@ -14,4 +14,10 @@ interface SemesterDao {
@Query("SELECT * FROM Semesters WHERE student_id = :studentId") @Query("SELECT * FROM Semesters WHERE student_id = :studentId")
fun getSemester(studentId: Int): Single<List<Semester>> fun getSemester(studentId: Int): Single<List<Semester>>
@Query("UPDATE Semesters SET is_current = 0")
fun resetCurrentSemester()
@Query("UPDATE Semesters SET is_current = 1 WHERE semester_id = :semesterId")
fun setCurrentSemester(semesterId: Int)
} }

View File

@ -9,41 +9,44 @@ import java.io.Serializable
@Entity(tableName = "Grades") @Entity(tableName = "Grades")
data class Grade( data class Grade(
@ColumnInfo(name = "semester_id") @ColumnInfo(name = "semester_id")
var semesterId: Int, var semesterId: Int,
@ColumnInfo(name = "student_id") @ColumnInfo(name = "student_id")
var studentId: Int, var studentId: Int,
var subject: String, var subject: String,
var entry: String, var entry: String,
var value: Int, var value: Int,
var modifier: Double, var modifier: Double,
var comment: String, var comment: String,
var color: String, var color: String,
@ColumnInfo(name = "grade_symbol") @ColumnInfo(name = "grade_symbol")
var gradeSymbol: String, var gradeSymbol: String,
var description: String, var description: String,
var weight: String, var weight: String,
var weightValue: Int, var weightValue: Int,
var date: LocalDate, var date: LocalDate,
var teacher: String var teacher: String
) : Serializable { ) : Serializable {
@PrimaryKey(autoGenerate = true) @PrimaryKey(autoGenerate = true)
var id: Long = 0 var id: Long = 0
@ColumnInfo(name = "is_new") @ColumnInfo(name = "is_read")
var isNew: Boolean = false var isRead: Boolean = true
@ColumnInfo(name = "is_notified")
var isNotified: Boolean = true
} }

View File

@ -14,29 +14,39 @@ import javax.inject.Singleton
@Singleton @Singleton
class GradeRepository @Inject constructor( class GradeRepository @Inject constructor(
private val settings: InternetObservingSettings, private val settings: InternetObservingSettings,
private val local: GradeLocal, private val local: GradeLocal,
private val remote: GradeRemote private val remote: GradeRemote
) { ) {
fun getGrades(semester: Semester, forceRefresh: Boolean = false): Single<List<Grade>> { fun getGrades(semester: Semester, forceRefresh: Boolean = false, notify: Boolean = false): Single<List<Grade>> {
return local.getGrades(semester).filter { !forceRefresh } return local.getGrades(semester).filter { !forceRefresh }
.switchIfEmpty(ReactiveNetwork.checkInternetConnectivity(settings) .switchIfEmpty(ReactiveNetwork.checkInternetConnectivity(settings)
.flatMap { .flatMap {
if (it) remote.getGrades(semester) if (it) remote.getGrades(semester)
else Single.error(UnknownHostException()) else Single.error(UnknownHostException())
}.flatMap { newGrades -> }.flatMap { newGrades ->
local.getGrades(semester).toSingle(emptyList()) local.getGrades(semester).toSingle(emptyList())
.doOnSuccess { oldGrades -> .doOnSuccess { oldGrades ->
local.deleteGrades(oldGrades - newGrades) local.deleteGrades(oldGrades - newGrades)
local.saveGrades((newGrades - oldGrades) local.saveGrades((newGrades - oldGrades)
.onEach { if (oldGrades.isNotEmpty()) it.isNew = true }) .onEach {
} if (oldGrades.isNotEmpty()) it.isRead = false
}.flatMap { local.getGrades(semester).toSingle(emptyList()) }) if (notify) it.isNotified = false
})
}
}.flatMap { local.getGrades(semester).toSingle(emptyList()) })
}
fun getNewGrades(semester: Semester): Single<List<Grade>> {
return local.getNewGrades(semester).toSingle(emptyList())
} }
fun updateGrade(grade: Grade): Completable { fun updateGrade(grade: Grade): Completable {
return local.updateGrade(grade) return local.updateGrade(grade)
} }
fun updateGrades(grades: List<Grade>): Completable {
return local.updateGrades(grades)
}
} }

View File

@ -1,19 +1,39 @@
package io.github.wulkanowy.data.repositories package io.github.wulkanowy.data.repositories
import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import io.github.wulkanowy.R
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class PreferencesRepository @Inject constructor(private val sharedPref: SharedPreferences) { class PreferencesRepository @Inject constructor(
private val sharedPref: SharedPreferences,
val context: Context
) {
val startMenuIndex: Int val startMenuIndex: Int
get() = sharedPref.getString("start_menu", "0")?.toInt() ?: 0 get() = sharedPref.getString(context.getString(R.string.pref_key_start_menu), "0")?.toInt() ?: 0
val showPresent: Boolean val showPresent: Boolean
get() = sharedPref.getBoolean("attendance_present", true) get() = sharedPref.getBoolean(context.getString(R.string.pref_key_attendance_present), true)
val currentThemeKey: String = context.getString(R.string.pref_key_theme)
val currentTheme: Int val currentTheme: Int
get() = sharedPref.getString("theme", "1")?.toInt() ?: 1 get() = sharedPref.getString(currentThemeKey, "1")?.toInt() ?: 1
}
val serviceEnablesKey: String = context.getString(R.string.pref_key_services_enable)
val serviceEnabled: Boolean
get() = sharedPref.getBoolean(serviceEnablesKey, true)
val servicesIntervalKey: String = context.getString(R.string.pref_key_services_interval)
val servicesInterval: Int
get() = sharedPref.getString(servicesIntervalKey, "60")?.toInt() ?: 60
val servicesOnlyWifiKey: String = context.getString(R.string.pref_key_services_wifi_only)
val servicesOnlyWifi: Boolean
get() = sharedPref.getBoolean(servicesOnlyWifiKey, true)
val notificationsEnable: Boolean
get() = sharedPref.getBoolean(context.getString(R.string.pref_key_notifications_enable), true)
}

View File

@ -14,9 +14,10 @@ import javax.inject.Singleton
@Singleton @Singleton
class SessionRepository @Inject constructor( class SessionRepository @Inject constructor(
private val local: SessionLocal, private val local: SessionLocal,
private val remote: SessionRemote, private val remote: SessionRemote,
private val settings: InternetObservingSettings) { private val settings: InternetObservingSettings
) {
val isSessionSaved val isSessionSaved
get() = local.isSessionSaved get() = local.isSessionSaved
@ -26,24 +27,39 @@ class SessionRepository @Inject constructor(
fun getConnectedStudents(email: String, password: String, symbol: String, endpoint: String): Single<List<Student>> { fun getConnectedStudents(email: String, password: String, symbol: String, endpoint: String): Single<List<Student>> {
cachedStudents = ReactiveNetwork.checkInternetConnectivity(settings) cachedStudents = ReactiveNetwork.checkInternetConnectivity(settings)
.flatMap { isConnected -> .flatMap { isConnected ->
if (isConnected) remote.getConnectedStudents(email, password, symbol, endpoint) if (isConnected) remote.getConnectedStudents(email, password, symbol, endpoint)
else Single.error<List<Student>>(UnknownHostException("No internet connection")) else Single.error<List<Student>>(UnknownHostException("No internet connection"))
}.doOnSuccess { cachedStudents = Single.just(it) } }.doOnSuccess { cachedStudents = Single.just(it) }
return cachedStudents return cachedStudents
} }
fun getSemesters(): Single<List<Semester>> { fun saveStudent(student: Student): Completable {
return local.getLastStudent() return remote.getSemesters(student)
.flatMapSingle { .flatMapCompletable { local.saveSemesters(it) }
remote.initApi(it, true) .concatWith(local.saveStudent(student))
local.getSemesters(it)
}
} }
fun saveStudent(student: Student): Completable { fun getSemesters(forceRefresh: Boolean = false): Single<List<Semester>> {
return remote.getSemesters(student).flatMapCompletable { local.saveSemesters(it) } return local.getLastStudent()
.concatWith(local.saveStudent(student)) .flatMapSingle { student ->
remote.initApi(student)
local.getSemesters(student).filter { !forceRefresh }
.switchIfEmpty(ReactiveNetwork.checkInternetConnectivity(settings).flatMap {
if (it) remote.getCurrentSemester(student)
else Single.error(UnknownHostException())
}.flatMap { current ->
local.getSemesters(student).doOnSuccess { semesters ->
if (semesters.single { it.current }.semesterId != current.semesterId) {
local.saveSemesters(listOf(current)).andThen {
local.setCurrentSemester(current.semesterId)
}
}
}
}.flatMap {
local.getSemesters(student)
})
}
} }
fun clearCache() { fun clearCache() {

View File

@ -15,6 +15,10 @@ class GradeLocal @Inject constructor(private val gradeDb: GradeDao) {
return gradeDb.getGrades(semester.semesterId, semester.studentId).filter { !it.isEmpty() } return gradeDb.getGrades(semester.semesterId, semester.studentId).filter { !it.isEmpty() }
} }
fun getNewGrades(semester: Semester): Maybe<List<Grade>> {
return gradeDb.getNewGrades(semester.semesterId, semester.studentId)
}
fun saveGrades(grades: List<Grade>) { fun saveGrades(grades: List<Grade>) {
gradeDb.insertAll(grades) gradeDb.insertAll(grades)
} }
@ -23,6 +27,10 @@ class GradeLocal @Inject constructor(private val gradeDb: GradeDao) {
return Completable.fromCallable { gradeDb.update(grade) } return Completable.fromCallable { gradeDb.update(grade) }
} }
fun updateGrades(grade: List<Grade>): Completable {
return Completable.fromCallable { gradeDb.updateAll(grade) }
}
fun deleteGrades(grades: List<Grade>) { fun deleteGrades(grades: List<Grade>) {
gradeDb.deleteAll(grades) gradeDb.deleteAll(grades)
} }

View File

@ -46,4 +46,10 @@ class SessionLocal @Inject constructor(
fun getSemesters(student: Student): Single<List<Semester>> { fun getSemesters(student: Student): Single<List<Semester>> {
return semesterDb.getSemester(student.studentId) return semesterDb.getSemester(student.studentId)
} }
fun setCurrentSemester(semesterId: Int): Completable {
return Single.fromCallable { semesterDb.resetCurrentSemester() }.ignoreElement().andThen {
semesterDb.setCurrentSemester(semesterId)
}
}
} }

View File

@ -12,37 +12,46 @@ import javax.inject.Singleton
class SessionRemote @Inject constructor(private val api: Api) { class SessionRemote @Inject constructor(private val api: Api) {
fun getConnectedStudents(email: String, password: String, symbol: String, endpoint: String): Single<List<Student>> { fun getConnectedStudents(email: String, password: String, symbol: String, endpoint: String): Single<List<Student>> {
return Single.just(initApi(Student(email = email, password = password, symbol = symbol, endpoint = endpoint, loginType = "AUTO"))) return Single.just(
.flatMap { _ -> initApi(
api.getPupils().map { students -> Student(
students.map { email = email,
Student( password = password,
email = email, symbol = symbol,
password = password, endpoint = endpoint,
symbol = it.symbol, loginType = "AUTO"
studentId = it.studentId, ), true
studentName = it.studentName, )
schoolSymbol = it.schoolSymbol, ).flatMap {
schoolName = it.schoolName, api.getPupils().map { students ->
endpoint = endpoint, students.map { pupil ->
loginType = it.loginType.name Student(
) email = email,
} password = password,
} symbol = pupil.symbol,
studentId = pupil.studentId,
studentName = pupil.studentName,
schoolSymbol = pupil.schoolSymbol,
schoolName = pupil.schoolName,
endpoint = endpoint,
loginType = pupil.loginType.name
)
} }
}
}
} }
fun getSemesters(student: Student): Single<List<Semester>> { fun getSemesters(student: Student): Single<List<Semester>> {
return Single.just(initApi(student)).flatMap { _ -> return Single.just(initApi(student)).flatMap {
api.getSemesters().map { semesters -> api.getSemesters().map { semesters ->
semesters.map { semesters.map { semester ->
Semester( Semester(
studentId = student.studentId, studentId = student.studentId,
diaryId = it.diaryId, diaryId = semester.diaryId,
diaryName = it.diaryName, diaryName = semester.diaryName,
semesterId = it.semesterId, semesterId = semester.semesterId,
semesterName = it.semesterNumber, semesterName = semester.semesterNumber,
current = it.current current = semester.current
) )
} }
@ -50,8 +59,21 @@ class SessionRemote @Inject constructor(private val api: Api) {
} }
} }
fun initApi(student: Student, checkInit: Boolean = false) { fun getCurrentSemester(student: Student): Single<Semester> {
if (if (checkInit) 0 == api.studentId else true) { return api.getCurrentSemester().map {
Semester(
studentId = student.studentId,
diaryId = it.diaryId,
diaryName = it.diaryName,
semesterId = it.semesterId,
semesterName = it.semesterNumber,
current = it.current
)
}
}
fun initApi(student: Student, reInitialize: Boolean = false) {
if (if (reInitialize) true else 0 == api.studentId) {
api.run { api.run {
email = student.email email = student.email
password = student.password password = student.password

View File

@ -14,6 +14,7 @@ import javax.inject.Singleton
RepositoryModule::class, RepositoryModule::class,
BuilderModule::class]) BuilderModule::class])
interface AppComponent : AndroidInjector<WulkanowyApp> { interface AppComponent : AndroidInjector<WulkanowyApp> {
@Component.Builder @Component.Builder
abstract class Builder : AndroidInjector.Builder<WulkanowyApp>() abstract class Builder : AndroidInjector.Builder<WulkanowyApp>()
} }

View File

@ -1,6 +1,8 @@
package io.github.wulkanowy.di package io.github.wulkanowy.di
import android.content.Context import android.content.Context
import com.firebase.jobdispatcher.FirebaseJobDispatcher
import com.firebase.jobdispatcher.GooglePlayDriver
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.FlexibleAdapter
@ -22,4 +24,10 @@ internal class AppModule {
@Provides @Provides
fun provideFlexibleAdapter() = FlexibleAdapter<AbstractFlexibleItem<*>>(null, null, true) fun provideFlexibleAdapter() = FlexibleAdapter<AbstractFlexibleItem<*>>(null, null, true)
@Singleton
@Provides
fun provideJobDispatcher(context: Context): FirebaseJobDispatcher {
return FirebaseJobDispatcher(GooglePlayDriver(context))
}
} }

View File

@ -3,6 +3,7 @@ package io.github.wulkanowy.di
import dagger.Module import dagger.Module
import dagger.android.ContributesAndroidInjector import dagger.android.ContributesAndroidInjector
import io.github.wulkanowy.di.scopes.PerActivity import io.github.wulkanowy.di.scopes.PerActivity
import io.github.wulkanowy.services.job.SyncWorker
import io.github.wulkanowy.ui.modules.login.LoginActivity import io.github.wulkanowy.ui.modules.login.LoginActivity
import io.github.wulkanowy.ui.modules.login.LoginModule import io.github.wulkanowy.ui.modules.login.LoginModule
import io.github.wulkanowy.ui.modules.main.MainActivity import io.github.wulkanowy.ui.modules.main.MainActivity
@ -23,4 +24,7 @@ internal abstract class BuilderModule {
@PerActivity @PerActivity
@ContributesAndroidInjector(modules = [MainModule::class]) @ContributesAndroidInjector(modules = [MainModule::class])
abstract fun bindMainActivity(): MainActivity abstract fun bindMainActivity(): MainActivity
@ContributesAndroidInjector
abstract fun bindSyncJob(): SyncWorker
} }

View File

@ -0,0 +1,57 @@
package io.github.wulkanowy.services.job
import com.firebase.jobdispatcher.Constraint.ON_ANY_NETWORK
import com.firebase.jobdispatcher.Constraint.ON_UNMETERED_NETWORK
import com.firebase.jobdispatcher.FirebaseJobDispatcher
import com.firebase.jobdispatcher.Lifetime.FOREVER
import com.firebase.jobdispatcher.RetryStrategy.DEFAULT_EXPONENTIAL
import com.firebase.jobdispatcher.Trigger.executionWindow
import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.utils.isHolidays
import org.threeten.bp.LocalDate
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ServiceHelper @Inject constructor(
private val prefRepository: PreferencesRepository,
private val dispatcher: FirebaseJobDispatcher
) {
fun reloadFullSyncService() {
startFullSyncService(true)
}
fun startFullSyncService(replaceCurrent: Boolean = false) {
if (LocalDate.now().isHolidays || !prefRepository.serviceEnabled) {
Timber.d("Services disabled or it's holidays")
return
}
dispatcher.mustSchedule(
dispatcher.newJobBuilder()
.setLifetime(FOREVER)
.setRecurring(true)
.setService(SyncWorker::class.java)
.setTag(SyncWorker.WORK_TAG)
.setTrigger(
executionWindow(
prefRepository.servicesInterval * 60,
(prefRepository.servicesInterval + 10) * 60
)
)
.setConstraints(if (prefRepository.servicesOnlyWifi) ON_UNMETERED_NETWORK else ON_ANY_NETWORK)
.setReplaceCurrent(replaceCurrent)
.setRetryStrategy(DEFAULT_EXPONENTIAL)
.build()
)
Timber.d("Services started")
}
fun stopFullSyncService() {
dispatcher.cancel(SyncWorker.WORK_TAG)
Timber.d("Services stopped")
}
}

View File

@ -0,0 +1,110 @@
package io.github.wulkanowy.services.job
import com.firebase.jobdispatcher.JobParameters
import com.firebase.jobdispatcher.SimpleJobService
import dagger.android.AndroidInjection
import io.github.wulkanowy.data.repositories.AttendanceRepository
import io.github.wulkanowy.data.repositories.ExamRepository
import io.github.wulkanowy.data.repositories.GradeRepository
import io.github.wulkanowy.data.repositories.GradeSummaryRepository
import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.data.repositories.SessionRepository
import io.github.wulkanowy.data.repositories.TimetableRepository
import io.github.wulkanowy.services.notification.GradeNotification
import io.github.wulkanowy.utils.friday
import io.github.wulkanowy.utils.isHolidays
import io.github.wulkanowy.utils.monday
import io.reactivex.Single
import io.reactivex.disposables.CompositeDisposable
import org.threeten.bp.LocalDate
import timber.log.Timber
import javax.inject.Inject
class SyncWorker : SimpleJobService() {
@Inject
lateinit var session: SessionRepository
@Inject
lateinit var gradesDetails: GradeRepository
@Inject
lateinit var gradesSummary: GradeSummaryRepository
@Inject
lateinit var attendance: AttendanceRepository
@Inject
lateinit var exam: ExamRepository
@Inject
lateinit var timetable: TimetableRepository
@Inject
lateinit var prefRepository: PreferencesRepository
private val disposable = CompositeDisposable()
companion object {
const val WORK_TAG = "FULL_SYNC"
}
override fun onCreate() {
super.onCreate()
AndroidInjection.inject(this)
}
override fun onRunJob(job: JobParameters?): Int {
Timber.d("Synchronization started")
val start = LocalDate.now().monday
val end = LocalDate.now().friday
if (start.isHolidays) return RESULT_FAIL_NORETRY
var error: Throwable? = null
disposable.add(session.getSemesters(true)
.map { it.single { semester -> semester.current } }
.flatMapPublisher {
Single.merge(
listOf(
gradesDetails.getGrades(it, true, true),
gradesSummary.getGradesSummary(it, true),
attendance.getAttendance(it, start, end, true),
exam.getExams(it, start, end, true),
timetable.getTimetable(it, start, end, true)
)
)
}
.subscribe({}, { error = it }))
return if (null === error) {
if (prefRepository.notificationsEnable) sendNotifications()
Timber.d("Synchronization successful")
RESULT_SUCCESS
} else {
Timber.e(error, "Synchronization failed")
RESULT_FAIL_RETRY
}
}
private fun sendNotifications() {
disposable.add(session.getSemesters(true)
.map { it.single { semester -> semester.current } }
.flatMap { gradesDetails.getNewGrades(it) }
.map { it.filter { grade -> !grade.isNotified } }
.subscribe({
if (it.isNotEmpty()) {
Timber.d("Found ${it.size} unread grades")
GradeNotification(applicationContext).sendNotification(it)
gradesDetails.updateGrades(it.map { grade -> grade.apply { isNotified = true } }).subscribe()
}
}) { Timber.e("Notifications sending failed") })
}
override fun onDestroy() {
super.onDestroy()
disposable.clear()
}
}

View File

@ -0,0 +1,34 @@
package io.github.wulkanowy.services.notification
import android.app.Notification
import android.app.NotificationManager
import android.content.Context
import android.content.Context.NOTIFICATION_SERVICE
import android.os.Build.VERSION.SDK_INT
import android.os.Build.VERSION_CODES.O
import androidx.core.app.NotificationCompat
import timber.log.Timber
import kotlin.random.Random
abstract class BaseNotification(protected val context: Context) {
protected val notificationManager: NotificationManager by lazy {
context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
}
fun notify(notification: Notification) {
notificationManager.notify(Random.nextInt(1000), notification)
}
fun notificationBuilder(channelId: String): NotificationCompat.Builder {
if (SDK_INT >= O) createChannel(channelId)
return NotificationCompat.Builder(context, channelId)
}
fun cancelAll() {
notificationManager.cancelAll()
Timber.d("Notifications canceled")
}
abstract fun createChannel(channelId: String)
}

View File

@ -0,0 +1,60 @@
package io.github.wulkanowy.services.notification
import android.annotation.TargetApi
import android.app.Notification.VISIBILITY_PUBLIC
import android.app.NotificationChannel
import android.app.NotificationManager.IMPORTANCE_HIGH
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Grade
import io.github.wulkanowy.ui.modules.main.MainActivity
import io.github.wulkanowy.ui.modules.main.MainActivity.Companion.EXTRA_CARD_ID_KEY
import timber.log.Timber
class GradeNotification(context: Context) : BaseNotification(context) {
private val channelId = "Grade_Notify"
@TargetApi(26)
override fun createChannel(channelId: String) {
notificationManager.createNotificationChannel(NotificationChannel(
channelId, context.getString(R.string.notify_grade_channel), IMPORTANCE_HIGH
).apply {
enableLights(true)
enableVibration(true)
lockscreenVisibility = VISIBILITY_PUBLIC
})
}
fun sendNotification(items: List<Grade>) {
notify(notificationBuilder(channelId)
.setContentTitle(context.resources.getQuantityString(R.plurals.grade_new_items, items.size, items.size))
.setContentText(context.resources.getQuantityString(R.plurals.notify_grade_new_items, items.size, items.size))
.setSmallIcon(R.drawable.ic_stat_notify_grade)
.setAutoCancel(true)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setColor(ContextCompat.getColor(context, R.color.colorPrimary))
.setContentIntent(
PendingIntent.getActivity(context, 0,
MainActivity.getStartIntent(context).putExtra(EXTRA_CARD_ID_KEY, 0),
FLAG_UPDATE_CURRENT
)
)
.setStyle(NotificationCompat.InboxStyle().run {
setSummaryText(context.resources.getQuantityString(R.plurals.grade_number_item, items.size, items.size))
items.forEach {
addLine("${it.subject}: ${it.entry}")
}
this
})
.build()
)
Timber.d("Notification sent")
}
}

View File

@ -1,6 +1,7 @@
package io.github.wulkanowy.ui.base package io.github.wulkanowy.ui.base
import io.github.wulkanowy.data.ErrorHandler import io.github.wulkanowy.data.ErrorHandler
import io.github.wulkanowy.ui.modules.main.MainView
import io.reactivex.disposables.CompositeDisposable import io.reactivex.disposables.CompositeDisposable
open class BasePresenter<T : BaseView>(private val errorHandler: ErrorHandler) { open class BasePresenter<T : BaseView>(private val errorHandler: ErrorHandler) {

View File

@ -12,4 +12,3 @@ class AboutModule {
@Provides @Provides
fun provideLibsFragmentCompat() = LibsFragmentCompat() fun provideLibsFragmentCompat() = LibsFragmentCompat()
} }

View File

@ -7,4 +7,4 @@ interface AboutView : BaseView {
fun openSourceWebView() fun openSourceWebView()
fun openIssuesWebView() fun openIssuesWebView()
} }

View File

@ -29,4 +29,3 @@ abstract class GradeModule {
@ContributesAndroidInjector @ContributesAndroidInjector
abstract fun binGradeSummaryFragment(): GradeSummaryFragment abstract fun binGradeSummaryFragment(): GradeSummaryFragment
} }

View File

@ -75,6 +75,4 @@ class GradeDetailsDialog : DialogFragment() {
gradeDialogClose.setOnClickListener { dismiss() } gradeDialogClose.setOnClickListener { dismiss() }
} }
} }

View File

@ -34,7 +34,7 @@ class GradeDetailsItem(val grade: Grade, private val weightString: String, priva
gradeItemDescription.text = if (grade.description.isNotBlank()) grade.description else grade.gradeSymbol gradeItemDescription.text = if (grade.description.isNotBlank()) grade.description else grade.gradeSymbol
gradeItemDate.text = grade.date.toFormattedString() gradeItemDate.text = grade.date.toFormattedString()
gradeItemWeight.text = "$weightString: ${grade.weight}" gradeItemWeight.text = "$weightString: ${grade.weight}"
gradeItemNote.visibility = if (grade.isNew) VISIBLE else GONE gradeItemNote.visibility = if (!grade.isRead) VISIBLE else GONE
} }
} }

View File

@ -9,6 +9,7 @@ import io.github.wulkanowy.ui.base.BasePresenter
import io.github.wulkanowy.utils.SchedulersProvider import io.github.wulkanowy.utils.SchedulersProvider
import io.github.wulkanowy.utils.calcAverage import io.github.wulkanowy.utils.calcAverage
import io.github.wulkanowy.utils.valueColor import io.github.wulkanowy.utils.valueColor
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
class GradeDetailsPresenter @Inject constructor( class GradeDetailsPresenter @Inject constructor(
@ -51,8 +52,8 @@ class GradeDetailsPresenter @Inject constructor(
if (item is GradeDetailsItem) { if (item is GradeDetailsItem) {
view?.apply { view?.apply {
showGradeDialog(item.grade) showGradeDialog(item.grade)
if (item.grade.isNew) { if (!item.grade.isRead) {
item.grade.isNew = false item.grade.isRead = true
updateItem(item) updateItem(item)
getHeaderOfItem(item)?.let { header -> getHeaderOfItem(item)?.let { header ->
if (header is GradeDetailsHeader) { if (header is GradeDetailsHeader) {
@ -94,7 +95,7 @@ class GradeDetailsPresenter @Inject constructor(
subject = it.key, subject = it.key,
average = formatAverage(average), average = formatAverage(average),
number = view?.getGradeNumberString(it.value.size).orEmpty(), number = view?.getGradeNumberString(it.value.size).orEmpty(),
newGrades = it.value.filter { grade -> grade.isNew }.size newGrades = it.value.filter { grade -> !grade.isRead }.size
).apply { ).apply {
subItems = it.value.map { item -> subItems = it.value.map { item ->
GradeDetailsItem( GradeDetailsItem(
@ -120,5 +121,6 @@ class GradeDetailsPresenter @Inject constructor(
.subscribeOn(schedulers.backgroundThread) .subscribeOn(schedulers.backgroundThread)
.observeOn(schedulers.mainThread) .observeOn(schedulers.mainThread)
.subscribe({}) { error -> errorHandler.proceed(error) }) .subscribe({}) { error -> errorHandler.proceed(error) })
Timber.d("Grade ${grade.id} updated")
} }
} }

View File

@ -20,4 +20,3 @@ class LoginErrorHandler(resources: Resources) : ErrorHandler(resources) {
doOnBadCredentials = {} doOnBadCredentials = {}
} }
} }

View File

@ -3,4 +3,4 @@ package io.github.wulkanowy.ui.modules.login
interface LoginSwitchListener { interface LoginSwitchListener {
fun switchFragment(position: Int) fun switchFragment(position: Int)
} }

View File

@ -10,6 +10,7 @@ import com.aurelhubert.ahbottomnavigation.AHBottomNavigationItem
import com.ncapdevi.fragnav.FragNavController import com.ncapdevi.fragnav.FragNavController
import com.ncapdevi.fragnav.FragNavController.Companion.HIDE import com.ncapdevi.fragnav.FragNavController.Companion.HIDE
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.services.notification.GradeNotification
import io.github.wulkanowy.ui.base.BaseActivity import io.github.wulkanowy.ui.base.BaseActivity
import io.github.wulkanowy.ui.modules.attendance.AttendanceFragment import io.github.wulkanowy.ui.modules.attendance.AttendanceFragment
import io.github.wulkanowy.ui.modules.exam.ExamFragment import io.github.wulkanowy.ui.modules.exam.ExamFragment
@ -31,6 +32,7 @@ class MainActivity : BaseActivity(), MainView {
lateinit var navController: FragNavController lateinit var navController: FragNavController
companion object { companion object {
const val EXTRA_CARD_ID_KEY = "cardId"
fun getStartIntent(context: Context) = Intent(context, MainActivity::class.java) fun getStartIntent(context: Context) = Intent(context, MainActivity::class.java)
} }
@ -51,7 +53,7 @@ class MainActivity : BaseActivity(), MainView {
setSupportActionBar(mainToolbar) setSupportActionBar(mainToolbar)
messageContainer = mainFragmentContainer messageContainer = mainFragmentContainer
presenter.onAttachView(this) presenter.onAttachView(this, intent.getIntExtra(EXTRA_CARD_ID_KEY, -1))
navController.initialize(startMenuIndex, savedInstanceState) navController.initialize(startMenuIndex, savedInstanceState)
} }
@ -66,13 +68,15 @@ class MainActivity : BaseActivity(), MainView {
override fun initView() { override fun initView() {
mainBottomNav.run { mainBottomNav.run {
addItems(mutableListOf( addItems(
mutableListOf(
AHBottomNavigationItem(R.string.grade_title, R.drawable.ic_menu_main_grade_26dp, 0), AHBottomNavigationItem(R.string.grade_title, R.drawable.ic_menu_main_grade_26dp, 0),
AHBottomNavigationItem(R.string.attendance_title, R.drawable.ic_menu_main_attendance_24dp, 0), AHBottomNavigationItem(R.string.attendance_title, R.drawable.ic_menu_main_attendance_24dp, 0),
AHBottomNavigationItem(R.string.exam_title, R.drawable.ic_menu_main_exam_24dp, 0), AHBottomNavigationItem(R.string.exam_title, R.drawable.ic_menu_main_exam_24dp, 0),
AHBottomNavigationItem(R.string.timetable_title, R.drawable.ic_menu_main_timetable_24dp, 0), AHBottomNavigationItem(R.string.timetable_title, R.drawable.ic_menu_main_timetable_24dp, 0),
AHBottomNavigationItem(R.string.more_title, R.drawable.ic_menu_main_more_24dp, 0) AHBottomNavigationItem(R.string.more_title, R.drawable.ic_menu_main_more_24dp, 0)
)) )
)
accentColor = ContextCompat.getColor(context, R.color.colorPrimary) accentColor = ContextCompat.getColor(context, R.color.colorPrimary)
inactiveColor = getThemeAttrColor(android.R.attr.textColorSecondary) inactiveColor = getThemeAttrColor(android.R.attr.textColorSecondary)
defaultBackgroundColor = getThemeAttrColor(R.attr.bottomNavBackground) defaultBackgroundColor = getThemeAttrColor(R.attr.bottomNavBackground)
@ -89,11 +93,11 @@ class MainActivity : BaseActivity(), MainView {
setOnViewChangeListener { presenter.onViewStart() } setOnViewChangeListener { presenter.onViewStart() }
fragmentHideStrategy = HIDE fragmentHideStrategy = HIDE
rootFragments = listOf( rootFragments = listOf(
GradeFragment.newInstance(), GradeFragment.newInstance(),
AttendanceFragment.newInstance(), AttendanceFragment.newInstance(),
ExamFragment.newInstance(), ExamFragment.newInstance(),
TimetableFragment.newInstance(), TimetableFragment.newInstance(),
MoreFragment.newInstance() MoreFragment.newInstance()
) )
} }
} }
@ -126,6 +130,10 @@ class MainActivity : BaseActivity(), MainView {
presenter.onBackPressed { super.onBackPressed() } presenter.onBackPressed { super.onBackPressed() }
} }
override fun cancelNotifications() {
GradeNotification(applicationContext).cancelAll()
}
override fun onSaveInstanceState(outState: Bundle?) { override fun onSaveInstanceState(outState: Bundle?) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
navController.onSaveInstanceState(outState) navController.onSaveInstanceState(outState)

View File

@ -14,6 +14,7 @@ import io.github.wulkanowy.ui.modules.exam.ExamFragment
import io.github.wulkanowy.ui.modules.grade.GradeFragment import io.github.wulkanowy.ui.modules.grade.GradeFragment
import io.github.wulkanowy.ui.modules.grade.GradeModule import io.github.wulkanowy.ui.modules.grade.GradeModule
import io.github.wulkanowy.ui.modules.more.MoreFragment import io.github.wulkanowy.ui.modules.more.MoreFragment
import io.github.wulkanowy.ui.modules.settings.SettingsFragment
import io.github.wulkanowy.ui.modules.timetable.TimetableFragment import io.github.wulkanowy.ui.modules.timetable.TimetableFragment
@Module @Module
@ -53,4 +54,7 @@ abstract class MainModule {
@PerFragment @PerFragment
@ContributesAndroidInjector(modules = [AboutModule::class]) @ContributesAndroidInjector(modules = [AboutModule::class])
abstract fun bindAboutFragment(): AboutFragment abstract fun bindAboutFragment(): AboutFragment
@ContributesAndroidInjector
abstract fun bindSettingsFragment(): SettingsFragment
} }

View File

@ -2,20 +2,26 @@ package io.github.wulkanowy.ui.modules.main
import io.github.wulkanowy.data.ErrorHandler import io.github.wulkanowy.data.ErrorHandler
import io.github.wulkanowy.data.repositories.PreferencesRepository import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.services.job.ServiceHelper
import io.github.wulkanowy.ui.base.BasePresenter import io.github.wulkanowy.ui.base.BasePresenter
import javax.inject.Inject import javax.inject.Inject
class MainPresenter @Inject constructor( class MainPresenter @Inject constructor(
errorHandler: ErrorHandler, errorHandler: ErrorHandler,
private val prefRepository: PreferencesRepository) private val prefRepository: PreferencesRepository,
: BasePresenter<MainView>(errorHandler) { private val serviceHelper: ServiceHelper
) : BasePresenter<MainView>(errorHandler) {
override fun onAttachView(view: MainView) { fun onAttachView(view: MainView, init: Int) {
super.onAttachView(view) super.onAttachView(view)
view.run { view.run {
startMenuIndex = prefRepository.startMenuIndex cancelNotifications()
startMenuIndex = if (init != -1) init else prefRepository.startMenuIndex
initView() initView()
} }
serviceHelper.startFullSyncService()
} }
fun onViewStart() { fun onViewStart() {
@ -33,7 +39,6 @@ class MainPresenter @Inject constructor(
return true return true
} }
fun onBackPressed(default: () -> Unit) { fun onBackPressed(default: () -> Unit) {
view?.run { view?.run {
if (isRootView) default() if (isRootView) default()

View File

@ -24,6 +24,8 @@ interface MainView : BaseView {
fun popView() fun popView()
fun cancelNotifications()
interface MainChildView { interface MainChildView {
fun onFragmentReselected() fun onFragmentReselected()

View File

@ -1,14 +1,21 @@
package io.github.wulkanowy.ui.modules.settings package io.github.wulkanowy.ui.modules.settings
import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import com.takisoft.preferencex.PreferenceFragmentCompat import com.takisoft.preferencex.PreferenceFragmentCompat
import dagger.android.support.AndroidSupportInjection
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.ui.modules.main.MainView import io.github.wulkanowy.ui.modules.main.MainView
import javax.inject.Inject
class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedPreferenceChangeListener, class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedPreferenceChangeListener,
MainView.TitledView { MainView.TitledView, SettingsView {
@Inject
lateinit var presenter: SettingsPresenter
companion object { companion object {
fun newInstance() = SettingsFragment() fun newInstance() = SettingsFragment()
@ -17,19 +24,40 @@ class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedP
override val titleStringId: Int override val titleStringId: Int
get() = R.string.settings_title get() = R.string.settings_title
override fun onAttach(context: Context) {
AndroidSupportInjection.inject(this)
super.onAttach(context)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
presenter.onAttachView(this)
}
override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.scheme_preferences) addPreferencesFromResource(R.xml.scheme_preferences)
} }
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String) { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when(key) { presenter.onSharedPreferenceChanged(key)
"theme" -> { }
AppCompatDelegate.setDefaultNightMode(sharedPreferences?.getString("theme", "1")?.toInt() ?: 1)
activity?.recreate() override fun setTheme(theme: Int) {
} AppCompatDelegate.setDefaultNightMode(theme)
activity?.recreate()
}
override fun setServicesSuspended(serviceEnablesKey: String, isHolidays: Boolean) {
findPreference(serviceEnablesKey).run {
summary = if (isHolidays) getString(R.string.pref_services_suspended) else ""
isEnabled = !isHolidays
} }
} }
override fun showMessage(text: String) {
Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
preferenceScreen.sharedPreferences.registerOnSharedPreferenceChangeListener(this) preferenceScreen.sharedPreferences.registerOnSharedPreferenceChangeListener(this)

View File

@ -0,0 +1,40 @@
package io.github.wulkanowy.ui.modules.settings
import io.github.wulkanowy.data.ErrorHandler
import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.services.job.ServiceHelper
import io.github.wulkanowy.ui.base.BasePresenter
import io.github.wulkanowy.utils.isHolidays
import org.threeten.bp.LocalDate.now
import javax.inject.Inject
class SettingsPresenter @Inject constructor(
errorHandler: ErrorHandler,
private val preferencesRepository: PreferencesRepository,
private val serviceHelper: ServiceHelper
) : BasePresenter<SettingsView>(errorHandler) {
override fun onAttachView(view: SettingsView) {
super.onAttachView(view)
view.run {
setServicesSuspended(preferencesRepository.serviceEnablesKey, now().isHolidays)
}
}
fun onSharedPreferenceChanged(key: String) {
when (key) {
preferencesRepository.serviceEnablesKey -> {
if (preferencesRepository.serviceEnabled) serviceHelper.startFullSyncService()
else serviceHelper.stopFullSyncService()
}
preferencesRepository.servicesIntervalKey,
preferencesRepository.servicesOnlyWifiKey -> {
serviceHelper.reloadFullSyncService()
}
preferencesRepository.currentThemeKey -> {
view?.setTheme(preferencesRepository.currentTheme)
}
}
}
}

View File

@ -0,0 +1,10 @@
package io.github.wulkanowy.ui.modules.settings
import io.github.wulkanowy.ui.base.BaseView
interface SettingsView : BaseView {
fun setTheme(theme: Int)
fun setServicesSuspended(serviceEnablesKey: String, isHolidays: Boolean)
}

View File

@ -1,6 +1,7 @@
package io.github.wulkanowy.ui.modules.splash package io.github.wulkanowy.ui.modules.splash
import android.os.Bundle import android.os.Bundle
import io.github.wulkanowy.services.notification.GradeNotification
import io.github.wulkanowy.ui.base.BaseActivity import io.github.wulkanowy.ui.base.BaseActivity
import io.github.wulkanowy.ui.modules.login.LoginActivity import io.github.wulkanowy.ui.modules.login.LoginActivity
import io.github.wulkanowy.ui.modules.main.MainActivity import io.github.wulkanowy.ui.modules.main.MainActivity

View File

@ -140,4 +140,3 @@ private fun generateKeyPair(context: Context) {
} }
Timber.i("A new KeyPair has been generated") Timber.i("A new KeyPair has been generated")
} }

View File

@ -62,6 +62,16 @@
</plurals> </plurals>
<!--Grade notify-->
<string name="notify_grade_channel">Nowe oceny</string>
<plurals name="notify_grade_new_items">
<item quantity="one">Dostałeś %1$d ocenę</item>
<item quantity="few">"Dostałeś %1$d oceny</item>
<item quantity="many">Dostałeś %1$d ocen</item>
<item quantity="other">Dostałeś %1$d ocen</item>
</plurals>
<!--Timetable--> <!--Timetable-->
<string name="timetable_lesson">Lekcja</string> <string name="timetable_lesson">Lekcja</string>
<string name="timetable_room">Sala</string> <string name="timetable_room">Sala</string>
@ -128,24 +138,12 @@
<string name="pref_notify_header">Powiadomienia</string> <string name="pref_notify_header">Powiadomienia</string>
<string name="pref_notify_switch">Pokazuj powiadomienia</string> <string name="pref_notify_switch">Pokazuj powiadomienia</string>
<string name="pref_services_header">Usługi</string> <string name="pref_services_header">Synchronizacja</string>
<string name="pref_services_switch">Automatyczna aktualizacja</string> <string name="pref_services_switch">Automatyczna aktualizacja</string>
<string name="pref_services_suspended">Zawieszone na wakacjach</string> <string name="pref_services_suspended">Zawieszona na wakacjach</string>
<string name="pref_services_interval">Interwał aktualizacji</string> <string name="pref_services_interval">Interwał aktualizacji</string>
<string name="pref_services_wifi">Tylko WiFi</string> <string name="pref_services_wifi">Tylko WiFi</string>
<string name="pref_restart">Wymagany restart</string>
<!--Grade notify-->
<string name="notify_grade_chanel">Nowe oceny</string>
<plurals name="notify_grade_new_items">
<item quantity="one">Dostałeś %1$d ocenę</item>
<item quantity="few">"Dostałeś %1$d oceny</item>
<item quantity="many">Dostałeś %1$d ocen</item>
<item quantity="other">Dostałeś %1$d ocen</item>
</plurals>
<!--Colors--> <!--Colors-->
<string name="all_black">Czarny</string> <string name="all_black">Czarny</string>

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string-array name="services_interval_entries"> <string-array name="services_interval_entries">
<item>10 minut</item> <item>15 minut</item>
<item>30 minut</item> <item>30 minut</item>
<item>1 godzinę</item> <item>1 godzina</item>
<item>2 godziny</item> <item>2 godziny</item>
<item>6 godzin</item> <item>6 godzin</item>
<item>12 godzin</item> <item>12 godzin</item>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="pref_key_start_menu">start_menu</string>
<string name="pref_key_attendance_present">attendance_present</string>
<string name="pref_key_theme">theme</string>
<string name="pref_key_services_enable">services_enable</string>
<string name="pref_key_services_interval">services_interval</string>
<string name="pref_key_services_wifi_only">services_disable_wifi_only</string>
<string name="pref_key_notifications_enable">notifications_enable</string>
</resources>

View File

@ -57,6 +57,13 @@
<item quantity="other">New grades</item> <item quantity="other">New grades</item>
</plurals> </plurals>
<!--Grade notify-->
<string name="notify_grade_channel">New grades</string>
<plurals name="notify_grade_new_items">
<item quantity="one">You received %1$d grade</item>
<item quantity="other">You received %1$d grades</item>
</plurals>
<!--Timetable--> <!--Timetable-->
<string name="timetable_lesson">Lesson</string> <string name="timetable_lesson">Lesson</string>
@ -124,22 +131,12 @@
<string name="pref_notify_header">Notifications</string> <string name="pref_notify_header">Notifications</string>
<string name="pref_notify_switch">Show notifications</string> <string name="pref_notify_switch">Show notifications</string>
<string name="pref_services_header">Services</string> <string name="pref_services_header">Synchronization</string>
<string name="pref_services_switch">Automatic update</string> <string name="pref_services_switch">Automatic update</string>
<string name="pref_services_suspended">Suspended on holiday</string> <string name="pref_services_suspended">Suspended on holiday</string>
<string name="pref_services_interval">Updates interval</string> <string name="pref_services_interval">Updates interval</string>
<string name="pref_services_wifi">Only WiFi</string> <string name="pref_services_wifi">Only WiFi</string>
<string name="pref_restart">Restart required</string>
<!--Grade notify-->
<string name="notify_grade_chanel">New grades</string>
<plurals name="notify_grade_new_items">
<item quantity="one">You received %1$d grade</item>
<item quantity="other">You received %1$d grades</item>
</plurals>
<!--Colors--> <!--Colors-->
<string name="all_black">Black</string> <string name="all_black">Black</string>

View File

@ -27,7 +27,7 @@
</string-array> </string-array>
<string-array name="services_interval_entries"> <string-array name="services_interval_entries">
<item>10 minutes</item> <item>15 minutes</item>
<item>30 minutes</item> <item>30 minutes</item>
<item>1 hour</item> <item>1 hour</item>
<item>2 hours</item> <item>2 hours</item>
@ -36,7 +36,7 @@
<item>24 hours</item> <item>24 hours</item>
</string-array> </string-array>
<string-array name="services_interval_value" translatable="false"> <string-array name="services_interval_value" translatable="false">
<item>10</item> <item>15</item>
<item>30</item> <item>30</item>
<item>60</item> <item>60</item>
<item>120</item> <item>120</item>

View File

@ -1,29 +1,63 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android" <PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory <PreferenceCategory
android:title="@string/pref_view_header" android:title="@string/pref_view_header"
app:iconSpaceReserved="false"> app:iconSpaceReserved="false">
<ListPreference <ListPreference
android:defaultValue="0" android:defaultValue="0"
android:entries="@array/startup_tab_entries" android:entries="@array/startup_tab_entries"
android:entryValues="@array/startup_tab_value" android:entryValues="@array/startup_tab_value"
android:key="start_menu" android:key="@string/pref_key_start_menu"
android:summary="%s" android:summary="%s"
android:title="@string/pref_view_list" android:title="@string/pref_view_list"
app:iconSpaceReserved="false"/> app:iconSpaceReserved="false" />
<ListPreference
android:defaultValue="1"
android:entries="@array/theme_entries"
android:entryValues="@array/theme_values"
android:key="theme"
android:summary="%s"
android:title="@string/pref_view_theme_dark"
app:iconSpaceReserved="false" />
<SwitchPreference <SwitchPreference
android:defaultValue="true" android:defaultValue="true"
android:key="attendance_present" android:key="@string/pref_key_attendance_present"
android:title="@string/pref_view_present" android:title="@string/pref_view_present"
app:iconSpaceReserved="false"/> app:iconSpaceReserved="false" />
</PreferenceCategory>
<PreferenceCategory
android:title="@string/pref_services_header"
app:iconSpaceReserved="false">
<SwitchPreference
android:defaultValue="true"
android:key="@string/pref_key_services_enable"
android:title="@string/pref_services_switch"
app:iconSpaceReserved="false" />
<ListPreference <ListPreference
android:defaultValue="1" android:defaultValue="60"
android:entries="@array/theme_entries" android:dependency="services_enable"
android:entryValues="@array/theme_values" android:entries="@array/services_interval_entries"
android:key="theme" android:entryValues="@array/services_interval_value"
android:summary="%s" android:key="@string/pref_key_services_interval"
android:title="@string/pref_view_theme_dark" android:summary="%s"
app:iconSpaceReserved="false"/> android:title="@string/pref_services_interval"
app:iconSpaceReserved="false" />
<SwitchPreference
android:defaultValue="false"
android:dependency="services_enable"
android:key="@string/pref_key_services_wifi_only"
android:title="@string/pref_services_wifi"
app:iconSpaceReserved="false" />
</PreferenceCategory>
<PreferenceCategory
android:title="@string/pref_notify_header"
app:iconSpaceReserved="false">
<SwitchPreference
android:defaultValue="true"
android:dependency="services_enable"
android:key="@string/pref_key_notifications_enable"
android:title="@string/pref_notify_switch"
app:iconSpaceReserved="false" />
</PreferenceCategory> </PreferenceCategory>
</PreferenceScreen> </PreferenceScreen>

View File

@ -2,6 +2,7 @@ package io.github.wulkanowy.ui.modules.main
import io.github.wulkanowy.data.ErrorHandler import io.github.wulkanowy.data.ErrorHandler
import io.github.wulkanowy.data.repositories.PreferencesRepository import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.services.job.ServiceHelper
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.mockito.Mock import org.mockito.Mock
@ -17,6 +18,9 @@ class MainPresenterTest {
@Mock @Mock
lateinit var prefRepository: PreferencesRepository lateinit var prefRepository: PreferencesRepository
@Mock
lateinit var serviceHelper: ServiceHelper
@Mock @Mock
lateinit var mainView: MainView lateinit var mainView: MainView
@ -27,8 +31,8 @@ class MainPresenterTest {
MockitoAnnotations.initMocks(this) MockitoAnnotations.initMocks(this)
clearInvocations(mainView) clearInvocations(mainView)
presenter = MainPresenter(errorHandler, prefRepository) presenter = MainPresenter(errorHandler, prefRepository, serviceHelper)
presenter.onAttachView(mainView) presenter.onAttachView(mainView, -1)
} }
@Test @Test

View File

@ -1,5 +1,5 @@
buildscript { buildscript {
ext.kotlin_version = '1.2.71' ext.kotlin_version = '1.3.0'
repositories { repositories {
mavenCentral() mavenCentral()
google() google()