From 8094bbec4e7d095a52f17e61153bbb093c4208c3 Mon Sep 17 00:00:00 2001 From: Mariotaku Lee Date: Thu, 17 May 2018 01:19:46 +0800 Subject: [PATCH] WIP: account stats implementation --- build.gradle | 2 + gradle.properties | 3 +- .../twidere/annotation/CustomTabType.java | 2 + .../model/tab/argument/TabArguments.java | 3 +- twidere/build.gradle | 13 + .../1.json | 57 ++++ .../2.json | 67 ++++ .../twidere/util/CustomTabUtils.java | 3 +- .../twidere/activity/HomeActivity.kt | 5 + .../content/database/TwidereDatabase.kt | 24 ++ .../database/converter/LocalDateConverter.kt | 25 ++ .../converter/TimestampDateTimeConverter.kt | 17 + .../database/converter/UserKeyConverter.kt | 18 + .../content/database/dao/AccountDailyStats.kt | 153 +++++++++ .../twidere/content/model/AccountStats.kt | 47 +++ .../twidere/data/ComputableLiveData.kt | 2 + .../twidere/extension/CalendarExtensions.kt | 50 ++- .../extension/lang/ThreadLocalProperty.kt | 51 +++ .../fragment/stats/AccountStatsFragment.kt | 66 ++++ .../fragment/timeline/AbsTimelineFragment.kt | 3 +- .../twidere/model/tab/TabConfiguration.kt | 4 +- .../tab/impl/AccountStatsConfiguration.kt | 40 +++ .../task/worker/AccountDailyStatWorker.kt | 68 ++++ .../twidere/view/chart/LineChartView.kt | 131 ++++++++ .../layout/adapter_item_account_stat_card.xml | 78 +++++ .../res/layout/fragment_account_stats.xml | 18 + twidere/src/main/res/layout/header_user.xml | 2 +- twidere/src/main/res/values/attrs.xml | 309 +++++++++--------- twidere/src/main/res/values/strings.xml | 1 + 29 files changed, 1102 insertions(+), 160 deletions(-) create mode 100644 twidere/schemas/org.mariotaku.twidere.content.database.TwidereDatabase/1.json create mode 100644 twidere/schemas/org.mariotaku.twidere.content.database.TwidereDatabase/2.json create mode 100644 twidere/src/main/kotlin/org/mariotaku/twidere/content/database/TwidereDatabase.kt create mode 100644 twidere/src/main/kotlin/org/mariotaku/twidere/content/database/converter/LocalDateConverter.kt create mode 100644 twidere/src/main/kotlin/org/mariotaku/twidere/content/database/converter/TimestampDateTimeConverter.kt create mode 100644 twidere/src/main/kotlin/org/mariotaku/twidere/content/database/converter/UserKeyConverter.kt create mode 100644 twidere/src/main/kotlin/org/mariotaku/twidere/content/database/dao/AccountDailyStats.kt create mode 100644 twidere/src/main/kotlin/org/mariotaku/twidere/content/model/AccountStats.kt create mode 100644 twidere/src/main/kotlin/org/mariotaku/twidere/extension/lang/ThreadLocalProperty.kt create mode 100644 twidere/src/main/kotlin/org/mariotaku/twidere/fragment/stats/AccountStatsFragment.kt create mode 100644 twidere/src/main/kotlin/org/mariotaku/twidere/model/tab/impl/AccountStatsConfiguration.kt create mode 100644 twidere/src/main/kotlin/org/mariotaku/twidere/task/worker/AccountDailyStatWorker.kt create mode 100644 twidere/src/main/kotlin/org/mariotaku/twidere/view/chart/LineChartView.kt create mode 100644 twidere/src/main/res/layout/adapter_item_account_stat_card.xml create mode 100644 twidere/src/main/res/layout/fragment_account_stats.xml diff --git a/build.gradle b/build.gradle index 38b189a67..68892bb49 100644 --- a/build.gradle +++ b/build.gradle @@ -81,8 +81,10 @@ subprojects { StethoBeanShellREPL : '0.5', ArchLifecycleExtensions: '1.1.1', ArchPaging : '1.0.0', + Room : '1.1.0', ConstraintLayout : '1.1.0', MessageBubbleView : '2.1', + WorkManager : '1.0.0-alpha01', ] } diff --git a/gradle.properties b/gradle.properties index 6dd8a6b05..13df2474a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1,2 @@ -org.gradle.jvmargs=-Xmx3584m \ No newline at end of file +org.gradle.jvmargs=-Xmx3584m +android.databinding.enableV2=true \ No newline at end of file diff --git a/twidere.component.common/src/main/java/org/mariotaku/twidere/annotation/CustomTabType.java b/twidere.component.common/src/main/java/org/mariotaku/twidere/annotation/CustomTabType.java index 14da927c2..da0daf6ed 100644 --- a/twidere.component.common/src/main/java/org/mariotaku/twidere/annotation/CustomTabType.java +++ b/twidere.component.common/src/main/java/org/mariotaku/twidere/annotation/CustomTabType.java @@ -38,6 +38,7 @@ import java.lang.annotation.RetentionPolicy; CustomTabType.LIST_TIMELINE, CustomTabType.PUBLIC_TIMELINE, CustomTabType.NETWORK_PUBLIC_TIMELINE, + CustomTabType.ACCOUNT_STATS, }) @Retention(RetentionPolicy.SOURCE) public @interface CustomTabType { @@ -52,4 +53,5 @@ public @interface CustomTabType { String LIST_TIMELINE = "list_timeline"; String PUBLIC_TIMELINE = "public_timeline"; String NETWORK_PUBLIC_TIMELINE = "network_public_timeline"; + String ACCOUNT_STATS = "account_stats"; } diff --git a/twidere.component.common/src/main/java/org/mariotaku/twidere/model/tab/argument/TabArguments.java b/twidere.component.common/src/main/java/org/mariotaku/twidere/model/tab/argument/TabArguments.java index f884d3e22..fbf96e9c3 100644 --- a/twidere.component.common/src/main/java/org/mariotaku/twidere/model/tab/argument/TabArguments.java +++ b/twidere.component.common/src/main/java/org/mariotaku/twidere/model/tab/argument/TabArguments.java @@ -104,7 +104,8 @@ public class TabArguments implements Parcelable { case CustomTabType.DIRECT_MESSAGES: case CustomTabType.TRENDS_SUGGESTIONS: case CustomTabType.PUBLIC_TIMELINE: - case CustomTabType.NETWORK_PUBLIC_TIMELINE: { + case CustomTabType.NETWORK_PUBLIC_TIMELINE: + case CustomTabType.ACCOUNT_STATS: { return LoganSquare.parse(json, TabArguments.class); } case CustomTabType.USER_TIMELINE: diff --git a/twidere/build.gradle b/twidere/build.gradle index 23a719752..4d6966c7b 100644 --- a/twidere/build.gradle +++ b/twidere/build.gradle @@ -57,6 +57,16 @@ android { additionalParameters '--no-version-vectors' } + kapt { + arguments { + arg("room.schemaLocation", "$projectDir/schemas") + } + } + + dataBinding { + enabled = true + } + flavorDimensions 'channel' productFlavors { @@ -164,6 +174,7 @@ dependencies { kapt "com.google.dagger:dagger-compiler:${libVersions['Dagger']}" kapt "com.github.mariotaku.ObjectCursor:processor:${libVersions['ObjectCursor']}" kapt "com.github.bumptech.glide:compiler:${libVersions['Glide']}" + kapt "android.arch.persistence.room:compiler:${libVersions['Room']}" implementation project(':twidere.component.common') implementation project(':twidere.component.nyan') @@ -204,6 +215,8 @@ dependencies { implementation "android.arch.lifecycle:extensions:${libVersions['ArchLifecycleExtensions']}" implementation "android.arch.paging:runtime:${libVersions['ArchPaging']}" + implementation "android.arch.persistence.room:runtime:${libVersions['Room']}" + implementation "android.arch.work:work-runtime-ktx:${libVersions['WorkManager']}" implementation "com.android.support:multidex:${libVersions['MultiDex']}" implementation "com.android.support:support-annotations:${libVersions['SupportLib']}" implementation "com.android.support:support-compat:${libVersions['SupportLib']}" diff --git a/twidere/schemas/org.mariotaku.twidere.content.database.TwidereDatabase/1.json b/twidere/schemas/org.mariotaku.twidere.content.database.TwidereDatabase/1.json new file mode 100644 index 000000000..091179d26 --- /dev/null +++ b/twidere/schemas/org.mariotaku.twidere.content.database.TwidereDatabase/1.json @@ -0,0 +1,57 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "df1fe758472ff9d1d8e21da6f4d8b5fc", + "entities": [ + { + "tableName": "account_daily_stats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `createdAt` TEXT NOT NULL, `accountKey` TEXT NOT NULL, `statusesCount` INTEGER NOT NULL, `followersCount` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountKey", + "columnName": "accountKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusesCount", + "columnName": "statusesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "followersCount", + "columnName": "followersCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "rowId" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"df1fe758472ff9d1d8e21da6f4d8b5fc\")" + ] + } +} \ No newline at end of file diff --git a/twidere/schemas/org.mariotaku.twidere.content.database.TwidereDatabase/2.json b/twidere/schemas/org.mariotaku.twidere.content.database.TwidereDatabase/2.json new file mode 100644 index 000000000..7c80a81a4 --- /dev/null +++ b/twidere/schemas/org.mariotaku.twidere.content.database.TwidereDatabase/2.json @@ -0,0 +1,67 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "9ab8ea60785ca135c5f1dc50547c143f", + "entities": [ + { + "tableName": "account_daily_stats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `createdAt` TEXT NOT NULL, `accountKey` TEXT NOT NULL, `statusesCount` INTEGER NOT NULL, `followersCount` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountKey", + "columnName": "accountKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusesCount", + "columnName": "statusesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "followersCount", + "columnName": "followersCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "rowId" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_account_daily_stats_createdAt_accountKey", + "unique": true, + "columnNames": [ + "createdAt", + "accountKey" + ], + "createSql": "CREATE UNIQUE INDEX `index_account_daily_stats_createdAt_accountKey` ON `${TABLE_NAME}` (`createdAt`, `accountKey`)" + } + ], + "foreignKeys": [] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"9ab8ea60785ca135c5f1dc50547c143f\")" + ] + } +} \ No newline at end of file diff --git a/twidere/src/main/java/org/mariotaku/twidere/util/CustomTabUtils.java b/twidere/src/main/java/org/mariotaku/twidere/util/CustomTabUtils.java index 984962bd6..35ed19d2a 100644 --- a/twidere/src/main/java/org/mariotaku/twidere/util/CustomTabUtils.java +++ b/twidere/src/main/java/org/mariotaku/twidere/util/CustomTabUtils.java @@ -126,7 +126,8 @@ public class CustomTabUtils implements Constants { case CustomTabType.DIRECT_MESSAGES: case CustomTabType.TRENDS_SUGGESTIONS: case CustomTabType.PUBLIC_TIMELINE: - case CustomTabType.NETWORK_PUBLIC_TIMELINE: { + case CustomTabType.NETWORK_PUBLIC_TIMELINE: + case CustomTabType.ACCOUNT_STATS: { return new TabArguments(); } case CustomTabType.USER_TIMELINE: diff --git a/twidere/src/main/kotlin/org/mariotaku/twidere/activity/HomeActivity.kt b/twidere/src/main/kotlin/org/mariotaku/twidere/activity/HomeActivity.kt index 668162413..7f4a6eaca 100644 --- a/twidere/src/main/kotlin/org/mariotaku/twidere/activity/HomeActivity.kt +++ b/twidere/src/main/kotlin/org/mariotaku/twidere/activity/HomeActivity.kt @@ -62,6 +62,8 @@ import android.view.View.OnClickListener import android.view.View.OnLongClickListener import android.view.ViewGroup.MarginLayoutParams import android.widget.Toast +import androidx.work.WorkManager +import androidx.work.ktx.OneTimeWorkRequestBuilder import com.getkeepsafe.taptargetview.TapTarget import com.getkeepsafe.taptargetview.TapTargetView import com.squareup.otto.Subscribe @@ -103,6 +105,7 @@ import org.mariotaku.twidere.receiver.NotificationReceiver import org.mariotaku.twidere.service.StreamingService import org.mariotaku.twidere.singleton.BusSingleton import org.mariotaku.twidere.singleton.PreferencesSingleton +import org.mariotaku.twidere.task.worker.AccountDailyStatWorker import org.mariotaku.twidere.util.* import org.mariotaku.twidere.util.KeyboardShortcutsHandler.KeyboardShortcutCallback import org.mariotaku.twidere.util.premium.ExtraFeaturesService @@ -293,6 +296,8 @@ class HomeActivity : BaseActivity(), OnClickListener, OnPageChangeListener, Supp if (!showDrawerTutorial() && !PreferencesSingleton.get(this)[defaultAutoRefreshAskedKey]) { showAutoRefreshConfirm() } + + WorkManager.getInstance().enqueue(OneTimeWorkRequestBuilder().build()) } override fun onPostCreate(savedInstanceState: Bundle?) { diff --git a/twidere/src/main/kotlin/org/mariotaku/twidere/content/database/TwidereDatabase.kt b/twidere/src/main/kotlin/org/mariotaku/twidere/content/database/TwidereDatabase.kt new file mode 100644 index 000000000..d07d28fcb --- /dev/null +++ b/twidere/src/main/kotlin/org/mariotaku/twidere/content/database/TwidereDatabase.kt @@ -0,0 +1,24 @@ +package org.mariotaku.twidere.content.database + +import android.arch.persistence.room.Database +import android.arch.persistence.room.Room +import android.arch.persistence.room.RoomDatabase +import android.arch.persistence.room.TypeConverters +import org.mariotaku.twidere.content.database.converter.UserKeyConverter +import org.mariotaku.twidere.content.database.dao.AccountDailyStats +import org.mariotaku.twidere.content.model.AccountStats +import org.mariotaku.twidere.util.lang.ApplicationContextSingletonHolder + +@Database(entities = [AccountStats::class], version = 2) +@TypeConverters(UserKeyConverter::class) +abstract class TwidereDatabase : RoomDatabase() { + + abstract fun accountDailyStats(): AccountDailyStats + + companion object : ApplicationContextSingletonHolder({ + Room.databaseBuilder(it, TwidereDatabase::class.java, "twidere.db") + .fallbackToDestructiveMigration() + .build() + }) + +} diff --git a/twidere/src/main/kotlin/org/mariotaku/twidere/content/database/converter/LocalDateConverter.kt b/twidere/src/main/kotlin/org/mariotaku/twidere/content/database/converter/LocalDateConverter.kt new file mode 100644 index 000000000..6a0cf8db6 --- /dev/null +++ b/twidere/src/main/kotlin/org/mariotaku/twidere/content/database/converter/LocalDateConverter.kt @@ -0,0 +1,25 @@ +package org.mariotaku.twidere.content.database.converter + +import android.arch.persistence.room.TypeConverter +import org.mariotaku.twidere.extension.lang.threadLocal +import java.text.SimpleDateFormat +import java.util.* + +class LocalDateConverter { + + @TypeConverter + fun parse(time: String): Date { + return simpleDateFormat.parse(time) + } + + @TypeConverter + fun serialize(date: Date): String { + return simpleDateFormat.format(date) + } + + companion object { + val simpleDateFormat by threadLocal { + SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH) + } + } +} \ No newline at end of file diff --git a/twidere/src/main/kotlin/org/mariotaku/twidere/content/database/converter/TimestampDateTimeConverter.kt b/twidere/src/main/kotlin/org/mariotaku/twidere/content/database/converter/TimestampDateTimeConverter.kt new file mode 100644 index 000000000..7fb9ad105 --- /dev/null +++ b/twidere/src/main/kotlin/org/mariotaku/twidere/content/database/converter/TimestampDateTimeConverter.kt @@ -0,0 +1,17 @@ +package org.mariotaku.twidere.content.database.converter + +import android.arch.persistence.room.TypeConverter +import java.util.* + +class TimestampDateTimeConverter { + + @TypeConverter + fun parse(time: Long): Date { + return Date(time) + } + + @TypeConverter + fun serialize(date: Date): Long { + return date.time + } +} \ No newline at end of file diff --git a/twidere/src/main/kotlin/org/mariotaku/twidere/content/database/converter/UserKeyConverter.kt b/twidere/src/main/kotlin/org/mariotaku/twidere/content/database/converter/UserKeyConverter.kt new file mode 100644 index 000000000..5f56b1f5b --- /dev/null +++ b/twidere/src/main/kotlin/org/mariotaku/twidere/content/database/converter/UserKeyConverter.kt @@ -0,0 +1,18 @@ +package org.mariotaku.twidere.content.database.converter + +import android.arch.persistence.room.TypeConverter +import org.mariotaku.twidere.model.UserKey + +class UserKeyConverter { + + @TypeConverter + fun parse(str: String): UserKey { + return UserKey.valueOf(str) + } + + @TypeConverter + fun serialize(key: UserKey): String { + return key.toString() + } + +} diff --git a/twidere/src/main/kotlin/org/mariotaku/twidere/content/database/dao/AccountDailyStats.kt b/twidere/src/main/kotlin/org/mariotaku/twidere/content/database/dao/AccountDailyStats.kt new file mode 100644 index 000000000..1a3c24736 --- /dev/null +++ b/twidere/src/main/kotlin/org/mariotaku/twidere/content/database/dao/AccountDailyStats.kt @@ -0,0 +1,153 @@ +package org.mariotaku.twidere.content.database.dao + +import android.arch.persistence.room.* +import org.mariotaku.twidere.content.database.converter.LocalDateConverter +import org.mariotaku.twidere.content.model.AccountStats +import org.mariotaku.twidere.extension.julianDay +import org.mariotaku.twidere.extension.time +import org.mariotaku.twidere.model.UserKey +import java.util.* +import kotlin.math.sign + +@Dao +abstract class AccountDailyStats { + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract fun insert(stats: AccountStats) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract fun insert(stats: Collection) + + @Query("SELECT * FROM `account_daily_stats` WHERE `createdAt` = :date") + abstract fun list(@TypeConverters(LocalDateConverter::class) date: Date): List + + @Query("SELECT * FROM `account_daily_stats` WHERE `accountKey` = :accountKey AND `createdAt` BETWEEN :since AND :until ORDER BY `createdAt`") + abstract fun list(accountKey: UserKey, @TypeConverters(LocalDateConverter::class) since: Date, + @TypeConverters(LocalDateConverter::class) until: Date): List + + @Query("SELECT * FROM `account_daily_stats` WHERE `accountKey` = :accountKey AND `createdAt` >= :since ORDER BY `createdAt` LIMIT 1") + abstract fun firstSince(accountKey: UserKey, @TypeConverters(LocalDateConverter::class) since: Date): AccountStats? + + fun listSparse(accountKey: UserKey, @TypeConverters(LocalDateConverter::class) since: Date, + @TypeConverters(LocalDateConverter::class) until: Date): Array { + val sinceJulianDay = Calendar.getInstance().time(since).julianDay() + val count = Calendar.getInstance().time(until).julianDay() - sinceJulianDay + 1 + val result = arrayOfNulls(count) + val tempCal = Calendar.getInstance() + list(accountKey, since, until).forEach { + tempCal.time = it.createdAt + val index = tempCal.julianDay() - sinceJulianDay + if (index in result.indices) { + result[index] = it + } + } + return result + } + + fun monthlySummary(accountKey: UserKey, date: Date): AccountStats.Summaries { + val since = Calendar.getInstance().apply { + time = date + add(Calendar.DATE, -27) + }.time + + val prevPeriodFirst = firstSince(accountKey, Calendar.getInstance().apply { + time = date + add(Calendar.DATE, -55) + }.time) + + val currentPeriodStats = listSparse(accountKey, since, date) + + val statuses = currentPeriodStats.statusesSummary(prevPeriodFirst) + + val followers = currentPeriodStats.followersSummary() + + return AccountStats.Summaries(statuses, followers) + } + + private fun Array.statusesSummary(prevPeriodFirst: AccountStats?): AccountStats.DisplaySummary { + val firstNumber = numberAt(0, AccountStats::statusesCount) + val lastNumber = numberAt(lastIndex, AccountStats::statusesCount) + + var growthText: String? = null + var growthSign = 0 + if (prevPeriodFirst != null) { + val prevPeriodCount = firstNumber - prevPeriodFirst.statusesCount + val currPeriodCount = lastNumber - firstNumber + + val growthPercent = currPeriodCount / prevPeriodCount.toFloat() - 1 + growthText = String.format(Locale.US, "%.1f%%", Math.abs(growthPercent * 100)) + growthSign = growthPercent.sign.toInt() + } + + val firstNonNullIndex = indexOfFirst { it != null } + if (firstNonNullIndex < 0) throw DataNotReadyException() + + val diffs = LongArray(size - firstNonNullIndex) item@{ index -> + val statIndex = index + firstNonNullIndex + if (statIndex == 0) return@item 0 + return@item numberAt(statIndex, AccountStats::statusesCount) - numberAt(statIndex - 1, + AccountStats::statusesCount) + } + var maxDiff = diffs.max()!! + if (maxDiff <= 0L) { + maxDiff = Math.abs(diffs.min()!!) + } + if (maxDiff <= 0L) { + maxDiff = 10 + } + + val values = FloatArray(size - firstNonNullIndex) item@{ index -> + return@item diffs[index] / maxDiff.toFloat() + } + + val periodSum = lastNumber - firstNumber + return AccountStats.DisplaySummary(periodSum, growthSign, growthText, size, values) + } + + private fun Array.followersSummary(): AccountStats.DisplaySummary { + + val firstNumber = numberAt(0, AccountStats::followersCount) + val lastNumber = numberAt(lastIndex, AccountStats::followersCount) + + val maxNumber = maxBy { it?.followersCount ?: Long.MIN_VALUE }!!.followersCount + val valuesCount = size + val values = indices.map { index -> + val number = numberAt(index, AccountStats::followersCount) + return@map (number - firstNumber) / (maxNumber - firstNumber).toFloat() + }.toFloatArray() + + val growth = lastNumber - firstNumber + return AccountStats.DisplaySummary(firstNumber, growth.sign, Math.abs(growth).toString(), + valuesCount, values) + } + + private fun Array.numberAt(index: Int, selector: (AccountStats) -> Long): Long { + val itemAt = this[index] + if (itemAt != null) return selector(itemAt) + val firstNonNullIndex = indexOfFirst { it != null } + val lastNonNullIndex = indexOfLast { it != null } + + if (firstNonNullIndex < 0 || lastNonNullIndex < 0 || firstNonNullIndex == lastNonNullIndex) { + throw DataNotReadyException() + } + + val count = lastNonNullIndex - firstNonNullIndex + val firstNonNullNumber = selector(this[firstNonNullIndex]!!) + val lastNonNullNumber = selector(this[lastNonNullIndex]!!) + val delta = (lastNonNullNumber - firstNonNullNumber) / count + when { + index < firstNonNullIndex -> return firstNonNullNumber - delta * (firstNonNullIndex - index) + index > lastNonNullIndex -> return lastNonNullNumber + delta * (index - lastNonNullIndex) + else -> { + val startIndex = (0 until index).last { this[it] != null } + val endIndex = (index + 1..lastIndex).first { this[it] != null } + val start = selector(this[startIndex]!!) + val end = selector(this[endIndex]!!) + val delta2 = (end - start) / (endIndex - startIndex) + return start + delta2 * (index - startIndex) + } + } + } + + class DataNotReadyException : Exception() + +} diff --git a/twidere/src/main/kotlin/org/mariotaku/twidere/content/model/AccountStats.kt b/twidere/src/main/kotlin/org/mariotaku/twidere/content/model/AccountStats.kt new file mode 100644 index 000000000..604b2c841 --- /dev/null +++ b/twidere/src/main/kotlin/org/mariotaku/twidere/content/model/AccountStats.kt @@ -0,0 +1,47 @@ +package org.mariotaku.twidere.content.model + +import android.arch.persistence.room.Entity +import android.arch.persistence.room.Index +import android.arch.persistence.room.PrimaryKey +import android.arch.persistence.room.TypeConverters +import org.mariotaku.twidere.content.database.converter.LocalDateConverter +import org.mariotaku.twidere.model.UserKey +import java.util.* + +@Entity(tableName = "account_daily_stats", + indices = [Index("createdAt", "accountKey", unique = true)]) +@TypeConverters(LocalDateConverter::class) +data class AccountStats( + @PrimaryKey(autoGenerate = true) + val rowId: Long = 0, + val createdAt: Date, + val accountKey: UserKey, + val statusesCount: Long, + val followersCount: Long +) { + + + data class Summaries( + val statuses: DisplaySummary, + val followers: DisplaySummary + ) + + data class DisplaySummary( + val number: Long, + val growthSign: Int, + val growth: String?, + val valuesCount: Int, + val values: FloatArray + ) { + val numberDisplay: String + get() = number.toString() + + val growthDisplay: String? + get() = when { + growthSign < 0 -> "-$growth" + growthSign > 0 -> "+$growth" + else -> growth + } + } + +} diff --git a/twidere/src/main/kotlin/org/mariotaku/twidere/data/ComputableLiveData.kt b/twidere/src/main/kotlin/org/mariotaku/twidere/data/ComputableLiveData.kt index 91ff2b40a..f6195f581 100644 --- a/twidere/src/main/kotlin/org/mariotaku/twidere/data/ComputableLiveData.kt +++ b/twidere/src/main/kotlin/org/mariotaku/twidere/data/ComputableLiveData.kt @@ -24,6 +24,7 @@ import android.support.annotation.WorkerThread import nl.komponents.kovenant.task import nl.komponents.kovenant.ui.failUi import nl.komponents.kovenant.ui.successUi +import org.mariotaku.twidere.util.DebugLog abstract class ComputableLiveData(loadOnInstantiate: Boolean) : LiveData() { @@ -37,6 +38,7 @@ abstract class ComputableLiveData(loadOnInstantiate: Boolean) : LiveData() task(body = this::compute).successUi { postValue(it) }.failUi { + DebugLog.e(msg = "Exception in ComputableLiveData", tr = it) postValue(null) } } diff --git a/twidere/src/main/kotlin/org/mariotaku/twidere/extension/CalendarExtensions.kt b/twidere/src/main/kotlin/org/mariotaku/twidere/extension/CalendarExtensions.kt index 6f5138f53..7424c0def 100644 --- a/twidere/src/main/kotlin/org/mariotaku/twidere/extension/CalendarExtensions.kt +++ b/twidere/src/main/kotlin/org/mariotaku/twidere/extension/CalendarExtensions.kt @@ -20,12 +20,54 @@ package org.mariotaku.twidere.extension import java.util.* +import java.util.concurrent.TimeUnit + +private val epochCalendar = Calendar.getInstance().timeInMillis(0) +val epochWeekDay: Int = epochCalendar.get(Calendar.DAY_OF_WEEK) - epochCalendar.getMinimum(Calendar.DAY_OF_WEEK) +val epochJulianDay: Int = epochCalendar.julianDay() -/** - * Created by mariotaku on 2017/9/25. - */ fun Calendar.isSameDay(that: Calendar): Boolean { return this[Calendar.ERA] == that[Calendar.ERA] && this[Calendar.YEAR] == that[Calendar.YEAR] && this[Calendar.DAY_OF_YEAR] == that[Calendar.DAY_OF_YEAR] -} \ No newline at end of file +} + +fun Calendar.timeInMillis(time: Long): Calendar = apply { timeInMillis = time } +fun Calendar.time(time: Date): Calendar = apply { this.time = time } + +fun Calendar.daysSinceEpoch(day: Int): Calendar = apply { + timeInMillis = 0 + add(Calendar.DATE, day) +} + +fun Calendar.clearClockTime() { + set(Calendar.HOUR_OF_DAY, 0) + clear(Calendar.MINUTE) + clear(Calendar.SECOND) + clear(Calendar.MILLISECOND) +} + +fun Calendar.dayProgress(): Float { + val offsetHour = (get(Calendar.HOUR_OF_DAY) - 6 + 24) % 24 + val dayMillis = TimeUnit.HOURS.toMillis(offsetHour.toLong()) + + TimeUnit.MINUTES.toMillis(get(Calendar.MINUTE).toLong()) + + TimeUnit.SECONDS.toMillis(get(Calendar.SECOND).toLong()) + + get(Calendar.MILLISECOND) + return dayMillis / TimeUnit.HOURS.toMillis(24).toFloat() +} + +// http://en.wikipedia.org/wiki/Julian_day +fun Calendar.julianDay(): Int { + val year = get(Calendar.YEAR) + val month = get(Calendar.MONTH) + 1 + val day = get(Calendar.DATE) + + val a = (14 - month) / 12 + val y = year + 4800 - a + val m = month + 12 * a - 3 + return day + (153 * m + 2) / 5 + 365 * y + y / 4 - y / 100 + y / 400 - 32045 +} + +fun Calendar.daysSinceEpoch(): Int = julianDay() - epochJulianDay + +fun Calendar.weeksSinceEpoch(): Int = (daysSinceEpoch() + epochWeekDay) / 7 \ No newline at end of file diff --git a/twidere/src/main/kotlin/org/mariotaku/twidere/extension/lang/ThreadLocalProperty.kt b/twidere/src/main/kotlin/org/mariotaku/twidere/extension/lang/ThreadLocalProperty.kt new file mode 100644 index 000000000..c623bdc21 --- /dev/null +++ b/twidere/src/main/kotlin/org/mariotaku/twidere/extension/lang/ThreadLocalProperty.kt @@ -0,0 +1,51 @@ +/* + * Twidere - Twitter client for Android + * + * Copyright (C) 2012-2017 Mariotaku Lee + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.mariotaku.twidere.extension.lang + +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +class ThreadLocalDelegate(initial: () -> T) { + + private var threadLocal: ThreadLocal = PropertyThreadLocal(initial) + + operator fun getValue(thisRef: Any?, property: KProperty<*>): T = threadLocal.get() + + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { + threadLocal.set(value) + } + + private class PropertyThreadLocal(val initial: () -> T) : ThreadLocal() { + + override fun initialValue(): T { + return initial() + } + } +} + +private class ThreadLocalProperty(private val ref: ThreadLocal) : ReadOnlyProperty { + + override fun getValue(thisRef: Any, property: KProperty<*>): T? = ref.get() +} + +fun threadLocal(obj: () -> T): ThreadLocalDelegate = ThreadLocalDelegate(obj) + +operator fun ThreadLocal.provideDelegate(thisRef: Any, prop: KProperty<*>): ReadOnlyProperty = + ThreadLocalProperty(this) \ No newline at end of file diff --git a/twidere/src/main/kotlin/org/mariotaku/twidere/fragment/stats/AccountStatsFragment.kt b/twidere/src/main/kotlin/org/mariotaku/twidere/fragment/stats/AccountStatsFragment.kt new file mode 100644 index 000000000..d4897fa89 --- /dev/null +++ b/twidere/src/main/kotlin/org/mariotaku/twidere/fragment/stats/AccountStatsFragment.kt @@ -0,0 +1,66 @@ +package org.mariotaku.twidere.fragment.stats + +import android.arch.lifecycle.Observer +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import kotlinx.android.synthetic.main.fragment_account_stats.* +import org.mariotaku.twidere.R +import org.mariotaku.twidere.content.database.TwidereDatabase +import org.mariotaku.twidere.content.model.AccountStats +import org.mariotaku.twidere.data.ComputableLiveData +import org.mariotaku.twidere.databinding.AdapterItemAccountStatCardBinding +import org.mariotaku.twidere.fragment.BaseFragment +import org.mariotaku.twidere.model.UserKey +import org.mariotaku.twidere.util.Utils +import java.util.* + +class AccountStatsFragment : BaseFragment() { + + private lateinit var liveStats: AccountStatsLiveData + + private lateinit var statusesBinding: AdapterItemAccountStatCardBinding + private lateinit var followersBinding: AdapterItemAccountStatCardBinding + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + liveStats = AccountStatsLiveData(context!!, Utils.getAccountKeys(context!!, + arguments)!!.first()) + + + statusesBinding = AdapterItemAccountStatCardBinding.inflate(layoutInflater, + statsScrollContent, true) + followersBinding = AdapterItemAccountStatCardBinding.inflate(layoutInflater, + statsScrollContent, true) + + liveStats.observe(this, Observer { + displaySummaries(it) + }) + liveStats.load() + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_account_stats, container, false) + } + + private fun displaySummaries(summaries: AccountStats.Summaries?) { + if (summaries == null) return + statusesBinding.statTitle.text = getString(R.string.title_statuses) + followersBinding.statTitle.text = getString(R.string.title_followers) + + statusesBinding.summary = summaries.statuses + followersBinding.summary = summaries.followers + } + + class AccountStatsLiveData( + val context: Context, + val accountKey: UserKey + ) : ComputableLiveData(false) { + override fun compute(): AccountStats.Summaries { + return TwidereDatabase.get(context).accountDailyStats().monthlySummary(accountKey, Date()) + } + + } +} \ No newline at end of file diff --git a/twidere/src/main/kotlin/org/mariotaku/twidere/fragment/timeline/AbsTimelineFragment.kt b/twidere/src/main/kotlin/org/mariotaku/twidere/fragment/timeline/AbsTimelineFragment.kt index 1bbb64bc2..24ca03b4b 100644 --- a/twidere/src/main/kotlin/org/mariotaku/twidere/fragment/timeline/AbsTimelineFragment.kt +++ b/twidere/src/main/kotlin/org/mariotaku/twidere/fragment/timeline/AbsTimelineFragment.kt @@ -342,8 +342,9 @@ abstract class AbsTimelineFragment : AbsContentRecyclerViewFragment?) { + val context = context ?: return val firstVisiblePosition = positionBackup.getAndSet(null) ?: return - if (firstVisiblePosition.position == 0 && !PreferencesSingleton.get(context!!)[readFromBottomKey]) { + if (firstVisiblePosition.position == 0 && !PreferencesSingleton.get(context)[readFromBottomKey]) { scrollToPositionWithOffset(0, 0) } else { scrollToPositionWithOffset(firstVisiblePosition.position, firstVisiblePosition.offset) diff --git a/twidere/src/main/kotlin/org/mariotaku/twidere/model/tab/TabConfiguration.kt b/twidere/src/main/kotlin/org/mariotaku/twidere/model/tab/TabConfiguration.kt index a1977eb65..9e1023ff1 100644 --- a/twidere/src/main/kotlin/org/mariotaku/twidere/model/tab/TabConfiguration.kt +++ b/twidere/src/main/kotlin/org/mariotaku/twidere/model/tab/TabConfiguration.kt @@ -150,7 +150,8 @@ abstract class TabConfiguration { CustomTabType.TRENDS_SUGGESTIONS, CustomTabType.DIRECT_MESSAGES, CustomTabType.FAVORITES, CustomTabType.USER_TIMELINE, CustomTabType.USER_MEDIA_TIMELINE, CustomTabType.SEARCH_STATUSES, CustomTabType.LIST_TIMELINE, - CustomTabType.PUBLIC_TIMELINE, CustomTabType.NETWORK_PUBLIC_TIMELINE) + CustomTabType.PUBLIC_TIMELINE, CustomTabType.NETWORK_PUBLIC_TIMELINE, + CustomTabType.ACCOUNT_STATS) val all: List> = allTypes.mapNotNull { val conf = ofType(it) ?: return@mapNotNull null @@ -170,6 +171,7 @@ abstract class TabConfiguration { CustomTabType.SEARCH_STATUSES -> SearchTabConfiguration() CustomTabType.PUBLIC_TIMELINE -> PublicTimelineTabConfiguration() CustomTabType.NETWORK_PUBLIC_TIMELINE -> NetworkPublicTimelineTabConfiguration() + CustomTabType.ACCOUNT_STATS -> AccountStatsConfiguration() else -> null } } diff --git a/twidere/src/main/kotlin/org/mariotaku/twidere/model/tab/impl/AccountStatsConfiguration.kt b/twidere/src/main/kotlin/org/mariotaku/twidere/model/tab/impl/AccountStatsConfiguration.kt new file mode 100644 index 000000000..0b4427fbe --- /dev/null +++ b/twidere/src/main/kotlin/org/mariotaku/twidere/model/tab/impl/AccountStatsConfiguration.kt @@ -0,0 +1,40 @@ +/* + * Twidere - Twitter client for Android + * + * Copyright (C) 2012-2017 Mariotaku Lee + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.mariotaku.twidere.model.tab.impl + +import org.mariotaku.twidere.R +import org.mariotaku.twidere.annotation.TabAccountFlags +import org.mariotaku.twidere.fragment.stats.AccountStatsFragment +import org.mariotaku.twidere.model.tab.DrawableHolder +import org.mariotaku.twidere.model.tab.StringHolder +import org.mariotaku.twidere.model.tab.TabConfiguration + +class AccountStatsConfiguration : TabConfiguration() { + + override val name = StringHolder.resource(R.string.title_account_stats) + + override val icon = DrawableHolder.Builtin.TRENDS + + override val accountFlags = TabAccountFlags.FLAG_HAS_ACCOUNT or + TabAccountFlags.FLAG_ACCOUNT_REQUIRED or TabAccountFlags.FLAG_ACCOUNT_MUTABLE + + override val fragmentClass = AccountStatsFragment::class.java + +} diff --git a/twidere/src/main/kotlin/org/mariotaku/twidere/task/worker/AccountDailyStatWorker.kt b/twidere/src/main/kotlin/org/mariotaku/twidere/task/worker/AccountDailyStatWorker.kt new file mode 100644 index 000000000..48e40410f --- /dev/null +++ b/twidere/src/main/kotlin/org/mariotaku/twidere/task/worker/AccountDailyStatWorker.kt @@ -0,0 +1,68 @@ +package org.mariotaku.twidere.task.worker + +import android.accounts.AccountManager +import androidx.work.Worker +import org.mariotaku.microblog.library.Mastodon +import org.mariotaku.microblog.library.MicroBlog +import org.mariotaku.microblog.library.MicroBlogException +import org.mariotaku.twidere.annotation.AccountType +import org.mariotaku.twidere.content.database.TwidereDatabase +import org.mariotaku.twidere.content.database.dao.AccountDailyStats +import org.mariotaku.twidere.content.model.AccountStats +import org.mariotaku.twidere.extension.getAllDetails +import org.mariotaku.twidere.extension.model.api.mastodon.toParcelable +import org.mariotaku.twidere.extension.model.api.toParcelable +import org.mariotaku.twidere.extension.model.newMicroBlogInstance +import java.text.SimpleDateFormat +import java.util.* + +class AccountDailyStatWorker : Worker() { + + override fun doWork(): WorkerResult { + val am = AccountManager.get(applicationContext) + val dao = TwidereDatabase.get(applicationContext).accountDailyStats() + val date = Date() + val existingStats = dao.list(date) + val accounts = am.getAllDetails(true).filterNot { account -> + existingStats.any { it.accountKey == account.key } + } + val stats = accounts.mapNotNull { + val user = try { + when (it.type) { + AccountType.MASTODON -> it.newMicroBlogInstance(applicationContext, Mastodon::class.java) + .verifyCredentials().toParcelable(it.key) + else -> it.newMicroBlogInstance(applicationContext, MicroBlog::class.java) + .verifyCredentials().toParcelable(it.key, it.type) + } + } catch (e: MicroBlogException) { + return@mapNotNull null + } + return@mapNotNull AccountStats(createdAt = date, accountKey = it.key, + statusesCount = user.statuses_count, followersCount = user.followers_count) + } + dao.insert(stats) + return WorkerResult.SUCCESS + } + + private fun AccountDailyStats.insertTestData(stats: List) { + val cal = Calendar.getInstance() + val date = SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH).parse("2018-05-14") + var i = 1 + var s = stats + while (i < 90) { + cal.time = date + cal.add(Calendar.DATE, -i) + s = s.map { + it.copy( + rowId = 0, + createdAt = cal.time, + statusesCount = (it.statusesCount - Math.random() * 30).toLong().coerceAtLeast(0), + followersCount = ((it.followersCount + Math.random() * 20).toLong() - 10).coerceAtLeast(0) + ) + } + insert(s) + i++ + } + } + +} diff --git a/twidere/src/main/kotlin/org/mariotaku/twidere/view/chart/LineChartView.kt b/twidere/src/main/kotlin/org/mariotaku/twidere/view/chart/LineChartView.kt new file mode 100644 index 000000000..b22920714 --- /dev/null +++ b/twidere/src/main/kotlin/org/mariotaku/twidere/view/chart/LineChartView.kt @@ -0,0 +1,131 @@ +package org.mariotaku.twidere.view.chart + +import android.content.Context +import android.databinding.BindingMethod +import android.databinding.BindingMethods +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.util.AttributeSet +import android.view.View +import org.mariotaku.twidere.R + +@BindingMethods( + BindingMethod(type = LineChartView::class, attribute = "chartValues", method = "setValues"), + BindingMethod(type = LineChartView::class, attribute = "chartValuesCount", method = "setValuesCount") +) +class LineChartView(context: Context, attrs: AttributeSet?) : View(context, attrs) { + + var values: FloatArray? = null + set(value) { + field = value + updatePath() + invalidate() + } + + var valuesCount: Int = 0 + set(value) { + field = value + updatePath() + invalidate() + } + + private var chartLineColor: Int = Color.LTGRAY + set(value) { + field = value + linePaint.color = value + } + private var chartLineSize: Float = 1f + set(value) { + field = value + linePaint.strokeWidth = value + } + + private var chartBaseline: Float + + private var chartBaselineColor: Int = Color.LTGRAY + set(value) { + field = value + baselinePaint.color = value + } + private var chartBaselineSize: Float = 1f + set(value) { + field = value + baselinePaint.strokeWidth = value + } + private var chartEndpointColor: Int = Color.LTGRAY + set(value) { + field = value + endpointPaint.color = value + } + + private var chartEndpointSize: Float = 2f + set(value) { + field = value + endpointPaint.strokeWidth = value + invalidate() + } + + private val linePaint = createPaint() + private val baselinePaint = createPaint() + private val endpointPaint = createPaint() + + private val path: Path = Path() + + init { + val a = context.obtainStyledAttributes(attrs, R.styleable.LineChartView) + chartBaseline = a.getDimension(R.styleable.LineChartView_chartBaseline, 0f) + chartBaselineColor = a.getColor(R.styleable.LineChartView_chartBaselineColor, Color.LTGRAY) + chartBaselineSize = a.getDimension(R.styleable.LineChartView_chartBaselineSize, 1f) + chartLineColor = a.getColor(R.styleable.LineChartView_chartLineColor, Color.LTGRAY) + chartLineSize = a.getDimension(R.styleable.LineChartView_chartLineSize, 1f) + chartEndpointColor = a.getColor(R.styleable.LineChartView_chartEndpointColor, Color.DKGRAY) + chartEndpointSize = a.getDimension(R.styleable.LineChartView_chartEndpointSize, 2f) + a.recycle() + } + + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.drawPath(path, linePaint) + canvas.drawLine(0f, height - chartBaseline, width.toFloat(), + height - chartBaseline, baselinePaint) + + val values = this.values + if (values != null && values.isNotEmpty()) { + val x = width * (values.lastIndex / (valuesCount - 1f)) + val y = (height - chartBaseline) * (1 - values.last()) + canvas.drawPoint(x, y, endpointPaint) + } + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + updatePath() + } + + private fun updatePath() { + if (width <= 0 || height <= 0) return + path.reset() + if (valuesCount <= 1) return + val values = this.values ?: return + values.forEachIndexed { index, value -> + val x = width * (index / (valuesCount - 1f)) + val y = (height - chartBaseline) * (1 - value) + if (index == 0) { + path.moveTo(x, y) + } else { + path.lineTo(x, y) + } + } + } + + companion object { + private fun createPaint() = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + } + } +} \ No newline at end of file diff --git a/twidere/src/main/res/layout/adapter_item_account_stat_card.xml b/twidere/src/main/res/layout/adapter_item_account_stat_card.xml new file mode 100644 index 000000000..44c7bd6f2 --- /dev/null +++ b/twidere/src/main/res/layout/adapter_item_account_stat_card.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/twidere/src/main/res/layout/fragment_account_stats.xml b/twidere/src/main/res/layout/fragment_account_stats.xml new file mode 100644 index 000000000..81fc2ea9c --- /dev/null +++ b/twidere/src/main/res/layout/fragment_account_stats.xml @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/twidere/src/main/res/layout/header_user.xml b/twidere/src/main/res/layout/header_user.xml index 199482bbd..2377be208 100644 --- a/twidere/src/main/res/layout/header_user.xml +++ b/twidere/src/main/res/layout/header_user.xml @@ -246,7 +246,7 @@ android:baselineAligned="false" android:orientation="horizontal" android:splitMotionEvents="false" - app:layout_constraintTop_toBottomOf="@+id/createdAt"> + app:layout_constraintTop_toBottomOf="@+id/date"> - - - - - - - - - - - - + + + + + + + + + + + + - - + + - - + + - - + + - - - - - - - - - - - + + + + + + + + + + + - + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - + + + - - + + - + - - - - + + + + - - + + - + - + - - - - - - - - - + + + + + + + + + - + - - + + - - - + + + - - - - + + + + - - - + + + - - + + - + - + - + - + - - - - + + + + - - - - - - + + + + + + - + - + - - - - - - - + + + + + + + - - + + - + - - - - - - + + + + + + - + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - - + + + + - - + + - - + + - - - + + + - - - + + + - + \ No newline at end of file diff --git a/twidere/src/main/res/values/strings.xml b/twidere/src/main/res/values/strings.xml index 1b8396799..979a32778 100644 --- a/twidere/src/main/res/values/strings.xml +++ b/twidere/src/main/res/values/strings.xml @@ -1316,6 +1316,7 @@ Translators Trends + Stats Trends location Set location for local trends.