Native Dashboard | Web Lessons |
---|---|
WaniKani is a Japanese-language-learning app that uses a Spaced Repetition System (SRS) to help users learn Japanese Kanji characters.
Leap For Wanikani is an open-source app developed by the community with three main features:
- A Dashboard that syncs your current WaniKani lessons and reviews status to your device
- Push notifications that alert you if you have pending lessons or reviews in your queue
- An in-app browser that takes you directly to your lessons or reviews then back to the app's Dashboard
Leap for WaniKani also has a WaniKani Community forum post.
Leap For Wanikani is available for download on the Google Play Store.
The app follows Android's standard MVVM (Model View ViewModel) architecture and implements a main-safe repository layer with coroutines. This means that asyncronous functions to request local or remote data use suspend fun
instead of LiveData
(or RxJava) in the repository and are only wrapped as observable LiveData
in the ViewModel
.
Let's look at the data flow for a Summary that backs our lessons and reviews cards as well as push notifications.
Make a request to refresh our data (summary, assignments, etc.) from DashboardFragment#onResume
.
override fun onResume() {
super.onResume()
dashboardViewModel.refreshData()
}
Launch the request using the DashboardViewModel#viewModelScope
that will cancel the coroutine automatically once the ViewModel's lifecycle owner (the Fragment) is destroyed.
fun refreshData() {
viewModelScope.launch {
_summary.value = waniKaniRepository.getSummary()
...
}
}
The Repository layer is reponsible for returning local or remote data. Note that WKApiResponse.ApiNotModified
returns local data. (See E-tags and Conditional Requests)
override suspend fun getSummary(): LeapResult<WKReport.Summary> {
return withContext(ioDispatcher) {
return@withContext fetchSummaryRemoteOrLocal()
}
}
private suspend fun fetchSummaryRemoteOrLocal(updatedAfter: Long): LeapResult<WKReport.Summary> {
val remoteSummary = wkRemoteDataSource.getSummaryAsync(updatedAfter)
when (remoteSummary) {
is WKApiResponse.ApiError -> {
Log.w(TAG, "Remote summary source fetch failed")
// Try Local if remote fails
val localSummary = getSummaryFromLocal()
if (localSummary is LeapResult.Success) return localSummary
val exception = createException(remoteSummary.code, "ApiError fetching summary from remote and local")
return LeapResult.Error(exception)
}
is WKApiResponse.ApiNotModified -> {
Log.d(TAG, "Remote summary not modified. Returning local.")
return getSummaryFromLocal()
}
is WKApiResponse.ApiSuccess -> {
Log.d(TAG, "Remote summary success. Returning latest remote.")
refreshLocalSummary(remoteSummary.responseData)
return LeapResult.Success(remoteSummary.responseData)
}
is WKApiResponse.NoConnection -> {
Log.e(TAG, "No connection. Could not fetch fresh summary.")
return LeapResult.Offline
}
else -> throw IllegalStateException()
}
}
private suspend fun getSummaryFromLocal(): LeapResult<WKReport.Summary> {
return wkLocalDataSource.getSummary()
}
private suspend fun getSummaryRemote(): WKApiResponse<WKReport.Summary> {
return wkRemoteDataSource.getSummaryAsync()
}
WKRemoteDataSource
wraps a retrofit Response
with WKApiResponse
to handle modified/not modified API responses.
interface WKRemoteDataSource {
suspend fun getSummaryAsync(): WKApiResponse<WKReport.Summary>
}
The Retrofit WaniKaniApi
implements network requests.
interface WaniKaniApi {
@GET("summary")
suspend fun getSummaryAsync():Response<WKReport.Summary>
}
Async requests to a Room database are routed through WKLocalDataSource
using suspend
functions.
interface WKLocalDataSource {
suspend fun getSummary(): LeapResult<WKReport.Summary>
}
interface WKReportDao {
@Query("SELECT * FROM summary")
suspend fun getSummary(): WKReport.Summary?
The WaniKani API supports conditional requests with e-tags to determine whether or not a user's data has changed since the last time they made a reponse.
If their data has not changed, a 304 Not Modified
response is returned to the app which significantly reduces mobile network usage by eliminating unecessary downloads.
The LiveData<Summary>
emits changes when the local or remote data source is triggered.
val liveDataSummary: LiveData<LeapResult<WKReport.Summary>> =
liveData {
emitSource(_summary)
}
The DashboardFragment
observes the LiveData<WKReport.Summary>
and reacts when a new summary is emitted. It updates the UI based on a LeapResult.Success
,LeapResult.Error
, LeapResult.Loading
, or LeapResult.Offline
response so that a user's state is accurately represented.
dashboardViewModel.liveDataSummary.observe(viewLifecycleOwner, Observer { summary ->
when (summary) {
is LeapResult.Success<WKReport.Summary> -> {
// Lessons are grouped by the hour.
// [0] are the lessons available now, [1] are the lessons in an hour, etc. 24 hours provided.
adapter.bindAvailableStatus(availableStatus, summary.resultData.data.next_reviews_at)
adapter.bindLessonsCount(lessonsCardView, summary.resultData.data.lessons[0].subject_ids.size)
adapter.bindReviewsCount(reviewsCardView, summary.resultData.data.reviews[0].subject_ids.size)
progressBar.visibility = View.VISIBLE
}
is LeapResult.Error -> {
adapter.bindAvailableStatus(availableStatus, null)
adapter.bindLessonsCount(lessonsCardView, 0)
adapter.bindReviewsCount(reviewsCardView, 0)
progressBar.visibility = View.GONE
errorSnackbar.show()
}
is LeapResult.Loading -> {
progressBar.visibility = View.VISIBLE
}
is LeapResult.Offline -> {
progressBar.visibility = View.GONE
}
}
})
See the open issues for a list of proposed features (and known issues).
See the CHANGELOG for a summary of recent changes.
Before starting work, please the see the open issues so that work is not accidentally duplicated.
- Fork the Project
- Create your Feature Branch (
git checkout -b feature/AmazingFeature
) - Commit your Changes (
git commit -m 'Add some AmazingFeature'
) - Push to the Branch (
git push origin feature/AmazingFeature
) - Open a Pull Request
We use SemVer for versioning. For the versions available, see Releases.
Distributed under the GNU General Public License.
In short, this copyleft lisence allows you to use this code in your app as long as you also distribute it as an open-source project under the same GNU GPLv3 license. If you are Tofugu/WaniKani and would like to use it, please contact us.
See LICENSE for more information.