diff --git a/app/build.gradle b/app/build.gradle index 6b854adda..9dbea0cf4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -69,6 +69,7 @@ dependencies { implementation 'com.github.PhilJay:MPAndroidChart:v3.0.3' implementation 'androidx.cardview:cardview:1.0.0' implementation 'com.google.firebase:firebase-analytics:17.1.0' + implementation 'org.rekotlin:rekotlin:1.0.4' } -apply plugin: 'com.google.gms.google-services' \ No newline at end of file +apply plugin: 'com.google.gms.google-services' diff --git a/app/src/main/java/com/bogdan/codeforceswatcher/CwApplication.kt b/app/src/main/java/com/bogdan/codeforceswatcher/CwApplication.kt index 66a5753ba..39a8999d2 100644 --- a/app/src/main/java/com/bogdan/codeforceswatcher/CwApplication.kt +++ b/app/src/main/java/com/bogdan/codeforceswatcher/CwApplication.kt @@ -1,7 +1,18 @@ package com.bogdan.codeforceswatcher import android.app.Application +import com.bogdan.codeforceswatcher.redux.AppState +import com.bogdan.codeforceswatcher.redux.appMiddleware +import com.bogdan.codeforceswatcher.redux.appReducer +import com.bogdan.codeforceswatcher.room.RoomController import com.google.firebase.analytics.FirebaseAnalytics +import org.rekotlin.Store + +val store = Store( + reducer = ::appReducer, + state = RoomController.fetchAppState(), + middleware = listOf(appMiddleware) +) class CwApp : Application() { @@ -10,6 +21,7 @@ class CwApp : Application() { app = this + RoomController.onAppCreated() FirebaseAnalytics.getInstance(this) } diff --git a/app/src/main/java/com/bogdan/codeforceswatcher/activity/MainActivity.kt b/app/src/main/java/com/bogdan/codeforceswatcher/activity/MainActivity.kt index 9115c029d..765ec1600 100644 --- a/app/src/main/java/com/bogdan/codeforceswatcher/activity/MainActivity.kt +++ b/app/src/main/java/com/bogdan/codeforceswatcher/activity/MainActivity.kt @@ -12,12 +12,12 @@ import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentPagerAdapter import androidx.viewpager.widget.ViewPager import com.bogdan.codeforceswatcher.R -import com.bogdan.codeforceswatcher.fragment.ContestsFragment +import com.bogdan.codeforceswatcher.feature.contests.ContestsFragment import com.bogdan.codeforceswatcher.fragment.UsersFragment +import com.bogdan.codeforceswatcher.network.UserLoader import com.bogdan.codeforceswatcher.receiver.StartAlarm import com.bogdan.codeforceswatcher.ui.AppRateDialog import com.bogdan.codeforceswatcher.util.Prefs -import com.bogdan.codeforceswatcher.network.UserLoader import kotlinx.android.synthetic.main.activity_main.bottomNavigation import kotlinx.android.synthetic.main.activity_main.fab import kotlinx.android.synthetic.main.activity_main.llToolbar diff --git a/app/src/main/java/com/bogdan/codeforceswatcher/feature/contests/ContestsFragment.kt b/app/src/main/java/com/bogdan/codeforceswatcher/feature/contests/ContestsFragment.kt new file mode 100644 index 000000000..6cf136c74 --- /dev/null +++ b/app/src/main/java/com/bogdan/codeforceswatcher/feature/contests/ContestsFragment.kt @@ -0,0 +1,65 @@ +package com.bogdan.codeforceswatcher.feature.contests + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.bogdan.codeforceswatcher.R +import com.bogdan.codeforceswatcher.adapter.ContestAdapter +import com.bogdan.codeforceswatcher.feature.contests.redux.ContestsState +import com.bogdan.codeforceswatcher.feature.contests.redux.request.ContestsRequests +import com.bogdan.codeforceswatcher.store +import com.bogdan.codeforceswatcher.util.Analytics +import kotlinx.android.synthetic.main.fragment_contests.recyclerView +import kotlinx.android.synthetic.main.fragment_contests.swipeToRefresh +import org.rekotlin.StoreSubscriber + +class ContestsFragment : Fragment(), SwipeRefreshLayout.OnRefreshListener, + StoreSubscriber { + + private val contestAdapter by lazy { ContestAdapter(listOf(), requireContext()) } + + override fun onStart() { + super.onStart() + store.subscribe(this) { state -> state.select { it.contests } } + } + + override fun onStop() { + super.onStop() + store.unsubscribe(this) + } + + override fun newState(state: ContestsState) { + swipeToRefresh.isRefreshing = (state.status == ContestsState.Status.PENDING) + contestAdapter.setItems(state.contests) + } + + override fun onRefresh() { + store.dispatch(ContestsRequests.FetchContests()) + Analytics.logContestsListRefresh() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = inflater.inflate(R.layout.fragment_contests, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + initViews() + + store.dispatch(ContestsRequests.FetchContests()) + } + + private fun initViews() { + swipeToRefresh.setOnRefreshListener(this) + + recyclerView.adapter = contestAdapter + recyclerView.layoutManager = LinearLayoutManager(requireContext()) + } +} diff --git a/app/src/main/java/com/bogdan/codeforceswatcher/feature/contests/redux/ContestsReducer.kt b/app/src/main/java/com/bogdan/codeforceswatcher/feature/contests/redux/ContestsReducer.kt new file mode 100644 index 000000000..8c45ef5b3 --- /dev/null +++ b/app/src/main/java/com/bogdan/codeforceswatcher/feature/contests/redux/ContestsReducer.kt @@ -0,0 +1,31 @@ +package com.bogdan.codeforceswatcher.feature.contests.redux + +import com.bogdan.codeforceswatcher.feature.contests.redux.request.ContestsRequests +import com.bogdan.codeforceswatcher.model.Contest +import com.bogdan.codeforceswatcher.redux.AppState +import org.rekotlin.Action + +fun contestsReducer(action: Action, state: AppState): ContestsState { + var newState = state.contests + + when (action) { + is ContestsRequests.FetchContests -> { + newState = newState.copy( + status = ContestsState.Status.PENDING + ) + } + is ContestsRequests.FetchContests.Success -> { + newState = newState.copy( + status = ContestsState.Status.IDLE, + contests = action.contests.filter { it.phase == "BEFORE" }.sortedBy(Contest::time) + ) + } + is ContestsRequests.FetchContests.Failure -> { + newState = newState.copy( + status = ContestsState.Status.IDLE + ) + } + } + + return newState +} diff --git a/app/src/main/java/com/bogdan/codeforceswatcher/feature/contests/redux/ContestsState.kt b/app/src/main/java/com/bogdan/codeforceswatcher/feature/contests/redux/ContestsState.kt new file mode 100644 index 000000000..eedb46b44 --- /dev/null +++ b/app/src/main/java/com/bogdan/codeforceswatcher/feature/contests/redux/ContestsState.kt @@ -0,0 +1,12 @@ +package com.bogdan.codeforceswatcher.feature.contests.redux + +import com.bogdan.codeforceswatcher.model.Contest +import org.rekotlin.StateType + +data class ContestsState( + val status: Status = Status.IDLE, + val contests: List = listOf() +) : StateType { + + enum class Status { IDLE, PENDING } +} diff --git a/app/src/main/java/com/bogdan/codeforceswatcher/network/model/ContestResponse.kt b/app/src/main/java/com/bogdan/codeforceswatcher/feature/contests/redux/request/ContestResponse.kt similarity index 66% rename from app/src/main/java/com/bogdan/codeforceswatcher/network/model/ContestResponse.kt rename to app/src/main/java/com/bogdan/codeforceswatcher/feature/contests/redux/request/ContestResponse.kt index cdd26c420..2c9a55a9a 100644 --- a/app/src/main/java/com/bogdan/codeforceswatcher/network/model/ContestResponse.kt +++ b/app/src/main/java/com/bogdan/codeforceswatcher/feature/contests/redux/request/ContestResponse.kt @@ -1,4 +1,4 @@ -package com.bogdan.codeforceswatcher.network.model +package com.bogdan.codeforceswatcher.feature.contests.redux.request import com.bogdan.codeforceswatcher.model.Contest diff --git a/app/src/main/java/com/bogdan/codeforceswatcher/feature/contests/redux/request/ContestsRequests.kt b/app/src/main/java/com/bogdan/codeforceswatcher/feature/contests/redux/request/ContestsRequests.kt new file mode 100644 index 000000000..6b48bed6d --- /dev/null +++ b/app/src/main/java/com/bogdan/codeforceswatcher/feature/contests/redux/request/ContestsRequests.kt @@ -0,0 +1,38 @@ +package com.bogdan.codeforceswatcher.feature.contests.redux.request + +import com.bogdan.codeforceswatcher.model.Contest +import com.bogdan.codeforceswatcher.network.RestClient +import com.bogdan.codeforceswatcher.redux.Request +import com.bogdan.codeforceswatcher.store +import org.rekotlin.Action +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response + +class ContestsRequests { + + class FetchContests : Request() { + + override fun execute() { + RestClient.getContests().enqueue(object : Callback { + + override fun onResponse( + call: Call, + response: Response + ) { + response.body()?.result?.let { contests -> + store.dispatch(Success(contests)) + } ?: store.dispatch(Failure()) + } + + override fun onFailure(call: Call, t: Throwable) { + store.dispatch(Failure(t)) + } + }) + } + + data class Success(val contests: List) : Action + + data class Failure(val t: Throwable? = null) : Action + } +} diff --git a/app/src/main/java/com/bogdan/codeforceswatcher/room/ContestDb.kt b/app/src/main/java/com/bogdan/codeforceswatcher/feature/contests/room/ContestDao.kt similarity index 52% rename from app/src/main/java/com/bogdan/codeforceswatcher/room/ContestDb.kt rename to app/src/main/java/com/bogdan/codeforceswatcher/feature/contests/room/ContestDao.kt index bfe25303c..b9552a1bb 100644 --- a/app/src/main/java/com/bogdan/codeforceswatcher/room/ContestDb.kt +++ b/app/src/main/java/com/bogdan/codeforceswatcher/feature/contests/room/ContestDao.kt @@ -1,8 +1,6 @@ -package com.bogdan.codeforceswatcher.room +package com.bogdan.codeforceswatcher.feature.contests.room -import androidx.lifecycle.LiveData import androidx.room.Dao -import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query @@ -11,12 +9,12 @@ import com.bogdan.codeforceswatcher.model.Contest @Dao interface ContestDao { - @Query("SELECT * FROM contest WHERE phase = 'BEFORE'") - fun getUpcomingContests(): LiveData> + @Query("SELECT * FROM contest") + fun getUpcomingContests(): List @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(contests: List) - @Delete - fun deleteAll(contests: List) + @Query("DELETE FROM contest") + fun deleteAll() } diff --git a/app/src/main/java/com/bogdan/codeforceswatcher/fragment/ContestsFragment.kt b/app/src/main/java/com/bogdan/codeforceswatcher/fragment/ContestsFragment.kt deleted file mode 100644 index 589cf2e45..000000000 --- a/app/src/main/java/com/bogdan/codeforceswatcher/fragment/ContestsFragment.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.bogdan.codeforceswatcher.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.lifecycle.Observer -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import com.bogdan.codeforceswatcher.CwApp -import com.bogdan.codeforceswatcher.R -import com.bogdan.codeforceswatcher.adapter.ContestAdapter -import com.bogdan.codeforceswatcher.model.Contest -import com.bogdan.codeforceswatcher.network.RestClient -import com.bogdan.codeforceswatcher.network.model.ContestResponse -import com.bogdan.codeforceswatcher.room.DatabaseClient -import com.bogdan.codeforceswatcher.util.Analytics -import kotlinx.android.synthetic.main.fragment_contests.recyclerView -import kotlinx.android.synthetic.main.fragment_contests.swipeToRefresh -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response - -class ContestsFragment : Fragment(), SwipeRefreshLayout.OnRefreshListener { - - private lateinit var contestAdapter: ContestAdapter - - override fun onRefresh() { - updateContestList() - Analytics.logContestsListRefresh() - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View = inflater.inflate(R.layout.fragment_contests, container, false) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - initViews() - } - - private fun initViews() { - swipeToRefresh.setOnRefreshListener(this) - updateContestList(false) - - contestAdapter = ContestAdapter(listOf(), requireContext()) - recyclerView.adapter = contestAdapter - recyclerView.layoutManager = LinearLayoutManager(requireContext()) - - val liveData = DatabaseClient.contestDao.getUpcomingContests() - liveData.observe(this, Observer> { contestList -> - contestList?.let { contestsList -> contestAdapter.setItems(contestsList.sortedBy(Contest::time)) } - }) - } - - private fun updateContestList(shouldDisplayError: Boolean = true) { - RestClient.getContests().enqueue(object : Callback { - - override fun onResponse( - call: Call, - response: Response - ) { - if (response.body() != null) { - val contestList = response.body()?.result - if (contestList != null) { - DatabaseClient.contestDao.deleteAll(contestList) - DatabaseClient.contestDao.insert(contestList) - } - } - if (activity != null) swipeToRefresh.isRefreshing = false - } - - override fun onFailure(call: Call, t: Throwable) { - if (activity != null) swipeToRefresh.isRefreshing = false - if (shouldDisplayError) - Toast.makeText( - CwApp.app, - getString(R.string.no_internet_connection), - Toast.LENGTH_SHORT - ).show() - } - }) - } -} diff --git a/app/src/main/java/com/bogdan/codeforceswatcher/network/CodeforcesApi.kt b/app/src/main/java/com/bogdan/codeforceswatcher/network/CodeforcesApi.kt index 5d1c4a725..aa7531313 100644 --- a/app/src/main/java/com/bogdan/codeforceswatcher/network/CodeforcesApi.kt +++ b/app/src/main/java/com/bogdan/codeforceswatcher/network/CodeforcesApi.kt @@ -1,6 +1,6 @@ package com.bogdan.codeforceswatcher.network -import com.bogdan.codeforceswatcher.network.model.ContestResponse +import com.bogdan.codeforceswatcher.feature.contests.redux.request.ContestResponse import com.bogdan.codeforceswatcher.network.model.RatingChangeResponse import com.bogdan.codeforceswatcher.network.model.UserResponse import retrofit2.Call diff --git a/app/src/main/java/com/bogdan/codeforceswatcher/redux/AppMiddleware.kt b/app/src/main/java/com/bogdan/codeforceswatcher/redux/AppMiddleware.kt new file mode 100644 index 000000000..f55ce18fa --- /dev/null +++ b/app/src/main/java/com/bogdan/codeforceswatcher/redux/AppMiddleware.kt @@ -0,0 +1,13 @@ +package com.bogdan.codeforceswatcher.redux + +import org.rekotlin.Middleware +import org.rekotlin.StateType + +val appMiddleware: Middleware = { _, _ -> + { next -> + { action -> + (action as? Request)?.execute() + next(action) + } + } +} diff --git a/app/src/main/java/com/bogdan/codeforceswatcher/redux/AppReducer.kt b/app/src/main/java/com/bogdan/codeforceswatcher/redux/AppReducer.kt new file mode 100644 index 000000000..9209ec872 --- /dev/null +++ b/app/src/main/java/com/bogdan/codeforceswatcher/redux/AppReducer.kt @@ -0,0 +1,11 @@ +package com.bogdan.codeforceswatcher.redux + +import com.bogdan.codeforceswatcher.feature.contests.redux.contestsReducer +import org.rekotlin.Action + +fun appReducer(action: Action, state: AppState?): AppState { + requireNotNull(state) + return AppState( + contests = contestsReducer(action, state) + ) +} diff --git a/app/src/main/java/com/bogdan/codeforceswatcher/redux/AppState.kt b/app/src/main/java/com/bogdan/codeforceswatcher/redux/AppState.kt new file mode 100644 index 000000000..00002cf4f --- /dev/null +++ b/app/src/main/java/com/bogdan/codeforceswatcher/redux/AppState.kt @@ -0,0 +1,8 @@ +package com.bogdan.codeforceswatcher.redux + +import com.bogdan.codeforceswatcher.feature.contests.redux.ContestsState +import org.rekotlin.StateType + +data class AppState( + val contests: ContestsState = ContestsState() +) : StateType diff --git a/app/src/main/java/com/bogdan/codeforceswatcher/redux/Request.kt b/app/src/main/java/com/bogdan/codeforceswatcher/redux/Request.kt new file mode 100644 index 000000000..6e9a2862d --- /dev/null +++ b/app/src/main/java/com/bogdan/codeforceswatcher/redux/Request.kt @@ -0,0 +1,8 @@ +package com.bogdan.codeforceswatcher.redux + +import org.rekotlin.Action + +abstract class Request : Action { + + abstract fun execute() +} diff --git a/app/src/main/java/com/bogdan/codeforceswatcher/room/Database.kt b/app/src/main/java/com/bogdan/codeforceswatcher/room/Database.kt index fde3ea2e7..c0cd7329e 100644 --- a/app/src/main/java/com/bogdan/codeforceswatcher/room/Database.kt +++ b/app/src/main/java/com/bogdan/codeforceswatcher/room/Database.kt @@ -3,6 +3,7 @@ package com.bogdan.codeforceswatcher.room import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters +import com.bogdan.codeforceswatcher.feature.contests.room.ContestDao import com.bogdan.codeforceswatcher.model.Contest import com.bogdan.codeforceswatcher.model.User diff --git a/app/src/main/java/com/bogdan/codeforceswatcher/room/RoomController.kt b/app/src/main/java/com/bogdan/codeforceswatcher/room/RoomController.kt new file mode 100644 index 000000000..f7ef07bd2 --- /dev/null +++ b/app/src/main/java/com/bogdan/codeforceswatcher/room/RoomController.kt @@ -0,0 +1,26 @@ +package com.bogdan.codeforceswatcher.room + +import com.bogdan.codeforceswatcher.feature.contests.redux.ContestsState +import com.bogdan.codeforceswatcher.redux.AppState +import com.bogdan.codeforceswatcher.store +import org.rekotlin.StoreSubscriber + +object RoomController : StoreSubscriber { + + fun onAppCreated() { + store.subscribe(this) { + it.skipRepeats { oldState, newState -> + oldState.contests == newState.contests + } + } + } + + fun fetchAppState() = AppState( + contests = ContestsState(contests = DatabaseClient.contestDao.getUpcomingContests()) + ) + + override fun newState(state: AppState) { + DatabaseClient.contestDao.deleteAll() + DatabaseClient.contestDao.insert(state.contests.contests) + } +}