diff --git a/README.md b/README.md index d940650b..af466d89 100644 --- a/README.md +++ b/README.md @@ -31,31 +31,54 @@ Download the client secret file from the Google Cloud Console. This file is necessary in order to retrieve the JWT request token of the user during the login process. -## Versions +## Backlog +The backlog is currently empty. -### Backlog -Empty... +## Outlook & planned features -### Version 4.2 -- [ ] Wishlist for books that are not purchased yet +### Versions 5.x +- [ ] Use Firebase Data for book suggestions +- [ ] Let users suggest favorite books to others +- [ ] Improved search database (Google Books API) lookup query +- [ ] Shockbytes Backup +- [ ] Simplify book management + - [ ] Remove local backup + - [ ] Add online Shockbytes backup as only way to backup data + - [ ] Move Goodreads CSV import from BETA to RELEASE state + - [ ] Switch between online and offline storage (user has full control over the data) +- [ ] Add web client support +- [ ] Paged request when user clicks on "not my book" in book download view ### Version 4.1 -- [ ] Use Firebase Data for book suggestions +- [ ] Wishlist for books that are not purchased yet ### Version 4.0 - CAMPING WITH FIREBASE - [ ] Add online sync capability - [ ] Migrate from local to remote storage - -### Version 3.16 - [ ] Login with Firebase -- [ ] Add Onboarding + Login -### Version 3.15 -- [ ] Statistics pages over time / month + Goal per month -- [ ] Reset page statistics per book -- [ ] Hide page statistics in details page +## Current development + +### Version 3.16 - [ ] Send csv export via Mail - [ ] Move actions into Book item (https://github.com/florent37/ExpansionPanel) +- [ ] Add Onboarding + optional Login +- [ ] Experimental remote storage Firestore implementation (for test account) +- [ ] Fix bug with local book covers + - [ ] In MultiBareBoneBookView (not showing up) + - [ ] In the label overview (too big) + +## Changelog + +### Version 3.15 +* Statistics pages/books over time / month + Goal per month + * Fix issue where MarkerView draws out of ChartView bounds + * Change toolbar behavior in Statistics screen + * Make books per month zoomable + * Fix problems when setting books per month reading goal offset + * Fix issues with pages per month reading goal update +* Reset page statistics per book +* Hide page statistics in details page ### Version 3.14 - SUMMER CLEANUP * Move sort into settings diff --git a/app/build.gradle b/app/build.gradle index 61604272..b7841163 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "at.shockbytes.dante" minSdkVersion 21 targetSdkVersion 30 - versionCode 36 - versionName "3.14" + versionCode 37 + versionName "3.15" multiDexEnabled true vectorDrawables.useSupportLibrary = true @@ -79,7 +79,6 @@ dependencies { implementation "androidx.cardview:cardview:${rootProject.androidXVersion}" implementation "androidx.appcompat:appcompat:$androidXAppCompatVersion" implementation "androidx.core:core:$androidXCoreVersion" - implementation "androidx.preference:preference:$androidXPreferenceVersion" implementation 'androidx.fragment:fragment-ktx:1.2.5' implementation 'androidx.recyclerview:recyclerview:1.1.0' diff --git a/app/src/main/java/at/shockbytes/dante/DanteApp.kt b/app/src/main/java/at/shockbytes/dante/DanteApp.kt index f52707ee..94387e37 100644 --- a/app/src/main/java/at/shockbytes/dante/DanteApp.kt +++ b/app/src/main/java/at/shockbytes/dante/DanteApp.kt @@ -37,7 +37,7 @@ class DanteApp : MultiDexApplication(), CoreComponentProvider { private val coreComponent: CoreComponent by lazy { DaggerCoreComponent.builder() - .coreModule(CoreModule()) + .coreModule(CoreModule(this)) .networkModule(NetworkModule()) .build() } diff --git a/app/src/main/java/at/shockbytes/dante/backup/provider/shockbytes/ShockbytesHerokuServerBackupProvider.kt b/app/src/main/java/at/shockbytes/dante/backup/provider/shockbytes/ShockbytesHerokuServerBackupProvider.kt index 175f054e..96f706be 100644 --- a/app/src/main/java/at/shockbytes/dante/backup/provider/shockbytes/ShockbytesHerokuServerBackupProvider.kt +++ b/app/src/main/java/at/shockbytes/dante/backup/provider/shockbytes/ShockbytesHerokuServerBackupProvider.kt @@ -1,7 +1,6 @@ package at.shockbytes.dante.backup.provider.shockbytes import androidx.fragment.app.FragmentActivity -import at.shockbytes.dante.BuildConfig import at.shockbytes.dante.backup.model.BackupMetadata import at.shockbytes.dante.backup.model.BackupMetadataState import at.shockbytes.dante.backup.model.BackupStorageProvider diff --git a/app/src/main/java/at/shockbytes/dante/flagging/FeatureFlag.kt b/app/src/main/java/at/shockbytes/dante/flagging/FeatureFlag.kt index 1109c759..8a69cfc9 100644 --- a/app/src/main/java/at/shockbytes/dante/flagging/FeatureFlag.kt +++ b/app/src/main/java/at/shockbytes/dante/flagging/FeatureFlag.kt @@ -2,13 +2,12 @@ package at.shockbytes.dante.flagging enum class FeatureFlag(val key: String, val displayName: String, val defaultValue: Boolean) { - BOOK_SUGGESTIONS("book_suggestions", "Suggestions", false), - PAGE_RECORD_STATISTICS("page_record_statistics", "Page records stats", false); + BOOK_SUGGESTIONS("book_suggestions", "Suggestions", false); companion object { fun activeFlags(): List { - return listOf(BOOK_SUGGESTIONS, PAGE_RECORD_STATISTICS) + return listOf(BOOK_SUGGESTIONS) } } } \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/injection/AppComponent.kt b/app/src/main/java/at/shockbytes/dante/injection/AppComponent.kt index 50c1e097..a227d6d6 100644 --- a/app/src/main/java/at/shockbytes/dante/injection/AppComponent.kt +++ b/app/src/main/java/at/shockbytes/dante/injection/AppComponent.kt @@ -10,8 +10,29 @@ import at.shockbytes.dante.ui.activity.LoginActivity import at.shockbytes.dante.ui.activity.MainActivity import at.shockbytes.dante.ui.activity.NotesActivity import at.shockbytes.dante.ui.activity.SearchActivity -import at.shockbytes.dante.ui.activity.TimeLineActivity -import at.shockbytes.dante.ui.fragment.* +import at.shockbytes.dante.ui.fragment.AnnouncementFragment +import at.shockbytes.dante.ui.fragment.BackupBackupFragment +import at.shockbytes.dante.ui.fragment.BackupFragment +import at.shockbytes.dante.ui.fragment.BackupRestoreFragment +import at.shockbytes.dante.ui.fragment.BookDetailFragment +import at.shockbytes.dante.ui.fragment.FeatureFlagConfigFragment +import at.shockbytes.dante.ui.fragment.ImportBooksStorageFragment +import at.shockbytes.dante.ui.fragment.LabelCategoryBottomSheetFragment +import at.shockbytes.dante.ui.fragment.LabelPickerBottomSheetFragment +import at.shockbytes.dante.ui.fragment.LauncherIconPickerFragment +import at.shockbytes.dante.ui.fragment.LoginFragment +import at.shockbytes.dante.ui.fragment.MainBookFragment +import at.shockbytes.dante.ui.fragment.ManualAddFragment +import at.shockbytes.dante.ui.fragment.MenuFragment +import at.shockbytes.dante.ui.fragment.OnlineStorageFragment +import at.shockbytes.dante.ui.fragment.PageRecordsDetailFragment +import at.shockbytes.dante.ui.fragment.PickRandomBookFragment +import at.shockbytes.dante.ui.fragment.RateFragment +import at.shockbytes.dante.ui.fragment.SearchFragment +import at.shockbytes.dante.ui.fragment.SettingsFragment +import at.shockbytes.dante.ui.fragment.StatisticsFragment +import at.shockbytes.dante.ui.fragment.SuggestionsFragment +import at.shockbytes.dante.ui.fragment.TimeLineFragment import at.shockbytes.dante.ui.fragment.dialog.GoogleSignInDialogFragment import at.shockbytes.dante.ui.fragment.dialog.GoogleWelcomeScreenDialogFragment import at.shockbytes.dante.ui.fragment.dialog.SortStrategyDialogFragment @@ -102,7 +123,7 @@ interface AppComponent { fun inject(fragment: ImportBooksStorageFragment) - fun inject(activity: TimeLineActivity) - fun inject(fragment: PickRandomBookFragment) + + fun inject(fragment: PageRecordsDetailFragment) } diff --git a/app/src/main/java/at/shockbytes/dante/injection/AppModule.kt b/app/src/main/java/at/shockbytes/dante/injection/AppModule.kt index 9d787e8f..722a92e2 100644 --- a/app/src/main/java/at/shockbytes/dante/injection/AppModule.kt +++ b/app/src/main/java/at/shockbytes/dante/injection/AppModule.kt @@ -55,8 +55,8 @@ class AppModule(private val app: Application) { @Provides fun provideDanteSettings( - sharedPreferences: SharedPreferences, - schedulers: SchedulerFacade + sharedPreferences: SharedPreferences, + schedulers: SchedulerFacade ): DanteSettings { return DanteSettings(app.applicationContext, sharedPreferences, schedulers) } diff --git a/app/src/main/java/at/shockbytes/dante/injection/ViewModelModule.kt b/app/src/main/java/at/shockbytes/dante/injection/ViewModelModule.kt index 945d111c..526d3ac8 100644 --- a/app/src/main/java/at/shockbytes/dante/injection/ViewModelModule.kt +++ b/app/src/main/java/at/shockbytes/dante/injection/ViewModelModule.kt @@ -15,6 +15,7 @@ import at.shockbytes.dante.ui.viewmodel.LoginViewModel import at.shockbytes.dante.ui.viewmodel.MainViewModel import at.shockbytes.dante.ui.viewmodel.ManualAddViewModel import at.shockbytes.dante.ui.viewmodel.OnlineStorageViewModel +import at.shockbytes.dante.ui.viewmodel.PageRecordsDetailViewModel import at.shockbytes.dante.ui.viewmodel.SearchViewModel import at.shockbytes.dante.ui.viewmodel.StatisticsViewModel import at.shockbytes.dante.ui.viewmodel.TimelineViewModel @@ -127,4 +128,9 @@ abstract class ViewModelModule { @IntoMap @ViewModelKey(LauncherIconPickerViewModel::class) internal abstract fun launcherIconPickerViewModel(viewModel: LauncherIconPickerViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(PageRecordsDetailViewModel::class) + internal abstract fun pageRecordsDetailViewModel(viewModel: PageRecordsDetailViewModel): ViewModel } \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/stats/BookStatsBuilder.kt b/app/src/main/java/at/shockbytes/dante/stats/BookStatsBuilder.kt index a54d99a1..e3fdfa68 100644 --- a/app/src/main/java/at/shockbytes/dante/stats/BookStatsBuilder.kt +++ b/app/src/main/java/at/shockbytes/dante/stats/BookStatsBuilder.kt @@ -1,34 +1,40 @@ package at.shockbytes.dante.stats import android.graphics.Color +import at.shockbytes.dante.R import at.shockbytes.dante.core.bareBone -import at.shockbytes.dante.core.book.* -import at.shockbytes.dante.flagging.FeatureFlag -import at.shockbytes.dante.flagging.FeatureFlagging +import at.shockbytes.dante.core.book.BareBoneBook +import at.shockbytes.dante.core.book.BookEntity +import at.shockbytes.dante.core.book.BookState +import at.shockbytes.dante.core.book.Languages +import at.shockbytes.dante.core.book.PageRecord +import at.shockbytes.dante.core.book.ReadingGoal import at.shockbytes.dante.ui.adapter.stats.model.LabelStatsItem +import at.shockbytes.dante.ui.custom.bookspages.BooksAndPageRecordDataPoint import at.shockbytes.util.AppUtils import org.joda.time.DateTime import org.joda.time.Duration import org.joda.time.Months +import org.joda.time.format.DateTimeFormat -class BookStatsBuilder(private val featureFlagging: FeatureFlagging) { +object BookStatsBuilder { - fun createFrom( - books: List, - pageRecords: List + fun build( + books: List, + pageRecords: List, + pagesPerMonthGoal: ReadingGoal.PagesPerMonthReadingGoal, + booksPerMonthGoal: ReadingGoal.BooksPerMonthReadingGoal ): List { return mutableListOf( createBooksAndPagesItem(books), + createPagesOverTimeItem(pageRecords, pagesPerMonthGoal), + createBooksOverTimeItem(books, booksPerMonthGoal), createReadingDurationItem(books), createFavoriteItem(books), createLanguageItem(books), createLabelItem(books), createOthersItem(books) - ).apply { - if (featureFlagging[FeatureFlag.PAGE_RECORD_STATISTICS]) { - this.add(1, createPagesOverTimeItem(pageRecords)) - } - } + ) } private fun createBooksAndPagesItem(books: List): BookStatsViewItem { @@ -63,11 +69,65 @@ class BookStatsBuilder(private val featureFlagging: FeatureFlagging) { ) } - private fun createPagesOverTimeItem(pageRecords: List): BookStatsViewItem { - // TODO Implement this method later... - return BookStatsViewItem.PagesOverTime.Empty + private fun createPagesOverTimeItem( + pageRecords: List, + pagesPerMonthGoal: ReadingGoal.PagesPerMonthReadingGoal + ): BookStatsViewItem.BooksAndPagesOverTime { + + if (pageRecords.isEmpty()) { + return BookStatsViewItem.BooksAndPagesOverTime.Empty(R.string.statistics_header_pages_over_time) + } + val format = DateTimeFormat.forPattern("MMM yy") + + return pageRecords + .groupBy { record -> + val dt = record.dateTime + MonthYear(dt.monthOfYear, dt.year) + } + .toSortedMap() + .map { (monthYear, records) -> + + val pages = records + .sumBy { it.diffPages } + // There can be negative values, hard bounce them at 0 + // Example: User logs 100 pages in July but deletes 20 pages in August + // which leads to a value of -20. This should not happen! + .coerceAtLeast(0) + + BooksAndPageRecordDataPoint(pages, formattedDate = format.print(monthYear.dateTime)) + } + .let { pageRecordDataPoints -> + BookStatsViewItem.BooksAndPagesOverTime.Present.Pages(pageRecordDataPoints, pagesPerMonthGoal) + } } + private fun createBooksOverTimeItem( + books: List, + booksPerMonthGoal: ReadingGoal.BooksPerMonthReadingGoal + ): BookStatsViewItem.BooksAndPagesOverTime { + + if (books.isEmpty()) { + return BookStatsViewItem.BooksAndPagesOverTime.Empty(R.string.statistics_header_books_over_time) + } + val format = DateTimeFormat.forPattern("MMM yy") + + return books + .filter { it.state == BookState.READ } + .groupBy { book -> + val dt = DateTime(book.endDate) + MonthYear(dt.monthOfYear, dt.year) + } + .toSortedMap() + .map { (monthYear, booksPerMonth) -> + BooksAndPageRecordDataPoint( + value = booksPerMonth.count(), + formattedDate = format.print(monthYear.dateTime) + ) + } + .let { pageRecordDataPoints -> + BookStatsViewItem.BooksAndPagesOverTime.Present.Books(pageRecordDataPoints, booksPerMonthGoal) + } + } private fun createReadingDurationItem(books: List): BookStatsViewItem { @@ -108,7 +168,6 @@ class BookStatsBuilder(private val featureFlagging: FeatureFlagging) { private fun favoriteAuthor(books: List): FavoriteAuthor? { return books - .asSequence() .groupBy { book -> book.author } @@ -150,8 +209,13 @@ class BookStatsBuilder(private val featureFlagging: FeatureFlagging) { val labels = books.asSequence() .map { it.labels } .flatten() - .groupBy { LabelStatsItem(it.title, Color.parseColor(it.hexColor)) } + .groupBy { Pair(it.title, it.hexColor) } .mapValues { it.value.size } + .map { (labelPair, size) -> + val (title, hexColor) = labelPair + LabelStatsItem(title, Color.parseColor(hexColor), size) + } + .sortedBy { it.size } return if (labels.isEmpty()) { BookStatsViewItem.LabelStats.Empty diff --git a/app/src/main/java/at/shockbytes/dante/stats/BookStatsViewItem.kt b/app/src/main/java/at/shockbytes/dante/stats/BookStatsViewItem.kt index a04c8af7..955ad71d 100644 --- a/app/src/main/java/at/shockbytes/dante/stats/BookStatsViewItem.kt +++ b/app/src/main/java/at/shockbytes/dante/stats/BookStatsViewItem.kt @@ -1,10 +1,13 @@ package at.shockbytes.dante.stats import androidx.annotation.LayoutRes +import androidx.annotation.StringRes import at.shockbytes.dante.R import at.shockbytes.dante.core.book.BareBoneBook import at.shockbytes.dante.core.book.Languages +import at.shockbytes.dante.core.book.ReadingGoal import at.shockbytes.dante.ui.adapter.stats.model.LabelStatsItem +import at.shockbytes.dante.ui.custom.bookspages.BooksAndPageRecordDataPoint sealed class BookStatsViewItem { @@ -20,13 +23,24 @@ sealed class BookStatsViewItem { data class Present(val booksAndPages: BooksPagesInfo) : BooksAndPages() } - sealed class PagesOverTime : BookStatsViewItem() { + sealed class BooksAndPagesOverTime : BookStatsViewItem() { override val layoutId: Int = R.layout.item_stats_pages_over_time - object Empty : PagesOverTime() + data class Empty(@StringRes val headerRes: Int) : BooksAndPagesOverTime() - data class Present(val pagesPerMonths: List) : PagesOverTime() + sealed class Present : BooksAndPagesOverTime() { + + data class Pages( + val pagesPerMonths: List, + val readingGoal: ReadingGoal.PagesPerMonthReadingGoal + ) : Present() + + data class Books( + val booksPerMonths: List, + val readingGoal: ReadingGoal.BooksPerMonthReadingGoal + ) : Present() + } } sealed class ReadingDuration : BookStatsViewItem() { @@ -70,9 +84,7 @@ sealed class BookStatsViewItem { object Empty : LabelStats() - data class Present( - val labels: Map - ) : LabelStats() + data class Present(val labels: List) : LabelStats() } sealed class Others : BookStatsViewItem() { diff --git a/app/src/main/java/at/shockbytes/dante/stats/MonthYear.kt b/app/src/main/java/at/shockbytes/dante/stats/MonthYear.kt index d0eece32..82b360ae 100644 --- a/app/src/main/java/at/shockbytes/dante/stats/MonthYear.kt +++ b/app/src/main/java/at/shockbytes/dante/stats/MonthYear.kt @@ -1,3 +1,17 @@ package at.shockbytes.dante.stats -data class MonthYear(val month: Int, val year: Int) \ No newline at end of file +import org.joda.time.DateTime + +data class MonthYear(val month: Int, val year: Int) : Comparable { + + val dateTime: DateTime + get() = DateTime(year, month, 1, 0, 0, 0) + + override fun compareTo(other: MonthYear): Int { + return if (year - other.year == 0) { + month - other.month + } else { + year - other.year + } + } +} \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/stats/PagesPerMonth.kt b/app/src/main/java/at/shockbytes/dante/stats/PagesPerMonth.kt deleted file mode 100644 index 40dd61e7..00000000 --- a/app/src/main/java/at/shockbytes/dante/stats/PagesPerMonth.kt +++ /dev/null @@ -1,6 +0,0 @@ -package at.shockbytes.dante.stats - -data class PagesPerMonth( - val pages: Int, - val date: MonthYear -) \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/ui/activity/MainActivity.kt b/app/src/main/java/at/shockbytes/dante/ui/activity/MainActivity.kt index b26666a7..78970af4 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/activity/MainActivity.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/activity/MainActivity.kt @@ -31,9 +31,15 @@ import at.shockbytes.dante.util.settings.DanteSettings import at.shockbytes.dante.flagging.FeatureFlag import at.shockbytes.dante.navigation.Destination import at.shockbytes.dante.ui.fragment.AnnouncementFragment -import at.shockbytes.dante.util.* +import at.shockbytes.dante.util.DanteUtils +import at.shockbytes.dante.util.ExceptionHandlers +import at.shockbytes.dante.util.addTo +import at.shockbytes.dante.util.retrieveActiveActivityAlias +import at.shockbytes.dante.util.runDelayed import at.shockbytes.dante.util.settings.LauncherIconState import at.shockbytes.dante.util.settings.ThemeState +import at.shockbytes.dante.util.toggle +import at.shockbytes.dante.util.viewModelOf import at.shockbytes.util.AppUtils import io.reactivex.android.schedulers.AndroidSchedulers import kotlinx.android.synthetic.main.activity_main.* diff --git a/app/src/main/java/at/shockbytes/dante/ui/activity/ManualAddActivity.kt b/app/src/main/java/at/shockbytes/dante/ui/activity/ManualAddActivity.kt index c3f85969..de6d84d9 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/activity/ManualAddActivity.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/activity/ManualAddActivity.kt @@ -31,7 +31,6 @@ class ManualAddActivity : ContainerTintableBackNavigableActivity() { companion object { - const val ACTION_BOOK_UPDATED = "action_book_updated" const val EXTRA_UPDATED_BOOK_STATE = "extra_updated_book_state" private const val ARG_BOOK_ENTITY_UPDATE = "arg_book_entity_update" diff --git a/app/src/main/java/at/shockbytes/dante/ui/activity/StatisticsActivity.kt b/app/src/main/java/at/shockbytes/dante/ui/activity/StatisticsActivity.kt index 8573bb7c..e21274c0 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/activity/StatisticsActivity.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/activity/StatisticsActivity.kt @@ -2,20 +2,20 @@ package at.shockbytes.dante.ui.activity import android.content.Context import android.content.Intent -import at.shockbytes.dante.injection.AppComponent -import at.shockbytes.dante.ui.activity.core.ContainerBackNavigableActivity +import android.os.Bundle +import at.shockbytes.dante.ui.activity.core.ContainerActivity import at.shockbytes.dante.ui.fragment.StatisticsFragment -class StatisticsActivity : ContainerBackNavigableActivity() { +class StatisticsActivity : ContainerActivity() { override val displayFragment = StatisticsFragment.newInstance() - override fun injectToGraph(appComponent: AppComponent) = Unit + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + supportActionBar?.hide() + } companion object { - - fun newIntent(context: Context): Intent { - return Intent(context, StatisticsActivity::class.java) - } + fun newIntent(context: Context): Intent = Intent(context, StatisticsActivity::class.java) } } \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/ui/activity/TimeLineActivity.kt b/app/src/main/java/at/shockbytes/dante/ui/activity/TimeLineActivity.kt index 3f8d81a0..8bd8b4b0 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/activity/TimeLineActivity.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/activity/TimeLineActivity.kt @@ -3,73 +3,20 @@ package at.shockbytes.dante.ui.activity import android.content.Context import android.content.Intent import android.os.Bundle -import android.view.Menu -import android.view.MenuItem import androidx.fragment.app.Fragment -import androidx.lifecycle.ViewModelProvider -import at.shockbytes.dante.R -import at.shockbytes.dante.injection.AppComponent -import at.shockbytes.dante.ui.activity.core.ContainerBackNavigableActivity +import at.shockbytes.dante.ui.activity.core.ContainerActivity import at.shockbytes.dante.ui.fragment.TimeLineFragment -import at.shockbytes.dante.ui.viewmodel.TimelineViewModel -import at.shockbytes.dante.util.getStringList -import at.shockbytes.dante.util.viewModelOf -import at.shockbytes.util.AppUtils -import com.afollestad.materialdialogs.MaterialDialog -import com.afollestad.materialdialogs.list.listItemsSingleChoice -import javax.inject.Inject -class TimeLineActivity : ContainerBackNavigableActivity() { +class TimeLineActivity : ContainerActivity() { - @Inject - lateinit var vmFactory: ViewModelProvider.Factory - - private lateinit var viewModel: TimelineViewModel - - override val displayFragment: Fragment - get() = TimeLineFragment.newInstance() + override val displayFragment: Fragment = TimeLineFragment.newInstance() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - viewModel = viewModelOf(vmFactory) - } - - override fun injectToGraph(appComponent: AppComponent) { - appComponent.inject(this) - } - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.menu_timeline, menu) - return super.onCreateOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - - if (item.itemId == R.id.menu_timeline_sort_by) { - MaterialDialog(this) - .title(R.string.dialogfragment_sort_by) - .message(R.string.timeline_sort_explanation) - .listItemsSingleChoice( - items = getStringList(R.array.sort_timeline), - initialSelection = viewModel.selectedTimeLineSortStrategyIndex - ) { _, index, _ -> - viewModel.updateSortStrategy(index) - } - .icon(R.drawable.ic_timeline_sort) - .cornerRadius(AppUtils.convertDpInPixel(6, this).toFloat()) - .cancelOnTouchOutside(true) - .positiveButton(R.string.apply) { - it.dismiss() - } - .show() - } - - return super.onOptionsItemSelected(item) + supportActionBar?.hide() } companion object { - - fun newIntent(context: Context): Intent { - return Intent(context, TimeLineActivity::class.java) - } + fun newIntent(context: Context) = Intent(context, TimeLineActivity::class.java) } } \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/ui/activity/core/ActivityTransition.kt b/app/src/main/java/at/shockbytes/dante/ui/activity/core/ActivityTransition.kt index 32a73f9a..10b62aa1 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/activity/core/ActivityTransition.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/activity/core/ActivityTransition.kt @@ -5,16 +5,16 @@ import android.transition.Fade import android.transition.Transition data class ActivityTransition( - val enterTransition: Transition, - val exitTransition: Transition + val enterTransition: Transition, + val exitTransition: Transition ) { companion object { fun default(): ActivityTransition { return ActivityTransition( - enterTransition = Explode(), - exitTransition = Fade() + enterTransition = Explode(), + exitTransition = Fade() ) } } diff --git a/app/src/main/java/at/shockbytes/dante/ui/activity/core/ContainerActivity.kt b/app/src/main/java/at/shockbytes/dante/ui/activity/core/ContainerActivity.kt new file mode 100644 index 00000000..44e18d5b --- /dev/null +++ b/app/src/main/java/at/shockbytes/dante/ui/activity/core/ContainerActivity.kt @@ -0,0 +1,20 @@ +package at.shockbytes.dante.ui.activity.core + +import android.os.Bundle +import androidx.fragment.app.Fragment +import at.shockbytes.dante.injection.AppComponent + +abstract class ContainerActivity : BaseActivity() { + + abstract val displayFragment: Fragment + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + supportFragmentManager.beginTransaction() + .replace(android.R.id.content, displayFragment) + .commit() + } + + override fun injectToGraph(appComponent: AppComponent) = Unit +} \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/ui/adapter/BackupEntryAdapter.kt b/app/src/main/java/at/shockbytes/dante/ui/adapter/BackupEntryAdapter.kt index 506de4a6..680cab22 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/adapter/BackupEntryAdapter.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/adapter/BackupEntryAdapter.kt @@ -53,7 +53,7 @@ class BackupEntryAdapter( item_backup_entry_imgview_provider.setImageResource(storageProvider.icon) item_backup_entry_txt_time.text = DanteUtils.formatTimestamp(timestamp) - item_backup_entry_txt_books.text = context.getString(R.string.backup_books_amount, books) + item_backup_entry_txt_books.text = context.getString(R.string.books_amount, books) item_backup_entry_txt_device.text = device if (content is BackupMetadataState.Active) { diff --git a/app/src/main/java/at/shockbytes/dante/ui/adapter/main/BookAdapter.kt b/app/src/main/java/at/shockbytes/dante/ui/adapter/main/BookAdapter.kt index 04b1d70e..695f4205 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/adapter/main/BookAdapter.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/adapter/main/BookAdapter.kt @@ -16,13 +16,13 @@ import java.util.Collections * Date: 30.12.2017 */ class BookAdapter( - context: Context, - private val imageLoader: ImageLoader, - private val onOverflowActionClickedListener: (BookEntity) -> Unit, - private val onLabelClickedListener: (BookLabel) -> Unit, - private val randomPickCallback: RandomPickCallback, - onItemClickListener: OnItemClickListener, - onItemMoveListener: OnItemMoveListener + context: Context, + private val imageLoader: ImageLoader, + private val onOverflowActionClickedListener: (BookEntity) -> Unit, + private val onLabelClickedListener: (BookLabel) -> Unit, + private val randomPickCallback: RandomPickCallback, + onItemClickListener: OnItemClickListener, + onItemMoveListener: OnItemMoveListener ) : BaseAdapter( context, onItemClickListener = onItemClickListener, @@ -47,16 +47,16 @@ class BookAdapter( BookAdapterEntity.VIEW_TYPE_BOOK -> { BookViewHolder.forParent( - parent, - imageLoader, - onOverflowActionClickedListener, - onLabelClickedListener + parent, + imageLoader, + onOverflowActionClickedListener, + onLabelClickedListener ) } BookAdapterEntity.VIEW_TYPE_RANDOM_PICK -> { RandomPickViewHolder.forParent( - parent, - randomPickCallback + parent, + randomPickCallback ) } else -> throw IllegalStateException("Unknown view type $viewType") @@ -98,6 +98,4 @@ class BookAdapter( diffResult.dispatchUpdatesTo(this) } - - } \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/ui/adapter/main/BookAdapterEntity.kt b/app/src/main/java/at/shockbytes/dante/ui/adapter/main/BookAdapterEntity.kt index 6ba79373..0abbe3be 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/adapter/main/BookAdapterEntity.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/adapter/main/BookAdapterEntity.kt @@ -8,8 +8,8 @@ sealed class BookAdapterEntity { abstract val viewType: Int data class Book( - val bookEntity: BookEntity, - override val viewType: Int = VIEW_TYPE_BOOK + val bookEntity: BookEntity, + override val viewType: Int = VIEW_TYPE_BOOK ) : BookAdapterEntity() { override val id: Long diff --git a/app/src/main/java/at/shockbytes/dante/ui/adapter/main/BookViewHolder.kt b/app/src/main/java/at/shockbytes/dante/ui/adapter/main/BookViewHolder.kt index a0000bf0..2ea75f84 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/adapter/main/BookViewHolder.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/adapter/main/BookViewHolder.kt @@ -20,10 +20,10 @@ import kotlinx.android.extensions.LayoutContainer import kotlinx.android.synthetic.main.item_book.* class BookViewHolder( - override val containerView: View, - private val imageLoader: ImageLoader, - private val onOverflowActionClickedListener: (BookEntity) -> Unit, - private val onLabelClickedListener: (BookLabel) -> Unit + override val containerView: View, + private val imageLoader: ImageLoader, + private val onOverflowActionClickedListener: (BookEntity) -> Unit, + private val onLabelClickedListener: (BookLabel) -> Unit ) : BaseAdapter.ViewHolder(containerView), LayoutContainer { private fun context(): Context = containerView.context @@ -44,10 +44,10 @@ class BookViewHolder( val isNightModeEnabled = context().isNightModeEnabled() labels - .map { label -> - buildChipViewFromLabel(label, isNightModeEnabled) - } - .forEach(chips_item_book_label::addView) + .map { label -> + buildChipViewFromLabel(label, isNightModeEnabled) + } + .forEach(chips_item_book_label::addView) } private fun buildChipViewFromLabel(label: BookLabel, isNightModeEnabled: Boolean): Chip { @@ -80,8 +80,8 @@ class BookViewHolder( if (showProgress) { val progress = DanteUtils.computePercentage( - t.currentPage.toDouble(), - t.pageCount.toDouble() + t.currentPage.toDouble(), + t.pageCount.toDouble() ) animateBookProgress(progress) item_book_tv_progress.text = context().getString(R.string.percentage_formatter, progress) @@ -97,10 +97,10 @@ class BookViewHolder( private fun updateImageThumbnail(address: String?) { if (!address.isNullOrEmpty()) { imageLoader.loadImageWithCornerRadius( - context(), - address, - item_book_img_thumb, - cornerDimension = context().resources.getDimension(R.dimen.thumbnail_rounded_corner).toInt() + context(), + address, + item_book_img_thumb, + cornerDimension = context().resources.getDimension(R.dimen.thumbnail_rounded_corner).toInt() ) } else { // Books with no image will recycle another cover if not cleared here @@ -120,16 +120,16 @@ class BookViewHolder( companion object { fun forParent( - parent: ViewGroup, - imageLoader: ImageLoader, - onOverflowActionClickedListener: (BookEntity) -> Unit, - onLabelClickedListener: (BookLabel) -> Unit + parent: ViewGroup, + imageLoader: ImageLoader, + onOverflowActionClickedListener: (BookEntity) -> Unit, + onLabelClickedListener: (BookLabel) -> Unit ): BookViewHolder { return BookViewHolder( - LayoutInflater.from(parent.context).inflate(R.layout.item_book, parent, false), - imageLoader, - onOverflowActionClickedListener, - onLabelClickedListener + LayoutInflater.from(parent.context).inflate(R.layout.item_book, parent, false), + imageLoader, + onOverflowActionClickedListener, + onLabelClickedListener ) } } diff --git a/app/src/main/java/at/shockbytes/dante/ui/adapter/main/RandomPickViewHolder.kt b/app/src/main/java/at/shockbytes/dante/ui/adapter/main/RandomPickViewHolder.kt index 25dc6ead..5d997aab 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/adapter/main/RandomPickViewHolder.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/adapter/main/RandomPickViewHolder.kt @@ -9,8 +9,8 @@ import kotlinx.android.extensions.LayoutContainer import kotlinx.android.synthetic.main.item_random_pick.* class RandomPickViewHolder( - override val containerView: View, - private val callback: RandomPickCallback + override val containerView: View, + private val callback: RandomPickCallback ) : BaseAdapter.ViewHolder(containerView), LayoutContainer { override fun bindToView(content: BookAdapterEntity, position: Int) { @@ -27,12 +27,12 @@ class RandomPickViewHolder( companion object { fun forParent( - parent: ViewGroup, - callback: RandomPickCallback - ) : RandomPickViewHolder { + parent: ViewGroup, + callback: RandomPickCallback + ): RandomPickViewHolder { return RandomPickViewHolder( - containerView = LayoutInflater.from(parent.context).inflate(R.layout.item_random_pick, parent, false), - callback = callback + containerView = LayoutInflater.from(parent.context).inflate(R.layout.item_random_pick, parent, false), + callback = callback ) } } diff --git a/app/src/main/java/at/shockbytes/dante/ui/adapter/pagerecords/PageRecordDetailItem.kt b/app/src/main/java/at/shockbytes/dante/ui/adapter/pagerecords/PageRecordDetailItem.kt new file mode 100644 index 00000000..4615a080 --- /dev/null +++ b/app/src/main/java/at/shockbytes/dante/ui/adapter/pagerecords/PageRecordDetailItem.kt @@ -0,0 +1,9 @@ +package at.shockbytes.dante.ui.adapter.pagerecords + +import at.shockbytes.dante.core.book.PageRecord + +data class PageRecordDetailItem( + val pageRecord: PageRecord, + val formattedPagesRead: String, + val formattedDate: String +) \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/ui/adapter/pagerecords/PageRecordsAdapter.kt b/app/src/main/java/at/shockbytes/dante/ui/adapter/pagerecords/PageRecordsAdapter.kt new file mode 100644 index 00000000..71424d83 --- /dev/null +++ b/app/src/main/java/at/shockbytes/dante/ui/adapter/pagerecords/PageRecordsAdapter.kt @@ -0,0 +1,48 @@ +package at.shockbytes.dante.ui.adapter.pagerecords + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import at.shockbytes.dante.R +import at.shockbytes.dante.core.book.PageRecord +import at.shockbytes.util.adapter.BaseAdapter +import kotlinx.android.extensions.LayoutContainer +import kotlinx.android.synthetic.main.item_page_records_detail.* + +class PageRecordsAdapter( + context: Context, + private val onItemDeletedListener: (PageRecord) -> Unit +) : BaseAdapter(context) { + + fun updateData(updatedData: List) { + + data.clear() + data.addAll(updatedData) + + notifyDataSetChanged() + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): ViewHolder { + val view = inflater.inflate(R.layout.item_page_records_detail, parent, false) + return PageRecordsViewHolder(view) + } + + inner class PageRecordsViewHolder( + override val containerView: View + ) : BaseAdapter.ViewHolder(containerView), LayoutContainer { + + override fun bindToView(content: PageRecordDetailItem, position: Int) { + with(content) { + tv_item_page_records_detail_date.text = formattedDate + tv_item_page_records_detail_pages.text = formattedPagesRead + + btn_item_page_records_detail_delete.setOnClickListener { + onItemDeletedListener(pageRecord) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/StatsAdapter.kt b/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/StatsAdapter.kt index 5a17bbae..3dd0c58e 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/StatsAdapter.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/StatsAdapter.kt @@ -4,12 +4,14 @@ import android.content.Context import android.view.LayoutInflater import at.shockbytes.dante.core.image.ImageLoader import at.shockbytes.dante.stats.BookStatsViewItem +import at.shockbytes.dante.ui.adapter.stats.model.ReadingGoalType import at.shockbytes.util.adapter.MultiViewHolderBaseAdapter import at.shockbytes.util.adapter.ViewHolderTypeFactory class StatsAdapter( context: Context, - private val imageLoader: ImageLoader + private val imageLoader: ImageLoader, + private val onChangeGoalActionListener: (ReadingGoalType) -> Unit ) : MultiViewHolderBaseAdapter(context) { fun updateData(items: List) { @@ -20,5 +22,5 @@ class StatsAdapter( } override val vhFactory: ViewHolderTypeFactory - get() = StatsViewHolderFactory(LayoutInflater.from(context), imageLoader) + get() = StatsViewHolderFactory(LayoutInflater.from(context), imageLoader, onChangeGoalActionListener) } \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/StatsViewHolderFactory.kt b/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/StatsViewHolderFactory.kt index 08669cf0..6dd4118c 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/StatsViewHolderFactory.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/StatsViewHolderFactory.kt @@ -5,13 +5,21 @@ import android.view.ViewGroup import at.shockbytes.dante.R import at.shockbytes.dante.core.image.ImageLoader import at.shockbytes.dante.stats.BookStatsViewItem -import at.shockbytes.dante.ui.adapter.stats.viewholder.* +import at.shockbytes.dante.ui.adapter.stats.model.ReadingGoalType +import at.shockbytes.dante.ui.adapter.stats.viewholder.BookStatsBookAndPagesViewHolder +import at.shockbytes.dante.ui.adapter.stats.viewholder.BookStatsFavoritesViewHolder +import at.shockbytes.dante.ui.adapter.stats.viewholder.BookStatsLabelsViewHolder +import at.shockbytes.dante.ui.adapter.stats.viewholder.BookStatsLanguageViewHolder +import at.shockbytes.dante.ui.adapter.stats.viewholder.BookStatsOthersViewHolder +import at.shockbytes.dante.ui.adapter.stats.viewholder.BookStatsPagesOverTimeViewHolder +import at.shockbytes.dante.ui.adapter.stats.viewholder.BookStatsReadingDurationViewHolder import at.shockbytes.util.adapter.BaseAdapter import at.shockbytes.util.adapter.ViewHolderTypeFactory class StatsViewHolderFactory( private val inflater: LayoutInflater, - private val imageLoader: ImageLoader + private val imageLoader: ImageLoader, + private val onChangeGoalActionListener: (ReadingGoalType) -> Unit ) : ViewHolderTypeFactory { override fun type(item: BookStatsViewItem): Int { @@ -26,7 +34,7 @@ class StatsViewHolderFactory( R.layout.item_stats_languages -> BookStatsLanguageViewHolder(inflater.inflate(viewType, parent, false)) R.layout.item_stats_others -> BookStatsOthersViewHolder(inflater.inflate(viewType, parent, false)) R.layout.item_stats_labels -> BookStatsLabelsViewHolder(inflater.inflate(viewType, parent, false)) - R.layout.item_stats_pages_over_time -> BookStatsPagesOverTimeViewHolder(inflater.inflate(viewType, parent, false)) + R.layout.item_stats_pages_over_time -> BookStatsPagesOverTimeViewHolder(inflater.inflate(viewType, parent, false), onChangeGoalActionListener) else -> throw IllegalStateException("Unknown view type $viewType") } } diff --git a/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/model/LabelStatsItem.kt b/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/model/LabelStatsItem.kt index 487be5c7..cf522fe6 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/model/LabelStatsItem.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/model/LabelStatsItem.kt @@ -4,5 +4,6 @@ import androidx.annotation.ColorInt data class LabelStatsItem( val title: String, - @ColorInt val color: Int + @ColorInt val color: Int, + val size: Int ) \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/model/ReadingGoalType.kt b/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/model/ReadingGoalType.kt new file mode 100644 index 00000000..3f8c7cba --- /dev/null +++ b/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/model/ReadingGoalType.kt @@ -0,0 +1,27 @@ +package at.shockbytes.dante.ui.adapter.stats.model + +import androidx.annotation.StringRes +import at.shockbytes.dante.R + +enum class ReadingGoalType( + @StringRes val title: Int, + @StringRes val labelTemplate: Int, + val sliderValueTo: Float, + val sliderValueFrom: Float, + val sliderStepSize: Float +) { + PAGES( + R.string.reading_goal_pages, + R.string.pages_formatted, + sliderValueFrom = 30f, + sliderValueTo = 3000f, + sliderStepSize = 10f + ), + BOOKS( + R.string.reading_goal_books, + R.string.books_formatted, + sliderValueFrom = 1f, + sliderValueTo = 30f, + sliderStepSize = 1f + ); +} \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/viewholder/BookStatsLabelsViewHolder.kt b/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/viewholder/BookStatsLabelsViewHolder.kt index 85b15bf2..6fee812e 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/viewholder/BookStatsLabelsViewHolder.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/viewholder/BookStatsLabelsViewHolder.kt @@ -2,6 +2,7 @@ package at.shockbytes.dante.ui.adapter.stats.viewholder import android.view.View import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat import at.shockbytes.dante.R import at.shockbytes.dante.stats.BookStatsViewItem import at.shockbytes.dante.ui.adapter.stats.model.LabelStatsItem @@ -38,16 +39,16 @@ class BookStatsLabelsViewHolder( chart_item_stats_labels.setVisible(false) } - private fun showLabelsCharts(labels: Map) { + private fun showLabelsCharts(labels: List) { item_stats_labels_empty.setVisible(false) chart_item_stats_labels.setVisible(true) - val entries = labels.values.mapIndexed { index, count -> - BarEntry(index.toFloat(), count.toFloat()) + val entries = labels.mapIndexed { index, item -> + BarEntry(index.toFloat(), item.size.toFloat()) } val barDataSet = BarDataSet(entries, "").apply { - setColors(*labels.map { it.key.color }.toIntArray()) + setColors(*labels.map { it.color }.toIntArray()) setDrawValues(false) setDrawIcons(false) } @@ -69,20 +70,24 @@ class BookStatsLabelsViewHolder( setDrawGridLines(false) setDrawAxisLine(false) setDrawGridBackground(false) + typeface = ResourcesCompat.getFont(context, R.font.montserrat) textColor = ContextCompat.getColor(containerView.context, R.color.colorPrimaryText) - valueFormatter = IndexAxisValueFormatter(labels.map { it.key.title }) + valueFormatter = IndexAxisValueFormatter(labels.map { it.title }) } getAxis(YAxis.AxisDependency.LEFT).apply { isEnabled = false + typeface = ResourcesCompat.getFont(context, R.font.montserrat) setDrawAxisLine(false) setDrawGridLines(false) setDrawZeroLine(false) setDrawAxisLine(false) } + getAxis(YAxis.AxisDependency.RIGHT).apply { isEnabled = true setDrawAxisLine(false) + typeface = ResourcesCompat.getFont(context, R.font.montserrat) textColor = ContextCompat.getColor(containerView.context, R.color.colorPrimaryText) } diff --git a/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/viewholder/BookStatsLanguageViewHolder.kt b/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/viewholder/BookStatsLanguageViewHolder.kt index ec3a9b33..78d94d93 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/viewholder/BookStatsLanguageViewHolder.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/viewholder/BookStatsLanguageViewHolder.kt @@ -2,9 +2,12 @@ package at.shockbytes.dante.ui.adapter.stats.viewholder import android.view.View import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat import at.shockbytes.dante.R import at.shockbytes.dante.core.book.Languages import at.shockbytes.dante.stats.BookStatsViewItem +import at.shockbytes.dante.ui.custom.DanteMarkerView +import at.shockbytes.dante.ui.custom.bookspages.MarkerViewLabelFactory import at.shockbytes.dante.util.setVisible import at.shockbytes.util.adapter.BaseAdapter import com.github.mikephil.charting.components.Legend @@ -44,7 +47,7 @@ class BookStatsLanguageViewHolder( val entries = languages.map { (language, books) -> val title = containerView.context.getString(language.title) - val iconDrawable = containerView.context.getDrawable(language.image) + val iconDrawable = ContextCompat.getDrawable(containerView.context, language.image) PieEntry(books.toFloat(), title, iconDrawable) } @@ -62,8 +65,7 @@ class BookStatsLanguageViewHolder( description.isEnabled = false setUsePercentValues(true) setDrawEntryLabels(false) - setTouchEnabled(false) - isRotationEnabled = false + isRotationEnabled = true legend.apply { isWordWrapEnabled = true @@ -75,11 +77,20 @@ class BookStatsLanguageViewHolder( textColor = ContextCompat.getColor(context, R.color.colorSecondaryText) form = Legend.LegendForm.CIRCLE textSize = 13f + typeface = ResourcesCompat.getFont(context, R.font.montserrat) orientation = Legend.LegendOrientation.HORIZONTAL isWordWrapEnabled = true setDrawInside(false) } + setTouchEnabled(true) + setDrawMarkers(true) + marker = DanteMarkerView( + context, + chart_item_stats_language, + MarkerViewLabelFactory.forPlainEntries(R.string.books_amount) + ) + data = PieData(pieDataSet) animateXY(400, 400) invalidate() diff --git a/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/viewholder/BookStatsPagesOverTimeViewHolder.kt b/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/viewholder/BookStatsPagesOverTimeViewHolder.kt index a78d9031..066658a4 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/viewholder/BookStatsPagesOverTimeViewHolder.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/adapter/stats/viewholder/BookStatsPagesOverTimeViewHolder.kt @@ -1,37 +1,98 @@ package at.shockbytes.dante.ui.adapter.stats.viewholder import android.view.View +import androidx.annotation.StringRes +import at.shockbytes.dante.R import at.shockbytes.dante.stats.BookStatsViewItem -import at.shockbytes.dante.stats.PagesPerMonth +import at.shockbytes.dante.ui.adapter.stats.model.ReadingGoalType +import at.shockbytes.dante.ui.custom.bookspages.BooksAndPageRecordDataPoint +import at.shockbytes.dante.ui.custom.bookspages.BooksAndPagesDiagramAction +import at.shockbytes.dante.ui.custom.bookspages.BooksAndPagesDiagramOptions +import at.shockbytes.dante.ui.custom.bookspages.BooksAndPagesDiagramView +import at.shockbytes.dante.ui.custom.bookspages.MarkerViewLabelFactory import at.shockbytes.dante.util.setVisible import at.shockbytes.util.adapter.BaseAdapter import kotlinx.android.extensions.LayoutContainer import kotlinx.android.synthetic.main.item_stats_pages_over_time.* -class BookStatsPagesOverTimeViewHolder(override val containerView: View +class BookStatsPagesOverTimeViewHolder( + override val containerView: View, + private val onChangeGoalActionListener: (ReadingGoalType) -> Unit ) : BaseAdapter.ViewHolder(containerView), LayoutContainer { override fun bindToView(content: BookStatsViewItem, position: Int) { - with(content as BookStatsViewItem.PagesOverTime) { + with(content as BookStatsViewItem.BooksAndPagesOverTime) { when (this) { - BookStatsViewItem.PagesOverTime.Empty -> { - showEmptyState() + is BookStatsViewItem.BooksAndPagesOverTime.Empty -> { + showEmptyState(headerRes) } - is BookStatsViewItem.PagesOverTime.Present -> { - showPagesPerMonth(pagesPerMonths) + is BookStatsViewItem.BooksAndPagesOverTime.Present.Pages -> { + showPagesPerMonth(pagesPerMonths, readingGoal.pagesPerMonth) + } + is BookStatsViewItem.BooksAndPagesOverTime.Present.Books -> { + showBooksPerMonth(booksPerMonths, readingGoal.booksPerMonth) } } } } - private fun showEmptyState() { + private fun showEmptyState(@StringRes headerRes: Int) { + item_books_pages_over_time_header.setHeaderTitleResource(headerRes) item_pages_over_time_empty.setVisible(true) item_stats_pages_over_time_content.setVisible(false) } - private fun showPagesPerMonth(pagesPerMonths: List) { + private fun showPagesPerMonth( + dataPoints: List, + pagesPerMonthGoal: Int? + ) { + item_books_pages_over_time_header.setHeaderTitleResource(R.string.statistics_header_pages_over_time) item_pages_over_time_empty.setVisible(false) item_stats_pages_over_time_content.setVisible(true) - // TODO + + item_pages_stats_diagram_view.apply { + + headerTitle = if (pagesPerMonthGoal != null) { + context.getString(R.string.set_pages_goal_header_with_goal, pagesPerMonthGoal) + } else context.getString(R.string.set_goal_header_no_goal) + + action = BooksAndPagesDiagramAction.Action(context.getString(R.string.set_goal)) + registerOnActionClick { + onChangeGoalActionListener(ReadingGoalType.PAGES) + } + setData( + dataPoints, + diagramOptions = BooksAndPagesDiagramOptions(isZoomable = true), + labelFactory = MarkerViewLabelFactory.ofBooksAndPageRecordDataPoints(dataPoints, R.string.pages_formatted) + ) + readingGoal(pagesPerMonthGoal, BooksAndPagesDiagramView.LimitLineOffsetType.PAGES) + } + } + + private fun showBooksPerMonth( + dataPoints: List, + booksPerMonthGoal: Int? + ) { + item_books_pages_over_time_header.setHeaderTitleResource(R.string.statistics_header_books_over_time) + item_pages_over_time_empty.setVisible(false) + item_stats_pages_over_time_content.setVisible(true) + + item_pages_stats_diagram_view.apply { + + headerTitle = if (booksPerMonthGoal != null) { + context.getString(R.string.set_books_goal_header_with_goal, booksPerMonthGoal) + } else context.getString(R.string.set_goal_header_no_goal) + + action = BooksAndPagesDiagramAction.Action(context.getString(R.string.set_goal)) + registerOnActionClick { + onChangeGoalActionListener(ReadingGoalType.BOOKS) + } + setData( + dataPoints, + diagramOptions = BooksAndPagesDiagramOptions(isZoomable = true), + labelFactory = MarkerViewLabelFactory.ofBooksAndPageRecordDataPoints(dataPoints, R.string.books_formatted) + ) + readingGoal(booksPerMonthGoal, BooksAndPagesDiagramView.LimitLineOffsetType.BOOKS) + } } } diff --git a/app/src/main/java/at/shockbytes/dante/ui/custom/DanteMarkerView.kt b/app/src/main/java/at/shockbytes/dante/ui/custom/DanteMarkerView.kt index 98d7f41a..c4afa7c6 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/custom/DanteMarkerView.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/custom/DanteMarkerView.kt @@ -3,25 +3,48 @@ package at.shockbytes.dante.ui.custom import android.content.Context import android.view.View import at.shockbytes.dante.R +import at.shockbytes.dante.ui.custom.bookspages.MarkerViewLabelFactory +import com.github.mikephil.charting.charts.Chart import com.github.mikephil.charting.components.MarkerView import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.PieEntry import com.github.mikephil.charting.highlight.Highlight import kotlinx.android.extensions.LayoutContainer import kotlinx.android.synthetic.main.dante_marker_view.* class DanteMarkerView( - context: Context -): MarkerView(context, R.layout.dante_marker_view), LayoutContainer { + context: Context, + chartView: Chart<*>, + private val labelFactory: MarkerViewLabelFactory +) : MarkerView(context, R.layout.dante_marker_view), LayoutContainer { + + init { + setChartView(chartView) + } override val containerView: View get() = this override fun refreshContent(e: Entry?, highlight: Highlight?) { - e?.y?.toInt()?.let { pages -> - tv_dante_marker_view.text = context.getString(R.string.pages_formatted, pages) + e?.let { entry -> + when (entry) { + is PieEntry -> handlePieEntry(entry) + else -> handleGenericEntry(entry) + } } super.refreshContent(e, highlight) } + + private fun handlePieEntry(entry: PieEntry) { + tv_dante_marker_view.text = labelFactory.createLabelForValue(context, entry.value) + } + + private fun handleGenericEntry(entry: Entry) { + val idx = entry.x.toInt().dec() + if (idx >= 0) { + tv_dante_marker_view.text = labelFactory.createLabelForIndex(context, idx) + } + } } \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/ui/custom/MultiBareBoneBookView.kt b/app/src/main/java/at/shockbytes/dante/ui/custom/MultiBareBoneBookView.kt index 07af2332..f2fd654a 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/custom/MultiBareBoneBookView.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/custom/MultiBareBoneBookView.kt @@ -11,6 +11,8 @@ import kotlinx.android.synthetic.main.multi_bare_bone_book_view.view.* class MultiBareBoneBookView(context: Context, attrs: AttributeSet?) : FrameLayout(context, attrs) { + private val booksToDisplay = 8 + init { inflate(context, R.layout.multi_bare_bone_book_view, this) } @@ -23,27 +25,29 @@ class MultiBareBoneBookView(context: Context, attrs: AttributeSet?) : FrameLayou container_multi_bare_bone_book_view.removeAllViews() urls - .mapNotNull { url -> - url?.let { + .mapIndexedNotNull { _, url -> + if (!url.isNullOrEmpty()) { createImageView().apply { imageLoader.loadImageWithCornerRadius( - context = context, - url = url, - target = this, - cornerDimension = context.resources.getDimension(R.dimen.thumbnail_rounded_corner).toInt() + context = context, + url = url, + target = this, + cornerDimension = context.resources.getDimension(R.dimen.thumbnail_rounded_corner).toInt() ) } - } + } else null } - .take(5) + .take(booksToDisplay) .forEach(container_multi_bare_bone_book_view::addView) } private fun createImageView(): ImageView { return ImageView(context).apply { layoutParams = MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT).apply { + height = AppUtils.convertDpInPixel(72, context) marginStart = AppUtils.convertDpInPixel(-16, context) } + scaleType = ImageView.ScaleType.FIT_XY } } } \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/ui/custom/PagesDiagramView.kt b/app/src/main/java/at/shockbytes/dante/ui/custom/PagesDiagramView.kt deleted file mode 100644 index 8fde9898..00000000 --- a/app/src/main/java/at/shockbytes/dante/ui/custom/PagesDiagramView.kt +++ /dev/null @@ -1,21 +0,0 @@ -package at.shockbytes.dante.ui.custom - -import android.content.Context -import android.util.AttributeSet -import android.widget.FrameLayout -import at.shockbytes.dante.R -import com.github.mikephil.charting.charts.LineChart -import kotlinx.android.synthetic.main.pages_diagram_view.view.* - -class PagesDiagramView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null -) : FrameLayout(context, attrs) { - - init { - inflate(context, R.layout.pages_diagram_view, this) - } - - val chart: LineChart - get() = lc_page_records -} \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/ui/custom/StatsHeaderView.kt b/app/src/main/java/at/shockbytes/dante/ui/custom/StatsHeaderView.kt index 54266090..cc7acc9f 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/custom/StatsHeaderView.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/custom/StatsHeaderView.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.res.TypedArray import android.util.AttributeSet import android.widget.FrameLayout +import androidx.annotation.StringRes import at.shockbytes.dante.R import at.shockbytes.dante.util.setVisible import kotlinx.android.synthetic.main.stats_header_view.view.* @@ -39,6 +40,10 @@ class StatsHeaderView @JvmOverloads constructor( tv_stats_header_view.text = title } + fun setHeaderTitleResource(@StringRes titleRes: Int) { + tv_stats_header_view.setText(titleRes) + } + fun showDivider(showDivider: Boolean) { view_stats_header_view_1.setVisible(showDivider) view_stats_header_view_2.setVisible(showDivider) diff --git a/app/src/main/java/at/shockbytes/dante/ui/custom/bookspages/BooksAndPageRecordDataPoint.kt b/app/src/main/java/at/shockbytes/dante/ui/custom/bookspages/BooksAndPageRecordDataPoint.kt new file mode 100644 index 00000000..3c46e27d --- /dev/null +++ b/app/src/main/java/at/shockbytes/dante/ui/custom/bookspages/BooksAndPageRecordDataPoint.kt @@ -0,0 +1,6 @@ +package at.shockbytes.dante.ui.custom.bookspages + +data class BooksAndPageRecordDataPoint( + val value: Int, + val formattedDate: String +) \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/ui/custom/bookspages/BooksAndPagesDiagramAction.kt b/app/src/main/java/at/shockbytes/dante/ui/custom/bookspages/BooksAndPagesDiagramAction.kt new file mode 100644 index 00000000..cba64cab --- /dev/null +++ b/app/src/main/java/at/shockbytes/dante/ui/custom/bookspages/BooksAndPagesDiagramAction.kt @@ -0,0 +1,7 @@ +package at.shockbytes.dante.ui.custom.bookspages + +sealed class BooksAndPagesDiagramAction { + object Gone : BooksAndPagesDiagramAction() + object Overflow : BooksAndPagesDiagramAction() + data class Action(val title: String) : BooksAndPagesDiagramAction() +} \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/ui/custom/bookspages/BooksAndPagesDiagramOptions.kt b/app/src/main/java/at/shockbytes/dante/ui/custom/bookspages/BooksAndPagesDiagramOptions.kt new file mode 100644 index 00000000..6f10390d --- /dev/null +++ b/app/src/main/java/at/shockbytes/dante/ui/custom/bookspages/BooksAndPagesDiagramOptions.kt @@ -0,0 +1,6 @@ +package at.shockbytes.dante.ui.custom.bookspages + +data class BooksAndPagesDiagramOptions( + val initialZero: Boolean = false, + val isZoomable: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/ui/custom/bookspages/BooksAndPagesDiagramView.kt b/app/src/main/java/at/shockbytes/dante/ui/custom/bookspages/BooksAndPagesDiagramView.kt new file mode 100644 index 00000000..559b91f4 --- /dev/null +++ b/app/src/main/java/at/shockbytes/dante/ui/custom/bookspages/BooksAndPagesDiagramView.kt @@ -0,0 +1,250 @@ +package at.shockbytes.dante.ui.custom.bookspages + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.FrameLayout +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import at.shockbytes.dante.R +import at.shockbytes.dante.ui.custom.DanteMarkerView +import at.shockbytes.dante.util.setVisible +import com.github.mikephil.charting.charts.LineChart +import com.github.mikephil.charting.components.LimitLine +import com.github.mikephil.charting.components.XAxis +import com.github.mikephil.charting.components.YAxis +import com.github.mikephil.charting.data.BarEntry +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.LineData +import com.github.mikephil.charting.data.LineDataSet +import com.github.mikephil.charting.formatter.IndexAxisValueFormatter +import kotlinx.android.synthetic.main.pages_diagram_view.view.* + +class BooksAndPagesDiagramView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : FrameLayout(context, attrs) { + + init { + inflate(context, R.layout.pages_diagram_view, this) + } + + private val chart: LineChart + get() = lc_page_records + + var headerTitle: String = "" + set(value) { + field = value + tv_page_record_header.text = value + } + + var action: BooksAndPagesDiagramAction = BooksAndPagesDiagramAction.Gone + set(value) { + field = value + setActionVisibility(value) + } + + val actionView: View + get() { + return when (action) { + is BooksAndPagesDiagramAction.Overflow -> iv_page_record_overflow + is BooksAndPagesDiagramAction.Action -> btn_page_record_action + is BooksAndPagesDiagramAction.Gone -> throw IllegalStateException("No action view for action type GONE") + } + } + + fun setData( + dataPoints: List, + diagramOptions: BooksAndPagesDiagramOptions = BooksAndPagesDiagramOptions(), + labelFactory: MarkerViewLabelFactory + ) { + + val formattedDates = dataPoints.map { it.formattedDate } + val dataSet = createDataSet(createDataSetEntries(dataPoints, diagramOptions.initialZero)) + + styleChartAndSetData(dataSet, labelFactory, formattedDates, diagramOptions.isZoomable) + } + + private fun createDataSetEntries( + dataPoints: List, + initialZero: Boolean + ): List { + return dataPoints + .mapIndexed { index, dp -> + Entry(index.inc().toFloat(), dp.value.toFloat()) + } + .toMutableList() + .apply { + if (initialZero) { + add(0, BarEntry(0f, 0f)) // Initial entry + } + } + } + + private fun createDataSet(entries: List): LineDataSet { + return LineDataSet(entries, "").apply { + setColor(ContextCompat.getColor(context, R.color.page_record_data), 255) + setDrawValues(false) + setDrawIcons(false) + setDrawFilled(true) + setDrawHighlightIndicators(false) + isHighlightEnabled = true + setCircleColor(ContextCompat.getColor(context, R.color.page_record_data)) + mode = LineDataSet.Mode.HORIZONTAL_BEZIER + fillDrawable = ContextCompat.getDrawable(context, R.drawable.page_record_gradient) + } + } + + private fun styleChartAndSetData( + dataSet: LineDataSet, + markerViewLabelFactory: MarkerViewLabelFactory, + formattedDates: List, + isZoomable: Boolean + ) { + chart.apply { + // Clear old values first, might be null (Java...) + data?.clearValues() + + description.isEnabled = false + legend.isEnabled = false + + setTouchEnabled(true) + setDrawGridBackground(false) + setScaleEnabled(isZoomable) + isDragEnabled = isZoomable + + xAxis.apply { + isEnabled = true + position = XAxis.XAxisPosition.BOTTOM + labelCount = formattedDates.size / 2 + setDrawAxisLine(false) + labelRotationAngle = -30f + textSize = 8f + setDrawGridLines(false) + typeface = ResourcesCompat.getFont(context, R.font.montserrat) + setDrawAxisLine(false) + setDrawGridBackground(false) + textColor = ContextCompat.getColor(context, R.color.colorPrimaryText) + valueFormatter = IndexAxisValueFormatter(formattedDates) + } + + getAxis(YAxis.AxisDependency.LEFT).apply { + isEnabled = true + setDrawLimitLinesBehindData(true) + setDrawGridLines(false) + setDrawZeroLine(false) + setDrawAxisLine(false) + typeface = ResourcesCompat.getFont(context, R.font.montserrat) + textColor = ContextCompat.getColor(context, R.color.colorPrimaryText) + } + getAxis(YAxis.AxisDependency.RIGHT).apply { + isEnabled = false + setDrawAxisLine(false) + textColor = ContextCompat.getColor(context, R.color.colorPrimaryText) + } + + setDrawMarkers(true) + marker = DanteMarkerView(context, chart, markerViewLabelFactory) + + data = LineData(dataSet) + invalidate() + } + } + + fun registerOnActionClick(clickAction: () -> Unit) { + when (action) { + is BooksAndPagesDiagramAction.Overflow -> iv_page_record_overflow.setOnClickListener { clickAction() } + is BooksAndPagesDiagramAction.Action -> btn_page_record_action.setOnClickListener { clickAction() } + is BooksAndPagesDiagramAction.Gone -> Unit // Do nothing + } + } + + private fun setActionVisibility(value: BooksAndPagesDiagramAction) { + when (value) { + BooksAndPagesDiagramAction.Overflow -> { + iv_page_record_overflow.setVisible(true) + btn_page_record_action.setVisible(false) + } + BooksAndPagesDiagramAction.Gone -> { + iv_page_record_overflow.setVisible(false) + btn_page_record_action.setVisible(false) + } + is BooksAndPagesDiagramAction.Action -> { + iv_page_record_overflow.setVisible(false) + btn_page_record_action.apply { + setVisible(true) + text = value.title + } + } + } + } + + fun readingGoal(value: Int?, offsetType: LimitLineOffsetType) { + + // Anyway, remove all limit lines + chart.getAxis(YAxis.AxisDependency.LEFT).apply { + removeAllLimitLines() + // setDrawGridLines(false) + } + + if (value != null) { + createLimitLine(value.toFloat()) + .let(::addLimitLineToChart) + + checkLineBoundaries(value.toFloat(), offsetType) + } + } + + private fun checkLineBoundaries(value: Float, offsetType: LimitLineOffsetType) { + val yAxis = chart.getAxis(YAxis.AxisDependency.LEFT) + + when (yAxis.isLimitLineShown(value)) { + LimitLinePosition.EXCEEDS_UPPER_BOUND -> { + yAxis.axisMaximum = value.plus(offsetType.offset) + } + LimitLinePosition.EXCEEDS_LOWER_BOUND -> { + yAxis.axisMinimum = value.minus(offsetType.offset) + } + LimitLinePosition.IS_VISIBLE -> Unit + } + } + + private enum class LimitLinePosition { + EXCEEDS_UPPER_BOUND, + EXCEEDS_LOWER_BOUND, + IS_VISIBLE + } + + private fun YAxis.isLimitLineShown(limit: Float): LimitLinePosition { + return when { + limit <= axisMinimum -> LimitLinePosition.EXCEEDS_LOWER_BOUND + limit >= axisMaximum -> LimitLinePosition.EXCEEDS_UPPER_BOUND + else -> LimitLinePosition.IS_VISIBLE + } + } + + private fun createLimitLine(value: Float): LimitLine { + return LimitLine(value, context.getString(R.string.reading_goal)).apply { + lineColor = ContextCompat.getColor(context, R.color.tabcolor_done) + lineWidth = 0.8f + enableDashedLine(20f, 20f, 0f) + labelPosition = LimitLine.LimitLabelPosition.RIGHT_TOP + typeface = ResourcesCompat.getFont(context, R.font.montserrat) + textSize = 10f + textColor = ContextCompat.getColor(context, R.color.colorPrimaryText) + } + } + + private fun addLimitLineToChart(limitLine: LimitLine) { + chart.getAxis(YAxis.AxisDependency.LEFT).apply { + // setDrawGridLines(true) + addLimitLine(limitLine) + } + invalidate() + } + + enum class LimitLineOffsetType(val offset: Int) { + PAGES(offset = 10), + BOOKS(offset = 1) + } +} \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/ui/custom/bookspages/MarkerViewLabelFactory.kt b/app/src/main/java/at/shockbytes/dante/ui/custom/bookspages/MarkerViewLabelFactory.kt new file mode 100644 index 00000000..5ff4c677 --- /dev/null +++ b/app/src/main/java/at/shockbytes/dante/ui/custom/bookspages/MarkerViewLabelFactory.kt @@ -0,0 +1,40 @@ +package at.shockbytes.dante.ui.custom.bookspages + +import android.content.Context + +class MarkerViewLabelFactory private constructor( + private val indexFactoryMethod: ((Context, Int) -> String?)? = null, + private val valueFactoryMethod: ((Context, Float) -> String?)? = null +) { + + fun createLabelForIndex(context: Context, index: Int): String? { + return indexFactoryMethod?.invoke(context, index) + } + + fun createLabelForValue(context: Context, value: Float): String? { + return valueFactoryMethod?.invoke(context, value) + } + + companion object { + + fun ofBooksAndPageRecordDataPoints( + dp: List, + markerTemplateResource: Int + ): MarkerViewLabelFactory { + return MarkerViewLabelFactory( + indexFactoryMethod = { context, index -> + val (content, date) = dp[index] + context.getString(markerTemplateResource, content, date) + } + ) + } + + fun forPlainEntries(markerTemplateResource: Int): MarkerViewLabelFactory { + return MarkerViewLabelFactory( + valueFactoryMethod = { context, value -> + context.getString(markerTemplateResource, value.toInt()) + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/ui/fragment/BookDetailFragment.kt b/app/src/main/java/at/shockbytes/dante/ui/fragment/BookDetailFragment.kt index f6631407..c5565cd3 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/fragment/BookDetailFragment.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/fragment/BookDetailFragment.kt @@ -21,10 +21,10 @@ import android.view.View import android.view.animation.DecelerateInterpolator import android.widget.TextView import androidx.annotation.ColorInt +import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityOptionsCompat import androidx.core.app.SharedElementCallback import androidx.core.content.ContextCompat -import androidx.core.content.res.ResourcesCompat import androidx.core.graphics.drawable.DrawableCompat import androidx.lifecycle.Observer import androidx.localbroadcastmanager.content.LocalBroadcastManager @@ -39,9 +39,11 @@ import at.shockbytes.dante.core.image.ImageLoader import at.shockbytes.dante.core.image.ImageLoadingCallback import at.shockbytes.dante.navigation.ActivityNavigator import at.shockbytes.dante.navigation.Destination -import at.shockbytes.dante.ui.activity.ManualAddActivity import at.shockbytes.dante.ui.activity.NotesActivity -import at.shockbytes.dante.ui.custom.DanteMarkerView +import at.shockbytes.dante.ui.custom.bookspages.BooksAndPageRecordDataPoint +import at.shockbytes.dante.ui.custom.bookspages.BooksAndPagesDiagramAction +import at.shockbytes.dante.ui.custom.bookspages.BooksAndPagesDiagramOptions +import at.shockbytes.dante.ui.custom.bookspages.MarkerViewLabelFactory import at.shockbytes.dante.ui.viewmodel.BookDetailViewModel import at.shockbytes.dante.util.AnimationUtils import at.shockbytes.dante.util.ColorUtils @@ -49,12 +51,11 @@ import at.shockbytes.dante.util.DanteUtils import at.shockbytes.dante.util.ExceptionHandlers import at.shockbytes.dante.util.addTo import at.shockbytes.dante.util.isNightModeEnabled +import at.shockbytes.dante.util.registerForPopupMenu import at.shockbytes.dante.util.setVisible import at.shockbytes.dante.util.viewModelOf -import com.github.mikephil.charting.components.XAxis -import com.github.mikephil.charting.components.YAxis -import com.github.mikephil.charting.data.* -import com.github.mikephil.charting.formatter.IndexAxisValueFormatter +import at.shockbytes.util.AppUtils +import com.afollestad.materialdialogs.MaterialDialog import com.google.android.material.chip.Chip import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers @@ -159,7 +160,7 @@ class BookDetailFragment : BaseFragment(), private fun registerLocalBroadcastReceiver() { LocalBroadcastManager.getInstance(requireContext()).apply { registerReceiver(notesReceiver, IntentFilter(NotesActivity.ACTION_NOTES)) - registerReceiver(bookUpdatedReceiver, IntentFilter(ManualAddActivity.ACTION_BOOK_UPDATED)) + registerReceiver(bookUpdatedReceiver, IntentFilter(ACTION_BOOK_CHANGED)) } } @@ -217,7 +218,6 @@ class BookDetailFragment : BaseFragment(), initializeTimeInformation(viewState.book) }) - viewModel.getPageRecordsViewState().observe(this, Observer(::handlePageRecordViewState)) viewModel.showBookFinishedDialogEvent @@ -397,12 +397,12 @@ class BookDetailFragment : BaseFragment(), } private fun handlePageRecordViewState( - pageRecordViewState: BookDetailViewModel.PageRecordsViewState + pageRecordViewState: BookDetailViewModel.PageRecordsViewState ) { when (pageRecordViewState) { is BookDetailViewModel.PageRecordsViewState.Present -> { group_details_pages.setVisible(true) - handlePageRecords(pageRecordViewState.dataPoints) + handlePageRecords(pageRecordViewState.dataPoints, pageRecordViewState.bookId) } BookDetailViewModel.PageRecordsViewState.Absent -> { group_details_pages.setVisible(false) @@ -410,107 +410,75 @@ class BookDetailFragment : BaseFragment(), } } - private fun handlePageRecords(dataPoints: List) { - - val entries: List = dataPoints - .mapIndexed { index, dp -> - Entry(index.inc().toFloat(), dp.page.toFloat()) - } - .toMutableList() - .apply { - add(0, BarEntry(0f, 0f)) // Initial entry - } - - val dataSet = LineDataSet(entries, "").apply { - setColor(ContextCompat.getColor(requireContext(), R.color.page_record_data), 255) - setDrawValues(false) - setDrawIcons(false) - setDrawFilled(true) - setDrawHighlightIndicators(false) - isHighlightEnabled = true - setCircleColor(ContextCompat.getColor(requireContext(), R.color.page_record_data)) - mode = LineDataSet.Mode.HORIZONTAL_BEZIER - fillDrawable = ContextCompat.getDrawable(requireContext(), R.drawable.page_record_gradient) - } - - pages_diagram_view.chart.apply { - description.isEnabled = false - legend.isEnabled = false - - setDrawGridBackground(false) - setScaleEnabled(false) - setTouchEnabled(true) - - xAxis.apply { - isEnabled = true - position = XAxis.XAxisPosition.BOTTOM - labelCount = entries.size / 2 - setDrawAxisLine(false) - labelRotationAngle = -30f - textSize = 8f - setDrawGridLines(false) - typeface = ResourcesCompat.getFont(requireContext(), R.font.montserrat) - setDrawAxisLine(false) - setDrawGridBackground(false) - textColor = ContextCompat.getColor(context, R.color.colorPrimaryText) - valueFormatter = IndexAxisValueFormatter(dataPoints.map { it.formattedDate }) - } - - getAxis(YAxis.AxisDependency.LEFT).apply { - isEnabled = false - setDrawAxisLine(false) - setDrawGridLines(false) - setDrawZeroLine(false) - setDrawAxisLine(false) + private fun handlePageRecords( + dataPoints: List, + bookId: Long + ) { + pages_diagram_view.apply { + setData( + dataPoints, + diagramOptions = BooksAndPagesDiagramOptions(initialZero = true), + labelFactory = MarkerViewLabelFactory.ofBooksAndPageRecordDataPoints(dataPoints, R.string.pages_formatted) + ) + action = BooksAndPagesDiagramAction.Overflow + registerOnActionClick { + showPageRecordsOverview(bookId) } - getAxis(YAxis.AxisDependency.RIGHT).apply { - isEnabled = false - setDrawAxisLine(false) - textColor = ContextCompat.getColor(context, R.color.colorPrimaryText) + headerTitle = getString(R.string.reading_behavior) + } + } + + private fun showPageRecordsOverview(bookId: Long) { + registerForPopupMenu( + pages_diagram_view.actionView, + R.menu.menu_page_records_details, + PopupMenu.OnMenuItemClickListener { item -> + when (item.itemId) { + R.id.menu_page_records_details -> { + DanteUtils.addFragmentToActivity( + parentFragmentManager, + PageRecordsDetailFragment.newInstance(bookId), + android.R.id.content, + addToBackStack = true + ) + } + R.id.menu_page_records_reset -> { + MaterialDialog(requireContext()).show { + icon(R.drawable.ic_delete) + title(text = getString(R.string.ask_for_all_page_record_deletion_title)) + message(text = getString(R.string.ask_for_all_page_record_deletion_msg)) + positiveButton(R.string.action_delete) { + viewModel.deleteAllPageRecords() + } + negativeButton(android.R.string.cancel) { + dismiss() + } + cancelOnTouchOutside(false) + cornerRadius(AppUtils.convertDpInPixel(6, requireContext()).toFloat()) + } + } + } + true } - - setDrawMarkers(true) - marker = DanteMarkerView(requireContext()) - - data = LineData(dataSet) - invalidate() - } + ) } private fun setupViewListener() { - btn_detail_pages.setOnClickListener { v -> - v.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) - viewModel.requestPageDialog() - } - btn_detail_published.setOnClickListener { v -> - v.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) - showDatePicker(DATE_TARGET_PUBLISHED_DATE) - } - btn_detail_notes.setOnClickListener { v -> - v.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) - viewModel.requestNotesDialog() - } - btn_detail_rate.setOnClickListener { v -> - v.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) - viewModel.requestRatingDialog() - } - btn_detail_wishhlist_date.setOnClickListener { v -> - v.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) - showDatePicker(DATE_TARGET_WISHLIST_DATE) - } - btn_detail_start_date.setOnClickListener { v -> - v.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) - showDatePicker(DATE_TARGET_START_DATE) - } - btn_detail_end_date.setOnClickListener { v -> - v.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) - showDatePicker(DATE_TARGET_END_DATE) - } + btn_detail_pages.setHapticClickListener(viewModel::requestPageDialog) + btn_detail_published.setHapticClickListener { showDatePicker(DATE_TARGET_PUBLISHED_DATE) } + btn_detail_notes.setHapticClickListener(viewModel::requestNotesDialog) + btn_detail_rate.setHapticClickListener(viewModel::requestRatingDialog) + btn_detail_wishhlist_date.setHapticClickListener { showDatePicker(DATE_TARGET_WISHLIST_DATE) } + btn_detail_start_date.setHapticClickListener { showDatePicker(DATE_TARGET_START_DATE) } + btn_detail_end_date.setHapticClickListener { showDatePicker(DATE_TARGET_END_DATE) } + btn_add_label.setHapticClickListener(viewModel::requestAddLabels) + } - btn_add_label.setOnClickListener { v -> + private fun View.setHapticClickListener(action: () -> Unit) { + this.setOnClickListener { v -> v.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) - viewModel.requestAddLabels() + action() } } @@ -522,17 +490,17 @@ class BookDetailFragment : BaseFragment(), } .subscribeOn(Schedulers.computation()) .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ (drawable, v) -> - v.setCompoundDrawablesWithIntrinsicBounds(null, drawable, null, null) - }, { throwable -> - throwable.printStackTrace() - }, { - // In the end start the component animations in onComplete() - startComponentAnimations() - }) + // In the end (onComplete) start the component animations in onComplete() + .subscribe(::setCompoundDrawable, ExceptionHandlers::defaultExceptionHandler, ::startComponentAnimations) .addTo(compositeDisposable) } + private fun setCompoundDrawable(pair: Pair) { + + val (drawable, view) = pair + view.setCompoundDrawablesWithIntrinsicBounds(null, drawable, null, null) + } + private fun startComponentAnimations() { AnimationUtils.detailEnterAnimation( animatableViewsList, @@ -721,6 +689,8 @@ class BookDetailFragment : BaseFragment(), companion object { + const val ACTION_BOOK_CHANGED = "action_book_changed" + // Const callback values for time picker private const val DATE_TARGET_PUBLISHED_DATE = 1 private const val DATE_TARGET_WISHLIST_DATE = 2 diff --git a/app/src/main/java/at/shockbytes/dante/ui/fragment/MainBookFragment.kt b/app/src/main/java/at/shockbytes/dante/ui/fragment/MainBookFragment.kt index fcecd34f..8a56a702 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/fragment/MainBookFragment.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/fragment/MainBookFragment.kt @@ -27,11 +27,11 @@ import at.shockbytes.dante.navigation.Destination.BookDetail.BookDetailInfo import at.shockbytes.dante.ui.adapter.main.BookAdapter import at.shockbytes.dante.core.image.ImageLoader import at.shockbytes.dante.flagging.FeatureFlagging -import at.shockbytes.dante.ui.activity.ManualAddActivity import at.shockbytes.dante.ui.activity.ManualAddActivity.Companion.EXTRA_UPDATED_BOOK_STATE import at.shockbytes.dante.ui.adapter.OnBookActionClickedListener import at.shockbytes.dante.ui.adapter.main.BookAdapterEntity import at.shockbytes.dante.ui.adapter.main.RandomPickCallback +import at.shockbytes.dante.ui.fragment.BookDetailFragment.Companion.ACTION_BOOK_CHANGED import at.shockbytes.dante.ui.viewmodel.BookListViewModel import at.shockbytes.dante.util.DanteUtils import at.shockbytes.dante.util.addTo @@ -119,7 +119,7 @@ class MainBookFragment : BaseFragment(), private fun registerBookUpdatedBroadcastReceiver() { LocalBroadcastManager.getInstance(requireContext()) - .registerReceiver(bookUpdatedReceiver, IntentFilter(ManualAddActivity.ACTION_BOOK_UPDATED)) + .registerReceiver(bookUpdatedReceiver, IntentFilter(ACTION_BOOK_CHANGED)) } override fun injectToGraph(appComponent: AppComponent) { @@ -186,10 +186,9 @@ class MainBookFragment : BaseFragment(), true ) } - } BookListViewModel.RandomPickEvent.NoBookAvailable -> { - //TODO + // TODO } } } diff --git a/app/src/main/java/at/shockbytes/dante/ui/fragment/ManualAddFragment.kt b/app/src/main/java/at/shockbytes/dante/ui/fragment/ManualAddFragment.kt index 5c977194..3ac51b95 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/fragment/ManualAddFragment.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/fragment/ManualAddFragment.kt @@ -209,7 +209,7 @@ class ManualAddFragment : BaseFragment(), ImageLoadingCallback { private fun sendBookUpdatedBroadcast(bookState: BookState) { LocalBroadcastManager.getInstance(requireContext()) .sendBroadcast( - Intent(ManualAddActivity.ACTION_BOOK_UPDATED) + Intent(BookDetailFragment.ACTION_BOOK_CHANGED) .putExtra(ManualAddActivity.EXTRA_UPDATED_BOOK_STATE, bookState) ) } diff --git a/app/src/main/java/at/shockbytes/dante/ui/fragment/PageRecordsDetailFragment.kt b/app/src/main/java/at/shockbytes/dante/ui/fragment/PageRecordsDetailFragment.kt new file mode 100644 index 00000000..aa70a62a --- /dev/null +++ b/app/src/main/java/at/shockbytes/dante/ui/fragment/PageRecordsDetailFragment.kt @@ -0,0 +1,97 @@ +package at.shockbytes.dante.ui.fragment + +import android.content.Intent +import android.os.Bundle +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProvider +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import at.shockbytes.dante.injection.AppComponent +import at.shockbytes.dante.ui.viewmodel.PageRecordsDetailViewModel +import at.shockbytes.dante.util.arguments.argument +import at.shockbytes.dante.util.viewModelOf +import at.shockbytes.dante.R +import at.shockbytes.dante.core.book.PageRecord +import at.shockbytes.dante.ui.adapter.pagerecords.PageRecordsAdapter +import at.shockbytes.dante.util.addTo +import at.shockbytes.util.AppUtils +import com.afollestad.materialdialogs.MaterialDialog +import kotlinx.android.synthetic.main.fragment_page_records_details.* +import javax.inject.Inject + +class PageRecordsDetailFragment : BaseFragment() { + + @Inject + lateinit var vmFactory: ViewModelProvider.Factory + + private var bookId: Long by argument() + + private lateinit var viewModel: PageRecordsDetailViewModel + + override val layoutId: Int = R.layout.fragment_page_records_details + + private val recordsAdapter: PageRecordsAdapter by lazy { + PageRecordsAdapter(requireContext(), ::askForEntryDeletionConfirmation) + } + + private fun askForEntryDeletionConfirmation(pageRecord: PageRecord) { + MaterialDialog(requireContext()).show { + icon(R.drawable.ic_delete) + title(text = getString(R.string.ask_for_page_record_deletion_title)) + message(text = getString(R.string.ask_for_page_record_deletion_msg)) + positiveButton(R.string.action_delete) { + viewModel.deletePageRecord(pageRecord) + } + negativeButton(android.R.string.cancel) { + dismiss() + } + cancelOnTouchOutside(false) + cornerRadius(AppUtils.convertDpInPixel(6, requireContext()).toFloat()) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel = viewModelOf(vmFactory) + } + + override fun setupViews() { + btn_page_records_details_close.setOnClickListener { + parentFragmentManager.popBackStack() + } + layout_page_records_details.setOnClickListener { + parentFragmentManager.popBackStack() + } + + rv_page_records_details.adapter = recordsAdapter + } + + override fun injectToGraph(appComponent: AppComponent) { + appComponent.inject(this) + } + + override fun bindViewModel() { + viewModel.initialize(bookId) + + viewModel.getRecords().observe(this, Observer(recordsAdapter::updateData)) + + viewModel.onBookChangedEvent() + .subscribe(::sendBookChangedBroadcast) + .addTo(compositeDisposable) + } + + private fun sendBookChangedBroadcast(unused: Unit) { + LocalBroadcastManager.getInstance(requireContext()) + .sendBroadcast(Intent(BookDetailFragment.ACTION_BOOK_CHANGED)) + } + + override fun unbindViewModel() = Unit + + companion object { + + fun newInstance(bookId: Long): PageRecordsDetailFragment { + return PageRecordsDetailFragment().apply { + this.bookId = bookId + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/ui/fragment/ReadingGoalPickerFragment.kt b/app/src/main/java/at/shockbytes/dante/ui/fragment/ReadingGoalPickerFragment.kt new file mode 100644 index 00000000..5eaae7c1 --- /dev/null +++ b/app/src/main/java/at/shockbytes/dante/ui/fragment/ReadingGoalPickerFragment.kt @@ -0,0 +1,140 @@ +package at.shockbytes.dante.ui.fragment + +import at.shockbytes.dante.R +import at.shockbytes.dante.injection.AppComponent +import at.shockbytes.dante.ui.adapter.stats.model.ReadingGoalType +import at.shockbytes.dante.util.arguments.argument +import at.shockbytes.dante.util.arguments.argumentNullable +import kotlinx.android.synthetic.main.fragment_reading_goal_picker.* + +class ReadingGoalPickerFragment : BaseFragment() { + + interface OnReadingGoalPickedListener { + + fun onGoalPicked(goal: Int, goalType: ReadingGoalType) + + fun onDelete(goalType: ReadingGoalType) + } + + override val layoutId: Int = R.layout.fragment_reading_goal_picker + + private var type: ReadingGoalType by argument() + private var initialValue: Int? by argumentNullable() + + private var onReadingGoalPickedListener: OnReadingGoalPickedListener? = null + + override fun setupViews() { + setupLottieAnimation() + setupCloseListeners() + setupSlider() + setupCallbackListeners() + setupType() + + initialValue?.toFloat()?.let(slider_reading_goal::setValue) + } + + private fun setupCallbackListeners() { + btn_reading_goal_delete.setOnClickListener { + onReadingGoalPickedListener?.onDelete(type) + closeFragment() + } + + btn_reading_goal_apply.setOnClickListener { + onReadingGoalPickedListener?.onGoalPicked(slider_reading_goal.value.toInt(), type) + closeFragment() + } + } + + private fun setupLottieAnimation() { + lav_reading_goal.apply { + setMinFrame(MIN_FRAME) + setMaxFrame(MAX_FRAME) + } + } + + private fun setupCloseListeners() { + btn_reading_goal_close.setOnClickListener { + closeFragment() + } + layout_reading_goal_picker.setOnClickListener { + closeFragment() + } + } + + private fun closeFragment() { + parentFragmentManager.popBackStack() + } + + private fun setupSlider() { + slider_reading_goal.apply { + + valueTo = type.sliderValueTo + valueFrom = type.sliderValueFrom + stepSize = type.sliderStepSize + + addOnChangeListener { slider, value, _ -> + computeLottieFrame(slider.valueFrom, slider.valueTo, value) + updateLevel(slider.valueFrom, slider.valueTo, value) + updateGoalLabel(value.toInt()) + } + } + } + + private fun computeLottieFrame(minValue: Float, maxValue: Float, value: Float) { + + val valueP = ((100f / (maxValue - minValue)) * value).div(100) + val frame = ((MAX_FRAME - MIN_FRAME) * valueP).toInt() + MIN_FRAME + + lav_reading_goal.frame = frame + } + + private fun updateLevel(min: Float, max: Float, value: Float) { + + val diff = max - min + + val levelRes = when { + value > diff.times(0.8) -> R.string.reading_goal_level_3 + value > diff.times(0.6) -> R.string.reading_goal_level_2 + value > diff.times(0.4) -> R.string.reading_goal_level_1 + else -> R.string.reading_goal_level_0 + } + + tv_reading_goal_level.setText(levelRes) + } + + private fun setupType() { + tv_fragment_reading_goal_header.setText(type.title) + } + + private fun updateGoalLabel(value: Int) { + tv_reading_goal_label.text = getString(type.labelTemplate, value, getString(R.string.month)) + } + + fun setOnReadingGoalPickedListener( + listener: OnReadingGoalPickedListener + ): ReadingGoalPickerFragment { + return apply { + onReadingGoalPickedListener = listener + } + } + + override fun injectToGraph(appComponent: AppComponent) = Unit + override fun bindViewModel() = Unit + override fun unbindViewModel() = Unit + + companion object { + + private const val MIN_FRAME = 18 + private const val MAX_FRAME = 28 + + fun newInstance( + initialValue: Int? = null, + type: ReadingGoalType + ): ReadingGoalPickerFragment { + return ReadingGoalPickerFragment().apply { + this.initialValue = initialValue + this.type = type + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/ui/fragment/StatisticsFragment.kt b/app/src/main/java/at/shockbytes/dante/ui/fragment/StatisticsFragment.kt index dc358e60..ba44adc5 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/fragment/StatisticsFragment.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/fragment/StatisticsFragment.kt @@ -8,8 +8,13 @@ import at.shockbytes.dante.R import at.shockbytes.dante.core.image.ImageLoader import at.shockbytes.dante.injection.AppComponent import at.shockbytes.dante.ui.adapter.stats.StatsAdapter +import at.shockbytes.dante.ui.adapter.stats.model.ReadingGoalType import at.shockbytes.dante.ui.viewmodel.StatisticsViewModel +import at.shockbytes.dante.util.DanteUtils +import at.shockbytes.dante.util.addTo +import at.shockbytes.dante.util.setVisible import at.shockbytes.dante.util.viewModelOf +import kotlinx.android.synthetic.main.dante_toolbar.* import kotlinx.android.synthetic.main.fragment_statistics.* import javax.inject.Inject @@ -26,7 +31,11 @@ class StatisticsFragment : BaseFragment() { private lateinit var viewModel: StatisticsViewModel private val statsAdapter: StatsAdapter by lazy { - StatsAdapter(requireContext(), imageLoader) + StatsAdapter( + requireContext(), + imageLoader, + onChangeGoalActionListener = viewModel::requestPageGoalChangeAction + ) } override fun onCreate(savedInstanceState: Bundle?) { @@ -34,7 +43,19 @@ class StatisticsFragment : BaseFragment() { viewModel = viewModelOf(vmFactory) } + private fun setupToolbar() { + dante_toolbar_title.setText(R.string.label_stats) + dante_toolbar_back.apply { + setVisible(true) + setOnClickListener { + activity?.onBackPressed() + } + } + } + override fun setupViews() { + setupToolbar() + fragment_statistics_rv.apply { layoutManager = LinearLayoutManager(requireContext()) adapter = statsAdapter @@ -48,6 +69,31 @@ class StatisticsFragment : BaseFragment() { override fun bindViewModel() { viewModel.requestStatistics() viewModel.getStatistics().observe(this, Observer(statsAdapter::updateData)) + + viewModel.onPageGoalChangeRequest() + .subscribe(::handlePageReadingGoalState) + .addTo(compositeDisposable) + } + + private fun handlePageReadingGoalState(state: StatisticsViewModel.ReadingGoalState) { + + val initialValue = when (state) { + is StatisticsViewModel.ReadingGoalState.Present -> state.goal + is StatisticsViewModel.ReadingGoalState.Absent -> state.defaultGoal + } + + val fragment = ReadingGoalPickerFragment + .newInstance(initialValue, state.goalType) + .setOnReadingGoalPickedListener(object : ReadingGoalPickerFragment.OnReadingGoalPickedListener { + override fun onGoalPicked(goal: Int, goalType: ReadingGoalType) { + viewModel.onGoalPicked(goal, goalType) + } + + override fun onDelete(goalType: ReadingGoalType) { + viewModel.onGoalDeleted(goalType) + } + }) + DanteUtils.addFragmentToActivity(parentFragmentManager, fragment, android.R.id.content, true) } override fun unbindViewModel() { diff --git a/app/src/main/java/at/shockbytes/dante/ui/fragment/TimeLineFragment.kt b/app/src/main/java/at/shockbytes/dante/ui/fragment/TimeLineFragment.kt index 54e505c1..73748db1 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/fragment/TimeLineFragment.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/fragment/TimeLineFragment.kt @@ -16,10 +16,14 @@ import at.shockbytes.dante.navigation.Destination.BookDetail.BookDetailInfo import at.shockbytes.dante.timeline.TimeLineItem import at.shockbytes.dante.ui.adapter.timeline.TimeLineAdapter import at.shockbytes.dante.ui.viewmodel.TimelineViewModel +import at.shockbytes.dante.util.getStringList import at.shockbytes.dante.util.setVisible import at.shockbytes.dante.util.viewModelOfActivity import at.shockbytes.util.AppUtils import at.shockbytes.util.adapter.BaseAdapter +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.list.listItemsSingleChoice +import kotlinx.android.synthetic.main.dante_toolbar.* import kotlinx.android.synthetic.main.fragment_timeline.* import javax.inject.Inject @@ -79,6 +83,43 @@ class TimeLineFragment : BaseFragment() { } }) } + setupToolbar() + } + + private fun setupToolbar() { + dante_toolbar_title.setText(R.string.label_timeline) + dante_toolbar_back.apply { + setVisible(true) + setOnClickListener { + activity?.onBackPressed() + } + } + dante_toolbar_primary_action.apply { + setVisible(true) + setImageResource(R.drawable.ic_timeline_sort) + setOnClickListener { + showTimeLineDisplayPicker() + } + } + } + + private fun showTimeLineDisplayPicker() { + MaterialDialog(requireContext()) + .title(R.string.dialogfragment_sort_by) + .message(R.string.timeline_sort_explanation) + .listItemsSingleChoice( + items = getStringList(R.array.sort_timeline), + initialSelection = viewModel.selectedTimeLineSortStrategyIndex + ) { _, index, _ -> + viewModel.updateSortStrategy(index) + } + .icon(R.drawable.ic_timeline_sort) + .cornerRadius(AppUtils.convertDpInPixel(6, requireContext()).toFloat()) + .cancelOnTouchOutside(true) + .positiveButton(R.string.apply) { + it.dismiss() + } + .show() } override fun injectToGraph(appComponent: AppComponent) { diff --git a/app/src/main/java/at/shockbytes/dante/ui/viewmodel/BookDetailViewModel.kt b/app/src/main/java/at/shockbytes/dante/ui/viewmodel/BookDetailViewModel.kt index b7857c0d..483d89b1 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/viewmodel/BookDetailViewModel.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/viewmodel/BookDetailViewModel.kt @@ -10,8 +10,10 @@ import at.shockbytes.dante.core.book.PageRecord import at.shockbytes.dante.core.data.BookRepository import at.shockbytes.dante.core.data.PageRecordDao import at.shockbytes.dante.navigation.NotesBundle +import at.shockbytes.dante.ui.custom.bookspages.BooksAndPageRecordDataPoint import at.shockbytes.dante.util.ExceptionHandlers import at.shockbytes.dante.util.settings.DanteSettings +import io.reactivex.Completable import io.reactivex.Observable import io.reactivex.disposables.CompositeDisposable import io.reactivex.rxkotlin.addTo @@ -19,6 +21,7 @@ import io.reactivex.subjects.PublishSubject import kotlinx.android.parcel.Parcelize import org.joda.time.DateTime import org.joda.time.format.DateTimeFormat +import timber.log.Timber import javax.inject.Inject /** @@ -38,16 +41,14 @@ class BookDetailViewModel @Inject constructor( sealed class PageRecordsViewState { - data class Present(val dataPoints: List): PageRecordsViewState() + data class Present( + val bookId: Long, + val dataPoints: List + ) : PageRecordsViewState() object Absent : PageRecordsViewState() } - data class PageRecordDataPoint( - val page: Int, - val formattedDate: String - ) - private val viewState = MutableLiveData() fun getViewState(): LiveData = viewState @@ -94,18 +95,18 @@ class BookDetailViewModel @Inject constructor( private fun fetchBook(bookId: Long) { bookRepository.get(bookId) - ?.also { entity -> - pagesAtInit = entity.currentPage - } - ?.let(::craftViewState) - ?.let(viewState::postValue) + ?.also { entity -> + pagesAtInit = entity.currentPage + } + ?.let(::craftViewState) + ?.let(viewState::postValue) } private fun fetchPageRecords(bookId: Long) { pageRecordDao.pageRecordsForBook(bookId) - .map(::mapPageRecordsToDataPoints) - .subscribe(pageRecords::postValue, ExceptionHandlers::defaultExceptionHandler) - .addTo(viewCompositeDisposable) + .map(::mapPageRecordsToDataPoints) + .subscribe(pageRecords::postValue, ExceptionHandlers::defaultExceptionHandler) + .addTo(viewCompositeDisposable) } private fun mapPageRecordsToDataPoints(pageRecords: List): PageRecordsViewState { @@ -115,18 +116,20 @@ class BookDetailViewModel @Inject constructor( } else { val format = DateTimeFormat.forPattern("dd/MM/yy") pageRecords - .groupBy { record -> - DateTime(record.timestamp).withTimeAtStartOfDay() - } - .mapNotNull { (dtTimestamp, pageRecords) -> - pageRecords.maxBy { it.timestamp }?.let { record -> - PageRecordDataPoint( - page = record.toPage, - formattedDate = format.print(dtTimestamp) - ) - } + .groupBy { record -> + DateTime(record.timestamp).withTimeAtStartOfDay() + } + .mapNotNull { (dtTimestamp, pageRecords) -> + pageRecords.maxBy { it.timestamp }?.let { record -> + BooksAndPageRecordDataPoint( + value = record.toPage, + formattedDate = format.print(dtTimestamp) + ) } - .let(PageRecordsViewState::Present) + } + .let { dataPoints -> + PageRecordsViewState.Present(bookId, dataPoints) + } } } @@ -298,15 +301,39 @@ class BookDetailViewModel @Inject constructor( val currentPage = getBookFromLiveData()?.currentPage ?: 0 val startPage = pagesAtInit ?: 0 if (currentPage != startPage) { - pageRecordDao.insertPageRecordForId( - id = bookId, - fromPage = startPage, - toPage = currentPage, - nowInMillis = System.currentTimeMillis() + pageRecordDao.insertPageRecordForBookId( + bookId = bookId, + fromPage = startPage, + toPage = currentPage, + nowInMillis = System.currentTimeMillis() ) } } + /** + * 1. Delete all page records for this particular book + * 2. Reset the current page to 0 + * 3. Reload the whole view + */ + fun deleteAllPageRecords() { + + Completable + .concat( + listOf( + pageRecordDao.deleteAllPageRecordsForBookId(bookId), + resetCurrentPageToZero() + ) + ) + .subscribe(::reload, Timber::e) + .addTo(compositeDisposable) + } + + private fun resetCurrentPageToZero(): Completable { + return Completable.fromAction { + bookRepository.updateCurrentPage(bookId, currentPage = 0) + } + } + data class PageInfo( val currentPage: Int, val pageCount: Int, diff --git a/app/src/main/java/at/shockbytes/dante/ui/viewmodel/BookListViewModel.kt b/app/src/main/java/at/shockbytes/dante/ui/viewmodel/BookListViewModel.kt index 5287fd02..fa519339 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/viewmodel/BookListViewModel.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/viewmodel/BookListViewModel.kt @@ -52,10 +52,9 @@ class BookListViewModel @Inject constructor( private val sortComparator: Comparator get() = SortComparators.of(settings.sortStrategy) - sealed class RandomPickEvent { - data class RandomPick(val book: BookEntity): RandomPickEvent() + data class RandomPick(val book: BookEntity) : RandomPickEvent() object NoBookAvailable : RandomPickEvent() } @@ -86,7 +85,6 @@ class BookListViewModel @Inject constructor( .addTo(compositeDisposable) } - private fun loadBooks() { bookRepository.bookObservable .map { fetchedBooks -> diff --git a/app/src/main/java/at/shockbytes/dante/ui/viewmodel/PageRecordsDetailViewModel.kt b/app/src/main/java/at/shockbytes/dante/ui/viewmodel/PageRecordsDetailViewModel.kt new file mode 100644 index 00000000..eebd8e07 --- /dev/null +++ b/app/src/main/java/at/shockbytes/dante/ui/viewmodel/PageRecordsDetailViewModel.kt @@ -0,0 +1,103 @@ +package at.shockbytes.dante.ui.viewmodel + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import at.shockbytes.dante.core.book.PageRecord +import at.shockbytes.dante.core.data.BookRepository +import at.shockbytes.dante.core.data.PageRecordDao +import at.shockbytes.dante.ui.adapter.pagerecords.PageRecordDetailItem +import at.shockbytes.dante.util.addTo +import at.shockbytes.dante.util.indexOfOrNull +import at.shockbytes.dante.util.isLastIndexIn +import io.reactivex.Completable +import io.reactivex.Observable +import io.reactivex.subjects.PublishSubject +import org.joda.time.format.DateTimeFormat +import timber.log.Timber +import javax.inject.Inject + +class PageRecordsDetailViewModel @Inject constructor( + private val pageRecordDao: PageRecordDao, + private val bookRepository: BookRepository +) : BaseViewModel() { + + private val dateFormat = DateTimeFormat.forPattern("dd.MM.yyyy") + + private val records = MutableLiveData>() + fun getRecords(): LiveData> = records + + private val onBookChangedSubject = PublishSubject.create() + fun onBookChangedEvent(): Observable = onBookChangedSubject + + private var bookId: Long = -1L + + private var cachedRecords = listOf() + + fun initialize(bookId: Long) { + this.bookId = bookId + pageRecordDao.pageRecordsForBook(bookId) + .doOnNext(::cachePageRecords) + .map(::mapPageRecordToPageRecordDetailItem) + .subscribe(records::postValue, Timber::e) + .addTo(compositeDisposable) + } + + private fun cachePageRecords(cached: List) { + cachedRecords = cached + } + + private fun mapPageRecordToPageRecordDetailItem( + pageRecords: List + ): List { + return pageRecords.map { record -> + + val formattedPagesRead = "${record.fromPage} - ${record.toPage}" + val formattedDate = dateFormat.print(record.timestamp) + + PageRecordDetailItem(record, formattedPagesRead, formattedDate) + } + } + + fun deletePageRecord(pageRecord: PageRecord) { + + val index = cachedRecords.indexOfOrNull(pageRecord) ?: return + + val preAction = when { + // Single entry, reset current page to 0 + index == 0 && cachedRecords.size == 1 -> { + updateCurrentPage(0) + } + // Last index, just update current page to page of previous entry + index.isLastIndexIn(cachedRecords) -> { + val previousRecord = cachedRecords[index.dec()] + updateCurrentPage(previousRecord.toPage) + } + // More than one entries and not last entry, perform normal stitching + else -> { + val nextRecord = cachedRecords[index.inc()] + pageRecordDao.updatePageRecord(nextRecord, fromPage = pageRecord.fromPage, toPage = null) + } + } + + Completable + .concat( + listOf( + preAction, + pageRecordDao.deletePageRecordForBook(pageRecord) // Eventually delete page record + ) + ) + .subscribe({ + initialize(bookId) + onBookChangedSubject.onNext(Unit) + }, { throwable -> + Timber.e(throwable) + }) + .addTo(compositeDisposable) + } + + private fun updateCurrentPage(currentPage: Int): Completable { + return Completable.fromAction { + bookRepository.updateCurrentPage(bookId, currentPage) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/ui/viewmodel/StatisticsViewModel.kt b/app/src/main/java/at/shockbytes/dante/ui/viewmodel/StatisticsViewModel.kt index 62efb126..799cda61 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/viewmodel/StatisticsViewModel.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/viewmodel/StatisticsViewModel.kt @@ -4,42 +4,154 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import at.shockbytes.dante.core.book.BookEntity import at.shockbytes.dante.core.book.PageRecord -import at.shockbytes.dante.core.data.BookEntityDao +import at.shockbytes.dante.core.book.ReadingGoal import at.shockbytes.dante.core.data.BookRepository import at.shockbytes.dante.core.data.PageRecordDao -import at.shockbytes.dante.flagging.FeatureFlagging +import at.shockbytes.dante.core.data.ReadingGoalRepository import at.shockbytes.dante.stats.BookStatsViewItem import at.shockbytes.dante.stats.BookStatsBuilder +import at.shockbytes.dante.ui.adapter.stats.model.ReadingGoalType import at.shockbytes.dante.util.ExceptionHandlers +import at.shockbytes.dante.util.scheduler.SchedulerFacade +import io.reactivex.Completable import io.reactivex.Observable -import io.reactivex.functions.BiFunction +import io.reactivex.Single +import io.reactivex.functions.Function4 import io.reactivex.rxkotlin.addTo +import io.reactivex.subjects.PublishSubject import javax.inject.Inject class StatisticsViewModel @Inject constructor( - private val bookRepository: BookRepository, - private val recordDao: PageRecordDao, - featureFlagging: FeatureFlagging + private val bookRepository: BookRepository, + private val recordDao: PageRecordDao, + private val readingGoalRepository: ReadingGoalRepository, + private val schedulers: SchedulerFacade ) : BaseViewModel() { - private val bookStatsBuilder = BookStatsBuilder(featureFlagging) + private data class ZipContent( + val books: List, + val records: List, + val pagesReadingGoal: ReadingGoal.PagesPerMonthReadingGoal, + val booksReadingGoal: ReadingGoal.BooksPerMonthReadingGoal + ) + + private val zipper = Function4 { books: List, + records: List, + pagesReadingGoal: ReadingGoal.PagesPerMonthReadingGoal, + booksReadingGoal: ReadingGoal.BooksPerMonthReadingGoal -> + ZipContent(books, records, pagesReadingGoal, booksReadingGoal) + } + + sealed class ReadingGoalState { + + abstract val goalType: ReadingGoalType + + sealed class Present : ReadingGoalState() { + + abstract val goal: Int + + data class Pages( + override val goal: Int, + override val goalType: ReadingGoalType = ReadingGoalType.PAGES + ) : Present() + + data class Books( + override val goal: Int, + override val goalType: ReadingGoalType = ReadingGoalType.BOOKS + ) : Present() + } + + data class Absent( + val defaultGoal: Int, + override val goalType: ReadingGoalType + ) : ReadingGoalState() + } private val statisticsItems = MutableLiveData>() fun getStatistics(): LiveData> = statisticsItems + private val pageGoalChangeEvent = PublishSubject.create() + fun onPageGoalChangeRequest(): Observable = pageGoalChangeEvent + fun requestStatistics() { Observable - .zip( - bookRepository.bookObservable, - recordDao.allPageRecords(), - BiFunction { books: List, records: List -> - books to records + .zip( + bookRepository.bookObservable, + recordDao.allPageRecords(), + readingGoalRepository.retrievePagesPerMonthReadingGoal().toObservable(), + readingGoalRepository.retrieveBookPerMonthReadingGoal().toObservable(), + zipper + ) + .map { (books, pageRecords, pagesReadingGoal, booksReadingGoal) -> + BookStatsBuilder.build(books, pageRecords, pagesReadingGoal, booksReadingGoal) + } + .subscribe(statisticsItems::postValue, ExceptionHandlers::defaultExceptionHandler) + .addTo(compositeDisposable) + } + + fun requestPageGoalChangeAction(type: ReadingGoalType) { + + goalChangeObservableSourceByType(type) + .subscribe(pageGoalChangeEvent::onNext) + .addTo(compositeDisposable) + } + + private fun goalChangeObservableSourceByType(type: ReadingGoalType): Single { + return when (type) { + ReadingGoalType.PAGES -> { + readingGoalRepository.retrievePagesPerMonthReadingGoal() + .map { goal -> + if (goal.pagesPerMonth != null) { + ReadingGoalState.Present.Pages(goal.pagesPerMonth!!) + } else { + ReadingGoalState.Absent(PAGES_DEFAULT_GOAL, ReadingGoalType.PAGES) } - ) - .map { (books, pageRecords) -> - bookStatsBuilder.createFrom(books, pageRecords) - } - .subscribe(statisticsItems::postValue, ExceptionHandlers::defaultExceptionHandler) - .addTo(compositeDisposable) + } + } + ReadingGoalType.BOOKS -> { + readingGoalRepository.retrieveBookPerMonthReadingGoal() + .map { goal -> + if (goal.booksPerMonth != null) { + ReadingGoalState.Present.Books(goal.booksPerMonth!!) + } else { + ReadingGoalState.Absent(BOOKS_DEFAULT_GOAL, ReadingGoalType.BOOKS) + } + } + } + } + } + + fun onGoalPicked(readingGoal: Int, goalType: ReadingGoalType) { + getGoalStorageSource(readingGoal, goalType) + .observeOn(schedulers.ui) + .subscribe(::requestStatistics) + .addTo(compositeDisposable) + } + + private fun getGoalStorageSource(readingGoal: Int, goalType: ReadingGoalType): Completable { + return when (goalType) { + ReadingGoalType.PAGES -> readingGoalRepository.storePagesPerMonthReadingGoal(readingGoal) + ReadingGoalType.BOOKS -> readingGoalRepository.storeBooksPerMonthReadingGoal(readingGoal) + } + } + + fun onGoalDeleted(goalType: ReadingGoalType) { + getGoalResetSource(goalType) + .observeOn(schedulers.ui) + .subscribe(::requestStatistics) + .addTo(compositeDisposable) + } + + private fun getGoalResetSource(goalType: ReadingGoalType): Completable { + return when (goalType) { + ReadingGoalType.PAGES -> readingGoalRepository.resetPagesPerMonthReadingGoal() + ReadingGoalType.BOOKS -> readingGoalRepository.resetBooksPerMonthReadingGoal() + } + } + + companion object { + + private const val PAGES_DEFAULT_GOAL = 600 + private const val BOOKS_DEFAULT_GOAL = 4 } } \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/util/settings/delegate/DelegateExtensions.kt b/app/src/main/java/at/shockbytes/dante/util/settings/delegate/DelegateExtensions.kt index 7ee75b50..4941cb06 100644 --- a/app/src/main/java/at/shockbytes/dante/util/settings/delegate/DelegateExtensions.kt +++ b/app/src/main/java/at/shockbytes/dante/util/settings/delegate/DelegateExtensions.kt @@ -2,13 +2,12 @@ package at.shockbytes.dante.util.settings.delegate import android.content.SharedPreferences - fun SharedPreferences.boolDelegate( - key: String, - defaultValue: Boolean = true + key: String, + defaultValue: Boolean = true ): SharedPreferencesBoolPropertyDelegate = SharedPreferencesBoolPropertyDelegate(this, key, defaultValue) fun SharedPreferences.stringDelegate( - key: String, - defaultValue: String + key: String, + defaultValue: String ): SharedPreferencesStringPropertyDelegate = SharedPreferencesStringPropertyDelegate(this, key, defaultValue) \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/util/view/BookDiffUtilCallback.kt b/app/src/main/java/at/shockbytes/dante/util/view/BookDiffUtilCallback.kt index e6015e46..2104c14e 100644 --- a/app/src/main/java/at/shockbytes/dante/util/view/BookDiffUtilCallback.kt +++ b/app/src/main/java/at/shockbytes/dante/util/view/BookDiffUtilCallback.kt @@ -9,8 +9,8 @@ import at.shockbytes.dante.ui.adapter.main.BookAdapterEntity * Date: 12.06.2018 */ class BookDiffUtilCallback( - private val oldList: List, - private val newList: List + private val oldList: List, + private val newList: List ) : DiffUtil.Callback() { override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 161ec4a5..084d3773 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -29,17 +29,14 @@ android:id="@+id/txtMainToolbarTitle" android:layout_width="0dp" android:layout_height="0dp" - android:layout_gravity="center" android:alpha="0" - android:fontFamily="@font/montserrat_bold" - android:gravity="center_vertical" android:scaleX="0.9" android:scaleY="0.9" android:text="@string/app_name" - android:textAppearance="@style/TextAppearance.MaterialComponents.Overline" - android:textColor="@color/actionBarItemColor" - android:textSize="18sp" + android:textAppearance="@style/Theme.TextAppearance.Title" android:translationY="5dp" + android:gravity="center_vertical" + android:fontFamily="@font/montserrat_bold" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@+id/imgButtonMainToolbarSearch" app:layout_constraintStart_toStartOf="parent" diff --git a/app/src/main/res/layout/dante_toolbar.xml b/app/src/main/res/layout/dante_toolbar.xml new file mode 100644 index 00000000..44bed067 --- /dev/null +++ b/app/src/main/res/layout/dante_toolbar.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_book_detail.xml b/app/src/main/res/layout/fragment_book_detail.xml index f8feb76b..65f6e5c7 100644 --- a/app/src/main/res/layout/fragment_book_detail.xml +++ b/app/src/main/res/layout/fragment_book_detail.xml @@ -273,7 +273,7 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/linearLayout_additional_book_info" /> - + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_reading_goal_picker.xml b/app/src/main/res/layout/fragment_reading_goal_picker.xml new file mode 100644 index 00000000..ff5157ef --- /dev/null +++ b/app/src/main/res/layout/fragment_reading_goal_picker.xml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + +