Polls part 1 - displaying in timelines and voting (#1200)

* add entity classes

* change data models and add database migration

* add polls to StatusViewData

* show poll results

* add methods for vote handling

* add voting interface

* enable voting in TimelineFragment

* update polls immediately

* enable custom emojis for poll options

* enable voting from search fragment

* add voting layout to detailed statuses

* fix tests

* enable voting in ViewThreadFragment

* enable voting in ConversationsFragment

* small refactor for StatusBaseViewHolder
This commit is contained in:
Konrad Pozniak 2019-04-22 10:11:00 +02:00 committed by GitHub
parent 82d547caf8
commit fd7471f2ab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 1637 additions and 68 deletions

View file

@ -0,0 +1,674 @@
{
"formatVersion": 1,
"database": {
"version": 15,
"identityHash": "6a01315ce9f7d402cb61e611140e3c0a",
"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, `notificationsFilter` 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
},
{
"fieldPath": "notificationsFilter",
"columnName": "notificationsFilter",
"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, `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, `poll` 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": "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
},
{
"fieldPath": "poll",
"columnName": "poll",
"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, `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": "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, `s_poll` TEXT, 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
},
{
"fieldPath": "lastStatus.poll",
"columnName": "s_poll",
"affinity": "TEXT",
"notNull": false
}
],
"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, \"6a01315ce9f7d402cb61e611140e3c0a\")"
]
}
}

View file

@ -74,7 +74,7 @@ public class TuskyApplication extends Application implements HasActivityInjector
AppDatabase.MIGRATION_5_6, AppDatabase.MIGRATION_6_7, AppDatabase.MIGRATION_7_8, AppDatabase.MIGRATION_5_6, AppDatabase.MIGRATION_6_7, AppDatabase.MIGRATION_7_8,
AppDatabase.MIGRATION_8_9, AppDatabase.MIGRATION_9_10, AppDatabase.MIGRATION_10_11, AppDatabase.MIGRATION_8_9, AppDatabase.MIGRATION_9_10, AppDatabase.MIGRATION_10_11,
AppDatabase.MIGRATION_11_12, AppDatabase.MIGRATION_12_13, AppDatabase.MIGRATION_10_13, AppDatabase.MIGRATION_11_12, AppDatabase.MIGRATION_12_13, AppDatabase.MIGRATION_10_13,
AppDatabase.MIGRATION_13_14) AppDatabase.MIGRATION_13_14, AppDatabase.MIGRATION_14_15)
.build(); .build();
accountManager = new AccountManager(appDatabase); accountManager = new AccountManager(appDatabase);
serviceLocator = new ServiceLocator() { serviceLocator = new ServiceLocator() {

View file

@ -143,6 +143,7 @@ public class SearchResultsAdapter extends RecyclerView.Adapter {
public void updateStatusAtPosition(StatusViewData.Concrete status, int position) { public void updateStatusAtPosition(StatusViewData.Concrete status, int position) {
concreteStatusList.set(position - accountList.size(), status); concreteStatusList.set(position - accountList.size(), status);
notifyItemChanged(position);
} }
public void removeStatusAtPosition(int position) { public void removeStatusAtPosition(int position) {

View file

@ -7,8 +7,11 @@ import android.text.Spanned;
import android.text.TextUtils; import android.text.TextUtils;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ImageButton; import android.widget.ImageButton;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.RadioButton;
import android.widget.RadioGroup;
import android.widget.TextView; import android.widget.TextView;
import android.widget.ToggleButton; import android.widget.ToggleButton;
@ -18,6 +21,8 @@ import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Attachment.Focus; import com.keylesspalace.tusky.entity.Attachment.Focus;
import com.keylesspalace.tusky.entity.Attachment.MetaData; import com.keylesspalace.tusky.entity.Attachment.MetaData;
import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.PollOption;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.CustomEmojiHelper;
@ -31,6 +36,7 @@ import com.mikepenz.iconics.utils.Utils;
import java.text.NumberFormat; import java.text.NumberFormat;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@ -70,6 +76,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
public TextView content; public TextView content;
public TextView contentWarningDescription; public TextView contentWarningDescription;
private TextView[] pollResults;
private TextView pollDescription;
private RadioGroup pollRadioGroup;
private RadioButton[] pollRadioOptions;
private Button pollButton;
private boolean useAbsoluteTime; private boolean useAbsoluteTime;
private SimpleDateFormat shortSdf; private SimpleDateFormat shortSdf;
private SimpleDateFormat longSdf; private SimpleDateFormat longSdf;
@ -109,6 +121,25 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
contentWarningButton = itemView.findViewById(R.id.status_content_warning_button); contentWarningButton = itemView.findViewById(R.id.status_content_warning_button);
avatarInset = itemView.findViewById(R.id.status_avatar_inset); avatarInset = itemView.findViewById(R.id.status_avatar_inset);
pollResults = new TextView[] {
itemView.findViewById(R.id.status_poll_option_result_0),
itemView.findViewById(R.id.status_poll_option_result_1),
itemView.findViewById(R.id.status_poll_option_result_2),
itemView.findViewById(R.id.status_poll_option_result_3)
};
pollDescription = itemView.findViewById(R.id.status_poll_description);
pollRadioGroup = itemView.findViewById(R.id.status_poll_radio_group);
pollRadioOptions = new RadioButton[] {
pollRadioGroup.findViewById(R.id.status_poll_radio_button_0),
pollRadioGroup.findViewById(R.id.status_poll_radio_button_1),
pollRadioGroup.findViewById(R.id.status_poll_radio_button_2),
pollRadioGroup.findViewById(R.id.status_poll_radio_button_3)
};
pollButton = itemView.findViewById(R.id.status_poll_button);
this.useAbsoluteTime = useAbsoluteTime; this.useAbsoluteTime = useAbsoluteTime;
shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault());
longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()); longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault());
@ -218,10 +249,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private String getAbsoluteTime(@Nullable Date createdAt) { private String getAbsoluteTime(@Nullable Date createdAt) {
String time; String time;
if (createdAt != null) { if (createdAt != null) {
if (System.currentTimeMillis() - createdAt.getTime() > 86400000L) { if (android.text.format.DateUtils.isToday(createdAt.getTime())) {
time = longSdf.format(createdAt);
} else {
time = shortSdf.format(createdAt); time = shortSdf.format(createdAt);
} else {
time = longSdf.format(createdAt);
} }
} else { } else {
time = "??:??:??"; time = "??:??:??";
@ -588,6 +619,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getStatusEmojis(), listener); setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getStatusEmojis(), listener);
setContentDescription(status); setContentDescription(status);
setupPoll(status.getPoll(),status.getStatusEmojis(), listener);
// Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0 // Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0
// RecyclerView tries to set AccessibilityDelegateCompat to null // RecyclerView tries to set AccessibilityDelegateCompat to null
// but ViewCompat code replaces is with the default one. RecyclerView never // but ViewCompat code replaces is with the default one. RecyclerView never
@ -717,4 +751,124 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
return ""; return "";
} }
} }
protected void setupPoll(Poll poll, List<Emoji> emojis, StatusActionListener listener) {
if(poll == null) {
for(TextView pollResult: pollResults) {
pollResult.setVisibility(View.GONE);
}
pollDescription.setVisibility(View.GONE);
pollRadioGroup.setVisibility(View.GONE);
for(RadioButton radioButton: pollRadioOptions) {
radioButton.setVisibility(View.GONE);
}
pollButton.setVisibility(View.GONE);
} else {
Context context = pollDescription.getContext();
List<PollOption> options = poll.getOptions();
if(poll.getExpired() || poll.getVoted()) {
// no voting possible
for(int i = 0; i < Status.MAX_POLL_OPTIONS; i++) {
if(i < options.size()) {
long percent = calculatePollPercent(options.get(i).getVotesCount(), poll.getVotesCount());
String pollOptionText = context.getString(R.string.poll_option_format, percent, options.get(i).getTitle());
pollResults[i].setText(CustomEmojiHelper.emojifyText(HtmlUtils.fromHtml(pollOptionText), emojis, pollResults[i]));
pollResults[i].setVisibility(View.VISIBLE);
int level = (int) percent * 100;
pollResults[i].getBackground().setLevel(level);
} else {
pollResults[i].setVisibility(View.GONE);
}
}
pollRadioGroup.setVisibility(View.GONE);
for(RadioButton radioButton: pollRadioOptions) {
radioButton.setVisibility(View.GONE);
}
pollButton.setVisibility(View.GONE);
} else {
// voting possible
for(TextView pollResult: pollResults) {
pollResult.setVisibility(View.GONE);
}
pollRadioGroup.setVisibility(View.VISIBLE);
pollRadioGroup.clearCheck();
pollButton.setVisibility(View.VISIBLE);
for(int i = 0; i < Status.MAX_POLL_OPTIONS; i++) {
if(i < options.size()) {
pollRadioOptions[i].setText(CustomEmojiHelper.emojifyString(options.get(i).getTitle(), emojis, pollRadioOptions[i]));
pollRadioOptions[i].setVisibility(View.VISIBLE);
} else {
pollRadioOptions[i].setVisibility(View.GONE);
}
}
}
pollDescription.setVisibility(View.VISIBLE);
String votes = numberFormat.format(poll.getVotesCount());
String votesText = context.getResources().getQuantityString(R.plurals.poll_info_votes, poll.getVotesCount(), votes);
CharSequence pollDurationInfo;
if(poll.getExpired()) {
pollDurationInfo = context.getString(R.string.poll_info_closed);
} else {
if(useAbsoluteTime) {
pollDurationInfo = context.getString(R.string.poll_info_time_absolute, getAbsoluteTime(poll.getExpiresAt()));
} else {
String pollDuration = DateUtils.formatDuration(pollDescription.getContext(), poll.getExpiresAt().getTime(), System.currentTimeMillis());
pollDurationInfo = context.getString(R.string.poll_info_time_relative, pollDuration);
}
}
String pollInfo = pollDescription.getContext().getString(R.string.poll_info_format, votesText, pollDurationInfo);
pollDescription.setText(pollInfo);
pollButton.setOnClickListener(v -> {
int selectedRadioButtonIndex;
switch (pollRadioGroup.getCheckedRadioButtonId()) {
case R.id.status_poll_radio_button_0:
selectedRadioButtonIndex = 0;
break;
case R.id.status_poll_radio_button_1:
selectedRadioButtonIndex = 1;
break;
case R.id.status_poll_radio_button_2:
selectedRadioButtonIndex = 2;
break;
case R.id.status_poll_radio_button_3:
selectedRadioButtonIndex = 3;
break;
default:
return;
}
listener.onVoteInPoll(getAdapterPosition(), Collections.singletonList(selectedRadioButtonIndex));
});
}
}
private static long calculatePollPercent(int votes, int totalVotes) {
if(votes == 0) {
return 0;
}
return Math.round(votes / (double) totalVotes * 100);
}
} }

