mirror of https://github.com/tuskyapp/Tusky.git
Browse Source
* custom tabs * custom tabs interface * implement custom tab functionality * add database migration * fix bugs, improve ThemeUtils nullability handling * implement conversationsfragment * setup ConversationViewHolder * implement favs * add button functionality * revert 10.json * revert item_status_notification.xml * implement more menu, replying, fix stuff, clean up * fix tests * fix bug with expanding statuses * min and max number of tabs * settings support, fix bugs * database migration * fix scrolling to top after refresh * fix bugs * fix warning in item_conversationpull/1036/head
75 changed files with 3663 additions and 296 deletions
@ -0,0 +1,668 @@
|
||||
{ |
||||
"formatVersion": 1, |
||||
"database": { |
||||
"version": 12, |
||||
"identityHash": "d4d3d4c683ab7f681459b9edab92301c", |
||||
"entities": [ |
||||
{ |
||||
"tableName": "TootEntity", |
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER)", |
||||
"fields": [ |
||||
{ |
||||
"fieldPath": "uid", |
||||
"columnName": "uid", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "text", |
||||
"columnName": "text", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "urls", |
||||
"columnName": "urls", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "descriptions", |
||||
"columnName": "descriptions", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "contentWarning", |
||||
"columnName": "contentWarning", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "inReplyToId", |
||||
"columnName": "inReplyToId", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "inReplyToText", |
||||
"columnName": "inReplyToText", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "inReplyToUsername", |
||||
"columnName": "inReplyToUsername", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "visibility", |
||||
"columnName": "visibility", |
||||
"affinity": "INTEGER", |
||||
"notNull": false |
||||
} |
||||
], |
||||
"primaryKey": { |
||||
"columnNames": [ |
||||
"uid" |
||||
], |
||||
"autoGenerate": true |
||||
}, |
||||
"indices": [], |
||||
"foreignKeys": [] |
||||
}, |
||||
{ |
||||
"tableName": "AccountEntity", |
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL)", |
||||
"fields": [ |
||||
{ |
||||
"fieldPath": "id", |
||||
"columnName": "id", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "domain", |
||||
"columnName": "domain", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "accessToken", |
||||
"columnName": "accessToken", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "isActive", |
||||
"columnName": "isActive", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "accountId", |
||||
"columnName": "accountId", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "username", |
||||
"columnName": "username", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "displayName", |
||||
"columnName": "displayName", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "profilePictureUrl", |
||||
"columnName": "profilePictureUrl", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationsEnabled", |
||||
"columnName": "notificationsEnabled", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationsMentioned", |
||||
"columnName": "notificationsMentioned", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationsFollowed", |
||||
"columnName": "notificationsFollowed", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationsReblogged", |
||||
"columnName": "notificationsReblogged", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationsFavorited", |
||||
"columnName": "notificationsFavorited", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationSound", |
||||
"columnName": "notificationSound", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationVibration", |
||||
"columnName": "notificationVibration", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationLight", |
||||
"columnName": "notificationLight", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "defaultPostPrivacy", |
||||
"columnName": "defaultPostPrivacy", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "defaultMediaSensitivity", |
||||
"columnName": "defaultMediaSensitivity", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "alwaysShowSensitiveMedia", |
||||
"columnName": "alwaysShowSensitiveMedia", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "mediaPreviewEnabled", |
||||
"columnName": "mediaPreviewEnabled", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastNotificationId", |
||||
"columnName": "lastNotificationId", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "activeNotifications", |
||||
"columnName": "activeNotifications", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "emojis", |
||||
"columnName": "emojis", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "tabPreferences", |
||||
"columnName": "tabPreferences", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
} |
||||
], |
||||
"primaryKey": { |
||||
"columnNames": [ |
||||
"id" |
||||
], |
||||
"autoGenerate": true |
||||
}, |
||||
"indices": [ |
||||
{ |
||||
"name": "index_AccountEntity_domain_accountId", |
||||
"unique": true, |
||||
"columnNames": [ |
||||
"domain", |
||||
"accountId" |
||||
], |
||||
"createSql": "CREATE UNIQUE INDEX `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" |
||||
} |
||||
], |
||||
"foreignKeys": [] |
||||
}, |
||||
{ |
||||
"tableName": "InstanceEntity", |
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, PRIMARY KEY(`instance`))", |
||||
"fields": [ |
||||
{ |
||||
"fieldPath": "instance", |
||||
"columnName": "instance", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "emojiList", |
||||
"columnName": "emojiList", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "maximumTootCharacters", |
||||
"columnName": "maximumTootCharacters", |
||||
"affinity": "INTEGER", |
||||
"notNull": false |
||||
} |
||||
], |
||||
"primaryKey": { |
||||
"columnNames": [ |
||||
"instance" |
||||
], |
||||
"autoGenerate": false |
||||
}, |
||||
"indices": [], |
||||
"foreignKeys": [] |
||||
}, |
||||
{ |
||||
"tableName": "TimelineStatusEntity", |
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `instance` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", |
||||
"fields": [ |
||||
{ |
||||
"fieldPath": "serverId", |
||||
"columnName": "serverId", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "url", |
||||
"columnName": "url", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "timelineUserId", |
||||
"columnName": "timelineUserId", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "authorServerId", |
||||
"columnName": "authorServerId", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "instance", |
||||
"columnName": "instance", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "inReplyToId", |
||||
"columnName": "inReplyToId", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "inReplyToAccountId", |
||||
"columnName": "inReplyToAccountId", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "content", |
||||
"columnName": "content", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "createdAt", |
||||
"columnName": "createdAt", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "emojis", |
||||
"columnName": "emojis", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "reblogsCount", |
||||
"columnName": "reblogsCount", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "favouritesCount", |
||||
"columnName": "favouritesCount", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "reblogged", |
||||
"columnName": "reblogged", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "favourited", |
||||
"columnName": "favourited", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "sensitive", |
||||
"columnName": "sensitive", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "spoilerText", |
||||
"columnName": "spoilerText", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "visibility", |
||||
"columnName": "visibility", |
||||
"affinity": "INTEGER", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "attachments", |
||||
"columnName": "attachments", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "mentions", |
||||
"columnName": "mentions", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "application", |
||||
"columnName": "application", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "reblogServerId", |
||||
"columnName": "reblogServerId", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "reblogAccountId", |
||||
"columnName": "reblogAccountId", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
} |
||||
], |
||||
"primaryKey": { |
||||
"columnNames": [ |
||||
"serverId", |
||||
"timelineUserId" |
||||
], |
||||
"autoGenerate": false |
||||
}, |
||||
"indices": [ |
||||
{ |
||||
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId", |
||||
"unique": false, |
||||
"columnNames": [ |
||||
"authorServerId", |
||||
"timelineUserId" |
||||
], |
||||
"createSql": "CREATE INDEX `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" |
||||
} |
||||
], |
||||
"foreignKeys": [ |
||||
{ |
||||
"table": "TimelineAccountEntity", |
||||
"onDelete": "NO ACTION", |
||||
"onUpdate": "NO ACTION", |
||||
"columns": [ |
||||
"authorServerId", |
||||
"timelineUserId" |
||||
], |
||||
"referencedColumns": [ |
||||
"serverId", |
||||
"timelineUserId" |
||||
] |
||||
} |
||||
] |
||||
}, |
||||
{ |
||||
"tableName": "TimelineAccountEntity", |
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `instance` TEXT NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", |
||||
"fields": [ |
||||
{ |
||||
"fieldPath": "serverId", |
||||
"columnName": "serverId", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "timelineUserId", |
||||
"columnName": "timelineUserId", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "instance", |
||||
"columnName": "instance", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "localUsername", |
||||
"columnName": "localUsername", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "username", |
||||
"columnName": "username", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "displayName", |
||||
"columnName": "displayName", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "url", |
||||
"columnName": "url", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "avatar", |
||||
"columnName": "avatar", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "emojis", |
||||
"columnName": "emojis", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
} |
||||
], |
||||
"primaryKey": { |
||||
"columnNames": [ |
||||
"serverId", |
||||
"timelineUserId" |
||||
], |
||||
"autoGenerate": false |
||||
}, |
||||
"indices": [], |
||||
"foreignKeys": [] |
||||
}, |
||||
{ |
||||
"tableName": "ConversationEntity", |
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, PRIMARY KEY(`id`, `accountId`))", |
||||
"fields": [ |
||||
{ |
||||
"fieldPath": "accountId", |
||||
"columnName": "accountId", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "id", |
||||
"columnName": "id", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "accounts", |
||||
"columnName": "accounts", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "unread", |
||||
"columnName": "unread", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.id", |
||||
"columnName": "s_id", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.url", |
||||
"columnName": "s_url", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.inReplyToId", |
||||
"columnName": "s_inReplyToId", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.inReplyToAccountId", |
||||
"columnName": "s_inReplyToAccountId", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.account", |
||||
"columnName": "s_account", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.content", |
||||
"columnName": "s_content", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.createdAt", |
||||
"columnName": "s_createdAt", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.emojis", |
||||
"columnName": "s_emojis", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.favouritesCount", |
||||
"columnName": "s_favouritesCount", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.favourited", |
||||
"columnName": "s_favourited", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.sensitive", |
||||
"columnName": "s_sensitive", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.spoilerText", |
||||
"columnName": "s_spoilerText", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.attachments", |
||||
"columnName": "s_attachments", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.mentions", |
||||
"columnName": "s_mentions", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.showingHiddenContent", |
||||
"columnName": "s_showingHiddenContent", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.expanded", |
||||
"columnName": "s_expanded", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.collapsible", |
||||
"columnName": "s_collapsible", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.collapsed", |
||||
"columnName": "s_collapsed", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
} |
||||
], |
||||
"primaryKey": { |
||||
"columnNames": [ |
||||
"id", |
||||
"accountId" |
||||
], |
||||
"autoGenerate": false |
||||
}, |
||||
"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, \"d4d3d4c683ab7f681459b9edab92301c\")" |
||||
] |
||||
} |
||||
} |
||||
@ -0,0 +1,57 @@
|
||||
/* Copyright 2019 Conny Duck |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* 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. |
||||
* |
||||
* Tusky 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 Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky |
||||
|
||||
import androidx.annotation.DrawableRes |
||||
import androidx.annotation.StringRes |
||||
import androidx.fragment.app.Fragment |
||||
import com.keylesspalace.tusky.components.conversation.ConversationsFragment |
||||
import com.keylesspalace.tusky.fragment.NotificationsFragment |
||||
import com.keylesspalace.tusky.fragment.TimelineFragment |
||||
|
||||
/** this would be a good case for a sealed class, but that does not work nice with Room */ |
||||
|
||||
const val HOME = "Home" |
||||
const val NOTIFICATIONS = "Notifications" |
||||
const val LOCAL = "Local" |
||||
const val FEDERATED = "Federated" |
||||
const val DIRECT = "Direct" |
||||
|
||||
data class TabData(val id: String, |
||||
@StringRes val text: Int, |
||||
@DrawableRes val icon: Int, |
||||
val fragment: () -> Fragment) |
||||
|
||||
|
||||
fun createTabDataFromId(id: String): TabData { |
||||
return when (id) { |
||||
HOME -> TabData(HOME, R.string.title_home, R.drawable.ic_home_24dp) { TimelineFragment.newInstance(TimelineFragment.Kind.HOME) } |
||||
NOTIFICATIONS -> TabData(NOTIFICATIONS, R.string.title_notifications, R.drawable.ic_notifications_24dp) { NotificationsFragment.newInstance() } |
||||
LOCAL -> TabData(LOCAL, R.string.title_public_local, R.drawable.ic_local_24dp) { TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_LOCAL) } |
||||
FEDERATED -> TabData(FEDERATED, R.string.title_public_federated, R.drawable.ic_public_24dp) { TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_FEDERATED) } |
||||
DIRECT -> TabData(DIRECT, R.string.title_direct_messages, R.drawable.reblog_direct_dark) { ConversationsFragment.newInstance() } |
||||
else -> throw IllegalArgumentException("unknown tab type") |
||||
} |
||||
} |
||||
|
||||
fun defaultTabs(): List<TabData> { |
||||
return listOf( |
||||
createTabDataFromId(HOME), |
||||
createTabDataFromId(NOTIFICATIONS), |
||||
createTabDataFromId(LOCAL), |
||||
createTabDataFromId(FEDERATED) |
||||
) |
||||
} |
||||
@ -0,0 +1,205 @@
|
||||
/* Copyright 2019 Conny Duck |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* 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. |
||||
* |
||||
* Tusky 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 Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky |
||||
|
||||
import android.os.Bundle |
||||
import android.view.MenuItem |
||||
import androidx.recyclerview.widget.DividerItemDecoration |
||||
import androidx.recyclerview.widget.ItemTouchHelper |
||||
import androidx.recyclerview.widget.LinearLayoutManager |
||||
import androidx.recyclerview.widget.RecyclerView |
||||
import com.keylesspalace.tusky.adapter.ItemInteractionListener |
||||
import com.keylesspalace.tusky.adapter.TabAdapter |
||||
import com.keylesspalace.tusky.appstore.EventHub |
||||
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent |
||||
import com.keylesspalace.tusky.di.Injectable |
||||
import com.keylesspalace.tusky.util.visible |
||||
import kotlinx.android.synthetic.main.activity_tab_preference.* |
||||
import kotlinx.android.synthetic.main.toolbar_basic.* |
||||
import javax.inject.Inject |
||||
|
||||
class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListener { |
||||
|
||||
@Inject |
||||
lateinit var eventHub: EventHub |
||||
|
||||
private lateinit var currentTabs: MutableList<TabData> |
||||
private lateinit var currentTabsAdapter: TabAdapter |
||||
private lateinit var touchHelper: ItemTouchHelper |
||||
private lateinit var addTabAdapter: TabAdapter |
||||
|
||||
private val selectedItemElevation by lazy { resources.getDimension(R.dimen.selected_drag_item_elevation) } |
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) { |
||||
super.onCreate(savedInstanceState) |
||||
|
||||
setContentView(R.layout.activity_tab_preference) |
||||
|
||||
setSupportActionBar(toolbar) |
||||
|
||||
supportActionBar?.apply { |
||||
setTitle(R.string.title_tab_preferences) |
||||
setDisplayHomeAsUpEnabled(true) |
||||
setDisplayShowHomeEnabled(true) |
||||
} |
||||
|
||||
currentTabs = (accountManager.activeAccount?.tabPreferences ?: emptyList()).toMutableList() |
||||
currentTabsAdapter = TabAdapter(currentTabs, false, this) |
||||
currentTabsRecyclerView.adapter = currentTabsAdapter |
||||
currentTabsRecyclerView.layoutManager = LinearLayoutManager(this) |
||||
currentTabsRecyclerView.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL)) |
||||
|
||||
addTabAdapter = TabAdapter(listOf(createTabDataFromId(DIRECT)), true, this) |
||||
addTabRecyclerView.adapter = addTabAdapter |
||||
addTabRecyclerView.layoutManager = LinearLayoutManager(this) |
||||
|
||||
touchHelper = ItemTouchHelper(object: ItemTouchHelper.Callback(){ |
||||
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { |
||||
return makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.END) |
||||
} |
||||
|
||||
override fun isLongPressDragEnabled(): Boolean { |
||||
return true |
||||
} |
||||
|
||||
override fun isItemViewSwipeEnabled(): Boolean { |
||||
return MIN_TAB_COUNT < currentTabs.size |
||||
} |
||||
|
||||
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { |
||||
val temp = currentTabs[viewHolder.adapterPosition] |
||||
currentTabs[viewHolder.adapterPosition] = currentTabs[target.adapterPosition] |
||||
currentTabs[target.adapterPosition] = temp |
||||
|
||||
currentTabsAdapter.notifyItemMoved(viewHolder.adapterPosition, target.adapterPosition) |
||||
saveTabs() |
||||
return true |
||||
} |
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { |
||||
currentTabs.removeAt(viewHolder.adapterPosition) |
||||
currentTabsAdapter.notifyItemRemoved(viewHolder.adapterPosition) |
||||
updateAvailableTabs() |
||||
saveTabs() |
||||
} |
||||
|
||||
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { |
||||
if(actionState == ItemTouchHelper.ACTION_STATE_DRAG) { |
||||
viewHolder?.itemView?.elevation = selectedItemElevation |
||||
} |
||||
} |
||||
|
||||
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { |
||||
super.clearView(recyclerView, viewHolder) |
||||
viewHolder.itemView.elevation = 0f |
||||
} |
||||
}) |
||||
|
||||
touchHelper.attachToRecyclerView(currentTabsRecyclerView) |
||||
|
||||
|
||||
actionButton.setOnClickListener { |
||||
actionButton.isExpanded = true |
||||
} |
||||
|
||||
scrim.setOnClickListener { |
||||
actionButton.isExpanded = false |
||||
} |
||||
|
||||
updateAvailableTabs() |
||||
|
||||
} |
||||
|
||||
override fun onTabAdded(tab: TabData) { |
||||
currentTabs.add(tab) |
||||
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) |
||||
actionButton.isExpanded = false |
||||
updateAvailableTabs() |
||||
saveTabs() |
||||
} |
||||
|
||||
private fun updateAvailableTabs() { |
||||
val addableTabs: MutableList<TabData> = mutableListOf() |
||||
|
||||
val homeTab = createTabDataFromId(HOME) |
||||
if(!currentTabs.contains(homeTab)) { |
||||
addableTabs.add(homeTab) |
||||
} |
||||
val notificationTab = createTabDataFromId(NOTIFICATIONS) |
||||
if(!currentTabs.contains(notificationTab)) { |
||||
addableTabs.add(notificationTab) |
||||
} |
||||
val localTab = createTabDataFromId(LOCAL) |
||||
if(!currentTabs.contains(localTab)) { |
||||
addableTabs.add(localTab) |
||||
} |
||||
val federatedTab = createTabDataFromId(FEDERATED) |
||||
if(!currentTabs.contains(federatedTab)) { |
||||
addableTabs.add(federatedTab) |
||||
} |
||||
val directMessagesTab = createTabDataFromId(DIRECT) |
||||
if(!currentTabs.contains(directMessagesTab)) { |
||||
addableTabs.add(directMessagesTab) |
||||
} |
||||
|
||||
addTabAdapter.updateData(addableTabs) |
||||
|
||||
maxTabsInfo.visible(addableTabs.size == 0 || currentTabs.size >= MAX_TAB_COUNT) |
||||
|
||||
} |
||||
|
||||
override fun onStartDelete(viewHolder: RecyclerView.ViewHolder) { |
||||
touchHelper.startSwipe(viewHolder) |
||||
} |
||||
|
||||
override fun onStartDrag(viewHolder: RecyclerView.ViewHolder) { |
||||
touchHelper.startDrag(viewHolder) |
||||
} |
||||
|
||||
private fun saveTabs() { |
||||
accountManager.activeAccount?.let { |
||||
it.tabPreferences = currentTabs |
||||
accountManager.saveAccount(it) |
||||
} |
||||
} |
||||
|
||||
override fun onBackPressed() { |
||||
if (actionButton.isExpanded) { |
||||
actionButton.isExpanded = false |
||||
} else { |
||||
super.onBackPressed() |
||||
} |
||||
} |
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean { |
||||
if (item.itemId == android.R.id.home) { |
||||
onBackPressed() |
||||
return true |
||||
} |
||||
return false |
||||
} |
||||
|
||||
override fun onPause() { |
||||
super.onPause() |
||||
eventHub.dispatch(MainTabsChangedEvent(currentTabs)) |
||||
} |
||||
|
||||
companion object { |
||||
private const val MIN_TAB_COUNT = 2 |
||||
private const val MAX_TAB_COUNT = 5 |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,45 @@
|
||||
/* Copyright 2019 Conny Duck |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* 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. |
||||
* |
||||
* Tusky 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 Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.adapter |
||||
|
||||
import androidx.recyclerview.widget.RecyclerView |
||||
import android.view.View |
||||
import android.view.ViewGroup |
||||
import com.keylesspalace.tusky.util.NetworkState |
||||
import com.keylesspalace.tusky.util.Status |
||||
import com.keylesspalace.tusky.util.visible |
||||
import kotlinx.android.synthetic.main.item_network_state.view.* |
||||
|
||||
class NetworkStateViewHolder(itemView: View, |
||||
private val retryCallback: () -> Unit) |
||||
: RecyclerView.ViewHolder(itemView) { |
||||
|
||||
fun setUpWithNetworkState(state: NetworkState?, fullScreen: Boolean) { |
||||
itemView.progressBar.visible(state?.status == Status.RUNNING) |
||||
itemView.retryButton.visible(state?.status == Status.FAILED) |
||||
itemView.errorMsg.visible(state?.msg != null) |
||||
itemView.errorMsg.text = state?.msg |
||||
itemView.retryButton.setOnClickListener { |
||||
retryCallback() |
||||
} |
||||
if(fullScreen) { |
||||
itemView.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT |
||||
} else { |
||||
itemView.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,80 @@
|
||||
/* Copyright 2019 Conny Duck |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* 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. |
||||
* |
||||
* Tusky 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 Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.adapter |
||||
|
||||
import android.view.LayoutInflater |
||||
import android.view.MotionEvent |
||||
import android.view.View |
||||
import android.view.ViewGroup |
||||
|
||||
import androidx.recyclerview.widget.RecyclerView |
||||
import com.keylesspalace.tusky.R |
||||
import com.keylesspalace.tusky.TabData |
||||
import com.keylesspalace.tusky.util.ThemeUtils |
||||
import kotlinx.android.synthetic.main.item_tab_preference.view.* |
||||
|
||||
interface ItemInteractionListener { |
||||
fun onTabAdded(tab: TabData) |
||||
fun onStartDelete(viewHolder: RecyclerView.ViewHolder) |
||||
fun onStartDrag(viewHolder: RecyclerView.ViewHolder) |
||||
} |
||||
|
||||
class TabAdapter(var data: List<TabData>, |
||||
val small: Boolean = false, |
||||
val listener: ItemInteractionListener? = null) : RecyclerView.Adapter<TabAdapter.ViewHolder>() { |
||||
|
||||
fun updateData(newData: List<TabData>) { |
||||
this.data = newData |
||||
notifyDataSetChanged() |
||||
} |
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { |
||||
val layoutId = if(small) { |
||||
R.layout.item_tab_preference_small |
||||
} else { |
||||
R.layout.item_tab_preference |
||||
} |
||||
val view = LayoutInflater.from(parent.context).inflate(layoutId, parent, false) |
||||
return ViewHolder(view) |
||||
} |
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) { |
||||
holder.itemView.textView.setText(data[position].text) |
||||
val iconDrawable = ThemeUtils.getTintedDrawable(holder.itemView.context, data[position].icon, android.R.attr.textColorSecondary) |
||||
holder.itemView.textView.setCompoundDrawablesRelativeWithIntrinsicBounds(iconDrawable, null, null, null) |
||||
if(small) { |
||||
holder.itemView.textView.setOnClickListener { |
||||
listener?.onTabAdded(data[position]) |
||||
} |
||||
} |
||||
holder.itemView.imageView?.setOnTouchListener { _, event -> |
||||
if(event.action == MotionEvent.ACTION_DOWN) { |
||||
listener?.onStartDrag(holder) |
||||
true |
||||
} else { |
||||
false |
||||
} |
||||
} |
||||
} |
||||
|
||||
|
||||
|
||||
override fun getItemCount(): Int { |
||||
return data.size |
||||
} |
||||
|
||||
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) |
||||
} |
||||
@ -0,0 +1,108 @@
|
||||
package com.keylesspalace.tusky.components.conversation |
||||
|
||||
import android.view.LayoutInflater |
||||
import android.view.ViewGroup |
||||
import androidx.paging.AsyncPagedListDiffer |
||||
import androidx.paging.PagedList |
||||
import androidx.recyclerview.widget.AsyncDifferConfig |
||||
import androidx.recyclerview.widget.DiffUtil |
||||
import androidx.recyclerview.widget.ListUpdateCallback |
||||
import androidx.recyclerview.widget.RecyclerView |
||||
import com.keylesspalace.tusky.R |
||||
import com.keylesspalace.tusky.adapter.NetworkStateViewHolder |
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener |
||||
import com.keylesspalace.tusky.util.NetworkState |
||||
|
||||
class ConversationAdapter(private val useAbsoluteTime: Boolean, |
||||
private val mediaPreviewEnabled: Boolean, |
||||
private val listener: StatusActionListener, |
||||
private val topLoadedCallback: () -> Unit, |
||||
private val retryCallback: () -> Unit) |
||||
: RecyclerView.Adapter<RecyclerView.ViewHolder>() { |
||||
|
||||
private var networkState: NetworkState? = null |
||||
|
||||
private val differ: AsyncPagedListDiffer<ConversationEntity> = AsyncPagedListDiffer(object: ListUpdateCallback { |
||||
override fun onInserted(position: Int, count: Int) { |
||||
notifyItemRangeInserted(position, count) |
||||
if(position == 0) { |
||||
topLoadedCallback() |
||||
} |
||||
} |
||||
|
||||
override fun onRemoved(position: Int, count: Int) { |
||||
notifyItemRangeRemoved(position, count) |
||||
} |
||||
|
||||
override fun onMoved(fromPosition: Int, toPosition: Int) { |
||||
notifyItemMoved(fromPosition, toPosition) |
||||
} |
||||
|
||||
override fun onChanged(position: Int, count: Int, payload: Any?) { |
||||
notifyItemRangeChanged(position, count, payload) |
||||
} |
||||
}, AsyncDifferConfig.Builder<ConversationEntity>(CONVERSATION_COMPARATOR).build()) |
||||
|
||||
fun submitList(list: PagedList<ConversationEntity>) { |
||||
differ.submitList(list) |
||||
} |
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { |
||||
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false) |
||||
return when (viewType) { |
||||
R.layout.item_network_state -> NetworkStateViewHolder(view, retryCallback) |
||||
R.layout.item_conversation -> ConversationViewHolder(view, listener, useAbsoluteTime, mediaPreviewEnabled) |
||||
else -> throw IllegalArgumentException("unknown view type $viewType") |
||||
} |
||||
} |
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { |
||||
when (getItemViewType(position)) { |
||||
R.layout.item_network_state -> (holder as NetworkStateViewHolder).setUpWithNetworkState(networkState, differ.itemCount == 0) |
||||
R.layout.item_conversation -> (holder as ConversationViewHolder).setupWithConversation(differ.getItem(position)) |
||||
} |
||||
} |
||||
|
||||
private fun hasExtraRow() = networkState != null && networkState != NetworkState.LOADED |
||||
|
||||
override fun getItemViewType(position: Int): Int { |
||||
return if (hasExtraRow() && position == itemCount - 1) { |
||||
R.layout.item_network_state |
||||
} else { |
||||
R.layout.item_conversation |
||||
} |
||||
} |
||||
|
||||
override fun getItemCount(): Int { |
||||
return differ.itemCount + if (hasExtraRow()) 1 else 0 |
||||
} |
||||
|
||||
fun setNetworkState(newNetworkState: NetworkState?) { |
||||
val previousState = this.networkState |
||||
val hadExtraRow = hasExtraRow() |
||||
this.networkState = newNetworkState |
||||
val hasExtraRow = hasExtraRow() |
||||
if (hadExtraRow != hasExtraRow) { |
||||
if (hadExtraRow) { |
||||
notifyItemRemoved(differ.itemCount) |
||||
} else { |
||||
notifyItemInserted(differ.itemCount) |
||||
} |
||||
} else if (hasExtraRow && previousState != newNetworkState) { |
||||
notifyItemChanged(itemCount - 1) |
||||
} |
||||
} |
||||
|
||||
companion object { |
||||
|
||||
val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationEntity>() { |
||||
override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean = |
||||
oldItem == newItem |
||||
|
||||
override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean = |
||||
oldItem.id == newItem.id |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,186 @@
|
||||
/* Copyright 2019 Conny Duck |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* 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. |
||||
* |
||||
* Tusky 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 Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.components.conversation |
||||
|
||||
import android.text.Spanned |
||||
import android.text.SpannedString |
||||
import androidx.room.Embedded |
||||
import androidx.room.Entity |
||||
import androidx.room.TypeConverters |
||||
import com.keylesspalace.tusky.db.Converters |
||||
import com.keylesspalace.tusky.entity.* |
||||
import com.keylesspalace.tusky.util.SmartLengthInputFilter |
||||
import java.util.* |
||||
|
||||
@Entity(primaryKeys = ["id","accountId"]) |
||||
@TypeConverters(Converters::class) |
||||
data class ConversationEntity( |
||||
val accountId: Long, |
||||
val id: String, |
||||
val accounts: List<ConversationAccountEntity>, |
||||
val unread: Boolean, |
||||
@Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity |
||||
) |
||||
|
||||
data class ConversationAccountEntity( |
||||
val id: String, |
||||
val username: String, |
||||
val displayName: String, |
||||
val avatar: String, |
||||
val emojis: List<Emoji> |
||||
) { |
||||
fun toAccount(): Account { |
||||
return Account( |
||||
id = id, |
||||
username = username, |
||||
displayName = displayName, |
||||
avatar = avatar, |
||||
emojis = emojis, |
||||
url = "", |
||||
localUsername = "", |
||||
note = SpannedString(""), |
||||
header = "" |
||||
) |
||||
} |
||||
} |
||||
|
||||
@TypeConverters(Converters::class) |
||||
data class ConversationStatusEntity( |
||||
val id: String, |
||||
val url: String?, |
||||
val inReplyToId: String?, |
||||
val inReplyToAccountId: String?, |
||||
val account: ConversationAccountEntity, |
||||
val content: Spanned, |
||||
val createdAt: Date, |
||||
val emojis: List<Emoji>, |
||||
val favouritesCount: Int, |
||||
val favourited: Boolean, |
||||
val sensitive: Boolean, |
||||
val spoilerText: String, |
||||
val attachments: List<Attachment>, |
||||
val mentions: Array<Status.Mention>, |
||||
val showingHiddenContent: Boolean, |
||||
val expanded: Boolean, |
||||
val collapsible: Boolean, |
||||
val collapsed: Boolean |
||||
|
||||
) { |
||||
/** its necessary to override this because Spanned.equals does not work as expected */ |
||||
override fun equals(other: Any?): Boolean { |
||||
if (this === other) return true |
||||
if (javaClass != other?.javaClass) return false |
||||
|
||||
other as ConversationStatusEntity |
||||
|
||||
if (id != other.id) return false |
||||
if (url != other.url) return false |
||||
if (inReplyToId != other.inReplyToId) return false |
||||
if (inReplyToAccountId != other.inReplyToAccountId) return false |
||||
if (account != other.account) return false |
||||
if (content.toString() != other.content.toString()) return false //TODO find a better method to compare two spanned strings |
||||
if (createdAt != other.createdAt) return false |
||||
if (emojis != other.emojis) return false |
||||
if (favouritesCount != other.favouritesCount) return false |
||||
if (favourited != other.favourited) return false |
||||
if (sensitive != other.sensitive) return false |
||||
if (spoilerText != other.spoilerText) return false |
||||
if (attachments != other.attachments) return false |
||||
if (!mentions.contentEquals(other.mentions)) return false |
||||
if (showingHiddenContent != other.showingHiddenContent) return false |
||||
if (expanded != other.expanded) return false |
||||
if (collapsible != other.collapsible) return false |
||||
if (collapsed != other.collapsed) return false |
||||
|
||||
return true |
||||
} |
||||
|
||||
override fun hashCode(): Int { |
||||
var result = id.hashCode() |
||||
result = 31 * result + (url?.hashCode() ?: 0) |
||||
result = 31 * result + (inReplyToId?.hashCode() ?: 0) |
||||
result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0) |
||||
result = 31 * result + account.hashCode() |
||||
result = 31 * result + content.hashCode() |
||||
result = 31 * result + createdAt.hashCode() |
||||
result = 31 * result + emojis.hashCode() |
||||
result = 31 * result + favouritesCount |
||||
result = 31 * result + favourited.hashCode() |
||||
result = 31 * result + sensitive.hashCode() |
||||
result = 31 * result + spoilerText.hashCode() |
||||
result = 31 * result + attachments.hashCode() |
||||
result = 31 * result + mentions.contentHashCode() |
||||
result = 31 * result + showingHiddenContent.hashCode() |
||||
result = 31 * result + expanded.hashCode() |
||||
result = 31 * result + collapsible.hashCode() |
||||
result = 31 * result + collapsed.hashCode() |
||||
return result |
||||
} |
||||
|
||||
fun toStatus(): Status { |
||||
return Status( |
||||
id = id, |
||||
url = url, |
||||
account = account.toAccount(), |
||||
inReplyToId = inReplyToId, |
||||
inReplyToAccountId = inReplyToAccountId, |
||||
content = content, |
||||
reblog = null, |
||||
createdAt = createdAt, |
||||
emojis = emojis, |
||||
reblogsCount = 0, |
||||
favouritesCount = favouritesCount, |
||||
reblogged = false, |
||||
favourited = favourited, |
||||
sensitive= sensitive, |
||||
spoilerText = spoilerText, |
||||
visibility = Status.Visibility.PRIVATE, |
||||
attachments = attachments, |
||||
mentions = mentions, |
||||
application = null, |
||||
pinned = false) |
||||
} |
||||
} |
||||
|
||||
fun Account.toEntity() = |
||||
ConversationAccountEntity( |
||||
id, |
||||
username, |
||||
displayName, |
||||
avatar, |
||||
emojis ?: emptyList() |
||||
) |
||||
|
||||
fun Status.toEntity() = |
||||
ConversationStatusEntity( |
||||
id, url, inReplyToId, inReplyToAccountId, account.toEntity(), content, |
||||
createdAt, emojis, favouritesCount, favourited, sensitive, |
||||
spoilerText, attachments, mentions, |
||||
false, |
||||
false, |
||||
!SmartLengthInputFilter.hasBadRatio(content, SmartLengthInputFilter.LENGTH_DEFAULT), |
||||
true |
||||
) |
||||
|
||||
|
||||
fun Conversation.toEntity(accountId: Long) = |
||||
ConversationEntity( |
||||
accountId, |
||||
id, |
||||
accounts.map { it.toEntity() }, |
||||
unread, |
||||
lastStatus.toEntity() |
||||
) |
||||
@ -0,0 +1,157 @@
|
||||
/* Copyright 2017 Andrew Dawson |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* 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. |
||||
* |
||||
* Tusky 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 Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.conversation; |
||||
|
||||
import android.content.Context; |
||||
import android.text.InputFilter; |
||||
import android.text.TextUtils; |
||||
import android.view.View; |
||||
import android.widget.ImageView; |
||||
import android.widget.TextView; |
||||
import android.widget.ToggleButton; |
||||
|
||||
import com.keylesspalace.tusky.R; |
||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder; |
||||
import com.keylesspalace.tusky.entity.Attachment; |
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener; |
||||
import com.keylesspalace.tusky.util.SmartLengthInputFilter; |
||||
import com.squareup.picasso.Picasso; |
||||
|
||||
import java.util.List; |
||||
|
||||
import androidx.recyclerview.widget.RecyclerView; |
||||
|
||||
public class ConversationViewHolder extends StatusBaseViewHolder { |
||||
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; |
||||
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; |
||||
|
||||
private TextView conversationNameTextView; |
||||
private ToggleButton contentCollapseButton; |
||||
private ImageView[] avatars; |
||||
|
||||
private StatusActionListener listener; |
||||
private boolean mediaPreviewEnabled; |
||||
|
||||
ConversationViewHolder(View itemView, |
||||
StatusActionListener listener, |
||||
boolean useAbsoluteTime, |
||||
boolean mediaPreviewEnabled) { |
||||
super(itemView, useAbsoluteTime); |
||||
conversationNameTextView = itemView.findViewById(R.id.conversation_name); |
||||
contentCollapseButton = itemView.findViewById(R.id.button_toggle_content); |
||||
avatars = new ImageView[]{avatar, itemView.findViewById(R.id.status_avatar_1), itemView.findViewById(R.id.status_avatar_2)}; |
||||
|
||||
this.listener = listener; |
||||
this.mediaPreviewEnabled = mediaPreviewEnabled; |
||||
} |
||||
|
||||
@Override |
||||
protected int getMediaPreviewHeight(Context context) { |
||||
return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height); |
||||
} |
||||
|
||||
void setupWithConversation(ConversationEntity conversation) { |
||||
ConversationStatusEntity status = conversation.getLastStatus(); |
||||
ConversationAccountEntity account = status.getAccount(); |
||||
|
||||
setupCollapsedState(status.getCollapsible(), status.getCollapsed(), status.getExpanded(), status.getSpoilerText(), listener); |
||||
|
||||
setDisplayName(account.getDisplayName(), account.getEmojis()); |
||||
setUsername(account.getUsername()); |
||||
setCreatedAt(status.getCreatedAt()); |
||||
setIsReply(status.getInReplyToId() != null); |
||||
setFavourited(status.getFavourited()); |
||||
List<Attachment> attachments = status.getAttachments(); |
||||
boolean sensitive = status.getSensitive(); |
||||
if(mediaPreviewEnabled) { |
||||
setMediaPreviews(attachments, sensitive, listener, status.getShowingHiddenContent()); |
||||
|
||||
if (attachments.size() == 0) { |
||||
hideSensitiveMediaWarning(); |
||||
} |
||||
// Hide the unused label.
|
||||
mediaLabel.setVisibility(View.GONE); |
||||
} else { |
||||
setMediaLabel(attachments, sensitive, listener); |
||||
// Hide all unused views.
|
||||
mediaPreviews[0].setVisibility(View.GONE); |
||||
mediaPreviews[1].setVisibility(View.GONE); |
||||
mediaPreviews[2].setVisibility(View.GONE); |
||||
mediaPreviews[3].setVisibility(View.GONE); |
||||
hideSensitiveMediaWarning(); |
||||
} |
||||
|
||||
setupButtons(listener, account.getId()); |
||||
|
||||
setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getEmojis(), listener); |
||||
|
||||
setConversationName(conversation.getAccounts()); |
||||
|
||||
setAvatars(conversation.getAccounts()); |
||||
|
||||
} |
||||
|
||||
private void setConversationName(List<ConversationAccountEntity> accounts) { |
||||
Context context = conversationNameTextView.getContext(); |
||||
String conversationName; |
||||
if(accounts.size() == 1) { |
||||
conversationName = context.getString(R.string.conversation_1_recipients, accounts.get(0).getUsername()); |
||||
} else if(accounts.size() == 2) { |
||||
conversationName = context.getString(R.string.conversation_2_recipients, accounts.get(0).getUsername(), accounts.get(1).getUsername()); |
||||
} else { |
||||
conversationName = context.getString(R.string.conversation_more_recipients, accounts.get(0).getUsername(), accounts.get(1).getUsername(), accounts.size() - 2); |
||||
} |
||||
|
||||
conversationNameTextView.setText(conversationName); |
||||
} |
||||
|
||||
private void setAvatars(List<ConversationAccountEntity> accounts) { |
||||
for(int i=0; i < avatars.length; i++) { |
||||
ImageView avatarView = avatars[i]; |
||||
if(i < accounts.size()) { |
||||
Picasso.with(avatarView.getContext()) |
||||
.load(accounts.get(i).getAvatar()) |
||||
.into(avatarView); |
||||
avatarView.setVisibility(View.VISIBLE); |
||||
} else { |
||||
avatarView.setVisibility(View.GONE); |
||||
} |
||||
} |
||||
} |
||||
|
||||
private void setupCollapsedState(boolean collapsible, boolean collapsed, boolean expanded, String spoilerText, final StatusActionListener listener) { |
||||
/* input filter for TextViews have to be set before text */ |
||||
if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) { |
||||
contentCollapseButton.setOnCheckedChangeListener((buttonView, isChecked) -> { |
||||
int position = getAdapterPosition(); |
||||
if (position != RecyclerView.NO_POSITION) |
||||
listener.onContentCollapsedChange(isChecked, position); |
||||
}); |
||||
|
||||
contentCollapseButton.setVisibility(View.VISIBLE); |
||||
if (collapsed) { |
||||
contentCollapseButton.setChecked(true); |
||||
content.setFilters(COLLAPSE_INPUT_FILTER); |
||||
} else { |
||||
contentCollapseButton.setChecked(false); |
||||
content.setFilters(NO_INPUT_FILTER); |
||||
} |
||||
} else { |
||||
contentCollapseButton.setVisibility(View.GONE); |
||||
content.setFilters(NO_INPUT_FILTER); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,98 @@
|
||||
/* |
||||
* Copyright (C) 2017 The Android Open Source Project |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package com.keylesspalace.tusky.components.conversation |
||||
|
||||
import androidx.annotation.MainThread |
||||
import androidx.paging.PagedList |
||||
import com.keylesspalace.tusky.entity.Conversation |
||||
import com.keylesspalace.tusky.network.MastodonApi |
||||
import com.keylesspalace.tusky.util.PagingRequestHelper |
||||
import com.keylesspalace.tusky.util.createStatusLiveData |
||||
import retrofit2.Call |
||||
import retrofit2.Callback |
||||
import retrofit2.Response |
||||
import java.util.concurrent.Executor |
||||
|
||||
/** |
||||
* This boundary callback gets notified when user reaches to the edges of the list such that the |
||||
* database cannot provide any more data. |
||||
* <p> |
||||
* The boundary callback might be called multiple times for the same direction so it does its own |
||||
* rate limiting using the PagingRequestHelper class. |
||||
*/ |
||||
class ConversationsBoundaryCallback( |
||||
private val accountId: Long, |
||||
private val mastodonApi: MastodonApi, |
||||
private val handleResponse: (Long, List<Conversation>?) -> Unit, |
||||
private val ioExecutor: Executor, |
||||
private val networkPageSize: Int) |
||||
: PagedList.BoundaryCallback<ConversationEntity>() { |
||||
|
||||
val helper = PagingRequestHelper(ioExecutor) |
||||
val networkState = helper.createStatusLiveData() |
||||
|
||||
/** |
||||
* Database returned 0 items. We should query the backend for more items. |
||||
*/ |
||||
@MainThread |
||||
override fun onZeroItemsLoaded() { |
||||
helper.runIfNotRunning(PagingRequestHelper.RequestType.INITIAL) { |
||||
mastodonApi.getConversations(null, networkPageSize) |
||||
.enqueue(createWebserviceCallback(it)) |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* User reached to the end of the list. |
||||
*/ |
||||
@MainThread |
||||
override fun onItemAtEndLoaded(itemAtEnd: ConversationEntity) { |
||||
helper.runIfNotRunning(PagingRequestHelper.RequestType.AFTER) { |
||||
mastodonApi.getConversations(itemAtEnd.lastStatus.id, networkPageSize) |
||||
.enqueue(createWebserviceCallback(it)) |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* every time it gets new items, boundary callback simply inserts them into the database and |
||||
* paging library takes care of refreshing the list if necessary. |
||||
*/ |
||||
private fun insertItemsIntoDb( |
||||
response: Response<List<Conversation>>, |
||||
it: PagingRequestHelper.Request.Callback) { |
||||
ioExecutor.execute { |
||||
handleResponse(accountId, response.body()) |
||||
it.recordSuccess() |
||||
} |
||||
} |
||||
|
||||
override fun onItemAtFrontLoaded(itemAtFront: ConversationEntity) { |
||||
// ignored, since we only ever append to what's in the DB |
||||
} |
||||
|
||||
private fun createWebserviceCallback(it: PagingRequestHelper.Request.Callback): Callback<List<Conversation>> { |
||||
return object : Callback<List<Conversation>> { |
||||
override fun onFailure(call: Call<List<Conversation>>, t: Throwable) { |
||||
it.recordFailure(t) |
||||
} |
||||
|
||||
override fun onResponse(call: Call<List<Conversation>>, response: Response<List<Conversation>>) { |
||||
insertItemsIntoDb(response, it) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,189 @@
|
||||
/* Copyright 2019 Conny Duck |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* 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. |
||||
* |
||||
* Tusky 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 Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.components.conversation |
||||
|
||||
import android.content.Intent |
||||
import android.os.Bundle |
||||
import android.preference.PreferenceManager |
||||
import android.view.LayoutInflater |
||||
import android.view.View |
||||
import android.view.ViewGroup |
||||
import androidx.lifecycle.Observer |
||||
import androidx.lifecycle.ViewModelProviders |
||||
import androidx.paging.PagedList |
||||
import androidx.recyclerview.widget.DividerItemDecoration |
||||
import androidx.recyclerview.widget.LinearLayoutManager |
||||
import androidx.recyclerview.widget.SimpleItemAnimator |
||||
import com.keylesspalace.tusky.AccountActivity |
||||
import com.keylesspalace.tusky.R |
||||
import com.keylesspalace.tusky.ViewTagActivity |
||||
import com.keylesspalace.tusky.db.AppDatabase |
||||
import com.keylesspalace.tusky.di.Injectable |
||||
import com.keylesspalace.tusky.di.ViewModelFactory |
||||
import com.keylesspalace.tusky.fragment.SFragment |
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener |
||||
import com.keylesspalace.tusky.network.TimelineCases |
||||
import com.keylesspalace.tusky.util.NetworkState |
||||
import com.keylesspalace.tusky.util.ThemeUtils |
||||
import com.keylesspalace.tusky.util.hide |
||||
import com.keylesspalace.tusky.util.show |
||||
import kotlinx.android.synthetic.main.fragment_timeline.* |
||||
import javax.inject.Inject |
||||
|
||||
class ConversationsFragment : SFragment(), StatusActionListener, Injectable { |
||||
|
||||
@Inject |
||||
lateinit var timelineCases: TimelineCases |
||||
@Inject |
||||
lateinit var viewModelFactory: ViewModelFactory |
||||
@Inject |
||||
lateinit var db: AppDatabase |
||||
|
||||
private lateinit var viewModel: ConversationsViewModel |
||||
|
||||
private lateinit var adapter: ConversationAdapter |
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { |
||||
viewModel = ViewModelProviders.of(this, viewModelFactory)[ConversationsViewModel::class.java] |
||||
|
||||
return inflater.inflate(R.layout.fragment_timeline, container, false) |
||||
} |
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { |
||||
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context) |
||||
val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false) |
||||
|
||||
val account = accountManager.activeAccount |
||||
val mediaPreviewEnabled = account?.mediaPreviewEnabled ?: true |
||||
|
||||
|
||||
adapter = ConversationAdapter(useAbsoluteTime, mediaPreviewEnabled,this, ::onTopLoaded, viewModel::retry) |
||||
|
||||
val divider = DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL) |
||||
val drawable = ThemeUtils.getDrawable(view.context, R.attr.status_divider_drawable, R.drawable.status_divider_dark) |
||||
divider.setDrawable(drawable) |
||||
recyclerView.addItemDecoration(divider) |
||||
recyclerView.layoutManager = LinearLayoutManager(view.context) |
||||
recyclerView.adapter = adapter |
||||
(recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false |
||||
|
||||
progressBar.hide() |
||||
statusView.hide() |
||||
|
||||
initSwipeToRefresh() |
||||
|
||||
viewModel.conversations.observe(this, Observer<PagedList<ConversationEntity>> { |
||||
adapter.submitList(it) |
||||
}) |
||||
viewModel.networkState.observe(this, Observer { |
||||
adapter.setNetworkState(it) |
||||
}) |
||||
|
||||
viewModel.load() |
||||
|
||||
} |
||||
|
||||
private fun initSwipeToRefresh() { |
||||
viewModel.refreshState.observe(this, Observer { |
||||
swipeRefreshLayout.isRefreshing = it == NetworkState.LOADING |
||||
}) |
||||
swipeRefreshLayout.setOnRefreshListener { |
||||
viewModel.refresh() |
||||
} |
||||
swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) |
||||
swipeRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(swipeRefreshLayout.context, android.R.attr.colorBackground)) |
||||
} |
||||
|
||||
private fun onTopLoaded() { |
||||
recyclerView.scrollToPosition(0) |
||||
} |
||||
|
||||
override fun onReblog(reblog: Boolean, position: Int) { |
||||
// its impossible to reblog private messages |
||||
} |
||||
|
||||
override fun onFavourite(favourite: Boolean, position: Int) { |
||||
viewModel.favourite(favourite, position) |
||||
} |
||||
|
||||
override fun onMore(view: View, position: Int) { |
||||
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { |
||||
more(it.toStatus(), view, position) |
||||
} |
||||
} |
||||
|
||||
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View) { |
||||
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { |
||||
viewMedia(attachmentIndex, it.toStatus(), view) |
||||
} |
||||
} |
||||
|
||||
override fun onViewThread(position: Int) { |
||||
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { |
||||
viewThread(it.toStatus()) |
||||
} |
||||
} |
||||
|
||||
override fun onOpenReblog(position: Int) { |
||||
// there are no reblogs in search results |
||||
} |
||||
|
||||
override fun onExpandedChange(expanded: Boolean, position: Int) { |
||||
viewModel.expandHiddenStatus(expanded, position) |
||||
} |
||||
|
||||
override fun onContentHiddenChange(isShowing: Boolean, position: Int) { |
||||
viewModel.showContent(isShowing, position) |
||||
} |
||||
|
||||
override fun onLoadMore(position: Int) { |
||||
// not using the old way of pagination |
||||
} |
||||
|
||||
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { |
||||
viewModel.collapseLongStatus(isCollapsed, position) |
||||
} |
||||
|
||||
override fun onViewAccount(id: String) { |
||||
val intent = AccountActivity.getIntent(requireContext(), id) |
||||
startActivity(intent) |
||||
} |
||||
|
||||
override fun onViewTag(tag: String) { |
||||
val intent = Intent(context, ViewTagActivity::class.java) |
||||
intent.putExtra("hashtag", tag) |
||||
startActivity(intent) |
||||
} |
||||
|
||||
override fun timelineCases(): TimelineCases { |
||||
return timelineCases |
||||
} |
||||
|
||||
override fun removeItem(position: Int) { |
||||
viewModel.remove(position) |
||||
} |
||||
|
||||
override fun onReply(position: Int) { |
||||
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { |
||||
reply(it.toStatus()) |
||||
} |
||||
} |
||||
|
||||
companion object { |
||||
fun newInstance() = ConversationsFragment() |
||||
} |
||||
} |
||||
@ -0,0 +1,101 @@
|
||||
package com.keylesspalace.tusky.components.conversation |
||||
|
||||
import androidx.annotation.MainThread |
||||
import androidx.lifecycle.LiveData |
||||
import androidx.lifecycle.MutableLiveData |
||||
import androidx.lifecycle.Transformations |
||||
import androidx.paging.Config |
||||
import androidx.paging.toLiveData |
||||
import com.keylesspalace.tusky.db.AppDatabase |
||||
import com.keylesspalace.tusky.entity.Conversation |
||||
import com.keylesspalace.tusky.network.MastodonApi |
||||
import com.keylesspalace.tusky.util.Listing |
||||
import com.keylesspalace.tusky.util.NetworkState |
||||
import retrofit2.Call |
||||
import retrofit2.Callback |
||||
import retrofit2.Response |
||||
import java.util.concurrent.Executors |
||||
import javax.inject.Inject |
||||
import javax.inject.Singleton |
||||
|
||||
@Singleton |
||||
class ConversationsRepository @Inject constructor(val mastodonApi: MastodonApi, val db: AppDatabase) { |
||||
|
||||
private val ioExecutor = Executors.newSingleThreadExecutor() |
||||
|
||||
companion object { |
||||
private const val DEFAULT_PAGE_SIZE = 20 |
||||
} |
||||
|
||||
@MainThread |
||||
fun refresh(accountId: Long, showLoadingIndicator: Boolean): LiveData<NetworkState> { |
||||
val networkState = MutableLiveData<NetworkState>() |
||||
if(showLoadingIndicator) { |
||||
networkState.value = NetworkState.LOADING |
||||
} |
||||
|
||||
mastodonApi.getConversations(null, DEFAULT_PAGE_SIZE).enqueue( |
||||
object : Callback<List<Conversation>> { |
||||
override fun onFailure(call: Call<List<Conversation>>, t: Throwable) { |
||||
// retrofit calls this on main thread so safe to call set value |
||||
networkState.value = NetworkState.error(t.message) |
||||
} |
||||
|
||||
override fun onResponse(call: Call<List<Conversation>>, response: Response<List<Conversation>>) { |
||||
ioExecutor.execute { |
||||
db.runInTransaction { |
||||
db.conversationDao().deleteForAccount(accountId) |
||||
insertResultIntoDb(accountId, response.body()) |
||||
} |
||||
// since we are in bg thread now, post the result. |
||||
networkState.postValue(NetworkState.LOADED) |
||||
} |
||||
} |
||||
} |
||||
) |
||||
return networkState |
||||
} |
||||
|
||||
@MainThread |
||||
fun conversations(accountId: Long): Listing<ConversationEntity> { |
||||
// create a boundary callback which will observe when the user reaches to the edges of |
||||
// the list and update the database with extra data. |
||||
val boundaryCallback = ConversationsBoundaryCallback( |
||||
accountId = accountId, |
||||
mastodonApi = mastodonApi, |
||||
handleResponse = this::insertResultIntoDb, |
||||
ioExecutor = ioExecutor, |
||||
networkPageSize = DEFAULT_PAGE_SIZE) |
||||
// we are using a mutable live data to trigger refresh requests which eventually calls |
||||
// refresh method and gets a new live data. Each refresh request by the user becomes a newly |
||||
// dispatched data in refreshTrigger |
||||
val refreshTrigger = MutableLiveData<Unit>() |
||||
val refreshState = Transformations.switchMap(refreshTrigger) { |
||||
refresh(accountId, true) |
||||
} |
||||
|
||||
// We use toLiveData Kotlin extension function here, you could also use LivePagedListBuilder |
||||
val livePagedList = db.conversationDao().conversationsForAccount(accountId).toLiveData( |
||||
config = Config(pageSize = DEFAULT_PAGE_SIZE, prefetchDistance = DEFAULT_PAGE_SIZE / 2, enablePlaceholders = false), |
||||
boundaryCallback = boundaryCallback |
||||
) |
||||
|
||||
return Listing( |
||||
pagedList = livePagedList, |
||||
networkState = boundaryCallback.networkState, |
||||
retry = { |
||||
boundaryCallback.helper.retryAllFailed() |
||||
}, |
||||
refresh = { |
||||
refreshTrigger.value = null |
||||
}, |
||||
refreshState = refreshState |
||||
) |
||||
} |
||||
|
||||
private fun insertResultIntoDb(accountId: Long, result: List<Conversation>?) { |
||||
result?.let { conversations -> |
||||
db.conversationDao().insert(conversations.map { it.toEntity(accountId) }) |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,104 @@
|
||||
package com.keylesspalace.tusky.components.conversation |
||||
|
||||
import android.util.Log |
||||
import androidx.lifecycle.LiveData |
||||
import androidx.lifecycle.MutableLiveData |
||||
import androidx.lifecycle.Transformations |
||||
import androidx.lifecycle.ViewModel |
||||
import androidx.paging.PagedList |
||||
import com.keylesspalace.tusky.db.AccountManager |
||||
import com.keylesspalace.tusky.db.AppDatabase |
||||
import com.keylesspalace.tusky.network.TimelineCases |
||||
import com.keylesspalace.tusky.util.Listing |
||||
import com.keylesspalace.tusky.util.NetworkState |
||||
import io.reactivex.disposables.CompositeDisposable |
||||
import io.reactivex.rxkotlin.addTo |
||||
import javax.inject.Inject |
||||
|
||||
class ConversationsViewModel @Inject constructor( |
||||
private val repository: ConversationsRepository, |
||||
private val timelineCases: TimelineCases, |
||||
private val database: AppDatabase, |
||||
private val accountManager: AccountManager |
||||
): ViewModel() { |
||||
|
||||
private val repoResult = MutableLiveData<Listing<ConversationEntity>>() |
||||
|
||||
val conversations: LiveData<PagedList<ConversationEntity>> = Transformations.switchMap(repoResult) { it.pagedList } |
||||
val networkState: LiveData<NetworkState> = Transformations.switchMap(repoResult) { it.networkState } |
||||
val refreshState: LiveData<NetworkState> = Transformations.switchMap(repoResult) { it.refreshState } |
||||
|
||||
private val disposables = CompositeDisposable() |
||||
|
||||
fun load() { |
||||
val accountId = accountManager.activeAccount?.id ?: return |
||||
if(repoResult.value == null) { |
||||
repository.refresh(accountId, false) |
||||
} |
||||
repoResult.value = repository.conversations(accountId) |
||||
} |
||||
|
||||
fun refresh() { |
||||
repoResult.value?.refresh?.invoke() |
||||
} |
||||
|
||||
fun retry() { |
||||
repoResult.value?.retry?.invoke() |
||||
} |
||||
|
||||
fun favourite(favourite: Boolean, position: Int) { |
||||
conversations.value?.getOrNull(position)?.let { conversation -> |
||||
timelineCases.favourite(conversation.lastStatus.toStatus(), favourite) |
||||
.subscribe({ |
||||
val newConversation = conversation.copy( |
||||
lastStatus = conversation.lastStatus.copy(favourited = favourite) |
||||
) |
||||
database.conversationDao().insert(newConversation) |
||||
}, { t -> Log.w("ConversationViewModel", "Failed to favourite conversation", t) }) |
||||
.addTo(disposables) |
||||
} |
||||
|
||||
} |
||||
|
||||
fun expandHiddenStatus(expanded: Boolean, position: Int) { |
||||
conversations.value?.getOrNull(position)?.let { conversation -> |
||||
|
||||
val newConversation = conversation.copy( |
||||
lastStatus = conversation.lastStatus.copy(expanded = expanded) |
||||
) |
||||
database.conversationDao().insert(newConversation) |
||||
} |
||||
} |
||||
|
||||
fun collapseLongStatus(collapsed: Boolean, position: Int) { |
||||
conversations.value?.getOrNull(position)?.let { conversation -> |
||||
val newConversation = conversation.copy( |
||||
lastStatus = conversation.lastStatus.copy(collapsed = collapsed) |
||||
) |
||||
database.conversationDao().insert(newConversation) |
||||
} |
||||
} |
||||
|
||||
fun showContent(showing: Boolean, position: Int) { |
||||
conversations.value?.getOrNull(position)?.let { conversation -> |
||||
val newConversation = conversation.copy( |
||||
lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing) |
||||
) |
||||
database.conversationDao().insert(newConversation) |
||||
} |
||||
} |
||||
|
||||
fun remove(position: Int) { |
||||
conversations.value?.getOrNull(position)?.let { conversation -> |
||||
/* this is not ideal since deleting last toot from an conversation |
||||
should not delete the conversation but show another toot of the conversation */ |
||||
timelineCases.delete(conversation.lastStatus.id) |
||||
database.conversationDao().delete(conversation) |
||||
} |
||||
} |
||||
|
||||
override fun onCleared() { |
||||
disposables.dispose() |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,40 @@
|
||||
/* Copyright 2018 Conny Duck |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* 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. |
||||
* |
||||
* Tusky 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 Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.db |
||||
|
||||
import androidx.paging.DataSource |
||||
import androidx.room.* |
||||
import com.keylesspalace.tusky.components.conversation.ConversationEntity |
||||
|
||||
@Dao |
||||
interface ConversationsDao { |
||||
@Insert(onConflict = OnConflictStrategy.REPLACE) |
||||
fun insert(conversations: List<ConversationEntity>) |
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE) |
||||
fun insert(conversation: ConversationEntity) |
||||
|
||||
@Delete |
||||
fun delete(conversation: ConversationEntity) |
||||
|
||||
@Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC") |
||||
fun conversationsForAccount(accountId: Long) : DataSource.Factory<Int, ConversationEntity> |
||||
|
||||
@Query("DELETE FROM ConversationEntity WHERE accountId = :accountId") |
||||
fun deleteForAccount(accountId: Long) |
||||
|
||||
|
||||
} |
||||
@ -0,0 +1,25 @@
|
||||
/* Copyright 2019 Conny Duck |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* 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. |
||||
* |
||||
* Tusky 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 Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.entity |
||||
|
||||
import com.google.gson.annotations.SerializedName |
||||
|
||||
data class Conversation( |
||||
val id: String, |
||||
val accounts: List<Account>, |
||||
@SerializedName("last_status") val lastStatus: Status, |
||||
val unread: Boolean |
||||
) |
||||
@ -0,0 +1,46 @@
|
||||
/* Copyright 2017 Andrew Dawson |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* 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. |
||||
* |
||||
* Tusky 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 Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.pager |
||||
|
||||
import androidx.fragment.app.Fragment |
||||
import androidx.fragment.app.FragmentManager |
||||
import androidx.fragment.app.FragmentPagerAdapter |
||||
import androidx.viewpager.widget.PagerAdapter |
||||
import com.keylesspalace.tusky.TabData |
||||
|
||||
class MainPagerAdapter(val tabs: List<TabData>, manager: FragmentManager) : FragmentPagerAdapter(manager) { |
||||
|
||||
override fun getItem(position: Int): Fragment { |
||||
return tabs[position].fragment() |
||||
} |
||||
|
||||
override fun getCount(): Int { |
||||
return tabs.size |
||||
} |
||||
|
||||
override fun getPageTitle(position: Int): CharSequence? { |
||||
return null |
||||
} |
||||
|
||||
override fun getItemId(position: Int): Long { |
||||
return tabs[position].id.hashCode().toLong() |
||||
} |
||||
|
||||
override fun getItemPosition(item: Any): Int { |
||||
return PagerAdapter.POSITION_NONE |
||||
} |
||||
|
||||
} |
||||
@ -1,60 +0,0 @@
|
||||
/* Copyright 2017 Andrew Dawson |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* 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. |
||||
* |
||||
* Tusky 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 Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.pager; |
||||
|
||||
import androidx.fragment.app.Fragment; |
||||
import androidx.fragment.app.FragmentManager; |
||||
import androidx.fragment.app.FragmentPagerAdapter; |
||||
|
||||
import com.keylesspalace.tusky.fragment.NotificationsFragment; |
||||
import com.keylesspalace.tusky.fragment.TimelineFragment; |
||||
|
||||
public class TimelinePagerAdapter extends FragmentPagerAdapter { |
||||
public TimelinePagerAdapter(FragmentManager manager) { |
||||
super(manager); |
||||
} |
||||
|
||||
@Override |
||||
public Fragment getItem(int i) { |
||||
switch (i) { |
||||
case 0: { |
||||
return TimelineFragment.newInstance(TimelineFragment.Kind.HOME); |
||||
} |
||||
case 1: { |
||||
return NotificationsFragment.newInstance(); |
||||
} |
||||
case 2: { |
||||
return TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_LOCAL); |
||||
} |
||||
case 3: { |
||||
return TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_FEDERATED); |
||||
} |
||||
default: { |
||||
return null; |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public int getCount() { |
||||
return 4; |
||||
} |
||||
|
||||
@Override |
||||
public CharSequence getPageTitle(int position) { |
||||
return null; |
||||
} |
||||
} |
||||
@ -0,0 +1,36 @@
|
||||
/* |
||||
* Copyright (C) 2017 The Android Open Source Project |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package com.keylesspalace.tusky.util |
||||
|
||||
import androidx.lifecycle.LiveData |
||||
import androidx.paging.PagedList |
||||
|
||||
/** |
||||
* Data class that is necessary for a UI to show a listing and interact w/ the rest of the system |
||||
*/ |
||||
data class Listing<T>( |
||||
// the LiveData of paged lists for the UI to observe |
||||
val pagedList: LiveData<PagedList<T>>, |
||||
// represents the network request status to show to the user |
||||
val networkState: LiveData<NetworkState>, |
||||
// represents the refresh status to show to the user. Separate from networkState, this |
||||
// value is importantly only when refresh is requested. |
||||
val refreshState: LiveData<NetworkState>, |
||||
// refreshes the whole data and fetches it from scratch. |
||||
val refresh: () -> Unit, |
||||
// retries any failed requests. |
||||
val retry: () -> Unit) |
||||
@ -0,0 +1,34 @@
|
||||
/* |
||||
* Copyright (C) 2017 The Android Open Source Project |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package com.keylesspalace.tusky.util |
||||
|
||||
enum class Status { |
||||
RUNNING, |
||||
SUCCESS, |
||||
FAILED |
||||
} |
||||
|
||||
@Suppress("DataClassPrivateConstructor") |
||||
data class NetworkState private constructor( |
||||
val status: Status, |
||||
val msg: String? = null) { |
||||
companion object { |
||||
val LOADED = NetworkState(Status.SUCCESS) |
||||
val LOADING = NetworkState(Status.RUNNING) |
||||
fun error(msg: String?) = NetworkState(Status.FAILED, msg) |
||||
} |
||||
} |
||||
@ -0,0 +1,491 @@
|
||||
/* |
||||
* Copyright 2017 The Android Open Source Project |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
package com.keylesspalace.tusky.util; |
||||
|
||||
import androidx.annotation.AnyThread; |
||||
import androidx.annotation.GuardedBy; |
||||
import androidx.annotation.NonNull; |
||||
import androidx.annotation.Nullable; |
||||
import androidx.annotation.VisibleForTesting; |
||||
import java.util.Arrays; |
||||
import java.util.concurrent.CopyOnWriteArrayList; |
||||
import java.util.concurrent.Executor; |
||||
import java.util.concurrent.atomic.AtomicBoolean; |
||||
/** |
||||
* A helper class for {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}s and |
||||
* {@link DataSource}s to help with tracking network requests. |
||||
* <p> |
||||
* It is designed to support 3 types of requests, {@link RequestType#INITIAL INITIAL}, |
||||
* {@link RequestType#BEFORE BEFORE} and {@link RequestType#AFTER AFTER} and runs only 1 request |
||||
* for each of them via {@link #runIfNotRunning(RequestType, Request)}. |
||||
* <p> |
||||
* It tracks a {@link Status} and an {@code error} for each {@link RequestType}. |
||||
* <p> |
||||
* A sample usage of this class to limit requests looks like this: |
||||
* <pre> |
||||
* class PagingBoundaryCallback extends PagedList.BoundaryCallback<MyItem> { |
||||
* // TODO replace with an executor from your application
|
||||
* Executor executor = Executors.newSingleThreadExecutor(); |
||||
* PagingRequestHelper helper = new PagingRequestHelper(executor); |
||||
* // imaginary API service, using Retrofit
|
||||
* MyApi api; |
||||
* |
||||
* {@literal @}Override |
||||
* public void onItemAtFrontLoaded({@literal @}NonNull MyItem itemAtFront) { |
||||
* helper.runIfNotRunning(PagingRequestHelper.RequestType.BEFORE, |
||||
* helperCallback -> api.getTopBefore(itemAtFront.getName(), 10).enqueue( |
||||
* new Callback<ApiResponse>() { |
||||
* {@literal @}Override |
||||
* public void onResponse(Call<ApiResponse> call, |
||||
* Response<ApiResponse> response) { |
||||
* // TODO insert new records into database
|
||||
* helperCallback.recordSuccess(); |
||||
* } |
||||
* |
||||
* {@literal @}Override |
||||
* public void onFailure(Call<ApiResponse> call, Throwable t) { |
||||
* helperCallback.recordFailure(t); |
||||
* } |
||||
* })); |
||||
* } |
||||
* |
||||
* {@literal @}Override |
||||
* public void onItemAtEndLoaded({@literal @}NonNull MyItem itemAtEnd) { |
||||
* helper.runIfNotRunning(PagingRequestHelper.RequestType.AFTER, |
||||
* helperCallback -> api.getTopBefore(itemAtEnd.getName(), 10).enqueue( |
||||
* new Callback<ApiResponse>() { |
||||
* {@literal @}Override |
||||
* public void onResponse(Call<ApiResponse> call, |
||||
* Response<ApiResponse> response) { |
||||
* // TODO insert new records into database
|
||||
* helperCallback.recordSuccess(); |
||||
* } |
||||
* |
||||
* {@literal @}Override |
||||
* public void onFailure(Call<ApiResponse> call, Throwable t) { |
||||
* helperCallback.recordFailure(t); |
||||
* } |
||||
* })); |
||||
* } |
||||
* } |
||||
* </pre> |
||||
* <p> |
||||
* The helper provides an API to observe combined request status, which can be reported back to the |
||||
* application based on your business rules. |
||||
* <pre> |
||||
* MutableLiveData<PagingRequestHelper.Status> combined = new MutableLiveData<>(); |
||||
* helper.addListener(status -> { |
||||
* // merge multiple states per request type into one, or dispatch separately depending on
|
||||
* // your application logic.
|
||||
* if (status.hasRunning()) { |
||||
* combined.postValue(PagingRequestHelper.Status.RUNNING); |
||||
* } else if (status.hasError()) { |
||||
* // can also obtain the error via {@link StatusReport#getErrorFor(RequestType)}
|
||||
* combined.postValue(PagingRequestHelper.Status.FAILED); |
||||
* } else { |
||||
* combined.postValue(PagingRequestHelper.Status.SUCCESS); |
||||
* } |
||||
* }); |
||||
* </pre> |
||||
*/ |
||||
// THIS class is likely to be moved into the library in a future release. Feel free to copy it
|
||||
// from this sample.
|
||||
public class PagingRequestHelper { |
||||
private final Object mLock = new Object(); |
||||
private final Executor mRetryService; |
||||
@GuardedBy("mLock") |
||||
private final RequestQueue[] mRequestQueues = new RequestQueue[] |
||||
{new RequestQueue(RequestType.INITIAL), |
||||
new RequestQueue(RequestType.BEFORE), |
||||
new RequestQueue(RequestType.AFTER)}; |
||||
@NonNull |
||||
final CopyOnWriteArrayList<Listener> mListeners = new CopyOnWriteArrayList<>(); |
||||
/** |
||||
* Creates a new PagingRequestHelper with the given {@link Executor} which is used to run |
||||
* retry actions. |
||||
* |
||||
* @param retryService The {@link Executor} that can run the retry actions. |
||||
*/ |
||||
public PagingRequestHelper(@NonNull Executor retryService) { |
||||
mRetryService = retryService; |
||||
} |
||||
/** |
||||
* Adds a new listener that will be notified when any request changes {@link Status state}. |
||||
* |
||||
* @param listener The listener that will be notified each time a request's status changes. |
||||
* @return True if it is added, false otherwise (e.g. it already exists in the list). |
||||
*/ |
||||
@AnyThread |
||||
public boolean addListener(@NonNull Listener listener) { |
||||
return mListeners.add(listener); |
||||
} |
||||
/** |
||||
* Removes the given listener from the listeners list. |
||||
* |
||||
* @param listener The listener that will be removed. |
||||
* @return True if the listener is removed, false otherwise (e.g. it never existed) |
||||
*/ |
||||
public boolean removeListener(@NonNull Listener listener) { |
||||
return mListeners.remove(listener); |
||||
} |
||||
/** |
||||
* Runs the given {@link Request} if no other requests in the given request type is already |
||||
* running. |
||||
* <p> |
||||
* If run, the request will be run in the current thread. |
||||
* |
||||
* @param type The type of the request. |
||||
* @param request The request to run. |
||||
* @return True if the request is run, false otherwise. |
||||
*/ |
||||
@SuppressWarnings("WeakerAccess") |
||||
@AnyThread |
||||
public boolean runIfNotRunning(@NonNull RequestType type, @NonNull Request request) { |
||||
boolean hasListeners = !mListeners.isEmpty(); |
||||
StatusReport report = null; |
||||
synchronized (mLock) { |
||||
RequestQueue queue = mRequestQueues[type.ordinal()]; |
||||
if (queue.mRunning != null) { |
||||
return false; |
||||
} |
||||
queue.mRunning = request; |
||||
queue.mStatus = Status.RUNNING; |
||||
queue.mFailed = null; |
||||
queue.mLastError = null; |
||||
if (hasListeners) { |
||||
report = prepareStatusReportLocked(); |
||||
} |
||||
} |
||||
if (report != null) { |
||||
dispatchReport(report); |
||||
} |
||||
final RequestWrapper wrapper = new RequestWrapper(request, this, type); |
||||
wrapper.run(); |
||||
return true; |
||||
} |
||||
@GuardedBy("mLock") |
||||
private StatusReport prepareStatusReportLocked() { |
||||
Throwable[] errors = new Throwable[]{ |
||||
mRequestQueues[0].mLastError, |
||||
mRequestQueues[1].mLastError, |
||||
mRequestQueues[2].mLastError |
||||
}; |
||||
return new StatusReport( |
||||
getStatusForLocked(RequestType.INITIAL), |
||||
getStatusForLocked(RequestType.BEFORE), |
||||
getStatusForLocked(RequestType.AFTER), |
||||
errors |
||||
); |
||||
} |
||||
@GuardedBy("mLock") |
||||
private Status getStatusForLocked(RequestType type) { |
||||
return mRequestQueues[type.ordinal()].mStatus; |
||||
} |
||||
@AnyThread |
||||
@VisibleForTesting |
||||
void recordResult(@NonNull RequestWrapper wrapper, @Nullable Throwable throwable) { |
||||
StatusReport report = null; |
||||
final boolean success = throwable == null; |
||||
boolean hasListeners = !mListeners.isEmpty(); |
||||
synchronized (mLock) { |
||||
RequestQueue queue = mRequestQueues[wrapper.mType.ordinal()]; |
||||
queue.mRunning = null; |
||||
queue.mLastError = throwable; |
||||
if (success) { |
||||
queue.mFailed = null; |
||||
queue.mStatus = Status.SUCCESS; |
||||
} else { |
||||
queue.mFailed = wrapper; |
||||
queue.mStatus = Status.FAILED; |
||||
} |
||||
if (hasListeners) { |
||||
report = prepareStatusReportLocked(); |
||||
} |
||||
} |
||||
if (report != null) { |
||||
dispatchReport(report); |
||||
} |
||||
} |
||||
private void dispatchReport(StatusReport report) { |
||||
for (Listener listener : mListeners) { |
||||
listener.onStatusChange(report); |
||||
} |
||||
} |
||||
/** |
||||
* Retries all failed requests. |
||||
* |
||||
* @return True if any request is retried, false otherwise. |
||||
*/ |
||||
public boolean retryAllFailed() { |
||||
final RequestWrapper[] toBeRetried = new RequestWrapper[RequestType.values().length]; |
||||
boolean retried = false; |
||||
synchronized (mLock) { |
||||
for (int i = 0; i < RequestType.values().length; i++) { |
||||
toBeRetried[i] = mRequestQueues[i].mFailed; |
||||
mRequestQueues[i].mFailed = null; |
||||
} |
||||
} |
||||
for (RequestWrapper failed : toBeRetried) { |
||||
if (failed != null) { |
||||
failed.retry(mRetryService); |
||||
retried = true; |
||||
} |
||||
} |
||||
return retried; |
||||
} |
||||
static class RequestWrapper implements Runnable { |
||||
@NonNull |
||||
final Request mRequest; |
||||
@NonNull |
||||
final PagingRequestHelper mHelper; |
||||
@NonNull |
||||
final RequestType mType; |
||||
RequestWrapper(@NonNull Request request, @NonNull PagingRequestHelper helper, |
||||
@NonNull RequestType type) { |
||||
mRequest = request; |
||||
mHelper = helper; |
||||
mType = type; |
||||
} |
||||
@Override |
||||
public void run() { |
||||
mRequest.run(new Request.Callback(this, mHelper)); |
||||
} |
||||
void retry(Executor service) { |
||||
service.execute(new Runnable() { |
||||
@Override |
||||
public void run() { |
||||
mHelper.runIfNotRunning(mType, mRequest); |
||||
} |
||||
}); |
||||
} |
||||
} |
||||
/** |
||||
* Runner class that runs a request tracked by the {@link PagingRequestHelper}. |
||||
* <p> |
||||
* When a request is invoked, it must call one of {@link Callback#recordFailure(Throwable)} |
||||
* or {@link Callback#recordSuccess()} once and only once. This call |
||||
* can be made any time. Until that method call is made, {@link PagingRequestHelper} will |
||||
* consider the request is running. |
||||
*/ |
||||
@FunctionalInterface |
||||
public interface Request { |
||||
/** |
||||
* Should run the request and call the given {@link Callback} with the result of the |
||||
* request. |
||||
* |
||||
* @param callback The callback that should be invoked with the result. |
||||
*/ |
||||
void run(Callback callback); |
||||
/** |
||||
* Callback class provided to the {@link #run(Callback)} method to report the result. |
||||
*/ |
||||
class Callback { |
||||
private final AtomicBoolean mCalled = new AtomicBoolean(); |
||||
private final RequestWrapper mWrapper; |
||||
private final PagingRequestHelper mHelper; |
||||
Callback(RequestWrapper wrapper, PagingRequestHelper helper) { |
||||
mWrapper = wrapper; |
||||
mHelper = helper; |
||||
} |
||||
/** |
||||
* Call this method when the request succeeds and new data is fetched. |
||||
*/ |
||||
@SuppressWarnings("unused") |
||||
public final void recordSuccess() { |
||||
if (mCalled.compareAndSet(false, true)) { |
||||
mHelper.recordResult(mWrapper, null); |
||||
} else { |
||||
throw new IllegalStateException( |
||||
"already called recordSuccess or recordFailure"); |
||||
} |
||||
} |
||||
/** |
||||
* Call this method with the failure message and the request can be retried via |
||||
* {@link #retryAllFailed()}. |
||||
* |
||||
* @param throwable The error that occured while carrying out the request. |
||||
*/ |
||||
@SuppressWarnings("unused") |
||||
public final void recordFailure(@NonNull Throwable throwable) { |
||||
//noinspection ConstantConditions
|
||||
if (throwable == null) { |
||||
throw new IllegalArgumentException("You must provide a throwable describing" |
||||
+ " the error to record the failure"); |
||||
} |
||||
if (mCalled.compareAndSet(false, true)) { |
||||
mHelper.recordResult(mWrapper, throwable); |
||||
} else { |
||||
throw new IllegalStateException( |
||||
"already called recordSuccess or recordFailure"); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
/** |
||||
* Data class that holds the information about the current status of the ongoing requests |
||||
* using this helper. |
||||
*/ |
||||
public static final class StatusReport { |
||||
/** |
||||
* Status of the latest request that were submitted with {@link RequestType#INITIAL}. |
||||
*/ |
||||
@NonNull |
||||
public final Status initial; |
||||
/** |
||||
* Status of the latest request that were submitted with {@link RequestType#BEFORE}. |
||||
*/ |
||||
@NonNull |
||||
public final Status before; |
||||
/** |
||||
* Status of the latest request that were submitted with {@link RequestType#AFTER}. |
||||
*/ |
||||
@NonNull |
||||
public final Status after; |
||||
@NonNull |
||||
private final Throwable[] mErrors; |
||||
StatusReport(@NonNull Status initial, @NonNull Status before, @NonNull Status after, |
||||
@NonNull Throwable[] errors) { |
||||
this.initial = initial; |
||||
this.before = before; |
||||
this.after = after; |
||||
this.mErrors = errors; |
||||
} |
||||
/** |
||||
* Convenience method to check if there are any running requests. |
||||
* |
||||
* @return True if there are any running requests, false otherwise. |
||||
*/ |
||||
public boolean hasRunning() { |
||||
return initial == Status.RUNNING |
||||
|| before == Status.RUNNING |
||||
|| after == Status.RUNNING; |
||||
} |
||||
/** |
||||
* Convenience method to check if there are any requests that resulted in an error. |
||||
* |
||||
* @return True if there are any requests that finished with error, false otherwise. |
||||
*/ |
||||
public boolean hasError() { |
||||
return initial == Status.FAILED |
||||
|| before == Status.FAILED |
||||
|| after == Status.FAILED; |
||||
} |
||||
/** |
||||
* Returns the error for the given request type. |
||||
* |
||||
* @param type The request type for which the error should be returned. |
||||
* @return The {@link Throwable} returned by the failing request with the given type or |
||||
* {@code null} if the request for the given type did not fail. |
||||
*/ |
||||
@Nullable |
||||
public Throwable getErrorFor(@NonNull RequestType type) { |
||||
return mErrors[type.ordinal()]; |
||||
} |
||||
@Override |
||||
public String toString() { |
||||
return "StatusReport{" |
||||
+ "initial=" + initial |
||||
+ ", before=" + before |
||||
+ ", after=" + after |
||||
+ ", mErrors=" + Arrays.toString(mErrors) |
||||
+ '}'; |
||||
} |
||||
@Override |
||||
public boolean equals(Object o) { |
||||
if (this == o) return true; |
||||
if (o == null || getClass() != o.getClass()) return false; |
||||
StatusReport that = (StatusReport) o; |
||||
if (initial != that.initial) return false; |
||||
if (before != that.before) return false; |
||||
if (after != that.after) return false; |
||||
// Probably incorrect - comparing Object[] arrays with Arrays.equals
|
||||
return Arrays.equals(mErrors, that.mErrors); |
||||
} |
||||
@Override |
||||
public int hashCode() { |
||||
int result = initial.hashCode(); |
||||
result = 31 * result + before.hashCode(); |
||||
result = 31 * result + after.hashCode(); |
||||
result = 31 * result + Arrays.hashCode(mErrors); |
||||
return result; |
||||
} |
||||
} |
||||
/** |
||||
* Listener interface to get notified by request status changes. |
||||
*/ |
||||
public interface Listener { |
||||
/** |
||||
* Called when the status for any of the requests has changed. |
||||
* |
||||
* @param report The current status report that has all the information about the requests. |
||||
*/ |
||||
void onStatusChange(@NonNull StatusReport report); |
||||
} |
||||
/** |
||||
* Represents the status of a Request for each {@link RequestType}. |
||||
*/ |
||||
public enum Status { |
||||
/** |
||||
* There is current a running request. |
||||
*/ |
||||
RUNNING, |
||||
/** |
||||
* The last request has succeeded or no such requests have ever been run. |
||||
*/ |
||||
SUCCESS, |
||||
/** |
||||
* The last request has failed. |
||||
*/ |
||||
FAILED |
||||
} |
||||
/** |
||||
* Available request types. |
||||
*/ |
||||
public enum RequestType { |
||||
/** |
||||
* Corresponds to an initial request made to a {@link DataSource} or the empty state for |
||||
* a {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}. |
||||
*/ |
||||
INITIAL, |
||||
/** |
||||
* Corresponds to the {@code loadBefore} calls in {@link DataSource} or |
||||
* {@code onItemAtFrontLoaded} in |
||||
* {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}. |
||||
*/ |
||||
BEFORE, |
||||
/** |
||||
* Corresponds to the {@code loadAfter} calls in {@link DataSource} or |
||||
* {@code onItemAtEndLoaded} in |
||||
* {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}. |
||||
*/ |
||||
AFTER |
||||
} |
||||
class RequestQueue { |
||||
@NonNull |
||||
final RequestType mRequestType; |
||||
@Nullable |
||||
RequestWrapper mFailed; |
||||
@Nullable |
||||
Request mRunning; |
||||
@Nullable |
||||
Throwable mLastError; |
||||
@NonNull |
||||
Status mStatus = Status.SUCCESS; |
||||
RequestQueue(@NonNull RequestType requestType) { |
||||
mRequestType = requestType; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,23 @@
|
||||
package com.keylesspalace.tusky.util |
||||
|
||||
import androidx.lifecycle.LiveData |
||||
import androidx.lifecycle.MutableLiveData |
||||
|
||||
private fun getErrorMessage(report: PagingRequestHelper.StatusReport): String { |
||||
return PagingRequestHelper.RequestType.values().mapNotNull { |
||||
report.getErrorFor(it)?.message |
||||
}.first() |
||||
} |
||||
|
||||
fun PagingRequestHelper.createStatusLiveData(): LiveData<NetworkState> { |
||||
val liveData = MutableLiveData<NetworkState>() |
||||
addListener { report -> |
||||
when { |
||||
report.hasRunning() -> liveData.postValue(NetworkState.LOADING) |
||||
report.hasError() -> liveData.postValue( |
||||
NetworkState.error(getErrorMessage(report))) |
||||
else -> liveData.postValue(NetworkState.LOADED) |
||||
} |
||||
} |
||||
return liveData |
||||
} |
||||
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android"> |
||||
<item android:state_selected="false" android:color="?attr/toolbar_icon_tint"/> |
||||
<item android:state_selected="true" android:color="?attr/tab_icon_selected_tint"/> |
||||
</selector> |
||||
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"> |
||||
<solid android:color="?attr/window_background" /> |
||||
<corners android:radius="7dp"/> |
||||
<size android:height="52dp" android:width="52dp"/> |
||||
</shape> |
||||
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" |
||||
android:width="24dp" |
||||
android:height="24dp" |
||||
android:viewportWidth="24" |
||||
android:viewportHeight="24"> |
||||
<path |
||||
android:fillColor="?android:attr/textColorPrimary" |
||||
android:pathData="M11,18c0,1.1 -0.9,2 -2,2s-2,-0.9 -2,-2 0.9,-2 2,-2 2,0.9 2,2zM9,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM9,4c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM15,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM15,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM15,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z" /> |
||||
</vector> |
||||
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" |
||||
android:width="24dp" |
||||
android:height="24dp" |
||||
android:viewportWidth="24" |
||||
android:viewportHeight="24"> |
||||
<path |
||||
android:fillColor="#fff" |
||||
android:pathData="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z" /> |
||||
</vector> |
||||
@ -0,0 +1,80 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" |
||||
xmlns:app="http://schemas.android.com/apk/res-auto" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent"> |
||||
|
||||
<include layout="@layout/toolbar_basic" /> |
||||
|
||||
<androidx.recyclerview.widget.RecyclerView |
||||
android:id="@+id/currentTabsRecyclerView" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent" |
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" |
||||
app:layout_constraintTop_toBottomOf="@id/appbar" /> |
||||
|
||||
<View |
||||
android:id="@+id/scrim" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent" |
||||
android:background="?attr/scrimBackground" |
||||
android:visibility="invisible" |
||||
app:layout_behavior="@string/fab_transformation_scrim_behavior" /> |
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton |
||||
android:id="@+id/actionButton" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:layout_gravity="bottom|end" |
||||
android:layout_margin="16dp" |
||||
android:src="@drawable/ic_plus_24dp" /> |
||||
|
||||
<com.google.android.material.transformation.TransformationChildCard |
||||
android:id="@+id/sheet" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:layout_gravity="bottom|end" |
||||
android:layout_margin="16dp" |
||||
android:visibility="invisible" |
||||
app:cardBackgroundColor="?attr/colorSurface" |
||||
app:cardElevation="2dp" |
||||
app:layout_behavior="@string/fab_transformation_sheet_behavior"> |
||||
|
||||
<LinearLayout |
||||
android:layout_width="240dp" |
||||
android:layout_height="wrap_content" |
||||
android:orientation="vertical"> |
||||
|
||||
<androidx.recyclerview.widget.RecyclerView |
||||
android:id="@+id/addTabRecyclerView" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:overScrollMode="never" /> |
||||
|
||||
<TextView |
||||
android:id="@+id/maxTabsInfo" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:text="maximum of 5 tabs reached" |
||||
android:padding="8dp" |
||||
android:lineSpacingMultiplier="1.1" /> |
||||
|
||||
<TextView |
||||
android:layout_width="match_parent" |
||||
android:layout_height="48dp" |
||||
android:layout_gravity="bottom" |
||||
android:background="?attr/colorPrimary" |
||||
android:drawableStart="@drawable/ic_plus_24dp" |
||||
android:drawablePadding="12dp" |
||||
android:ellipsize="end" |
||||
android:gravity="center_vertical" |
||||
android:lines="1" |
||||
android:paddingStart="8dp" |
||||
android:paddingEnd="8dp" |
||||
android:text="@string/action_add_tab" |
||||
android:textColor="?attr/colorOnPrimary" |
||||
android:textSize="?attr/status_text_large" /> |
||||
</LinearLayout> |
||||
</com.google.android.material.transformation.TransformationChildCard> |
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout> |
||||
@ -0,0 +1,391 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" |
||||
xmlns:app="http://schemas.android.com/apk/res-auto" |
||||
xmlns:sparkbutton="http://schemas.android.com/apk/res-auto" |
||||
xmlns:tools="http://schemas.android.com/tools" |
||||
android:id="@+id/status_container" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:clipChildren="false" |
||||
android:clipToPadding="false" |
||||
android:paddingStart="12dp" |
||||
android:paddingEnd="14dp"> |
||||
|
||||
<androidx.emoji.widget.EmojiTextView |
||||
android:id="@+id/conversation_name" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginStart="2dp" |
||||
android:layout_marginTop="@dimen/status_reblogged_bar_padding_top" |
||||
android:gravity="center_vertical" |
||||
android:lineSpacingMultiplier="1.1" |
||||
android:textColor="?android:textColorPrimary" |
||||
android:textSize="?attr/status_text_medium" |
||||
android:textStyle="normal|bold" |
||||
app:layout_constraintLeft_toRightOf="parent" |
||||
app:layout_constraintRight_toLeftOf="parent" |
||||
app:layout_constraintTop_toTopOf="parent" |
||||
tools:ignore="RtlSymmetry" |
||||
tools:text="ConnyDuck boosted" |
||||
tools:visibility="visible" /> |
||||
|
||||
<com.keylesspalace.tusky.view.RoundedImageView |
||||
android:id="@+id/status_avatar_2" |
||||
android:layout_width="52dp" |
||||
android:layout_height="52dp" |
||||
android:layout_marginTop="22dp" |
||||
android:background="@drawable/avatar_border" |
||||
android:contentDescription="@string/action_view_profile" |
||||
android:padding="2dp" |
||||
android:scaleType="centerCrop" |
||||
app:layout_constraintStart_toStartOf="parent" |
||||
app:layout_constraintTop_toTopOf="@id/status_avatar_1" |
||||
tools:src="@drawable/avatar_default" /> |
||||
|
||||
<com.keylesspalace.tusky.view.RoundedImageView |
||||
android:id="@+id/status_avatar_1" |
||||
android:layout_width="52dp" |
||||
android:layout_height="52dp" |
||||
android:layout_marginTop="22dp" |
||||
android:background="@drawable/avatar_border" |
||||
android:contentDescription="@string/action_view_profile" |
||||
android:padding="2dp" |
||||
android:scaleType="centerCrop" |
||||
app:layout_constraintStart_toStartOf="parent" |
||||
app:layout_constraintTop_toTopOf="@id/status_avatar" |
||||
tools:src="@drawable/avatar_default" /> |
||||
|
||||
<com.keylesspalace.tusky.view.RoundedImageView |
||||
android:id="@+id/status_avatar" |
||||
android:layout_width="52dp" |
||||
android:layout_height="52dp" |
||||
android:layout_marginTop="14dp" |
||||
android:background="@drawable/avatar_border" |
||||
android:contentDescription="@string/action_view_profile" |
||||
android:padding="2dp" |
||||
android:scaleType="centerCrop" |
||||
app:layout_constraintStart_toStartOf="parent" |
||||
app:layout_constraintTop_toBottomOf="@id/conversation_name" |
||||
tools:src="@drawable/avatar_default" /> |
||||
|
||||
<com.keylesspalace.tusky.view.RoundedImageView |
||||
android:id="@+id/status_avatar_reblog" |
||||
android:layout_width="24dp" |
||||
android:layout_height="24dp" |
||||
android:contentDescription="@null" |
||||
android:visibility="gone" |
||||
app:layout_constraintBottom_toBottomOf="@id/status_avatar" |
||||
app:layout_constraintEnd_toEndOf="@id/status_avatar" |
||||
tools:src="@color/accent" |
||||
tools:visibility="visible" /> |
||||
|
||||
<androidx.emoji.widget.EmojiTextView |
||||
android:id="@+id/status_display_name" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginStart="14dp" |
||||
android:layout_marginTop="10dp" |
||||
android:ellipsize="end" |
||||
android:maxLines="1" |
||||
android:paddingEnd="@dimen/status_display_name_padding_end" |
||||
android:textColor="?android:textColorPrimary" |
||||
android:textSize="?attr/status_text_medium" |
||||
android:textStyle="normal|bold" |
||||
app:layout_constrainedWidth="true" |
||||
app:layout_constraintEnd_toStartOf="@id/status_timestamp_info" |
||||
app:layout_constraintHorizontal_bias="0" |
||||
app:layout_constraintStart_toEndOf="@id/status_avatar" |
||||
app:layout_constraintTop_toBottomOf="@id/conversation_name" |
||||
tools:text="Ente r the void you foooooo" /> |
||||
|
||||
<TextView |
||||
android:id="@+id/status_username" |
||||
android:layout_width="0dp" |
||||
android:layout_height="wrap_content" |
||||
android:ellipsize="end" |
||||
android:maxLines="1" |
||||
android:textColor="?android:textColorSecondary" |
||||
android:textSize="?attr/status_text_medium" |
||||
app:layout_constraintEnd_toStartOf="@id/status_timestamp_info" |
||||
app:layout_constraintStart_toEndOf="@id/status_display_name" |
||||
app:layout_constraintTop_toTopOf="@id/status_display_name" |
||||
tools:text="\@Entenhausen@birbsarecooooooooooool.site" /> |
||||
|
||||
<TextView |
||||
android:id="@+id/status_timestamp_info" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginStart="4dp" |
||||
android:textColor="?android:textColorSecondary" |
||||
android:textSize="?attr/status_text_medium" |
||||
app:layout_constraintEnd_toEndOf="parent" |
||||
app:layout_constraintTop_toTopOf="@id/status_display_name" |
||||
tools:text="13:37" /> |
||||
|
||||
<androidx.emoji.widget.EmojiTextView |
||||
android:id="@+id/status_content_warning_description" |
||||
android:layout_width="0dp" |
||||
android:layout_height="wrap_content" |
||||
android:lineSpacingMultiplier="1.1" |
||||
android:textColor="?android:textColorPrimary" |
||||
android:textSize="?attr/status_text_medium" |
||||
android:visibility="gone" |
||||
app:layout_constraintEnd_toEndOf="parent" |
||||
app:layout_constraintStart_toStartOf="@id/status_display_name" |
||||
app:layout_constraintTop_toBottomOf="@id/status_display_name" |
||||
tools:text="content warning which is very long and it doesn't fit" |
||||
tools:visibility="visible" /> |
||||
|
||||
<ToggleButton |
||||
android:id="@+id/status_content_warning_button" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginTop="4dp" |
||||
android:layout_marginBottom="4dp" |
||||
android:background="?attr/content_warning_button" |
||||
android:minWidth="150dp" |
||||
android:minHeight="0dp" |
||||
android:paddingLeft="16dp" |
||||
android:paddingTop="4dp" |
||||
android:paddingRight="16dp" |
||||
android:paddingBottom="4dp" |
||||
android:textAllCaps="true" |
||||
android:textOff="@string/status_content_warning_show_more" |
||||
android:textOn="@string/status_content_warning_show_less" |
||||
android:textSize="?attr/status_text_medium" |
||||
android:visibility="gone" |
||||
app:layout_constraintStart_toStartOf="@id/status_display_name" |
||||
app:layout_constraintTop_toBottomOf="@id/status_content_warning_description" |
||||
tools:visibility="visible" /> |
||||
|
||||
<androidx.emoji.widget.EmojiTextView |
||||
android:id="@+id/status_content" |
||||
android:layout_width="0dp" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginTop="8dp" |
||||
android:focusable="true" |
||||
android:lineSpacingMultiplier="1.1" |
||||
android:textColor="?android:textColorPrimary" |
||||
android:textSize="?attr/status_text_medium" |
||||
app:layout_constraintEnd_toEndOf="parent" |
||||
app:layout_constraintStart_toStartOf="@id/status_content_warning_button" |
||||
app:layout_constraintTop_toBottomOf="@id/status_content_warning_button" |
||||
tools:text="This is a status" /> |
||||
|
||||
<ToggleButton |
||||
android:id="@+id/button_toggle_content" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginTop="4dp" |
||||
android:layout_marginBottom="4dp" |
||||
android:background="?attr/content_warning_button" |
||||
android:minWidth="150dp" |
||||
android:minHeight="0dp" |
||||
android:paddingLeft="16dp" |
||||
android:paddingTop="4dp" |
||||
android:paddingRight="16dp" |
||||
android:paddingBottom="4dp" |
||||
android:textAllCaps="true" |
||||
android:textOff="@string/status_content_show_less" |
||||
android:textOn="@string/status_content_show_more" |
||||
android:textSize="?attr/status_text_medium" |
||||
android:visibility="gone" |
||||
app:layout_constraintStart_toStartOf="@id/status_display_name" |
||||
app:layout_constraintTop_toBottomOf="@id/status_content" |
||||
tools:visibility="visible" /> |
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout |
||||
android:id="@+id/status_media_preview_container" |
||||
android:layout_width="0dp" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginTop="@dimen/status_media_preview_margin_top" |
||||
app:layout_constraintEnd_toEndOf="parent" |
||||
app:layout_constraintStart_toStartOf="@id/status_display_name" |
||||
app:layout_constraintTop_toBottomOf="@id/button_toggle_content" |
||||
tools:visibility="gone"> |
||||
|
||||
<com.keylesspalace.tusky.view.MediaPreviewImageView |
||||
android:id="@+id/status_media_preview_0" |
||||
android:layout_width="0dp" |
||||
android:layout_height="@dimen/status_media_preview_height" |
||||
android:scaleType="centerCrop" |
||||
app:layout_constraintEnd_toStartOf="@+id/status_media_preview_1" |
||||
app:layout_constraintStart_toStartOf="parent" |
||||
app:layout_constraintTop_toTopOf="parent" |
||||
tools:ignore="ContentDescription" /> |
||||
|
||||
<com.keylesspalace.tusky.view.MediaPreviewImageView |
||||
android:id="@+id/status_media_preview_1" |
||||
android:layout_width="0dp" |
||||
android:layout_height="@dimen/status_media_preview_height" |
||||
android:layout_marginStart="4dp" |
||||
android:scaleType="centerCrop" |
||||
app:layout_constraintEnd_toEndOf="parent" |
||||
app:layout_constraintStart_toEndOf="@+id/status_media_preview_0" |
||||
app:layout_constraintTop_toTopOf="parent" |
||||
tools:ignore="ContentDescription" /> |
||||
|
||||
|
||||
<com.keylesspalace.tusky.view.MediaPreviewImageView |
||||
android:id="@+id/status_media_preview_2" |
||||
android:layout_width="0dp" |
||||
android:layout_height="@dimen/status_media_preview_height" |
||||
android:layout_marginTop="4dp" |
||||
android:scaleType="centerCrop" |
||||
app:layout_constraintEnd_toStartOf="@+id/status_media_preview_3" |
||||
app:layout_constraintStart_toStartOf="parent" |
||||
app:layout_constraintTop_toBottomOf="@+id/status_media_preview_0" |
||||
tools:ignore="ContentDescription" /> |
||||
|
||||
<com.keylesspalace.tusky.view.MediaPreviewImageView |
||||
android:id="@+id/status_media_preview_3" |
||||
android:layout_width="0dp" |
||||
android:layout_height="@dimen/status_media_preview_height" |
||||
android:layout_marginStart="4dp" |
||||
android:layout_marginTop="4dp" |
||||
android:scaleType="centerCrop" |
||||
app:layout_constraintEnd_toEndOf="parent" |
||||
app:layout_constraintStart_toEndOf="@+id/status_media_preview_2" |
||||
app:layout_constraintTop_toBottomOf="@+id/status_media_preview_1" |
||||
tools:ignore="ContentDescription" /> |
||||
|
||||
<ImageView |
||||
android:id="@+id/status_media_overlay_0" |
||||
android:layout_width="0dp" |
||||
android:layout_height="0dp" |
||||
android:scaleType="center" |
||||
app:layout_constraintBottom_toBottomOf="@+id/status_media_preview_0" |
||||
app:layout_constraintEnd_toEndOf="@+id/status_media_preview_0" |
||||
app:layout_constraintStart_toStartOf="@+id/status_media_preview_0" |
||||
app:layout_constraintTop_toTopOf="@+id/status_media_preview_0" |
||||
app:srcCompat="?attr/play_indicator_drawable" |
||||
tools:ignore="ContentDescription" /> |
||||
|
||||
<ImageView |
||||
android:id="@+id/status_media_overlay_1" |
||||
android:layout_width="0dp" |
||||
android:layout_height="0dp" |
||||
android:scaleType="center" |
||||
app:layout_constraintBottom_toBottomOf="@+id/status_media_preview_1" |
||||
app:layout_constraintEnd_toEndOf="@+id/status_media_preview_1" |
||||
app:layout_constraintStart_toStartOf="@+id/status_media_preview_1" |
||||
app:layout_constraintTop_toTopOf="@+id/status_media_preview_1" |
||||
app:srcCompat="?attr/play_indicator_drawable" |
||||
tools:ignore="ContentDescription" /> |
||||
|
||||
<ImageView |
||||
android:id="@+id/status_media_overlay_2" |
||||
android:layout_width="0dp" |
||||
android:layout_height="0dp" |
||||
android:scaleType="center" |
||||
app:layout_constraintBottom_toBottomOf="@+id/status_media_preview_2" |
||||
app:layout_constraintEnd_toEndOf="@+id/status_media_preview_2" |
||||
app:layout_constraintStart_toStartOf="@+id/status_media_preview_2" |
||||
app:layout_constraintTop_toTopOf="@+id/status_media_preview_2" |
||||
app:srcCompat="?attr/play_indicator_drawable" |
||||
tools:ignore="ContentDescription" /> |
||||
|
||||
<ImageView |
||||
android:id="@+id/status_media_overlay_3" |
||||
android:layout_width="0dp" |
||||
android:layout_height="0dp" |
||||
android:scaleType="center" |
||||
app:layout_constraintBottom_toBottomOf="@+id/status_media_preview_3" |
||||
app:layout_constraintEnd_toEndOf="@+id/status_media_preview_3" |
||||
app:layout_constraintStart_toStartOf="@+id/status_media_preview_3" |
||||
app:layout_constraintTop_toTopOf="@+id/status_media_preview_3" |
||||
app:srcCompat="?attr/play_indicator_drawable" |
||||
tools:ignore="ContentDescription" /> |
||||
|
||||
<ImageView |
||||
android:id="@+id/status_sensitive_media_button" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:alpha="0.7" |
||||
android:contentDescription="@null" |
||||
android:padding="@dimen/status_sensitive_media_button_padding" |
||||
android:visibility="gone" |
||||
app:layout_constraintLeft_toLeftOf="@+id/status_media_preview_container" |
||||
app:layout_constraintTop_toTopOf="@+id/status_media_preview_container" |
||||
app:srcCompat="@drawable/ic_eye_24dp" /> |
||||
|
||||
<androidx.emoji.widget.EmojiTextView |
||||
android:id="@+id/status_sensitive_media_warning" |
||||
android:layout_width="0dp" |
||||
android:layout_height="0dp" |
||||
android:background="?attr/sensitive_media_warning_background_color" |
||||
android:gravity="center" |
||||
android:lineSpacingMultiplier="1.2" |
||||
android:orientation="vertical" |
||||
android:padding="8dp" |
||||
android:textAlignment="center" |
||||
android:textColor="@android:color/white" |
||||
android:textSize="?attr/status_text_medium" |
||||
android:visibility="gone" |
||||
app:layout_constraintBottom_toBottomOf="parent" |
||||
app:layout_constraintEnd_toEndOf="parent" |
||||
app:layout_constraintStart_toStartOf="parent" |
||||
app:layout_constraintTop_toTopOf="parent" /> |
||||
|
||||
<!--TODO: Check if this needs emoji support--> |
||||
<androidx.emoji.widget.EmojiTextView |
||||
android:id="@+id/status_media_label" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:background="?attr/selectableItemBackground" |
||||
android:drawablePadding="4dp" |
||||
android:gravity="center_vertical" |
||||
android:textSize="?attr/status_text_medium" |
||||
android:visibility="gone" |
||||
app:layout_constraintStart_toStartOf="parent" |
||||
app:layout_constraintTop_toTopOf="parent" /> |
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout> |
||||
|
||||
<ImageButton |
||||
android:id="@+id/status_reply" |
||||
style="?attr/image_button_style" |
||||
android:layout_width="30dp" |
||||
android:layout_height="30dp" |
||||
android:layout_marginTop="4dp" |
||||
android:layout_marginBottom="4dp" |
||||
android:contentDescription="@string/action_reply" |
||||
android:padding="4dp" |
||||
app:layout_constraintBottom_toBottomOf="parent" |
||||
app:layout_constraintEnd_toStartOf="@id/status_favourite" |
||||
app:layout_constraintHorizontal_chainStyle="spread_inside" |
||||
app:layout_constraintStart_toStartOf="@id/status_display_name" |
||||
app:layout_constraintTop_toBottomOf="@id/status_media_preview_container" |
||||
app:srcCompat="@drawable/ic_reply_24dp" /> |
||||
|
||||
<at.connyduck.sparkbutton.SparkButton |
||||
android:id="@+id/status_favourite" |
||||
android:layout_width="30dp" |
||||
android:layout_height="30dp" |
||||
android:clipToPadding="false" |
||||
android:contentDescription="@string/action_favourite" |
||||
android:padding="4dp" |
||||
app:layout_constraintEnd_toStartOf="@id/status_more" |
||||
app:layout_constraintStart_toEndOf="@id/status_reply" |
||||
app:layout_constraintTop_toTopOf="@id/status_reply" |
||||
sparkbutton:activeImage="?attr/status_favourite_active_drawable" |
||||
sparkbutton:iconSize="28dp" |
||||
sparkbutton:inactiveImage="?attr/status_favourite_inactive_drawable" |
||||
sparkbutton:primaryColor="@color/tusky_orange" |
||||
sparkbutton:secondaryColor="@color/tusky_orange_light" /> |
||||
|
||||
<ImageButton |
||||
android:id="@+id/status_more" |
||||
style="?attr/image_button_style" |
||||
android:layout_width="24dp" |
||||
android:layout_height="30dp" |
||||
android:layout_marginEnd="8dp" |
||||
android:contentDescription="@string/action_more" |
||||
android:padding="4dp" |
||||
app:layout_constraintBottom_toBottomOf="@id/status_reply" |
||||
app:layout_constraintEnd_toEndOf="parent" |
||||
app:layout_constraintStart_toEndOf="@id/status_favourite" |
||||
app:layout_constraintTop_toTopOf="@id/status_reply" |
||||
app:srcCompat="@drawable/ic_more_horiz_24dp" /> |
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout> |
||||
@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:orientation="vertical" |
||||
android:gravity="center" |
||||
android:padding="8dp"> |
||||
<TextView |
||||
android:id="@+id/errorMsg" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content"/> |
||||
<ProgressBar |
||||
android:id="@+id/progressBar" |
||||
style="?android:attr/progressBarStyle" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content"/> |
||||
<Button |
||||
android:id="@+id/retryButton" |
||||
style="@style/TuskyButton.TextButton" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:text="@string/action_retry"/> |
||||
</LinearLayout> |
||||
@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:background="?android:colorBackground" |
||||
android:orientation="horizontal" |
||||
android:padding="16dp"> |
||||
<ImageView |
||||
android:id="@+id/imageView" |
||||
android:layout_gravity="end" |
||||
android:src="@drawable/ic_drag_indicator_24dp" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" /> |
||||
|
||||
<TextView |
||||
android:id="@+id/textView" |
||||
android:textSize="?attr/status_text_large" |
||||
android:textColor="?android:attr/textColorSecondary" |
||||
android:drawableStart="@drawable/ic_home_24dp" |
||||
android:layout_width="0dp" |
||||
android:layout_marginStart="8dp" |
||||
android:drawablePadding="12dp" |
||||
android:layout_weight="1" |
||||
android:layout_height="wrap_content"/> |
||||
|
||||
|
||||
</LinearLayout> |
||||
|
||||
@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android" |
||||
android:id="@+id/textView" |
||||
android:layout_width="match_parent" |
||||
android:drawableStart="@drawable/ic_home_24dp" |
||||
android:layout_height="48dp" |
||||
android:gravity="center_vertical" |
||||
android:drawablePadding="12dp" |
||||
android:paddingStart="8dp" |
||||
android:paddingEnd="8dp" |
||||
android:lines="1" |
||||
android:ellipsize="end" |
||||
android:background="?attr/selectableItemBackground" |
||||
android:textColor="?android:attr/textColorSecondary" |
||||
android:textSize="?attr/status_text_large" /> |
||||
|
||||
|
||||
Loading…
Reference in new issue