diff --git a/README.md b/README.md index 4dd584d5..8643288d 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ GitStat is a simple android app designed to aggregate Github profile data into i - List of your repositories with filters and sorting - Contributions summary - Charts for contributions (types, count per day, contribution rate) +- Contributions grid (Github-like) [Download from Google Play](https://play.google.com/store/apps/details?id=by.alexandr7035.gitstat) @@ -41,15 +42,17 @@ used to implement languages filter (see third screenshot). ## Screenshots

- - - + + + +

- - - + + + +

## Privacy Policy diff --git a/app/build.gradle b/app/build.gradle index 6dc36cf4..9a179691 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -32,8 +32,8 @@ android { applicationId 'by.alexandr7035.gitstat' minSdkVersion 21 targetSdkVersion 31 - versionCode 1700 - versionName "4.5.4" + versionCode 1800 + versionName "5.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/graphql/com/alexandr7035/gitstat/Contributions.graphql b/app/src/main/graphql/com/alexandr7035/gitstat/Contributions.graphql index edfdafd1..adb966e0 100644 --- a/app/src/main/graphql/com/alexandr7035/gitstat/Contributions.graphql +++ b/app/src/main/graphql/com/alexandr7035/gitstat/Contributions.graphql @@ -9,6 +9,7 @@ query Contributions($date_from: DateTime, $date_to: DateTime) { contributionDays { contributionCount date + color } } } diff --git a/app/src/main/java/by/alexandr7035/gitstat/core/App.kt b/app/src/main/java/by/alexandr7035/gitstat/core/App.kt index 7d79428b..45657b83 100644 --- a/app/src/main/java/by/alexandr7035/gitstat/core/App.kt +++ b/app/src/main/java/by/alexandr7035/gitstat/core/App.kt @@ -6,6 +6,7 @@ import android.app.NotificationManager import android.os.Build import by.alexandr7035.gitstat.BuildConfig import by.alexandr7035.gitstat.R +import by.alexandr7035.gitstat.data.AppPreferences import dagger.hilt.android.HiltAndroidApp import timber.log.Timber @@ -31,5 +32,14 @@ class App : Application() { val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager notificationManager.createNotificationChannel(channel) } + + + // Force cache (re)synchronization on start if new version was installed + // Cache DB schema may be changed and some fields may become 0 + val appPrefs = AppPreferences(this) + if (appPrefs.getLastInstalledVersionCode() != BuildConfig.VERSION_CODE) { + appPrefs.saveLastInstalledVersionCode(BuildConfig.VERSION_CODE) + appPrefs.saveLastCacheSyncDate(0) + } } } \ No newline at end of file diff --git a/app/src/main/java/by/alexandr7035/gitstat/core/ContributionCellView.kt b/app/src/main/java/by/alexandr7035/gitstat/core/ContributionCellView.kt new file mode 100644 index 00000000..ea2bc845 --- /dev/null +++ b/app/src/main/java/by/alexandr7035/gitstat/core/ContributionCellView.kt @@ -0,0 +1,30 @@ +package by.alexandr7035.gitstat.core + +import android.content.Context +import android.util.AttributeSet +import android.view.View + +// Use it with one side with WRAP_CONTENT +class ContributionCellView: View { + constructor(context: Context): super(context) + constructor(context: Context, attrs: AttributeSet): super(context, attrs) + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + + val widthMode = MeasureSpec.getMode(widthMeasureSpec) + val heightMode = MeasureSpec.getMode(heightMeasureSpec) + + val widthSize = MeasureSpec.getSize(widthMeasureSpec) + val heightSize = MeasureSpec.getSize(heightMeasureSpec) + + if (widthMode == MeasureSpec.EXACTLY && heightMode != MeasureSpec.EXACTLY) { + setMeasuredDimension(widthSize, widthSize) + } + else if(heightMode == MeasureSpec.EXACTLY && widthMode != MeasureSpec.EXACTLY) { + setMeasuredDimension(heightSize, heightSize) + } + else { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/by/alexandr7035/gitstat/core/KeyValueStorage.kt b/app/src/main/java/by/alexandr7035/gitstat/core/KeyValueStorage.kt index 1d9ce9b7..7d56d614 100644 --- a/app/src/main/java/by/alexandr7035/gitstat/core/KeyValueStorage.kt +++ b/app/src/main/java/by/alexandr7035/gitstat/core/KeyValueStorage.kt @@ -12,4 +12,8 @@ interface KeyValueStorage { fun getRepositoriesFilters(): String? fun saveRepositoriesFilters(filtersStr: String?) + + fun getLastInstalledVersionCode(): Int + + fun saveLastInstalledVersionCode(versionCode: Int) } \ No newline at end of file diff --git a/app/src/main/java/by/alexandr7035/gitstat/core/TimeHelper.kt b/app/src/main/java/by/alexandr7035/gitstat/core/TimeHelper.kt index d7277dea..1a933651 100644 --- a/app/src/main/java/by/alexandr7035/gitstat/core/TimeHelper.kt +++ b/app/src/main/java/by/alexandr7035/gitstat/core/TimeHelper.kt @@ -45,6 +45,13 @@ class TimeHelper { return getUnixDateFrom_yyyyMMdd(beginningStr) } + fun getBeginningOfDayForUnixDate_currentTz(currentDate: Long): Long { + val format = SimpleDateFormat("yyyy-MM-dd", Locale.US) + val beginningStr = format.format(currentDate) + + return getUnixDateFrom_yyyyMMdd(beginningStr) + } + fun getCurrentYearForUnixDate(currentDate: Long): Int { val format = SimpleDateFormat("yyyy", Locale.US) format.timeZone = TimeZone.getTimeZone("GMT") @@ -53,5 +60,11 @@ class TimeHelper { return yearStr.toInt() } + fun get_yyyyMM_fromUnixDate(unixDate: Long): String { + val format = SimpleDateFormat("yyyyMM", Locale.US) + format.timeZone = TimeZone.getTimeZone("GMT") + return format.format(unixDate) + } + data class Iso8601Year(val startDate: String, val endDate: String) } \ No newline at end of file diff --git a/app/src/main/java/by/alexandr7035/gitstat/data/AppPreferences.kt b/app/src/main/java/by/alexandr7035/gitstat/data/AppPreferences.kt index c241e95a..72465c95 100644 --- a/app/src/main/java/by/alexandr7035/gitstat/data/AppPreferences.kt +++ b/app/src/main/java/by/alexandr7035/gitstat/data/AppPreferences.kt @@ -34,4 +34,12 @@ class AppPreferences @Inject constructor(private val application: Application): override fun saveRepositoriesFilters(filtersStr: String?) { prefs.edit().putString(application.getString(R.string.shared_prefs_filters), filtersStr).apply() } + + override fun getLastInstalledVersionCode(): Int { + return prefs.getInt(application.getString(R.string.shared_pref_last_version_code), 0) + } + + override fun saveLastInstalledVersionCode(versionCode: Int) { + prefs.edit().putInt(application.getString(R.string.shared_pref_last_version_code), versionCode).apply() + } } \ No newline at end of file diff --git a/app/src/main/java/by/alexandr7035/gitstat/data/ContributionsRepository.kt b/app/src/main/java/by/alexandr7035/gitstat/data/ContributionsRepository.kt index 80bb9450..2d9fc033 100644 --- a/app/src/main/java/by/alexandr7035/gitstat/data/ContributionsRepository.kt +++ b/app/src/main/java/by/alexandr7035/gitstat/data/ContributionsRepository.kt @@ -4,10 +4,7 @@ import androidx.lifecycle.LiveData import by.alexandr7035.gitstat.core.KeyValueStorage import by.alexandr7035.gitstat.core.TimeHelper import by.alexandr7035.gitstat.data.local.dao.ContributionsDao -import by.alexandr7035.gitstat.data.local.model.ContributionDayEntity -import by.alexandr7035.gitstat.data.local.model.ContributionTypesEntity -import by.alexandr7035.gitstat.data.local.model.ContributionsYearWithDays -import by.alexandr7035.gitstat.data.local.model.ContributionsYearWithRates +import by.alexandr7035.gitstat.data.local.model.* import javax.inject.Inject import kotlin.math.round @@ -30,6 +27,10 @@ class ContributionsRepository @Inject constructor( return dao.getContributionYearsWithRatesCache() } + fun getContributionYearsWithMonthsLiveData(): LiveData> { + return dao.getContributionYearsWithMonthsLiveData() + } + fun getContributionTypesLiveData(): LiveData> { return dao.getContributionTypesLiveData() } diff --git a/app/src/main/java/by/alexandr7035/gitstat/data/DataSyncRepository.kt b/app/src/main/java/by/alexandr7035/gitstat/data/DataSyncRepository.kt index 87891328..6c74c703 100644 --- a/app/src/main/java/by/alexandr7035/gitstat/data/DataSyncRepository.kt +++ b/app/src/main/java/by/alexandr7035/gitstat/data/DataSyncRepository.kt @@ -9,7 +9,9 @@ import by.alexandr7035.gitstat.data.remote.mappers.* import by.alexandr7035.gitstat.extensions.performRequestWithDataResult import com.apollographql.apollo3.ApolloClient import timber.log.Timber +import java.util.* import javax.inject.Inject +import kotlin.collections.ArrayList class DataSyncRepository @Inject constructor( private val apolloClient: ApolloClient, @@ -44,10 +46,11 @@ class DataSyncRepository @Inject constructor( val repositories = fetchRepositories() syncStatusLiveData?.postValue(DataSyncStatus.PENDING_CONTRIBUTIONS) - val contributionYears = fetchContributionYears(accountCreationYear, currentYear) val contributionDays = fetchContributionDays(accountCreationYear, currentYear) val contributionTypes = fetchContributionTypes(accountCreationYear, currentYear, contributionDays) val contributionRates = fetchContributionRates(contributionDays) + val contributionYears = fetchContributionYears(accountCreationYear, currentYear) + val contributionMonths = fetchContributionMonths(contributionDays) // Write cache clearCache() @@ -64,12 +67,13 @@ class DataSyncRepository @Inject constructor( // NOTE! DO NOT CHANGE ORDER // LIVEDATA TRIGGERED IMMEDIATELY AFTER SAVING CACHE // AS ONE DATA MAY DEPEND ON THE OTHER, WRONG ORDER MAY CAUSE CRASH - // Years must be at the end + // Years and months must be at the end db.getContributionsDao().apply { insertContributionRatesCache(contributionRates) insertContributionDays(contributionDays) insertContributionTypes(contributionTypes) insertContributionYearsCache(contributionYears) + insertContributionMonthsCache(contributionMonths) } syncStatusLiveData?.postValue(DataSyncStatus.SUCCESS) @@ -118,6 +122,23 @@ class DataSyncRepository @Inject constructor( return years } + private fun fetchContributionMonths(contributionDays: List): ArrayList { + + // Use TreeMap as it is always sorted by key + val monthsMap: TreeMap = TreeMap() + val months = ArrayList() + + for (day in contributionDays) { + monthsMap[day.monthId] = day.yearId + } + + for ((id, yearId) in monthsMap) { + months.add(ContributionsMonthEntity(id, yearId)) + } + + return months + } + private suspend fun fetchContributionDays(profileCreationYear: Int, currentYear: Int): List { val contributionDaysCached = ArrayList() diff --git a/app/src/main/java/by/alexandr7035/gitstat/data/local/CacheDB.kt b/app/src/main/java/by/alexandr7035/gitstat/data/local/CacheDB.kt index 5e278226..3e5402f7 100644 --- a/app/src/main/java/by/alexandr7035/gitstat/data/local/CacheDB.kt +++ b/app/src/main/java/by/alexandr7035/gitstat/data/local/CacheDB.kt @@ -12,7 +12,8 @@ import by.alexandr7035.gitstat.data.local.model.* ContributionDayEntity::class, ContributionRateEntity::class, ContributionsYearEntity::class, - ContributionTypesEntity::class], version = 16) + ContributionTypesEntity::class, + ContributionsMonthEntity::class], version = 20) abstract class CacheDB : RoomDatabase() { diff --git a/app/src/main/java/by/alexandr7035/gitstat/data/local/dao/ContributionsDao.kt b/app/src/main/java/by/alexandr7035/gitstat/data/local/dao/ContributionsDao.kt index 79baa0dc..64369f75 100644 --- a/app/src/main/java/by/alexandr7035/gitstat/data/local/dao/ContributionsDao.kt +++ b/app/src/main/java/by/alexandr7035/gitstat/data/local/dao/ContributionsDao.kt @@ -1 +1 @@ -package by.alexandr7035.gitstat.data.local.dao import androidx.lifecycle.LiveData import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import by.alexandr7035.gitstat.data.local.model.* @Dao interface ContributionsDao { ///////////////////////////////////// // Contribution days ///////////////////////////////////// @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertContributionDays(contributions: List) @Query("select * from contributions order by date") fun getContributionDaysLiveData(): LiveData> @Query("select * from contributions order by date asc") fun getContributionDaysList(): List @Query("DELETE FROM contributions") suspend fun clearContributionDays() ///////////////////////////////////// // Contribution years ///////////////////////////////////// @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertContributionYearsCache(years: List) @Query("SELECT * FROM contribution_years") fun getContributionYearsWithDaysLiveData(): LiveData> @Query("SELECT * FROM contribution_years where id = (:yearId)") suspend fun getContributionYearWithDays(yearId: Int): ContributionsYearWithDays @Query("DELETE FROM contribution_years") fun clearContributionYears() ///////////////////////////////////// // Contribution rates ///////////////////////////////////// @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertContributionRatesCache(rates: List) @Query("SELECT * FROM contribution_years") fun getContributionYearsWithRatesCache(): LiveData> @Query("DELETE FROM contribution_rates") suspend fun clearContributionsYearsWithRatesCache() @Query("SELECT * FROM contribution_rates") suspend fun getContributionYearWithRates(): ContributionsYearWithRates ///////////////////////////////////// // Contribution types (issues, created repos, PRs, commits, code reviews, unknown) ///////////////////////////////////// @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertContributionTypes(typesData: List) @Query("select * from contribution_types") fun getContributionTypesLiveData(): LiveData> @Query("DELETE FROM contribution_types") suspend fun clearContributionTypes() } \ No newline at end of file +package by.alexandr7035.gitstat.data.local.dao import androidx.lifecycle.LiveData import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import by.alexandr7035.gitstat.data.local.model.* @Dao interface ContributionsDao { ///////////////////////////////////// // Contribution days ///////////////////////////////////// @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertContributionDays(contributions: List) @Query("select * from contributions order by date") fun getContributionDaysLiveData(): LiveData> @Query("select * from contributions order by date asc") fun getContributionDaysList(): List @Query("DELETE FROM contributions") suspend fun clearContributionDays() ///////////////////////////////////// // Contribution years and months // Use for convenience to avoid calculations and iterations in UI layer ///////////////////////////////////// @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertContributionYearsCache(years: List) @Query("SELECT * FROM contribution_years") fun getContributionYearsWithDaysLiveData(): LiveData> @Query("SELECT * FROM contribution_years where id = (:yearId)") suspend fun getContributionYearWithDays(yearId: Int): ContributionsYearWithDays @Query("DELETE FROM contribution_years") fun clearContributionYears() @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertContributionMonthsCache(months: List) @Query("SELECT * FROM contribution_years where id = (:yearId)") fun getContributionYearWithMonthsLiveData(yearId: Int): LiveData @Query("SELECT * FROM contribution_years") fun getContributionYearsWithMonthsLiveData(): LiveData> ///////////////////////////////////// // Contribution rates ///////////////////////////////////// @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertContributionRatesCache(rates: List) @Query("SELECT * FROM contribution_years") fun getContributionYearsWithRatesCache(): LiveData> @Query("DELETE FROM contribution_rates") suspend fun clearContributionsYearsWithRatesCache() @Query("SELECT * FROM contribution_rates") suspend fun getContributionYearWithRates(): ContributionsYearWithRates ///////////////////////////////////// // Contribution types (issues, created repos, PRs, commits, code reviews, unknown) ///////////////////////////////////// @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertContributionTypes(typesData: List) @Query("select * from contribution_types") fun getContributionTypesLiveData(): LiveData> @Query("DELETE FROM contribution_types") suspend fun clearContributionTypes() } \ No newline at end of file diff --git a/app/src/main/java/by/alexandr7035/gitstat/data/local/model/ContributionDayEntity.kt b/app/src/main/java/by/alexandr7035/gitstat/data/local/model/ContributionDayEntity.kt index 4c2df838..d5a4e2c2 100644 --- a/app/src/main/java/by/alexandr7035/gitstat/data/local/model/ContributionDayEntity.kt +++ b/app/src/main/java/by/alexandr7035/gitstat/data/local/model/ContributionDayEntity.kt @@ -9,4 +9,7 @@ class ContributionDayEntity( var id: Int = 0, var date: Long, var count: Int, - var yearId: Int) \ No newline at end of file + val color: Int, + + var yearId: Int, + var monthId: Int) \ No newline at end of file diff --git a/app/src/main/java/by/alexandr7035/gitstat/data/local/model/ContributionYearWithMonths.kt b/app/src/main/java/by/alexandr7035/gitstat/data/local/model/ContributionYearWithMonths.kt new file mode 100644 index 00000000..6e284d2e --- /dev/null +++ b/app/src/main/java/by/alexandr7035/gitstat/data/local/model/ContributionYearWithMonths.kt @@ -0,0 +1,12 @@ +package by.alexandr7035.gitstat.data.local.model + +import androidx.room.Embedded +import androidx.room.Relation + +class ContributionYearWithMonths( + @Embedded + val year: ContributionsYearEntity, + + @Relation(parentColumn = "id", entityColumn = "yearId", entity = ContributionsMonthEntity::class) + val contributionMonths: List +) \ No newline at end of file diff --git a/app/src/main/java/by/alexandr7035/gitstat/data/local/model/ContributionsMonthEntity.kt b/app/src/main/java/by/alexandr7035/gitstat/data/local/model/ContributionsMonthEntity.kt new file mode 100644 index 00000000..1fa61b2b --- /dev/null +++ b/app/src/main/java/by/alexandr7035/gitstat/data/local/model/ContributionsMonthEntity.kt @@ -0,0 +1,13 @@ +package by.alexandr7035.gitstat.data.local.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "contribution_months") +data class ContributionsMonthEntity( + // Save here "year"+ "month number". + // E.g. 2020 + 01 -> 202001 + @PrimaryKey + val id: Int, + val yearId: Int +) \ No newline at end of file diff --git a/app/src/main/java/by/alexandr7035/gitstat/data/local/model/ContributionsMonthWithDays.kt b/app/src/main/java/by/alexandr7035/gitstat/data/local/model/ContributionsMonthWithDays.kt new file mode 100644 index 00000000..ed6fa882 --- /dev/null +++ b/app/src/main/java/by/alexandr7035/gitstat/data/local/model/ContributionsMonthWithDays.kt @@ -0,0 +1,11 @@ +package by.alexandr7035.gitstat.data.local.model + +import androidx.room.Embedded +import androidx.room.Relation + +class ContributionsMonthWithDays( + @Embedded + val month: ContributionsMonthEntity, + + @Relation(parentColumn = "id", entityColumn = "monthId", entity = ContributionDayEntity::class) + val contributionDays: List) \ No newline at end of file diff --git a/app/src/main/java/by/alexandr7035/gitstat/data/remote/mappers/ContributionDayRemoteToCacheMapper.kt b/app/src/main/java/by/alexandr7035/gitstat/data/remote/mappers/ContributionDayRemoteToCacheMapper.kt index b9f1ab40..69ae65a5 100644 --- a/app/src/main/java/by/alexandr7035/gitstat/data/remote/mappers/ContributionDayRemoteToCacheMapper.kt +++ b/app/src/main/java/by/alexandr7035/gitstat/data/remote/mappers/ContributionDayRemoteToCacheMapper.kt @@ -1,5 +1,6 @@ package by.alexandr7035.gitstat.data.remote.mappers +import android.graphics.Color import by.alexandr7035.gitstat.apollo.ContributionsQuery import by.alexandr7035.gitstat.core.Mapper import by.alexandr7035.gitstat.core.TimeHelper @@ -12,8 +13,17 @@ class ContributionDayRemoteToCacheMapper @Inject constructor(private val timeHel val unixDate = timeHelper.getUnixDateFrom_yyyyMMdd(dateStr) val year = timeHelper.getYearFromUnixDate(unixDate) + val month = timeHelper.get_yyyyMM_fromUnixDate(unixDate).toInt() - return ContributionDayEntity(count = data.contributionCount, date = unixDate, yearId = year) + val intColor = Color.parseColor(data.color) + + return ContributionDayEntity( + count = data.contributionCount, + date = unixDate, + yearId = year, + color = intColor, + monthId = month + ) } } \ No newline at end of file diff --git a/app/src/main/java/by/alexandr7035/gitstat/view/contributions/ContributionsFragment.kt b/app/src/main/java/by/alexandr7035/gitstat/view/contributions/ContributionsFragment.kt index a799762d..59c8f94b 100644 --- a/app/src/main/java/by/alexandr7035/gitstat/view/contributions/ContributionsFragment.kt +++ b/app/src/main/java/by/alexandr7035/gitstat/view/contributions/ContributionsFragment.kt @@ -32,9 +32,6 @@ class ContributionsFragment : Fragment() { private var binding: FragmentContributionsBinding? = null private val viewModel by viewModels() -// private lateinit var yearContributionsAdapter: YearContributionsAdapter -// private lateinit var yearContributionsRateAdapter: YearContributionRatesAdapter - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = FragmentContributionsBinding.inflate(inflater, container, false) return binding!!.root @@ -179,6 +176,11 @@ class ContributionsFragment : Fragment() { getString(R.string.contribution_rate_dynamics_help_text) )) } + + + binding?.toContributionsGridBtn?.setOnClickListener { + findNavController().navigateSafe(ContributionsFragmentDirections.actionContributionsFragmentToContributionsGridFragment(2021)) + } } } \ No newline at end of file diff --git a/app/src/main/java/by/alexandr7035/gitstat/view/contributions/plots/contributions_per_year/YearContributionsAdapter.kt b/app/src/main/java/by/alexandr7035/gitstat/view/contributions/plots/contributions_per_year/YearContributionsAdapter.kt index f418d0f3..57ed9533 100644 --- a/app/src/main/java/by/alexandr7035/gitstat/view/contributions/plots/contributions_per_year/YearContributionsAdapter.kt +++ b/app/src/main/java/by/alexandr7035/gitstat/view/contributions/plots/contributions_per_year/YearContributionsAdapter.kt @@ -1,5 +1,6 @@ package by.alexandr7035.gitstat.view.contributions.plots.contributions_per_year +import android.annotation.SuppressLint import android.os.Bundle import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter @@ -10,8 +11,10 @@ class YearContributionsAdapter(fragment: Fragment): FragmentStateAdapter(fragmen private var items: List = emptyList() + @SuppressLint("NotifyDataSetChanged") fun setItems(years: List) { this.items = years + notifyDataSetChanged() } override fun getItemCount(): Int { diff --git a/app/src/main/java/by/alexandr7035/gitstat/view/contributions/plots/contributions_rate/YearContributionRatesAdapter.kt b/app/src/main/java/by/alexandr7035/gitstat/view/contributions/plots/contributions_rate/YearContributionRatesAdapter.kt index 1ff5a6c5..33acbf84 100644 --- a/app/src/main/java/by/alexandr7035/gitstat/view/contributions/plots/contributions_rate/YearContributionRatesAdapter.kt +++ b/app/src/main/java/by/alexandr7035/gitstat/view/contributions/plots/contributions_rate/YearContributionRatesAdapter.kt @@ -1,5 +1,6 @@ package by.alexandr7035.gitstat.view.contributions.plots.contributions_rate +import android.annotation.SuppressLint import android.os.Bundle import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter @@ -10,8 +11,10 @@ class YearContributionRatesAdapter(fragment: Fragment) : FragmentStateAdapter(fr // FIXME private var items: List = emptyList() + @SuppressLint("NotifyDataSetChanged") fun setItems(years: List) { this.items = years + notifyDataSetChanged() } override fun getItemCount(): Int { diff --git a/app/src/main/java/by/alexandr7035/gitstat/view/contributions_grid/ContributionDayDialogFragment.kt b/app/src/main/java/by/alexandr7035/gitstat/view/contributions_grid/ContributionDayDialogFragment.kt new file mode 100644 index 00000000..3be33bbc --- /dev/null +++ b/app/src/main/java/by/alexandr7035/gitstat/view/contributions_grid/ContributionDayDialogFragment.kt @@ -0,0 +1,47 @@ +package by.alexandr7035.gitstat.view.contributions_grid + +import android.graphics.drawable.GradientDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.navigation.fragment.navArgs +import by.alexandr7035.gitstat.R +import by.alexandr7035.gitstat.databinding.FragmentContributionDayDialogBinding +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import java.text.SimpleDateFormat +import java.util.* + +class ContributionDayDialogFragment : BottomSheetDialogFragment() { + + private var binding: FragmentContributionDayDialogBinding? = null + private val safeArgs by navArgs() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + // Inflate the layout for this fragment + binding = FragmentContributionDayDialogBinding.inflate(inflater, container, false) + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding?.contributionsCount?.text = getString( + R.string.daily_contributions_template, + safeArgs.contributionsCount.toString() + ) + + val dateFormat = SimpleDateFormat("dd MMM yyyy", Locale.US) + dateFormat.timeZone = TimeZone.getTimeZone("GMT") + binding?.contributionDate?.text = dateFormat.format(safeArgs.contributionDate) + + (binding?.contributionMark?.background as GradientDrawable).setColor(safeArgs.contributionsColor) + + + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/by/alexandr7035/gitstat/view/contributions_grid/ContributionsGridFragment.kt b/app/src/main/java/by/alexandr7035/gitstat/view/contributions_grid/ContributionsGridFragment.kt new file mode 100644 index 00000000..2be85ccc --- /dev/null +++ b/app/src/main/java/by/alexandr7035/gitstat/view/contributions_grid/ContributionsGridFragment.kt @@ -0,0 +1,147 @@ +package by.alexandr7035.gitstat.view.contributions_grid + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.LinearLayoutManager +import by.alexandr7035.gitstat.R +import by.alexandr7035.gitstat.data.local.model.ContributionDayEntity +import by.alexandr7035.gitstat.data.local.model.ContributionYearWithMonths +import by.alexandr7035.gitstat.data.local.model.ContributionsMonthWithDays +import by.alexandr7035.gitstat.databinding.FragmentContributionsGridBinding +import by.alexandr7035.gitstat.extensions.debug +import by.alexandr7035.gitstat.extensions.navigateSafe +import com.google.android.material.tabs.TabLayout +import dagger.hilt.android.AndroidEntryPoint +import timber.log.Timber +import java.text.SimpleDateFormat +import java.util.* + +@AndroidEntryPoint +class ContributionsGridFragment : Fragment(), DayClickListener { + + private var binding: FragmentContributionsGridBinding? = null + private val viewModel by viewModels() + private val safeArgs by navArgs() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + // Inflate the layout for this fragment + binding = FragmentContributionsGridBinding.inflate(inflater, container, false) + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding?.toolbar?.title = getString(R.string.year_toolbar_title, safeArgs.contributionYear) + binding?.toolbar?.setNavigationOnClickListener { + findNavController().navigateUp() + } + + val adapter = MonthsAdapter(this) + + binding?.monthRecycler?.adapter = adapter + binding?.monthRecycler?.layoutManager = LinearLayoutManager(requireContext()) + + viewModel.getContributionYearsWithMonthsLiveData().observe(viewLifecycleOwner, { years -> + + if (! years.isNullOrEmpty()) { + + // Add year tabs depending on years list (reversed) + for (year in years.reversed()) { + val tab = binding?.tabLayout?.newTab() + // Set year as tab text + tab?.text = year.year.id.toString() + + if (tab != null) { + binding?.tabLayout?.addTab(tab) + } + } + + + binding?.tabLayout?.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab) { + val year = years.reversed()[tab.position] + adapter.setItems(getMonthsToShow(years, tab.position)) + binding?.toolbar?.title = getString(R.string.year_toolbar_title, year.year.id) + } + + override fun onTabUnselected(tab: TabLayout.Tab) { + + } + + override fun onTabReselected(tab: TabLayout.Tab) { + val year = years.reversed()[tab.position] + adapter.setItems(getMonthsToShow(years, tab.position)) + binding?.toolbar?.title = getString(R.string.year_toolbar_title, year.year.id) + } + }) + + // Set initial tab position + val initialTab = binding?.tabLayout?.getTabAt(0) + initialTab?.select() + + } + + }) + + } + + + // FIXME move to data layer + fun getMonthsToShow(yearsWithMonths: List, tabPosition: Int): List { + val yearToDisplay = yearsWithMonths.reversed()[tabPosition] + var monthWithDays = yearToDisplay.contributionMonths + + // Get current year + val yearFormat = SimpleDateFormat("yyyy", Locale.US) + val currentYear = yearFormat.format(System.currentTimeMillis()).toInt() + + // Fist contribution year + val firstContributingYear = yearsWithMonths.first().year.id + + // Remove future month if display current year + if (yearToDisplay.year.id == currentYear) { + + // Get current month number + val monthFormat = SimpleDateFormat("MM", Locale.US) + val currentMonth = monthFormat.format(System.currentTimeMillis()).toInt() + + monthWithDays = monthWithDays.slice(0 until currentMonth) + } + + + // Remove months before first contribution + if (yearToDisplay.year.id == firstContributingYear) { + val firstMonthIndex = monthWithDays.indexOfFirst { it -> it.contributionDays.sumOf { it.count } != 0 } + + if (firstMonthIndex != -1) { + monthWithDays = monthWithDays.slice(firstMonthIndex until monthWithDays.size) + } + } + + return monthWithDays.reversed() + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + + // Handle contribution cells clicks here + override fun onDayItemClick(contributionDay: ContributionDayEntity) { + Timber.debug("click in FRAGMENT $contributionDay") + + findNavController().navigateSafe(ContributionsGridFragmentDirections.actionContributionsGridFragmentToContributionDayDialogFragment( + contributionDay.count, + contributionDay.date, + contributionDay.color + )) + } +} \ No newline at end of file diff --git a/app/src/main/java/by/alexandr7035/gitstat/view/contributions_grid/ContributionsGridViewModel.kt b/app/src/main/java/by/alexandr7035/gitstat/view/contributions_grid/ContributionsGridViewModel.kt new file mode 100644 index 00000000..ddf4bcab --- /dev/null +++ b/app/src/main/java/by/alexandr7035/gitstat/view/contributions_grid/ContributionsGridViewModel.kt @@ -0,0 +1,16 @@ +package by.alexandr7035.gitstat.view.contributions_grid + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import by.alexandr7035.gitstat.data.ContributionsRepository +import by.alexandr7035.gitstat.data.local.model.ContributionYearWithMonths +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class ContributionsGridViewModel @Inject constructor(private val repository: ContributionsRepository): ViewModel() { + + fun getContributionYearsWithMonthsLiveData(): LiveData> { + return repository.getContributionYearsWithMonthsLiveData() + } +} \ No newline at end of file diff --git a/app/src/main/java/by/alexandr7035/gitstat/view/contributions_grid/DayClickListener.kt b/app/src/main/java/by/alexandr7035/gitstat/view/contributions_grid/DayClickListener.kt new file mode 100644 index 00000000..80fc34ea --- /dev/null +++ b/app/src/main/java/by/alexandr7035/gitstat/view/contributions_grid/DayClickListener.kt @@ -0,0 +1,8 @@ +package by.alexandr7035.gitstat.view.contributions_grid + +import by.alexandr7035.gitstat.data.local.model.ContributionDayEntity + +// Pass clicks from cells -> months -> grid fragment using this interface +interface DayClickListener { + fun onDayItemClick(contributionDay: ContributionDayEntity) +} \ No newline at end of file diff --git a/app/src/main/java/by/alexandr7035/gitstat/view/contributions_grid/DaysAdapter.kt b/app/src/main/java/by/alexandr7035/gitstat/view/contributions_grid/DaysAdapter.kt new file mode 100644 index 00000000..cb31fc1d --- /dev/null +++ b/app/src/main/java/by/alexandr7035/gitstat/view/contributions_grid/DaysAdapter.kt @@ -0,0 +1,65 @@ +package by.alexandr7035.gitstat.view.contributions_grid + +import android.annotation.SuppressLint +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import by.alexandr7035.gitstat.R +import by.alexandr7035.gitstat.core.TimeHelper +import by.alexandr7035.gitstat.data.local.model.ContributionDayEntity +import by.alexandr7035.gitstat.databinding.ViewContributionsGridCellBinding +import by.alexandr7035.gitstat.extensions.debug +import timber.log.Timber + +class DaysAdapter(private val dayClickListener: DayClickListener): RecyclerView.Adapter() { + + private var items: List = emptyList() + + @SuppressLint("NotifyDataSetChanged") + fun setItems(items: List) { + this.items = items + notifyDataSetChanged() + } + + override fun getItemCount(): Int { + return items.size + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = ViewContributionsGridCellBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + (holder.binding.root.background as GradientDrawable).setColor(items[position].color) + + // FIXME + val timeHelper = TimeHelper() + + if (items[position].date == timeHelper.getBeginningOfDayForUnixDate_currentTz(System.currentTimeMillis())) { + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + holder.binding.root.foreground = + ContextCompat.getDrawable(holder.binding.root.context, R.drawable.foreground_contributions_grid_cell_current_day) + } + } + } + + inner class ViewHolder(val binding: ViewContributionsGridCellBinding): RecyclerView.ViewHolder(binding.root), View.OnClickListener { + + init { + binding.root.setOnClickListener(this) + } + + override fun onClick(view: View) { + val clickedDay = items[adapterPosition] + Timber.debug("clicked $clickedDay") + dayClickListener.onDayItemClick(clickedDay) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/by/alexandr7035/gitstat/view/contributions_grid/MonthsAdapter.kt b/app/src/main/java/by/alexandr7035/gitstat/view/contributions_grid/MonthsAdapter.kt new file mode 100644 index 00000000..17f90557 --- /dev/null +++ b/app/src/main/java/by/alexandr7035/gitstat/view/contributions_grid/MonthsAdapter.kt @@ -0,0 +1,72 @@ +package by.alexandr7035.gitstat.view.contributions_grid + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import by.alexandr7035.gitstat.data.local.model.ContributionDayEntity +import by.alexandr7035.gitstat.data.local.model.ContributionsMonthWithDays +import by.alexandr7035.gitstat.databinding.ViewMonthContributionsGridBinding +import by.alexandr7035.gitstat.extensions.debug +import timber.log.Timber +import java.text.SimpleDateFormat +import java.util.* + +class MonthsAdapter(private val fragmentDayClickListener: DayClickListener): RecyclerView.Adapter(), DayClickListener { + + // FIXME + private var items: List = emptyList() + + @SuppressLint("NotifyDataSetChanged") + fun setItems(items: List) { + this.items = items + notifyDataSetChanged() + } + + override fun getItemCount(): Int { + return items.size + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = ViewMonthContributionsGridBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + Timber.debug("month onBindViewHolder") + val monthId = items[position].month.id.toString() + + val format = SimpleDateFormat("yyyyMM", Locale.US) + format.timeZone = TimeZone.getTimeZone("GMT") + // Month id corresponds to date in this format + val unixDate = format.parse(monthId)!!.time + + val strFormat = SimpleDateFormat("MMMM yyyy", Locale.US) + strFormat.timeZone = TimeZone.getTimeZone("GMT") + val dateStr = strFormat.format(unixDate) + + // Moth (e.g. "November 2020") + holder.binding.monthCardTitle.text = dateStr + // Contributions count for this month + holder.binding.monthCardTotalContributions.text = items[position] + .contributionDays + .sumOf { it.count } + .toString() + + val adapter = DaysAdapter(this) + holder.binding.cellsRecycler.adapter = adapter +// holder.binding.cellsRecycler.layoutManager = FlexboxLayoutManager(holder.binding.root.context) + holder.binding.cellsRecycler.layoutManager = GridLayoutManager(holder.binding.root.context, 7) + + // Set days of the month to cells + adapter.setItems(items[position].contributionDays) + } + + class ViewHolder(val binding: ViewMonthContributionsGridBinding): RecyclerView.ViewHolder(binding.root) + + + override fun onDayItemClick(contributionDay: ContributionDayEntity) { + fragmentDayClickListener.onDayItemClick(contributionDay) + } +} \ No newline at end of file diff --git a/app/src/main/java/by/alexandr7035/gitstat/view/repositories/ReposOverviewFragment.kt b/app/src/main/java/by/alexandr7035/gitstat/view/repositories/ReposOverviewFragment.kt index e7871dcc..1cd4a0b0 100644 --- a/app/src/main/java/by/alexandr7035/gitstat/view/repositories/ReposOverviewFragment.kt +++ b/app/src/main/java/by/alexandr7035/gitstat/view/repositories/ReposOverviewFragment.kt @@ -7,7 +7,6 @@ import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController -import by.alexandr7035.gitstat.R import by.alexandr7035.gitstat.databinding.FragmentReposOverviewBinding import by.alexandr7035.gitstat.extensions.navigateSafe import by.alexandr7035.gitstat.view.MainActivity diff --git a/app/src/main/res/drawable/background_contribution_mark.xml b/app/src/main/res/drawable/background_contribution_mark.xml new file mode 100644 index 00000000..f0ffc7d4 --- /dev/null +++ b/app/src/main/res/drawable/background_contribution_mark.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_contributions_grid_cell.xml b/app/src/main/res/drawable/background_contributions_grid_cell.xml new file mode 100644 index 00000000..4cb5a8f4 --- /dev/null +++ b/app/src/main/res/drawable/background_contributions_grid_cell.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/foreground_contributions_grid_cell.xml b/app/src/main/res/drawable/foreground_contributions_grid_cell.xml new file mode 100644 index 00000000..0b10d0fb --- /dev/null +++ b/app/src/main/res/drawable/foreground_contributions_grid_cell.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/foreground_contributions_grid_cell_current_day.xml b/app/src/main/res/drawable/foreground_contributions_grid_cell_current_day.xml new file mode 100644 index 00000000..9be78f1f --- /dev/null +++ b/app/src/main/res/drawable/foreground_contributions_grid_cell_current_day.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-v23/view_contributions_grid_cell.xml b/app/src/main/res/layout-v23/view_contributions_grid_cell.xml new file mode 100644 index 00000000..0bb4bd23 --- /dev/null +++ b/app/src/main/res/layout-v23/view_contributions_grid_cell.xml @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_about_app.xml b/app/src/main/res/layout/fragment_about_app.xml index 5561d1ff..598aef30 100644 --- a/app/src/main/res/layout/fragment_about_app.xml +++ b/app/src/main/res/layout/fragment_about_app.xml @@ -18,48 +18,56 @@ + app:layout_constraintVertical_bias="1.0"> - + - + android:layout_margin="16dp" + android:layout_marginBottom="10dp" + android:background="@color/white" + android:elevation="5dp" + android:orientation="vertical" + android:padding="16dp" + app:layout_constraintEnd_toEndOf="parent"> - + + + + + - + diff --git a/app/src/main/res/layout/fragment_contribution_day_dialog.xml b/app/src/main/res/layout/fragment_contribution_day_dialog.xml new file mode 100644 index 00000000..371a831b --- /dev/null +++ b/app/src/main/res/layout/fragment_contribution_day_dialog.xml @@ -0,0 +1,42 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_contributions.xml b/app/src/main/res/layout/fragment_contributions.xml index 4ef529da..090848c4 100644 --- a/app/src/main/res/layout/fragment_contributions.xml +++ b/app/src/main/res/layout/fragment_contributions.xml @@ -31,6 +31,19 @@ app:layout_constraintStart_toEndOf="@id/drawerBtn" app:layout_constraintTop_toTopOf="parent"/> + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_contributions_grid_cell.xml b/app/src/main/res/layout/view_contributions_grid_cell.xml new file mode 100644 index 00000000..65c00030 --- /dev/null +++ b/app/src/main/res/layout/view_contributions_grid_cell.xml @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_month_contributions_grid.xml b/app/src/main/res/layout/view_month_contributions_grid.xml new file mode 100644 index 00000000..7fbdd966 --- /dev/null +++ b/app/src/main/res/layout/view_month_contributions_grid.xml @@ -0,0 +1,52 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index bbf0e1ff..9368d4df 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -48,7 +48,17 @@ android:id="@+id/contributionsFragment" android:name="by.alexandr7035.gitstat.view.contributions.ContributionsFragment" android:label="fragment_contributions" - tools:layout="@layout/fragment_contributions" /> + tools:layout="@layout/fragment_contributions" > + + + + + @@ -187,4 +197,36 @@ app:popUpToInclusive="true" app:popUpTo="@id/nav_graph" /> + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fc2d1a34..e24c8752 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,10 +11,11 @@ Major features:
- Github profile summary
- - Plot with your repositories languages
- - Your repositories list with filters
+ - Pie chart with programming languages of your repositories
+ - List of your repositories with filters and sorting
- Contributions summary
- - Contributions plots (contributions per day, contribution rate).
+ - Charts for contributions (types, count per day, contribution rate)
+ - Contributions grid (Github-like)
]]> github.com @@ -32,6 +33,7 @@ Private repos SHARED_PREF_TOKEN + SHARED_PREF_VERSION_CODE ID Created @@ -118,7 +120,7 @@ What is contribution rate? - Contribution rate is total contributions count for the given period divided by the day count of this period. + Contribution rate (CR) is an average contributions count for the given period. What\'s on these charts? On all of the years charts the total contribution rate dynamics is presented (for the period from registration date). @@ -132,5 +134,8 @@ GitStat service notification GITSTAT_NOTIFICATION_CHANNEL_ID + Your %1$d + + %1$s contribution(s) \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index b813cfcb..531121df 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -116,4 +116,37 @@ @color/gray_300 + + + + + + + + + + \ No newline at end of file diff --git a/doc/screenshot_contributions_grid_1.png b/doc/screenshot_contributions_grid_1.png new file mode 100644 index 00000000..dac148e7 Binary files /dev/null and b/doc/screenshot_contributions_grid_1.png differ diff --git a/doc/screenshot_profile.png b/doc/screenshot_profile.png new file mode 100644 index 00000000..0586ddf7 Binary files /dev/null and b/doc/screenshot_profile.png differ