View file

@ -2,6 +2,7 @@ package com.keylesspalace.tusky.appstore
import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.TabData
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Dispatchable data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Dispatchable
@ -14,3 +15,4 @@ data class StatusComposedEvent(val status: Status) : Dispatchable
data class ProfileEditedEvent(val newProfileData: Account) : Dispatchable data class ProfileEditedEvent(val newProfileData: Account) : Dispatchable
data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable
data class MainTabsChangedEvent(val newTabs: List<TabData>) : Dispatchable data class MainTabsChangedEvent(val newTabs: List<TabData>) : Dispatchable
data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable

View file

@ -76,7 +76,8 @@ data class ConversationStatusEntity(
val showingHiddenContent: Boolean, val showingHiddenContent: Boolean,
val expanded: Boolean, val expanded: Boolean,
val collapsible: Boolean, val collapsible: Boolean,
val collapsed: Boolean val collapsed: Boolean,
val poll: Poll?
) { ) {
/** its necessary to override this because Spanned.equals does not work as expected */ /** its necessary to override this because Spanned.equals does not work as expected */
@ -104,6 +105,7 @@ data class ConversationStatusEntity(
if (expanded != other.expanded) return false if (expanded != other.expanded) return false
if (collapsible != other.collapsible) return false if (collapsible != other.collapsible) return false
if (collapsed != other.collapsed) return false if (collapsed != other.collapsed) return false
if (poll != other.poll) return false
return true return true
} }
@ -127,6 +129,7 @@ data class ConversationStatusEntity(
result = 31 * result + expanded.hashCode() result = 31 * result + expanded.hashCode()
result = 31 * result + collapsible.hashCode() result = 31 * result + collapsible.hashCode()
result = 31 * result + collapsed.hashCode() result = 31 * result + collapsed.hashCode()
result = 31 * result + poll.hashCode()
return result return result
} }
@ -151,7 +154,8 @@ data class ConversationStatusEntity(
attachments = attachments, attachments = attachments,
mentions = mentions, mentions = mentions,
application = null, application = null,
pinned = false) pinned = false,
poll = poll)
} }
} }
@ -172,7 +176,8 @@ fun Status.toEntity() =
false, false,
false, false,
!SmartLengthInputFilter.hasBadRatio(content, SmartLengthInputFilter.LENGTH_DEFAULT), !SmartLengthInputFilter.hasBadRatio(content, SmartLengthInputFilter.LENGTH_DEFAULT),
true true,
poll
) )

View file

@ -102,6 +102,8 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
setAvatars(conversation.getAccounts()); setAvatars(conversation.getAccounts());
setupPoll(status.getPoll(), status.getEmojis(), listener);
} }
private void setConversationName(List<ConversationAccountEntity> accounts) { private void setConversationName(List<ConversationAccountEntity> accounts) {

View file

@ -18,9 +18,11 @@ package com.keylesspalace.tusky.components.conversation
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.preference.PreferenceManager import android.preference.PreferenceManager
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders import androidx.lifecycle.ViewModelProviders
import androidx.paging.PagedList import androidx.paging.PagedList
@ -34,11 +36,15 @@ import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.fragment.SFragment import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.fragment.SearchFragment
import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.NetworkState import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider
import com.uber.autodispose.autoDisposable
import io.reactivex.android.schedulers.AndroidSchedulers
import kotlinx.android.synthetic.main.fragment_timeline.* import kotlinx.android.synthetic.main.fragment_timeline.*
import javax.inject.Inject import javax.inject.Inject
@ -187,6 +193,10 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
jumpToTop() jumpToTop()
} }
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
viewModel.voteInPoll(position, choices)
}
companion object { companion object {
fun newInstance() = ConversationsFragment() fun newInstance() = ConversationsFragment()
} }

View file

@ -67,6 +67,25 @@ class ConversationsViewModel @Inject constructor(
} }
fun voteInPoll(position: Int, choices: MutableList<Int>) {
conversations.value?.getOrNull(position)?.let { conversation ->
timelineCases.voteInPoll(conversation.lastStatus.toStatus(), choices)
.flatMap { poll ->
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(poll = poll)
)
Single.fromCallable {
database.conversationDao().insert(newConversation)
}
}
.subscribeOn(Schedulers.io())
.doOnError { t -> Log.w("ConversationViewModel", "Failed to favourite conversation", t) }
.subscribe()
.addTo(disposables)
}
}
fun expandHiddenStatus(expanded: Boolean, position: Int) { fun expandHiddenStatus(expanded: Boolean, position: Int) {
conversations.value?.getOrNull(position)?.let { conversation -> conversations.value?.getOrNull(position)?.let { conversation ->
val newConversation = conversation.copy( val newConversation = conversation.copy(

View file

@ -30,7 +30,7 @@ import androidx.annotation.NonNull;
@Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, @Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class TimelineAccountEntity.class, ConversationEntity.class
}, version = 14) }, version = 15)
public abstract class AppDatabase extends RoomDatabase { public abstract class AppDatabase extends RoomDatabase {
public abstract TootDao tootDao(); public abstract TootDao tootDao();
@ -256,13 +256,6 @@ public abstract class AppDatabase extends RoomDatabase {
} }
}; };
public static final Migration MIGRATION_13_14 = new Migration(13, 14) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsFilter` TEXT NOT NULL DEFAULT '[]'");
}
};
public static final Migration MIGRATION_10_13 = new Migration(10, 13) { public static final Migration MIGRATION_10_13 = new Migration(10, 13) {
@Override @Override
public void migrate(@NonNull SupportSQLiteDatabase database) { public void migrate(@NonNull SupportSQLiteDatabase database) {
@ -271,4 +264,19 @@ public abstract class AppDatabase extends RoomDatabase {
} }
}; };
public static final Migration MIGRATION_13_14 = new Migration(13, 14) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsFilter` TEXT NOT NULL DEFAULT '[]'");
}
};
public static final Migration MIGRATION_14_15 = new Migration(14, 15) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `poll` TEXT");
database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_poll` TEXT");
}
};
} }

View file

@ -24,6 +24,7 @@ import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity
import com.keylesspalace.tusky.createTabDataFromId import com.keylesspalace.tusky.createTabDataFromId
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.json.SpannedTypeAdapter import com.keylesspalace.tusky.json.SpannedTypeAdapter
import com.keylesspalace.tusky.util.HtmlUtils import com.keylesspalace.tusky.util.HtmlUtils
@ -135,4 +136,14 @@ class Converters {
return HtmlUtils.fromHtml(spannedString) return HtmlUtils.fromHtml(spannedString)
} }
@TypeConverter
fun pollToJson(poll: Poll?): String? {
return gson.toJson(poll)
}
@TypeConverter
fun jsonToPoll(pollJson: String?): Poll? {
return gson.fromJson(pollJson, Poll::class.java)
}
} }

View file

@ -49,7 +49,8 @@ data class TimelineStatusEntity(
val mentions: String?, val mentions: String?,
val application: String?, val application: String?,
val reblogServerId: String?, // if it has a reblogged status, it's id is stored here val reblogServerId: String?, // if it has a reblogged status, it's id is stored here
val reblogAccountId: String? val reblogAccountId: String?,
val poll: String?
) )
@Entity( @Entity(

View file

@ -0,0 +1,33 @@
package com.keylesspalace.tusky.entity
import com.google.gson.annotations.SerializedName
import java.util.*
data class Poll(
val id: String,
@SerializedName("expires_at") val expiresAt: Date?,
val expired: Boolean,
val multiple: Boolean,
@SerializedName("votes_count") val votesCount: Int,
val options: List<PollOption>,
val voted: Boolean
) {
fun votedCopy(choices: List<Int>): Poll {
val newOptions = options.mapIndexed { index, option ->
if(choices.contains(index)) {
option.copy(votesCount = option.votesCount + 1)
} else {
option
}
}
return copy(options = newOptions, votesCount = votesCount + 1, voted = true)
}
}
data class PollOption(
val title: String,
@SerializedName("votes_count") val votesCount: Int
)

View file

@ -39,7 +39,8 @@ data class Status(
@SerializedName("media_attachments") var attachments: ArrayList<Attachment>, @SerializedName("media_attachments") var attachments: ArrayList<Attachment>,
val mentions: Array<Mention>, val mentions: Array<Mention>,
val application: Application?, val application: Application?,
var pinned: Boolean? var pinned: Boolean?,
val poll: Poll?
) { ) {
val actionableId: String val actionableId: String
@ -161,5 +162,6 @@ data class Status(
companion object { companion object {
const val MAX_MEDIA_ATTACHMENTS = 4 const val MAX_MEDIA_ATTACHMENTS = 4
const val MAX_POLL_OPTIONS = 4
} }
} }

View file

@ -46,6 +46,7 @@ import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.ActionButtonActivity; import com.keylesspalace.tusky.interfaces.ActionButtonActivity;
import com.keylesspalace.tusky.interfaces.ReselectableFragment; import com.keylesspalace.tusky.interfaces.ReselectableFragment;
@ -428,6 +429,24 @@ public class NotificationsFragment extends SFragment implements
updateAdapter(); updateAdapter();
} }
public void onVoteInPoll(int position, @NonNull List<Integer> choices) {
final Notification notification = notifications.get(position).asRight();
final Status status = notification.getStatus();
timelineCases.voteInPoll(status, choices)
.observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this)))
.subscribe(
(newPoll) -> setVoteForPoll(position, newPoll),
(t) -> Log.d(TAG,
"Failed to vote in poll: " + status.getId(), t)
);
}
private void setVoteForPoll(int position, Poll poll) {
// TODO
}
@Override @Override
public void onMore(@NonNull View view, int position) { public void onMore(@NonNull View view, int position) {
Notification notification = notifications.get(position).asRight(); Notification notification = notifications.get(position).asRight();

View file

@ -232,10 +232,6 @@ class SearchFragment : SFragment(), StatusActionListener {
searchRecyclerView.post { searchAdapter.notifyItemChanged(position, updatedStatus) } searchRecyclerView.post { searchAdapter.notifyItemChanged(position, updatedStatus) }
} }
companion object {
const val TAG = "SearchFragment"
}
override fun onViewAccount(id: String) { override fun onViewAccount(id: String) {
val intent = AccountActivity.getIntent(requireContext(), id) val intent = AccountActivity.getIntent(requireContext(), id)
startActivity(intent) startActivity(intent)
@ -247,4 +243,28 @@ class SearchFragment : SFragment(), StatusActionListener {
startActivity(intent) startActivity(intent)
} }
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
val status = searchAdapter.getStatusAtPosition(position)
if (status != null) {
timelineCases.voteInPoll(status, choices)
.observeOn(AndroidSchedulers.mainThread())
.autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe({poll ->
val viewData = ViewDataUtils.statusToViewData(
status,
alwaysShowSensitiveMedia
)
val newViewData = StatusViewData.Builder(viewData)
.setPoll(poll)
.createStatusViewData()
searchAdapter.updateStatusAtPosition(newViewData, position)
}, { t -> Log.d(TAG, "Failed to vote in poll " + status.id, t) })
}
}
companion object {
const val TAG = "SearchFragment"
}
} }

View file

@ -45,6 +45,7 @@ import com.keylesspalace.tusky.appstore.UnfollowEvent;
import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.entity.Filter; import com.keylesspalace.tusky.entity.Filter;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.ActionButtonActivity; import com.keylesspalace.tusky.interfaces.ActionButtonActivity;
import com.keylesspalace.tusky.interfaces.ReselectableFragment; import com.keylesspalace.tusky.interfaces.ReselectableFragment;
@ -620,6 +621,34 @@ public class TimelineFragment extends SFragment implements
updateAdapter(); updateAdapter();
} }
public void onVoteInPoll(int position, @NonNull List<Integer> choices) {
final Status status = statuses.get(position).asRight();
setVoteForPoll(position, status, status.getPoll().votedCopy(choices));
timelineCases.voteInPoll(status, choices)
.observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this)))
.subscribe(
(newPoll) -> setVoteForPoll(position, status, newPoll),
(t) -> Log.d(TAG,
"Failed to vote in poll: " + status.getId(), t)
);
}
private void setVoteForPoll(int position, Status status, Poll newPoll) {
Pair<StatusViewData.Concrete, Integer> actual =
findStatusAndPosition(position, status);
if (actual == null) return;
StatusViewData newViewData = new StatusViewData
.Builder(actual.first)
.setPoll(newPoll)
.createStatusViewData();
statuses.setPairedItem(actual.second, newViewData);
updateAdapter();
}
@Override @Override
public void onMore(@NonNull View view, final int position) { public void onMore(@NonNull View view, final int position) {
super.more(statuses.get(position).asRight(), view, position); super.more(statuses.get(position).asRight(), view, position);

View file

@ -42,6 +42,7 @@ import com.keylesspalace.tusky.appstore.StatusComposedEvent;
import com.keylesspalace.tusky.appstore.StatusDeletedEvent; import com.keylesspalace.tusky.appstore.StatusDeletedEvent;
import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.entity.Card; import com.keylesspalace.tusky.entity.Card;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.StatusContext; import com.keylesspalace.tusky.entity.StatusContext;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
@ -393,6 +394,33 @@ public final class ViewThreadFragment extends SFragment implements
adapter.setStatuses(statuses.getPairedCopy()); adapter.setStatuses(statuses.getPairedCopy());
} }
public void onVoteInPoll(int position, @NonNull List<Integer> choices) {
final Status status = statuses.get(position).getActionableStatus();
setVoteForPoll(position, status.getPoll().votedCopy(choices));
timelineCases.voteInPoll(status, choices)
.observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this)))
.subscribe(
(newPoll) -> setVoteForPoll(position, newPoll),
(t) -> Log.d(TAG,
"Failed to vote in poll: " + status.getId(), t)
);
}
private void setVoteForPoll(int position, Poll newPoll) {
StatusViewData.Concrete viewData = statuses.getPairedItem(position);
StatusViewData.Concrete newViewData = new StatusViewData.Builder(viewData)
.setPoll(newPoll)
.createStatusViewData();
statuses.setPairedItem(position, newViewData);
adapter.setItem(position, newViewData, true);
}
private void removeAllByAccountId(String accountId) { private void removeAllByAccountId(String accountId) {
Status status = null; Status status = null;
if (!statuses.isEmpty()) { if (!statuses.isEmpty()) {

View file

@ -17,6 +17,8 @@ package com.keylesspalace.tusky.interfaces;
import android.view.View; import android.view.View;
import java.util.List;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
@ -58,4 +60,6 @@ public interface StatusActionListener extends LinkListener {
*/ */
default void onShowFavs(int position) {} default void onShowFavs(int position) {}
void onVoteInPoll(int position, @NonNull List<Integer> choices);
} }

View file

@ -26,6 +26,7 @@ import com.keylesspalace.tusky.entity.Filter;
import com.keylesspalace.tusky.entity.Instance; import com.keylesspalace.tusky.entity.Instance;
import com.keylesspalace.tusky.entity.MastoList; import com.keylesspalace.tusky.entity.MastoList;
import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.Relationship; import com.keylesspalace.tusky.entity.Relationship;
import com.keylesspalace.tusky.entity.SearchResults; import com.keylesspalace.tusky.entity.SearchResults;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
@ -382,4 +383,11 @@ public interface MastodonApi {
Call<ResponseBody> deleteFilter( Call<ResponseBody> deleteFilter(
@Path("id") String id @Path("id") String id
); );
@FormUrlEncoded
@POST("api/v1/polls/{id}/votes")
Single<Poll> voteInPoll(
@Path("id") String id,
@Field("choices[]") List<Integer> choices
);
} }

View file

@ -16,6 +16,7 @@
package com.keylesspalace.tusky.network package com.keylesspalace.tusky.network
import com.keylesspalace.tusky.appstore.* import com.keylesspalace.tusky.appstore.*
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import io.reactivex.Single import io.reactivex.Single
@ -25,6 +26,7 @@ import okhttp3.ResponseBody
import retrofit2.Call import retrofit2.Call
import retrofit2.Callback import retrofit2.Callback
import retrofit2.Response import retrofit2.Response
import java.lang.IllegalStateException
/** /**
* Created by charlag on 3/24/18. * Created by charlag on 3/24/18.
@ -37,6 +39,8 @@ interface TimelineCases {
fun block(id: String) fun block(id: String)
fun delete(id: String) fun delete(id: String)
fun pin(status: Status, pin: Boolean) fun pin(status: Status, pin: Boolean)
fun voteInPoll(status: Status, choices: List<Int>): Single<Poll>
} }
class TimelineCasesImpl( class TimelineCasesImpl(
@ -116,4 +120,16 @@ class TimelineCasesImpl(
.addTo(this.cancelDisposable) .addTo(this.cancelDisposable)
} }
override fun voteInPoll(status: Status, choices: List<Int>): Single<Poll> {
val pollId = status.actionableStatus.poll?.id
if(pollId == null || choices.isEmpty()) {
return Single.error(IllegalStateException())
}
return mastodonApi.voteInPoll(pollId, choices).doAfterSuccess {
eventHub.dispatch(PollVoteEvent(status.id, it))
}
}
} }

View file

@ -4,9 +4,7 @@ import android.text.SpannedString
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import com.keylesspalace.tusky.db.* import com.keylesspalace.tusky.db.*
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.*
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.repository.TimelineRequestMode.DISK import com.keylesspalace.tusky.repository.TimelineRequestMode.DISK
@ -202,6 +200,7 @@ class TimelineRepositoryImpl(
val application = gson.fromJson(status.application, Status.Application::class.java) val application = gson.fromJson(status.application, Status.Application::class.java)
val emojis: List<Emoji> = gson.fromJson(status.emojis, val emojis: List<Emoji> = gson.fromJson(status.emojis,
object : TypeToken<List<Emoji>>() {}.type) ?: listOf() object : TypeToken<List<Emoji>>() {}.type) ?: listOf()
val poll: Poll? = gson.fromJson(status.poll, Poll::class.java)
val reblog = status.reblogServerId?.let { id -> val reblog = status.reblogServerId?.let { id ->
Status( Status(
@ -224,8 +223,8 @@ class TimelineRepositoryImpl(
attachments = attachments, attachments = attachments,
mentions = mentions, mentions = mentions,
application = application, application = application,
pinned = false pinned = false,
poll = poll
) )
} }
val status = if (reblog != null) { val status = if (reblog != null) {
@ -249,7 +248,8 @@ class TimelineRepositoryImpl(
attachments = ArrayList(), attachments = ArrayList(),
mentions = arrayOf(), mentions = arrayOf(),
application = null, application = null,
pinned = false pinned = false,
poll = null
) )
} else { } else {
Status( Status(
@ -272,7 +272,8 @@ class TimelineRepositoryImpl(
attachments = attachments, attachments = attachments,
mentions = mentions, mentions = mentions,
application = application, application = application,
pinned = false pinned = false,
poll = poll
) )
} }
return Either.Right(status) return Either.Right(status)
@ -339,8 +340,8 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
mentions = null, mentions = null,
application = null, application = null,
reblogServerId = null, reblogServerId = null,
reblogAccountId = null reblogAccountId = null,
poll = null
) )
} }
@ -369,7 +370,8 @@ fun Status.toEntity(timelineUserId: Long,
mentions = actionable.mentions.let(gson::toJson), mentions = actionable.mentions.let(gson::toJson),
application = actionable.let(gson::toJson), application = actionable.let(gson::toJson),
reblogServerId = reblog?.id, reblogServerId = reblog?.id,
reblogAccountId = reblog?.let { this.account.id } reblogAccountId = reblog?.let { this.account.id },
poll = actionable.poll.let(gson::toJson)
) )
} }

View file

@ -20,57 +20,86 @@ import android.content.Context;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
public class DateUtils { public class DateUtils {
private static final long SECOND_IN_MILLIS = 1000;
private static final long MINUTE_IN_MILLIS = SECOND_IN_MILLIS * 60;
private static final long HOUR_IN_MILLIS = MINUTE_IN_MILLIS * 60;
private static final long DAY_IN_MILLIS = HOUR_IN_MILLIS * 24;
private static final long YEAR_IN_MILLIS = DAY_IN_MILLIS * 365;
/** /**
* This is a rough duplicate of {@link android.text.format.DateUtils#getRelativeTimeSpanString}, * This is a rough duplicate of {@link android.text.format.DateUtils#getRelativeTimeSpanString},
* but even with the FORMAT_ABBREV_RELATIVE flag it wasn't abbreviating enough. * but even with the FORMAT_ABBREV_RELATIVE flag it wasn't abbreviating enough.
*/ */
public static String getRelativeTimeSpanString(Context context, long then, long now) { public static String getRelativeTimeSpanString(Context context, long then, long now) {
final long MINUTE = 60; long span = now - then;
final long HOUR = 60 * MINUTE;
final long DAY = 24 * HOUR;
final long YEAR = 365 * DAY;
long span = (now - then) / 1000;
boolean future = false; boolean future = false;
if (span < 0) { if (span < 0) {
future = true; future = true;
span = -span; span = -span;
} }
String format; int format;
if (span < MINUTE) { if (span < MINUTE_IN_MILLIS) {
span /= SECOND_IN_MILLIS;
if (future) { if (future) {
format = context.getString(R.string.abbreviated_in_seconds); format = R.string.abbreviated_in_seconds;
} else { } else {
format = context.getString(R.string.abbreviated_seconds_ago); format = R.string.abbreviated_seconds_ago;
} }
} else if (span < HOUR) { } else if (span < HOUR_IN_MILLIS) {
span /= MINUTE; span /= MINUTE_IN_MILLIS;
if (future) { if (future) {
format = context.getString(R.string.abbreviated_in_minutes); format = R.string.abbreviated_in_minutes;
} else { } else {
format = context.getString(R.string.abbreviated_minutes_ago); format = R.string.abbreviated_minutes_ago;
} }
} else if (span < DAY) { } else if (span < DAY_IN_MILLIS) {
span /= HOUR; span /= HOUR_IN_MILLIS;
if (future) { if (future) {
format = context.getString(R.string.abbreviated_in_hours); format = R.string.abbreviated_in_hours;
} else { } else {
format = context.getString(R.string.abbreviated_hours_ago); format = R.string.abbreviated_hours_ago;
} }
} else if (span < YEAR) { } else if (span < YEAR_IN_MILLIS) {
span /= DAY; span /= DAY_IN_MILLIS;
if (future) { if (future) {
format = context.getString(R.string.abbreviated_in_days); format = R.string.abbreviated_in_days;
} else { } else {
format = context.getString(R.string.abbreviated_days_ago); format = R.string.abbreviated_days_ago;
} }
} else { } else {
span /= YEAR; span /= YEAR_IN_MILLIS;
if (future) { if (future) {
format = context.getString(R.string.abbreviated_in_years); format = R.string.abbreviated_in_years;
} else { } else {
format = context.getString(R.string.abbreviated_years_ago); format = R.string.abbreviated_years_ago;
} }
} }
return String.format(format, span); return context.getString(format, span);
} }
public static String formatDuration(Context context, long then, long now) {
long span = then - now;
if (span < 0) {
span = 0;
}
int format;
if (span < MINUTE_IN_MILLIS) {
span /= SECOND_IN_MILLIS;
format = R.string.timespan_seconds;
} else if (span < HOUR_IN_MILLIS) {
span /= MINUTE_IN_MILLIS;
format = R.string.timespan_minutes;
} else if (span < DAY_IN_MILLIS) {
span /= HOUR_IN_MILLIS;
format = R.string.timespan_hours;
} else {
span /= DAY_IN_MILLIS;
format = R.string.timespan_days;
}
return context.getString(format, span);
}
} }

View file

@ -63,8 +63,8 @@ public final class ViewDataUtils {
SmartLengthInputFilter.LENGTH_DEFAULT SmartLengthInputFilter.LENGTH_DEFAULT
)) ))
.setCollapsed(true) .setCollapsed(true)
.setPoll(visibleStatus.getPoll())
.setIsBot(visibleStatus.getAccount().getBot()) .setIsBot(visibleStatus.getAccount().getBot())
.createStatusViewData(); .createStatusViewData();
} }

View file

@ -23,6 +23,7 @@ import android.text.Spanned;
import com.keylesspalace.tusky.entity.Attachment; import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Card; import com.keylesspalace.tusky.entity.Card;
import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import java.util.ArrayList; import java.util.ArrayList;
@ -87,6 +88,8 @@ public abstract class StatusViewData {
private final Card card; private final Card card;
private final boolean isCollapsible; /** Whether the status meets the requirement to be collapse */ private final boolean isCollapsible; /** Whether the status meets the requirement to be collapse */
final boolean isCollapsed; /** Whether the status is shown partially or fully */ final boolean isCollapsed; /** Whether the status is shown partially or fully */
@Nullable
private final Poll poll;
private final boolean isBot; private final boolean isBot;
public Concrete(String id, Spanned content, boolean reblogged, boolean favourited, public Concrete(String id, Spanned content, boolean reblogged, boolean favourited,
@ -96,7 +99,8 @@ public abstract class StatusViewData {
Date createdAt, int reblogsCount, int favouritesCount, @Nullable String inReplyToId, Date createdAt, int reblogsCount, int favouritesCount, @Nullable String inReplyToId,
@Nullable Status.Mention[] mentions, String senderId, boolean rebloggingEnabled, @Nullable Status.Mention[] mentions, String senderId, boolean rebloggingEnabled,
Status.Application application, List<Emoji> statusEmojis, List<Emoji> accountEmojis, @Nullable Card card, Status.Application application, List<Emoji> statusEmojis, List<Emoji> accountEmojis, @Nullable Card card,
boolean isCollapsible, boolean isCollapsed, boolean isBot) { boolean isCollapsible, boolean isCollapsed, @Nullable Poll poll, boolean isBot) {
this.id = id; this.id = id;
if (Build.VERSION.SDK_INT == 23) { if (Build.VERSION.SDK_INT == 23) {
// https://github.com/tuskyapp/Tusky/issues/563 // https://github.com/tuskyapp/Tusky/issues/563
@ -132,6 +136,7 @@ public abstract class StatusViewData {
this.card = card; this.card = card;
this.isCollapsible = isCollapsible; this.isCollapsible = isCollapsible;
this.isCollapsed = isCollapsed; this.isCollapsed = isCollapsed;
this.poll = poll;
this.isBot = isBot; this.isBot = isBot;
} }
@ -267,6 +272,11 @@ public abstract class StatusViewData {
return isCollapsed; return isCollapsed;
} }
@Nullable
public Poll getPoll() {
return poll;
}
@Override public long getViewDataId() { @Override public long getViewDataId() {
// Chance of collision is super low and impact of mistake is low as well // Chance of collision is super low and impact of mistake is low as well
return id.hashCode(); return id.hashCode();
@ -302,7 +312,8 @@ public abstract class StatusViewData {
Objects.equals(application, concrete.application) && Objects.equals(application, concrete.application) &&
Objects.equals(statusEmojis, concrete.statusEmojis) && Objects.equals(statusEmojis, concrete.statusEmojis) &&
Objects.equals(accountEmojis, concrete.accountEmojis) && Objects.equals(accountEmojis, concrete.accountEmojis) &&
Objects.equals(card, concrete.card) Objects.equals(card, concrete.card) &&
Objects.equals(poll, concrete.poll)
&& isCollapsed == concrete.isCollapsed; && isCollapsed == concrete.isCollapsed;
} }
@ -407,6 +418,7 @@ public abstract class StatusViewData {
private Card card; private Card card;
private boolean isCollapsible; /** Whether the status meets the requirement to be collapsed */ private boolean isCollapsible; /** Whether the status meets the requirement to be collapsed */
private boolean isCollapsed; /** Whether the status is shown partially or fully */ private boolean isCollapsed; /** Whether the status is shown partially or fully */
private Poll poll;
private boolean isBot; private boolean isBot;
public Builder() { public Builder() {
@ -441,6 +453,7 @@ public abstract class StatusViewData {
card = viewData.getCard(); card = viewData.getCard();
isCollapsible = viewData.isCollapsible(); isCollapsible = viewData.isCollapsible();
isCollapsed = viewData.isCollapsed(); isCollapsed = viewData.isCollapsed();
poll = viewData.poll;
isBot = viewData.isBot(); isBot = viewData.isBot();
} }
@ -603,6 +616,11 @@ public abstract class StatusViewData {
return this; return this;
} }
public Builder setPoll(Poll poll) {
this.poll = poll;
return this;
}
public StatusViewData.Concrete createStatusViewData() { public StatusViewData.Concrete createStatusViewData() {
if (this.statusEmojis == null) statusEmojis = Collections.emptyList(); if (this.statusEmojis == null) statusEmojis = Collections.emptyList();
if (this.accountEmojis == null) accountEmojis = Collections.emptyList(); if (this.accountEmojis == null) accountEmojis = Collections.emptyList();
@ -612,7 +630,7 @@ public abstract class StatusViewData {
attachments, rebloggedByUsername, rebloggedAvatar, isSensitive, isExpanded, attachments, rebloggedByUsername, rebloggedAvatar, isSensitive, isExpanded,
isShowingContent, userFullName, nickname, avatar, createdAt, reblogsCount, isShowingContent, userFullName, nickname, avatar, createdAt, reblogsCount,
favouritesCount, inReplyToId, mentions, senderId, rebloggingEnabled, application, favouritesCount, inReplyToId, mentions, senderId, rebloggingEnabled, application,
statusEmojis, accountEmojis, card, isCollapsible, isCollapsed, isBot); statusEmojis, accountEmojis, card, isCollapsible, isCollapsed, poll, isBot);
} }
} }
} }

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<clip
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/poll_option_shape"
android:clipOrientation="horizontal"
android:gravity="left|clip_horizontal|fill_vertical"/>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="?attr/pollOptionBackgroundColor" />
</shape>

View file

@ -341,6 +341,149 @@
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/status_poll_option_result_0"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="6dp"
android:paddingTop="2dp"
android:paddingEnd="6dp"
android:paddingBottom="2dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_media_preview_container"
tools:text="40%" />
<TextView
android:id="@+id/status_poll_option_result_1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="6dp"
android:paddingTop="2dp"
android:paddingEnd="6dp"
android:paddingBottom="2dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_0"
tools:text="10%" />
<TextView
android:id="@+id/status_poll_option_result_2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="6dp"
android:paddingTop="2dp"
android:paddingEnd="6dp"
android:paddingBottom="2dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_1"
tools:text="20%" />
<TextView
android:id="@+id/status_poll_option_result_3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="6dp"
android:paddingTop="2dp"
android:paddingEnd="6dp"
android:paddingBottom="2dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_2"
tools:text="30%" />
<RadioGroup
android:id="@+id/status_poll_radio_group"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_3">
<RadioButton
android:id="@+id/status_poll_radio_button_0"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 1" />
<RadioButton
android:id="@+id/status_poll_radio_button_1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 2" />
<RadioButton
android:id="@+id/status_poll_radio_button_2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 3" />
<RadioButton
android:id="@+id/status_poll_radio_button_3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 4" />
</RadioGroup>
<!-- using AppCompatButton because we don't want the inflater to turn it into a MaterialButton -->
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/status_poll_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/content_warning_button"
android:gravity="center"
android:minWidth="150dp"
android:minHeight="0dp"
android:paddingLeft="16dp"
android:paddingTop="4dp"
android:paddingRight="16dp"
android:paddingBottom="4dp"
android:layout_marginTop="4dp"
android:text="@string/poll_vote"
android:textSize="?attr/status_text_medium"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_radio_group" />
<TextView
android:id="@+id/status_poll_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_button"
tools:text="7 votes • 7 hours remaining" />
<ImageButton <ImageButton
android:id="@+id/status_reply" android:id="@+id/status_reply"
style="?attr/image_button_style" style="?attr/image_button_style"
@ -354,7 +497,7 @@
app:layout_constraintEnd_toStartOf="@id/status_favourite" app:layout_constraintEnd_toStartOf="@id/status_favourite"
app:layout_constraintHorizontal_chainStyle="spread_inside" app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toStartOf="@id/status_display_name" app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_media_preview_container" app:layout_constraintTop_toBottomOf="@id/status_poll_description"
app:srcCompat="@drawable/ic_reply_24dp" /> app:srcCompat="@drawable/ic_reply_24dp" />
<at.connyduck.sparkbutton.SparkButton <at.connyduck.sparkbutton.SparkButton

View file

@ -327,6 +327,149 @@
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/status_poll_option_result_0"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="6dp"
android:paddingTop="2dp"
android:paddingEnd="6dp"
android:paddingBottom="2dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_media_preview_container"
tools:text="40%" />
<TextView
android:id="@+id/status_poll_option_result_1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="6dp"
android:paddingTop="2dp"
android:paddingEnd="6dp"
android:paddingBottom="2dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_0"
tools:text="10%" />
<TextView
android:id="@+id/status_poll_option_result_2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="6dp"
android:paddingTop="2dp"
android:paddingEnd="6dp"
android:paddingBottom="2dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_1"
tools:text="20%" />
<TextView
android:id="@+id/status_poll_option_result_3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="6dp"
android:paddingTop="2dp"
android:paddingEnd="6dp"
android:paddingBottom="2dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_2"
tools:text="30%" />
<RadioGroup
android:id="@+id/status_poll_radio_group"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_3">
<RadioButton
android:id="@+id/status_poll_radio_button_0"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 1" />
<RadioButton
android:id="@+id/status_poll_radio_button_1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 2" />
<RadioButton
android:id="@+id/status_poll_radio_button_2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 3" />
<RadioButton
android:id="@+id/status_poll_radio_button_3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 4" />
</RadioGroup>
<!-- using AppCompatButton because we don't want the inflater to turn it into a MaterialButton -->
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/status_poll_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/content_warning_button"
android:gravity="center"
android:minWidth="150dp"
android:minHeight="0dp"
android:paddingLeft="16dp"
android:paddingTop="4dp"
android:paddingRight="16dp"
android:paddingBottom="4dp"
android:layout_marginTop="4dp"
android:text="@string/poll_vote"
android:textSize="?attr/status_text_medium"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_radio_group" />
<TextView
android:id="@+id/status_poll_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_button"
tools:text="7 votes • 7 hours remaining" />
<ImageButton <ImageButton
android:id="@+id/status_reply" android:id="@+id/status_reply"
style="?attr/image_button_style" style="?attr/image_button_style"
@ -341,7 +484,7 @@
app:layout_constraintEnd_toStartOf="@id/status_inset" app:layout_constraintEnd_toStartOf="@id/status_inset"
app:layout_constraintHorizontal_chainStyle="spread_inside" app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toStartOf="@id/status_display_name" app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_media_preview_container" app:layout_constraintTop_toBottomOf="@id/status_poll_description"
app:srcCompat="@drawable/ic_reply_24dp" /> app:srcCompat="@drawable/ic_reply_24dp" />
<at.connyduck.sparkbutton.SparkButton <at.connyduck.sparkbutton.SparkButton

View file

@ -335,6 +335,151 @@
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/status_poll_option_result_0"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="8dp"
android:paddingTop="4dp"
android:paddingEnd="8dp"
android:paddingBottom="4dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_media_preview_container"
tools:text="40%" />
<TextView
android:id="@+id/status_poll_option_result_1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="8dp"
android:paddingTop="4dp"
android:paddingEnd="8dp"
android:paddingBottom="4dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_0"
tools:text="10%" />
<TextView
android:id="@+id/status_poll_option_result_2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="8dp"
android:paddingTop="4dp"
android:paddingEnd="8dp"
android:paddingBottom="4dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_1"
tools:text="20%" />
<TextView
android:id="@+id/status_poll_option_result_3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/poll_option_background"
android:ellipsize="end"
android:lines="1"
android:paddingStart="8dp"
android:paddingTop="4dp"
android:paddingEnd="8dp"
android:paddingBottom="4dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_2"
tools:text="30%" />
<RadioGroup
android:id="@+id/status_poll_radio_group"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_3">
<RadioButton
android:id="@+id/status_poll_radio_button_0"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 1" />
<RadioButton
android:id="@+id/status_poll_radio_button_1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 2" />
<RadioButton
android:id="@+id/status_poll_radio_button_2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 3" />
<RadioButton
android:id="@+id/status_poll_radio_button_3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium"
tools:text="Option 4" />
</RadioGroup>
<!-- using AppCompatButton because we don't want the inflater to turn it into a MaterialButton -->
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/status_poll_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:background="?attr/content_warning_button"
android:gravity="center"
android:minWidth="150dp"
android:minHeight="0dp"
android:paddingLeft="16dp"
android:paddingTop="4dp"
android:paddingRight="16dp"
android:paddingBottom="4dp"
android:text="@string/poll_vote"
android:textSize="?attr/status_text_medium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_poll_radio_group" />
<TextView
android:id="@+id/status_poll_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_poll_button"
tools:text="7 votes • 7 hours remaining" />
<TextView <TextView
android:id="@+id/status_timestamp_info" android:id="@+id/status_timestamp_info"
android:layout_width="0dp" android:layout_width="0dp"
@ -346,7 +491,7 @@
android:textSize="?attr/status_text_medium" android:textSize="?attr/status_text_medium"
app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_media_preview_container" app:layout_constraintTop_toBottomOf="@id/status_poll_description"
tools:text="21 Dec 2018 18:45" /> tools:text="21 Dec 2018 18:45" />
<View <View
@ -355,8 +500,8 @@
android:layout_height="1dp" android:layout_height="1dp"
android:layout_below="@id/status_timestamp_info" android:layout_below="@id/status_timestamp_info"
android:layout_marginTop="6dp" android:layout_marginTop="6dp"
android:importantForAccessibility="no"
android:background="?android:attr/listDivider" android:background="?android:attr/listDivider"
android:importantForAccessibility="no"
android:paddingStart="16dp" android:paddingStart="16dp"
android:paddingEnd="16dp" android:paddingEnd="16dp"
app:layout_constraintTop_toBottomOf="@id/status_timestamp_info" /> app:layout_constraintTop_toBottomOf="@id/status_timestamp_info" />
@ -403,8 +548,8 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="1dp" android:layout_height="1dp"
android:layout_marginTop="6dp" android:layout_marginTop="6dp"
android:importantForAccessibility="no"
android:background="?android:attr/listDivider" android:background="?android:attr/listDivider"
android:importantForAccessibility="no"
android:paddingStart="16dp" android:paddingStart="16dp"
android:paddingEnd="16dp" android:paddingEnd="16dp"
app:layout_constraintTop_toBottomOf="@id/status_counters_barrier" /> app:layout_constraintTop_toBottomOf="@id/status_counters_barrier" />

View file

@ -78,6 +78,8 @@
<item name="minTouchTargetSize">32dp</item> <!-- this affects RadioButton size --> <item name="minTouchTargetSize">32dp</item> <!-- this affects RadioButton size -->
<item name="pollOptionBackgroundColor">@color/color_primary_dark</item>
</style> </style>
<style name="TuskyImageButton.Dark" parent="@style/Widget.MaterialComponents.Button.UnelevatedButton"> <style name="TuskyImageButton.Dark" parent="@style/Widget.MaterialComponents.Button.UnelevatedButton">

View file

@ -45,4 +45,6 @@
<attr name="status_text_medium" format="dimension" /> <attr name="status_text_medium" format="dimension" />
<attr name="status_text_large" format="dimension" /> <attr name="status_text_large" format="dimension" />
<attr name="pollOptionBackgroundColor" format="reference|color" />
</resources> </resources>

View file

@ -317,6 +317,12 @@
<string name="abbreviated_minutes_ago">%dm</string> <string name="abbreviated_minutes_ago">%dm</string>
<string name="abbreviated_seconds_ago">%ds</string> <string name="abbreviated_seconds_ago">%ds</string>
<!--These are for timestamps on polls -->
<string name="timespan_days">%d days</string>
<string name="timespan_hours">%d hours</string>
<string name="timespan_minutes">%d minutes</string>
<string name="timespan_seconds">%d seconds</string>
<string name="follows_you">Follows you</string> <string name="follows_you">Follows you</string>
<string name="pref_title_alway_show_sensitive_media">Always show sensitive content</string> <string name="pref_title_alway_show_sensitive_media">Always show sensitive content</string>
<string name="title_media">Media</string> <string name="title_media">Media</string>
@ -476,4 +482,21 @@
<string name="notification_clear_text">Are you sure you want to permanently clear all your notifications?</string> <string name="notification_clear_text">Are you sure you want to permanently clear all your notifications?</string>
<string name="poll_info_format">
<!-- 15 votes • 1 hour left -->
%1$s • %2$s</string>
<plurals name="poll_info_votes">
<item quantity="one">%s vote</item>
<item quantity="other">%s votes</item>
</plurals>
<string name="poll_info_time_relative">%s left</string>
<string name="poll_info_time_absolute">ends at %s</string>
<string name="poll_info_closed">closed</string>
<string name="poll_option_format">
<!-- 15% vote for this! -->
&lt;b>%1$d%%&lt;/b> %2$s</string>
<string name="poll_vote">Vote</string>
</resources> </resources>

View file

@ -140,6 +140,8 @@
<item name="minTouchTargetSize">32dp</item> <!-- this affects RadioButton size --> <item name="minTouchTargetSize">32dp</item> <!-- this affects RadioButton size -->
<item name="pollOptionBackgroundColor">@color/color_primary_dark_light</item>
</style> </style>
<style name="TuskyImageButton.Light" parent="@style/Widget.MaterialComponents.Button.UnelevatedButton"> <style name="TuskyImageButton.Light" parent="@style/Widget.MaterialComponents.Button.UnelevatedButton">

View file

@ -84,7 +84,8 @@ class BottomSheetActivityTest {
ArrayList(), ArrayList(),
arrayOf(), arrayOf(),
null, null,
pinned = false pinned = false,
poll = null
) )
private val statusCallback = FakeSearchResults(status) private val statusCallback = FakeSearchResults(status)

View file

@ -305,7 +305,8 @@ class TimelineRepositoryTest {
inReplyToId = null, inReplyToId = null,
pinned = false, pinned = false,
reblog = null, reblog = null,
url = "http://example.com/statuses/$id" url = "http://example.com/statuses/$id",
poll = null
) )
} }