Scheduled toot (#1004)

* Scheduled toot

* Hide scheduled toot button if version < 2.7.0

* Fix timeline reloading after toot

* Add edit icon to ComposeScheduleView

* Add button to reset scheduled toot

* Close bottom sheet and change button color after time a was selected

* Fix edit icon's size

* List of scheduled toots

* Fix instance version check

* Use MaterialDatePicker

* Set date and time consecutively

* Add licenses
This commit is contained in:
kyori19 2019-10-03 04:28:12 +09:00 committed by Konrad Pozniak
parent a6b9d2f67e
commit 9e4c19a47e
23 changed files with 933 additions and 56 deletions

View File

@ -100,7 +100,7 @@ dependencies {
implementation 'androidx.browser:browser:1.0.0' implementation 'androidx.browser:browser:1.0.0'
implementation 'androidx.recyclerview:recyclerview:1.0.0' implementation 'androidx.recyclerview:recyclerview:1.0.0'
implementation 'androidx.legacy:legacy-support-v13:1.0.0' implementation 'androidx.legacy:legacy-support-v13:1.0.0'
implementation 'com.google.android.material:material:1.1.0-alpha05' implementation 'com.google.android.material:material:1.1.0-alpha10'
implementation 'androidx.exifinterface:exifinterface:1.0.0' implementation 'androidx.exifinterface:exifinterface:1.0.0'
implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.preference:preference:1.1.0-alpha04' implementation 'androidx.preference:preference:1.1.0-alpha04'

View File

@ -135,6 +135,7 @@
android:name=".components.report.ReportActivity" android:name=".components.report.ReportActivity"
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" /> android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
<activity android:name=".components.instancemute.InstanceListActivity" /> <activity android:name=".components.instancemute.InstanceListActivity" />
<activity android:name=".ScheduledTootActivity" />
<receiver android:name=".receiver.NotificationClearBroadcastReceiver" /> <receiver android:name=".receiver.NotificationClearBroadcastReceiver" />
<receiver <receiver

View File

@ -17,7 +17,9 @@ package com.keylesspalace.tusky;
import android.Manifest; import android.Manifest;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.app.DatePickerDialog;
import android.app.ProgressDialog; import android.app.ProgressDialog;
import android.app.TimePickerDialog;
import android.content.ContentResolver; import android.content.ContentResolver;
import android.content.Context; import android.content.Context;
import android.content.DialogInterface; import android.content.DialogInterface;
@ -55,14 +57,35 @@ import android.view.Window;
import android.view.WindowManager; import android.view.WindowManager;
import android.webkit.MimeTypeMap; import android.webkit.MimeTypeMap;
import android.widget.Button; import android.widget.Button;
import android.widget.DatePicker;
import android.widget.EditText; import android.widget.EditText;
import android.widget.ImageButton; import android.widget.ImageButton;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.LinearLayout; import android.widget.LinearLayout;
import android.widget.PopupMenu; import android.widget.PopupMenu;
import android.widget.TextView; import android.widget.TextView;
import android.widget.TimePicker;
import android.widget.Toast; import android.widget.Toast;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.annotation.StringRes;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.widget.Toolbar;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.core.content.FileProvider;
import androidx.core.view.inputmethod.InputConnectionCompat;
import androidx.core.view.inputmethod.InputContentInfoCompat;
import androidx.lifecycle.Lifecycle;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.transition.TransitionManager;
import com.bumptech.glide.Glide; import com.bumptech.glide.Glide;
import com.google.android.material.bottomsheet.BottomSheetBehavior; import com.google.android.material.bottomsheet.BottomSheetBehavior;
import com.google.android.material.snackbar.Snackbar; import com.google.android.material.snackbar.Snackbar;
@ -95,9 +118,11 @@ import com.keylesspalace.tusky.util.SaveTootHelper;
import com.keylesspalace.tusky.util.SpanUtilsKt; import com.keylesspalace.tusky.util.SpanUtilsKt;
import com.keylesspalace.tusky.util.StringUtils; import com.keylesspalace.tusky.util.StringUtils;
import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.util.ThemeUtils;
import com.keylesspalace.tusky.util.VersionUtils;
import com.keylesspalace.tusky.view.AddPollDialog; import com.keylesspalace.tusky.view.AddPollDialog;
import com.keylesspalace.tusky.view.ComposeOptionsListener; import com.keylesspalace.tusky.view.ComposeOptionsListener;
import com.keylesspalace.tusky.view.ComposeOptionsView; import com.keylesspalace.tusky.view.ComposeOptionsView;
import com.keylesspalace.tusky.view.ComposeScheduleView;
import com.keylesspalace.tusky.view.EditTextTyped; import com.keylesspalace.tusky.view.EditTextTyped;
import com.keylesspalace.tusky.view.PollPreviewView; import com.keylesspalace.tusky.view.PollPreviewView;
import com.keylesspalace.tusky.view.ProgressImageView; import com.keylesspalace.tusky.view.ProgressImageView;
@ -123,25 +148,6 @@ import java.util.concurrent.CountDownLatch;
import javax.inject.Inject; import javax.inject.Inject;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.annotation.StringRes;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.widget.Toolbar;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.core.content.FileProvider;
import androidx.core.view.inputmethod.InputConnectionCompat;
import androidx.core.view.inputmethod.InputContentInfoCompat;
import androidx.lifecycle.Lifecycle;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.transition.TransitionManager;
import at.connyduck.sparkbutton.helpers.Utils; import at.connyduck.sparkbutton.helpers.Utils;
import io.reactivex.Single; import io.reactivex.Single;
import io.reactivex.SingleObserver; import io.reactivex.SingleObserver;
@ -169,7 +175,8 @@ public final class ComposeActivity
implements ComposeOptionsListener, implements ComposeOptionsListener,
ComposeAutoCompleteAdapter.AutocompletionProvider, ComposeAutoCompleteAdapter.AutocompletionProvider,
OnEmojiSelectedListener, OnEmojiSelectedListener,
Injectable, InputConnectionCompat.OnCommitContentListener { Injectable, InputConnectionCompat.OnCommitContentListener,
TimePickerDialog.OnTimeSetListener {
private static final String TAG = "ComposeActivity"; // logging tag private static final String TAG = "ComposeActivity"; // logging tag
static final int STATUS_CHARACTER_LIMIT = 500; static final int STATUS_CHARACTER_LIMIT = 500;
@ -192,6 +199,7 @@ public final class ComposeActivity
private static final String REPLYING_STATUS_AUTHOR_USERNAME_EXTRA = "replying_author_nickname_extra"; private static final String REPLYING_STATUS_AUTHOR_USERNAME_EXTRA = "replying_author_nickname_extra";
private static final String REPLYING_STATUS_CONTENT_EXTRA = "replying_status_content"; private static final String REPLYING_STATUS_CONTENT_EXTRA = "replying_status_content";
private static final String MEDIA_ATTACHMENTS_EXTRA = "media_attachments"; private static final String MEDIA_ATTACHMENTS_EXTRA = "media_attachments";
private static final String SCHEDULED_AT_EXTRA = "scheduled_at";
private static final String SENSITIVE_EXTRA = "sensitive"; private static final String SENSITIVE_EXTRA = "sensitive";
private static final String POLL_EXTRA = "poll"; private static final String POLL_EXTRA = "poll";
// Mastodon only counts URLs as this long in terms of status character limits // Mastodon only counts URLs as this long in terms of status character limits
@ -217,6 +225,7 @@ public final class ComposeActivity
private ImageButton contentWarningButton; private ImageButton contentWarningButton;
private ImageButton emojiButton; private ImageButton emojiButton;
private ImageButton hideMediaToggle; private ImageButton hideMediaToggle;
private ImageButton scheduleButton;
private TextView actionAddPoll; private TextView actionAddPoll;
private Button atButton; private Button atButton;
private Button hashButton; private Button hashButton;
@ -225,6 +234,8 @@ public final class ComposeActivity
private BottomSheetBehavior composeOptionsBehavior; private BottomSheetBehavior composeOptionsBehavior;
private BottomSheetBehavior addMediaBehavior; private BottomSheetBehavior addMediaBehavior;
private BottomSheetBehavior emojiBehavior; private BottomSheetBehavior emojiBehavior;
private BottomSheetBehavior scheduleBehavior;
private ComposeScheduleView scheduleView;
private RecyclerView emojiView; private RecyclerView emojiView;
private PollPreviewView pollPreview; private PollPreviewView pollPreview;
@ -278,6 +289,8 @@ public final class ComposeActivity
contentWarningButton = findViewById(R.id.composeContentWarningButton); contentWarningButton = findViewById(R.id.composeContentWarningButton);
emojiButton = findViewById(R.id.composeEmojiButton); emojiButton = findViewById(R.id.composeEmojiButton);
hideMediaToggle = findViewById(R.id.composeHideMediaButton); hideMediaToggle = findViewById(R.id.composeHideMediaButton);
scheduleButton = findViewById(R.id.composeScheduleButton);
scheduleView = findViewById(R.id.composeScheduleView);
emojiView = findViewById(R.id.emojiView); emojiView = findViewById(R.id.emojiView);
emojiList = Collections.emptyList(); emojiList = Collections.emptyList();
atButton = findViewById(R.id.atButton); atButton = findViewById(R.id.atButton);
@ -361,6 +374,8 @@ public final class ComposeActivity
addMediaBehavior = BottomSheetBehavior.from(findViewById(R.id.addMediaBottomSheet)); addMediaBehavior = BottomSheetBehavior.from(findViewById(R.id.addMediaBottomSheet));
scheduleBehavior = BottomSheetBehavior.from(scheduleView);
emojiBehavior = BottomSheetBehavior.from(emojiView); emojiBehavior = BottomSheetBehavior.from(emojiView);
emojiView.setLayoutManager(new GridLayoutManager(this, 3, GridLayoutManager.HORIZONTAL, false)); emojiView.setLayoutManager(new GridLayoutManager(this, 3, GridLayoutManager.HORIZONTAL, false));
@ -374,6 +389,8 @@ public final class ComposeActivity
contentWarningButton.setOnClickListener(v -> onContentWarningChanged()); contentWarningButton.setOnClickListener(v -> onContentWarningChanged());
emojiButton.setOnClickListener(v -> showEmojis()); emojiButton.setOnClickListener(v -> showEmojis());
hideMediaToggle.setOnClickListener(v -> toggleHideMedia()); hideMediaToggle.setOnClickListener(v -> toggleHideMedia());
scheduleButton.setOnClickListener(v -> showScheduleView());
scheduleView.setResetOnClickListener(v -> resetSchedule());
atButton.setOnClickListener(v -> atButtonClicked()); atButton.setOnClickListener(v -> atButtonClicked());
hashButton.setOnClickListener(v -> hashButtonClicked()); hashButton.setOnClickListener(v -> hashButtonClicked());
@ -521,6 +538,11 @@ public final class ComposeActivity
replyContentTextView.setText(intent.getStringExtra(REPLYING_STATUS_CONTENT_EXTRA)); replyContentTextView.setText(intent.getStringExtra(REPLYING_STATUS_CONTENT_EXTRA));
} }
String scheduledAt = intent.getStringExtra(SCHEDULED_AT_EXTRA);
if (!TextUtils.isEmpty(scheduledAt)) {
scheduleView.setDateTime(scheduledAt);
}
statusMarkSensitive = intent.getBooleanExtra(SENSITIVE_EXTRA, statusMarkSensitive); statusMarkSensitive = intent.getBooleanExtra(SENSITIVE_EXTRA, statusMarkSensitive);
if(intent.hasExtra(POLL_EXTRA) && (mediaAttachments == null || mediaAttachments.size() == 0)) { if(intent.hasExtra(POLL_EXTRA) && (mediaAttachments == null || mediaAttachments.size() == 0)) {
@ -536,6 +558,7 @@ public final class ComposeActivity
setStatusVisibility(startingVisibility); setStatusVisibility(startingVisibility);
updateHideMediaToggle(); updateHideMediaToggle();
updateScheduleButton();
updateVisibleCharactersLeft(); updateVisibleCharactersLeft();
// Setup the main text field. // Setup the main text field.
@ -799,11 +822,22 @@ public final class ComposeActivity
} }
} }
private void updateScheduleButton() {
@ColorInt int color;
if(scheduleView.getTime() == null) {
color = ThemeUtils.getColor(this, android.R.attr.textColorTertiary);
} else {
color = ContextCompat.getColor(this, R.color.tusky_blue);
}
scheduleButton.getDrawable().setColorFilter(color, PorterDuff.Mode.SRC_IN);
}
private void disableButtons() { private void disableButtons() {
pickButton.setClickable(false); pickButton.setClickable(false);
visibilityButton.setClickable(false); visibilityButton.setClickable(false);
emojiButton.setClickable(false); emojiButton.setClickable(false);
hideMediaToggle.setClickable(false); hideMediaToggle.setClickable(false);
scheduleButton.setClickable(false);
tootButton.setEnabled(false); tootButton.setEnabled(false);
} }
@ -812,6 +846,7 @@ public final class ComposeActivity
visibilityButton.setClickable(true); visibilityButton.setClickable(true);
emojiButton.setClickable(true); emojiButton.setClickable(true);
hideMediaToggle.setClickable(true); hideMediaToggle.setClickable(true);
scheduleButton.setClickable(true);
tootButton.setEnabled(true); tootButton.setEnabled(true);
} }
@ -859,12 +894,23 @@ public final class ComposeActivity
composeOptionsBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); composeOptionsBehavior.setState(BottomSheetBehavior.STATE_EXPANDED);
addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
} else { } else {
composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
} }
} }
private void showScheduleView() {
if (scheduleBehavior.getState() == BottomSheetBehavior.STATE_HIDDEN || scheduleBehavior.getState() == BottomSheetBehavior.STATE_COLLAPSED) {
scheduleBehavior.setState(BottomSheetBehavior.STATE_EXPANDED);
composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
} else {
scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
}
}
private void showEmojis() { private void showEmojis() {
if (emojiView.getAdapter() != null) { if (emojiView.getAdapter() != null) {
@ -876,7 +922,7 @@ public final class ComposeActivity
emojiBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); emojiBehavior.setState(BottomSheetBehavior.STATE_EXPANDED);
composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
} else { } else {
emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
} }
@ -891,7 +937,7 @@ public final class ComposeActivity
addMediaBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); addMediaBehavior.setState(BottomSheetBehavior.STATE_EXPANDED);
composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
} else { } else {
addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
} }
@ -1084,7 +1130,8 @@ public final class ComposeActivity
} }
Intent sendIntent = SendTootService.sendTootIntent(this, content, spoilerText, Intent sendIntent = SendTootService.sendTootIntent(this, content, spoilerText,
visibility, !mediaUris.isEmpty() && sensitive, mediaIds, mediaUris, mediaDescriptions, inReplyToId, poll, visibility, !mediaUris.isEmpty() && sensitive, mediaIds, mediaUris, mediaDescriptions,
scheduleView.getTime(), inReplyToId, poll,
getIntent().getStringExtra(REPLYING_STATUS_CONTENT_EXTRA), getIntent().getStringExtra(REPLYING_STATUS_CONTENT_EXTRA),
getIntent().getStringExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA), getIntent().getStringExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA),
getIntent().getStringExtra(SAVED_JSON_URLS_EXTRA), getIntent().getStringExtra(SAVED_JSON_URLS_EXTRA),
@ -1744,10 +1791,12 @@ public final class ComposeActivity
// Acting like a teen: deliberately ignoring parent. // Acting like a teen: deliberately ignoring parent.
if (composeOptionsBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED || if (composeOptionsBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED ||
addMediaBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED || addMediaBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED ||
emojiBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) { emojiBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED ||
scheduleBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) {
composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
return; return;
} }
@ -1947,6 +1996,10 @@ public final class ComposeActivity
updateVisibleCharactersLeft(); updateVisibleCharactersLeft();
} }
if (!new VersionUtils(instance.getVersion()).supportsScheduledToots()) {
scheduleButton.setVisibility(View.GONE);
}
if (instance.getPollLimits() != null) { if (instance.getPollLimits() != null) {
maxPollOptions = instance.getPollLimits().getMaxOptions(); maxPollOptions = instance.getPollLimits().getMaxOptions();
maxPollOptionLength = instance.getPollLimits().getMaxOptionChars(); maxPollOptionLength = instance.getPollLimits().getMaxOptionChars();
@ -2048,6 +2101,19 @@ public final class ComposeActivity
} }
} }
@Override
public void onTimeSet(TimePicker view, int hourOfDay, int minute) {
scheduleView.onTimeSet(hourOfDay, minute);
updateScheduleButton();
scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
}
public void resetSchedule() {
scheduleView.resetSchedule();
updateScheduleButton();
scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
}
public static final class IntentBuilder { public static final class IntentBuilder {
@Nullable @Nullable
private Integer savedTootUid; private Integer savedTootUid;
@ -2074,6 +2140,8 @@ public final class ComposeActivity
@Nullable @Nullable
private ArrayList<Attachment> mediaAttachments; private ArrayList<Attachment> mediaAttachments;
@Nullable @Nullable
private String scheduledAt;
@Nullable
private Boolean sensitive; private Boolean sensitive;
@Nullable @Nullable
private NewPoll poll; private NewPoll poll;
@ -2138,6 +2206,11 @@ public final class ComposeActivity
return this; return this;
} }
public IntentBuilder scheduledAt(String scheduledAt) {
this.scheduledAt = scheduledAt;
return this;
}
public IntentBuilder sensitive(boolean sensitive) { public IntentBuilder sensitive(boolean sensitive) {
this.sensitive = sensitive; this.sensitive = sensitive;
return this; return this;
@ -2188,6 +2261,9 @@ public final class ComposeActivity
if (mediaAttachments != null) { if (mediaAttachments != null) {
intent.putParcelableArrayListExtra(MEDIA_ATTACHMENTS_EXTRA, mediaAttachments); intent.putParcelableArrayListExtra(MEDIA_ATTACHMENTS_EXTRA, mediaAttachments);
} }
if (scheduledAt != null) {
intent.putExtra(SCHEDULED_AT_EXTRA, scheduledAt);
}
if (sensitive != null) { if (sensitive != null) {
intent.putExtra(SENSITIVE_EXTRA, sensitive); intent.putExtra(SENSITIVE_EXTRA, sensitive);
} }

View File

@ -15,26 +15,11 @@
package com.keylesspalace.tusky; package com.keylesspalace.tusky;
import androidx.lifecycle.Lifecycle;
import android.content.Intent; import android.content.Intent;
import android.graphics.Color; import android.graphics.Color;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import androidx.annotation.Nullable;
import com.bumptech.glide.Glide;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.android.material.tabs.TabLayout;
import androidx.emoji.text.EmojiCompat;
import androidx.fragment.app.Fragment;
import androidx.core.content.ContextCompat;
import androidx.viewpager.widget.ViewPager;
import androidx.appcompat.app.AlertDialog;
import android.os.Handler; import android.os.Handler;
import android.preference.PreferenceManager; import android.preference.PreferenceManager;
import android.util.Log; import android.util.Log;
@ -42,6 +27,17 @@ import android.view.KeyEvent;
import android.widget.ImageButton; import android.widget.ImageButton;
import android.widget.ImageView; import android.widget.ImageView;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat;
import androidx.emoji.text.EmojiCompat;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.Lifecycle;
import androidx.viewpager.widget.ViewPager;
import com.bumptech.glide.Glide;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.android.material.tabs.TabLayout;
import com.keylesspalace.tusky.appstore.CacheUpdater; import com.keylesspalace.tusky.appstore.CacheUpdater;
import com.keylesspalace.tusky.appstore.EventHub; import com.keylesspalace.tusky.appstore.EventHub;
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent; import com.keylesspalace.tusky.appstore.MainTabsChangedEvent;
@ -101,6 +97,7 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut
private static final long DRAWER_ITEM_ABOUT = 7; private static final long DRAWER_ITEM_ABOUT = 7;
private static final long DRAWER_ITEM_LOG_OUT = 8; private static final long DRAWER_ITEM_LOG_OUT = 8;
private static final long DRAWER_ITEM_FOLLOW_REQUESTS = 9; private static final long DRAWER_ITEM_FOLLOW_REQUESTS = 9;
private static final long DRAWER_ITEM_SCHEDULED_TOOT = 10;
public static final String STATUS_URL = "statusUrl"; public static final String STATUS_URL = "statusUrl";
@Inject @Inject
@ -391,6 +388,7 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut
listItems.add(new PrimaryDrawerItem().withIdentifier(DRAWER_ITEM_LISTS).withName(R.string.action_lists).withSelectable(false).withIcon(GoogleMaterial.Icon.gmd_list)); listItems.add(new PrimaryDrawerItem().withIdentifier(DRAWER_ITEM_LISTS).withName(R.string.action_lists).withSelectable(false).withIcon(GoogleMaterial.Icon.gmd_list));
listItems.add(new PrimaryDrawerItem().withIdentifier(DRAWER_ITEM_SEARCH).withName(R.string.action_search).withSelectable(false).withIcon(GoogleMaterial.Icon.gmd_search)); listItems.add(new PrimaryDrawerItem().withIdentifier(DRAWER_ITEM_SEARCH).withName(R.string.action_search).withSelectable(false).withIcon(GoogleMaterial.Icon.gmd_search));
listItems.add(new PrimaryDrawerItem().withIdentifier(DRAWER_ITEM_SAVED_TOOT).withName(R.string.action_access_saved_toot).withSelectable(false).withIcon(R.drawable.ic_notebook).withIconTintingEnabled(true)); listItems.add(new PrimaryDrawerItem().withIdentifier(DRAWER_ITEM_SAVED_TOOT).withName(R.string.action_access_saved_toot).withSelectable(false).withIcon(R.drawable.ic_notebook).withIconTintingEnabled(true));
listItems.add(new PrimaryDrawerItem().withIdentifier(DRAWER_ITEM_SCHEDULED_TOOT).withName(R.string.action_access_scheduled_toot).withSelectable(false).withIcon(R.drawable.ic_access_time).withIconTintingEnabled(true));
listItems.add(new DividerDrawerItem()); listItems.add(new DividerDrawerItem());
listItems.add(new SecondaryDrawerItem().withIdentifier(DRAWER_ITEM_ACCOUNT_SETTINGS).withName(R.string.action_view_account_preferences).withSelectable(false).withIcon(R.drawable.ic_account_settings).withIconTintingEnabled(true)); listItems.add(new SecondaryDrawerItem().withIdentifier(DRAWER_ITEM_ACCOUNT_SETTINGS).withName(R.string.action_view_account_preferences).withSelectable(false).withIcon(R.drawable.ic_account_settings).withIconTintingEnabled(true));
listItems.add(new SecondaryDrawerItem().withIdentifier(DRAWER_ITEM_SETTINGS).withName(R.string.action_view_preferences).withSelectable(false).withIcon(GoogleMaterial.Icon.gmd_settings)); listItems.add(new SecondaryDrawerItem().withIdentifier(DRAWER_ITEM_SETTINGS).withName(R.string.action_view_preferences).withSelectable(false).withIcon(GoogleMaterial.Icon.gmd_settings));
@ -433,6 +431,8 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut
} else if (drawerItemIdentifier == DRAWER_ITEM_SAVED_TOOT) { } else if (drawerItemIdentifier == DRAWER_ITEM_SAVED_TOOT) {
Intent intent = new Intent(MainActivity.this, SavedTootActivity.class); Intent intent = new Intent(MainActivity.this, SavedTootActivity.class);
startActivityWithSlideInAnimation(intent); startActivityWithSlideInAnimation(intent);
} else if (drawerItemIdentifier == DRAWER_ITEM_SCHEDULED_TOOT) {
startActivityWithSlideInAnimation(ScheduledTootActivity.newIntent(this));
} else if (drawerItemIdentifier == DRAWER_ITEM_LISTS) { } else if (drawerItemIdentifier == DRAWER_ITEM_LISTS) {
startActivityWithSlideInAnimation(ListsActivity.newIntent(this)); startActivityWithSlideInAnimation(ListsActivity.newIntent(this));
} }

View File

@ -0,0 +1,166 @@
package com.keylesspalace.tusky
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.keylesspalace.tusky.adapter.ScheduledTootAdapter
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.StatusScheduledEvent
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.ScheduledStatus
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.uber.autodispose.AutoDispose.autoDisposable
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from
import io.reactivex.android.schedulers.AndroidSchedulers
import kotlinx.android.synthetic.main.activity_scheduled_toot.*
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import javax.inject.Inject
class ScheduledTootActivity : BaseActivity(), ScheduledTootAdapter.ScheduledTootAction, Injectable {
companion object {
@JvmStatic
fun newIntent(context: Context): Intent {
return Intent(context, ScheduledTootActivity::class.java)
}
}
lateinit var adapter: ScheduledTootAdapter
@Inject
lateinit var mastodonApi: MastodonApi
@Inject
lateinit var eventHub: EventHub
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_scheduled_toot)
val toolbar = findViewById<Toolbar>(R.id.toolbar)
setSupportActionBar(toolbar)
val bar = supportActionBar
if (bar != null) {
bar.title = getString(R.string.title_scheduled_toot)
bar.setDisplayHomeAsUpEnabled(true)
bar.setDisplayShowHomeEnabled(true)
}
swipe_refresh_layout.setOnRefreshListener(this::refreshStatuses)
scheduled_toot_list.setHasFixedSize(true)
val layoutManager = LinearLayoutManager(this)
scheduled_toot_list.layoutManager = layoutManager
val divider = DividerItemDecoration(this, layoutManager.orientation)
scheduled_toot_list.addItemDecoration(divider)
adapter = ScheduledTootAdapter(this)
scheduled_toot_list.adapter = adapter
loadStatuses()
eventHub.events
.observeOn(AndroidSchedulers.mainThread())
.`as`(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe { event ->
if (event is StatusScheduledEvent) {
refreshStatuses()
}
}
}
fun loadStatuses() {
progress_bar.visibility = View.VISIBLE
mastodonApi.scheduledStatuses()
.enqueue(object : Callback<List<ScheduledStatus>> {
override fun onResponse(call: Call<List<ScheduledStatus>>, response: Response<List<ScheduledStatus>>) {
progress_bar.visibility = View.GONE
if (response.body().isNullOrEmpty()) {
errorMessageView.show()
errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.message_empty,
null)
} else {
show(response.body()!!)
}
}
override fun onFailure(call: Call<List<ScheduledStatus>>, t: Throwable) {
progress_bar.visibility = View.GONE
errorMessageView.show()
errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) {
errorMessageView.hide()
loadStatuses()
}
}
})
}
private fun refreshStatuses() {
swipe_refresh_layout.isRefreshing = true
mastodonApi.scheduledStatuses()
.enqueue(object : Callback<List<ScheduledStatus>> {
override fun onResponse(call: Call<List<ScheduledStatus>>, response: Response<List<ScheduledStatus>>) {
swipe_refresh_layout.isRefreshing = false
if (response.body().isNullOrEmpty()) {
errorMessageView.show()
errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.message_empty,
null)
} else {
show(response.body()!!)
}
}
override fun onFailure(call: Call<List<ScheduledStatus>>, t: Throwable) {
swipe_refresh_layout.isRefreshing = false
}
})
}
fun show(statuses: List<ScheduledStatus>) {
adapter.setItems(statuses)
adapter.notifyDataSetChanged()
}
override fun edit(position: Int, item: ScheduledStatus?) {
if (item == null) {
return
}
val intent = ComposeActivity.IntentBuilder()
.tootText(item.params.text)
.contentWarning(item.params.spoilerText)
.mediaAttachments(item.mediaAttachments)
.inReplyToId(item.params.inReplyToId)
.visibility(item.params.visibility)
.scheduledAt(item.scheduledAt)
.sensitive(item.params.sensitive)
.build(this)
startActivity(intent)
delete(position, item)
}
override fun delete(position: Int, item: ScheduledStatus?) {
if (item == null) {
return
}
mastodonApi.deleteScheduledStatus(item.id)
.enqueue(object : Callback<ResponseBody> {
override fun onResponse(call: Call<ResponseBody>, response: Response<ResponseBody>) {
adapter.removeItem(position)
}
override fun onFailure(call: Call<ResponseBody>, t: Throwable) {
}
})
}
}

View File

@ -0,0 +1,125 @@
/* Copyright 2019 kyori19
*
* 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.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.ScheduledStatus;
import java.util.ArrayList;
import java.util.List;
public class ScheduledTootAdapter extends RecyclerView.Adapter {
private List<ScheduledStatus> list;
private ScheduledTootAction handler;
public ScheduledTootAdapter(Context context) {
super();
list = new ArrayList<>();
handler = (ScheduledTootAction) context;
}
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_scheduled_toot, parent, false);
return new TootViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
TootViewHolder holder = (TootViewHolder) viewHolder;
holder.bind(getItem(position));
}
@Override
public int getItemCount() {
return list.size();
}
public void setItems(List<ScheduledStatus> newToot) {
list = new ArrayList<>();
list.addAll(newToot);
}
@Nullable
public ScheduledStatus removeItem(int position) {
if (position < 0 || position >= list.size()) {
return null;
}
ScheduledStatus toot = list.remove(position);
notifyItemRemoved(position);
return toot;
}
private ScheduledStatus getItem(int position) {
if (position >= 0 && position < list.size()) {
return list.get(position);
}
return null;
}
public interface ScheduledTootAction {
void edit(int position, ScheduledStatus item);
void delete(int position, ScheduledStatus item);
}
private class TootViewHolder extends RecyclerView.ViewHolder {
View view;
TextView text;
ImageButton edit;
ImageButton delete;
TootViewHolder(View view) {
super(view);
this.view = view;
this.text = view.findViewById(R.id.text);
this.edit = view.findViewById(R.id.edit);
this.delete = view.findViewById(R.id.delete);
}
void bind(final ScheduledStatus item) {
edit.setEnabled(true);
delete.setEnabled(true);
if (item != null) {
text.setText(item.getParams().getText());
edit.setOnClickListener(v -> {
v.setEnabled(false);
handler.edit(getAdapterPosition(), item);
});
delete.setOnClickListener(v -> {
v.setEnabled(false);
handler.delete(getAdapterPosition(), item);
});
}
}
}
}

View File

@ -12,6 +12,7 @@ data class BlockEvent(val accountId: String) : Dispatchable
data class MuteEvent(val accountId: String) : Dispatchable data class MuteEvent(val accountId: String) : Dispatchable
data class StatusDeletedEvent(val statusId: String) : Dispatchable data class StatusDeletedEvent(val statusId: String) : Dispatchable
data class StatusComposedEvent(val status: Status) : Dispatchable data class StatusComposedEvent(val status: Status) : Dispatchable
data class StatusScheduledEvent(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

View File

@ -97,4 +97,7 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) @ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesInstanceListActivity(): InstanceListActivity abstract fun contributesInstanceListActivity(): InstanceListActivity
@ContributesAndroidInjector
abstract fun contributesScheduledTootActivity(): ScheduledTootActivity
} }

View File

@ -26,6 +26,7 @@ data class NewStatus(
val visibility: String, val visibility: String,
val sensitive: Boolean, val sensitive: Boolean,
@SerializedName("media_ids") val mediaIds: List<String>?, @SerializedName("media_ids") val mediaIds: List<String>?,
@SerializedName("scheduled_at") val scheduledAt: String?,
val poll: NewPoll? val poll: NewPoll?
) )

View File

@ -0,0 +1,25 @@
/* Copyright 2019 kyori19
*
* 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 ScheduledStatus(
val id: String,
@SerializedName("scheduled_at") val scheduledAt: String,
val params: StatusParams,
@SerializedName("media_attachments") val mediaAttachments: ArrayList<Attachment>
)

View File

@ -0,0 +1,26 @@
/* Copyright 2019 kyori19
*
* 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 StatusParams(
val text: String,
val sensitive: Boolean,
val visibility: Status.Visibility,
@SerializedName("spoiler_text") val spoilerText: String,
@SerializedName("in_reply_to_id") val inReplyToId: String?
)

View File

@ -0,0 +1,53 @@
/* Copyright 2019 kyori19
*
* 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.fragment;
import android.app.Dialog;
import android.app.TimePickerDialog;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.fragment.app.DialogFragment;
import com.keylesspalace.tusky.ComposeActivity;
import java.util.Calendar;
import java.util.TimeZone;
public class TimePickerFragment extends DialogFragment {
public static final String PICKER_TIME_HOUR = "picker_time_hour";
public static final String PICKER_TIME_MINUTE = "picker_time_minute";
@Override
@NonNull
public Dialog onCreateDialog(Bundle savedInstanceState) {
Bundle args = getArguments();
Calendar calendar = Calendar.getInstance(TimeZone.getDefault());
if (args != null) {
calendar.set(Calendar.HOUR_OF_DAY, args.getInt(PICKER_TIME_HOUR));
calendar.set(Calendar.MINUTE, args.getInt(PICKER_TIME_MINUTE));
}
return new TimePickerDialog(getContext(),
android.R.style.Theme_DeviceDefault_Dialog,
(ComposeActivity) getActivity(),
calendar.get(Calendar.HOUR_OF_DAY),
calendar.get(Calendar.MINUTE),
true);
}
}

View File

@ -23,20 +23,8 @@ import okhttp3.RequestBody
import okhttp3.ResponseBody import okhttp3.ResponseBody
import retrofit2.Call import retrofit2.Call
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.*
import retrofit2.http.DELETE
import retrofit2.http.Field import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.HTTP
import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
/** /**
* for documentation of the Mastodon REST API see https://docs.joinmastodon.org/api/ * for documentation of the Mastodon REST API see https://docs.joinmastodon.org/api/
@ -202,6 +190,14 @@ interface MastodonApi {
@Path("id") statusId: String @Path("id") statusId: String
): Single<Status> ): Single<Status>
@GET("api/v1/scheduled_statuses")
fun scheduledStatuses(): Call<List<ScheduledStatus>>
@DELETE("api/v1/scheduled_statuses/{id}")
fun deleteScheduledStatus(
@Path("id") scheduledStatusId: String
): Call<ResponseBody>
@GET("api/v1/accounts/verify_credentials") @GET("api/v1/accounts/verify_credentials")
fun accountVerifyCredentials(): Single<Account> fun accountVerifyCredentials(): Single<Account>

View File

@ -18,11 +18,11 @@ package com.keylesspalace.tusky.receiver
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.app.RemoteInput import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import android.util.Log
import com.keylesspalace.tusky.ComposeActivity import com.keylesspalace.tusky.ComposeActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
@ -92,6 +92,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
emptyList(), emptyList(),
emptyList(), emptyList(),
emptyList(), emptyList(),
null,
citedStatusId, citedStatusId,
null, null,
null, null,

View File

@ -18,6 +18,7 @@ import androidx.core.content.ContextCompat
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent
import com.keylesspalace.tusky.appstore.StatusScheduledEvent
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
@ -140,6 +141,7 @@ class SendTootService : Service(), Injectable {
tootToSend.visibility, tootToSend.visibility,
tootToSend.sensitive, tootToSend.sensitive,
tootToSend.mediaIds, tootToSend.mediaIds,
tootToSend.scheduledAt,
tootToSend.poll tootToSend.poll
) )
@ -156,6 +158,7 @@ class SendTootService : Service(), Injectable {
val callback = object : Callback<Status> { val callback = object : Callback<Status> {
override fun onResponse(call: Call<Status>, response: Response<Status>) { override fun onResponse(call: Call<Status>, response: Response<Status>) {
val scheduled = !tootToSend.scheduledAt.isNullOrEmpty()
tootsToSend.remove(tootId) tootsToSend.remove(tootId)
if (response.isSuccessful) { if (response.isSuccessful) {
@ -164,7 +167,11 @@ class SendTootService : Service(), Injectable {
saveTootHelper.deleteDraft(tootToSend.savedTootUid) saveTootHelper.deleteDraft(tootToSend.savedTootUid)
} }
response.body()?.let(::StatusComposedEvent)?.let(eventHub::dispatch) if (scheduled) {
response.body()?.let(::StatusScheduledEvent)?.let(eventHub::dispatch)
} else {
response.body()?.let(::StatusComposedEvent)?.let(eventHub::dispatch)
}
notificationManager.cancel(tootId) notificationManager.cancel(tootId)
@ -284,6 +291,7 @@ class SendTootService : Service(), Injectable {
mediaIds: List<String>, mediaIds: List<String>,
mediaUris: List<Uri>, mediaUris: List<Uri>,
mediaDescriptions: List<String>, mediaDescriptions: List<String>,
scheduledAt: String?,
inReplyToId: String?, inReplyToId: String?,
poll: NewPoll?, poll: NewPoll?,
replyingStatusContent: String?, replyingStatusContent: String?,
@ -303,6 +311,7 @@ class SendTootService : Service(), Injectable {
mediaIds, mediaIds,
mediaUris.map { it.toString() }, mediaUris.map { it.toString() },
mediaDescriptions, mediaDescriptions,
scheduledAt,
inReplyToId, inReplyToId,
poll, poll,
replyingStatusContent, replyingStatusContent,
@ -346,6 +355,7 @@ data class TootToSend(val text: String,
val mediaIds: List<String>, val mediaIds: List<String>,
val mediaUris: List<String>, val mediaUris: List<String>,
val mediaDescriptions: List<String>, val mediaDescriptions: List<String>,
val scheduledAt: String?,
val inReplyToId: String?, val inReplyToId: String?,
val poll: NewPoll?, val poll: NewPoll?,
val replyingStatusContent: String?, val replyingStatusContent: String?,

View File

@ -0,0 +1,42 @@
/* Copyright 2019 kyori19
*
* 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.util;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class VersionUtils {
private int major;
private int minor;
private int patch;
public VersionUtils(String versionString) {
String regex = "([0-9]+)\\.([0-9]+)\\.([0-9]+).*";
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(versionString);
if (matcher.find()) {
major = Integer.parseInt(matcher.group(1));
minor = Integer.parseInt(matcher.group(2));
patch = Integer.parseInt(matcher.group(3));
}
}
public boolean supportsScheduledToots() {
return (major == 2) ? ( (minor == 7) ? (patch >= 0) : (minor > 7) ) : (major > 2);
}
}

View File

@ -0,0 +1,187 @@
/* Copyright 2019 kyori19
*
* 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.view;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.util.AttributeSet;
import android.widget.Button;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
import androidx.constraintlayout.widget.ConstraintLayout;
import com.google.android.material.datepicker.CalendarConstraints;
import com.google.android.material.datepicker.DateValidatorPointForward;
import com.google.android.material.datepicker.MaterialDatePicker;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.fragment.TimePickerFragment;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;
public class ComposeScheduleView extends ConstraintLayout {
private DateFormat dateFormat;
private DateFormat timeFormat;
private SimpleDateFormat iso8601;
private Button resetScheduleButton;
private TextView scheduledDateTimeView;
private Calendar scheduleDateTime;
public ComposeScheduleView(Context context) {
super(context);
init();
}
public ComposeScheduleView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public ComposeScheduleView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
inflate(getContext(), R.layout.view_compose_schedule, this);
dateFormat = SimpleDateFormat.getDateInstance();
timeFormat = SimpleDateFormat.getTimeInstance();
iso8601 = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault());
iso8601.setTimeZone(TimeZone.getTimeZone("UTC"));
resetScheduleButton = findViewById(R.id.resetScheduleButton);
scheduledDateTimeView = findViewById(R.id.scheduledDateTime);
scheduledDateTimeView.setOnClickListener(v -> openPickDateDialog());
scheduleDateTime = null;
setScheduledDateTime();
setEditIcons();
}
private void setScheduledDateTime() {
if (scheduleDateTime == null) {
scheduledDateTimeView.setText(R.string.hint_configure_scheduled_toot);
} else {
scheduledDateTimeView.setText(String.format("%s %s",
dateFormat.format(scheduleDateTime.getTime()),
timeFormat.format(scheduleDateTime.getTime())));
}
}
private void setEditIcons() {
final int size = scheduledDateTimeView.getLineHeight();
Drawable icon = getContext().getDrawable(R.drawable.ic_create_24dp);
if (icon == null) {
return;
}
icon.setBounds(0, 0, size, size);
scheduledDateTimeView.setCompoundDrawables(null, null, icon, null);
}
public void setResetOnClickListener(OnClickListener listener) {
resetScheduleButton.setOnClickListener(listener);
}
public void resetSchedule() {
scheduleDateTime = null;
setScheduledDateTime();
}
private void openPickDateDialog() {
long yesterday = Calendar.getInstance().getTimeInMillis() - 24 * 60 * 60 * 1000;
CalendarConstraints calendarConstraints = new CalendarConstraints.Builder()
.setValidator(new DateValidatorPointForward(yesterday))
.build();
if (scheduleDateTime == null) {
scheduleDateTime = Calendar.getInstance(TimeZone.getDefault());
}
MaterialDatePicker<Long> picker = MaterialDatePicker.Builder
.datePicker()
.setSelection(scheduleDateTime.getTimeInMillis())
.setCalendarConstraints(calendarConstraints)
.build();
picker.addOnPositiveButtonClickListener(this::onDateSet);
picker.show(((AppCompatActivity) getContext()).getSupportFragmentManager(), "date_picker");
}
private void openPickTimeDialog() {
TimePickerFragment picker = new TimePickerFragment();
if (scheduleDateTime != null) {
Bundle args = new Bundle();
args.putInt(TimePickerFragment.PICKER_TIME_HOUR, scheduleDateTime.get(Calendar.HOUR_OF_DAY));
args.putInt(TimePickerFragment.PICKER_TIME_MINUTE, scheduleDateTime.get(Calendar.MINUTE));
picker.setArguments(args);
}
picker.show(((AppCompatActivity) getContext()).getSupportFragmentManager(), "time_picker");
}
public void setDateTime(String scheduledAt) {
Date date;
try {
date = iso8601.parse(scheduledAt);
} catch (ParseException e) {
return;
}
if (scheduleDateTime == null) {
scheduleDateTime = Calendar.getInstance(TimeZone.getDefault());
}
scheduleDateTime.setTime(date);
setScheduledDateTime();
}
private void onDateSet(long selection) {
if (scheduleDateTime == null) {
scheduleDateTime = Calendar.getInstance(TimeZone.getDefault());
}
Calendar newDate = Calendar.getInstance(TimeZone.getDefault());
newDate.setTimeInMillis(selection);
scheduleDateTime.set(newDate.get(Calendar.YEAR), newDate.get(Calendar.MONTH), newDate.get(Calendar.DATE));
openPickTimeDialog();
}
public void onTimeSet(int hourOfDay, int minute) {
if (scheduleDateTime == null) {
scheduleDateTime = Calendar.getInstance(TimeZone.getDefault());
}
scheduleDateTime.set(Calendar.HOUR_OF_DAY, hourOfDay);
scheduleDateTime.set(Calendar.MINUTE, minute);
setScheduledDateTime();
}
public String getTime() {
if (scheduleDateTime == null) {
return null;
}
return iso8601.format(scheduleDateTime.getTime());
}
}

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8zM12.5,7H11v6l5.25,3.15 0.75,-1.23 -4.5,-2.67z"/>
</vector>

View File

@ -231,6 +231,20 @@
app:behavior_peekHeight="0dp" app:behavior_peekHeight="0dp"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" /> app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" />
<com.keylesspalace.tusky.view.ComposeScheduleView
android:id="@+id/composeScheduleView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:colorBackground"
android:elevation="12dp"
android:paddingStart="16dp"
android:paddingTop="8dp"
android:paddingEnd="16dp"
android:paddingBottom="52dp"
app:behavior_hideable="true"
app:behavior_peekHeight="0dp"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" />
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -299,6 +313,17 @@
android:tooltipText="@string/action_emoji_keyboard" android:tooltipText="@string/action_emoji_keyboard"
app:srcCompat="@drawable/ic_emoji_24dp" /> app:srcCompat="@drawable/ic_emoji_24dp" />
<ImageButton
android:id="@+id/composeScheduleButton"
style="?attr/image_button_style"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginEnd="4dp"
android:contentDescription="@string/action_schedule_toot"
android:padding="4dp"
android:tooltipText="@string/action_schedule_toot"
app:srcCompat="@drawable/ic_access_time" />
<Space <Space
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@ -0,0 +1,53 @@
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/activity_view_thread"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.keylesspalace.tusky.AccountListActivity">
<include layout="@layout/toolbar_basic" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<ProgressBar
android:id="@+id/progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.keylesspalace.tusky.view.BackgroundMessageView
android:id="@+id/errorMessageView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@android:color/transparent"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/elephant_error"
tools:visibility="visible" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/scheduled_toot_list"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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="wrap_content"
android:orientation="horizontal">
<androidx.emoji.widget.EmojiTextView
android:id="@+id/text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="0.91"
android:padding="8dp"
android:textSize="?attr/status_text_medium" />
<ImageButton
android:id="@+id/edit"
style="?attr/image_button_style"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_gravity="center_vertical"
android:layout_margin="12dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_edit"
android:padding="4dp"
app:srcCompat="@drawable/ic_create_24dp" />
<ImageButton
android:id="@+id/delete"
style="?attr/image_button_style"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_gravity="center_vertical"
android:layout_margin="12dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_delete"
android:padding="4dp"
app:srcCompat="@drawable/ic_clear_24dp" />
</LinearLayout>

View File

@ -0,0 +1,31 @@
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<Button
android:id="@+id/resetScheduleButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:text="@string/action_reset_schedule"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<TextView
android:id="@+id/scheduledDateTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingBottom="16dp"
android:paddingEnd="16dp"
android:paddingStart="4dp"
android:paddingTop="4dp"
android:textColor="?android:textColorTertiary"
android:textSize="?attr/status_text_medium"
android:drawablePadding="4dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:text="2020/01/01 00:00:00" />
</merge>

View File

@ -40,6 +40,7 @@
<string name="title_follow_requests">Follow Requests</string> <string name="title_follow_requests">Follow Requests</string>
<string name="title_edit_profile">Edit your profile</string> <string name="title_edit_profile">Edit your profile</string>
<string name="title_saved_toot">Drafts</string> <string name="title_saved_toot">Drafts</string>
<string name="title_scheduled_toot">Scheduled toots</string>
<string name="title_licenses">Licenses</string> <string name="title_licenses">Licenses</string>
<string name="status_username_format">\@%s</string> <string name="status_username_format">\@%s</string>
@ -80,6 +81,7 @@
<string name="action_hide_reblogs">Hide boosts</string> <string name="action_hide_reblogs">Hide boosts</string>
<string name="action_show_reblogs">Show boosts</string> <string name="action_show_reblogs">Show boosts</string>
<string name="action_report">Report</string> <string name="action_report">Report</string>
<string name="action_edit">Edit</string>
<string name="action_delete">Delete</string> <string name="action_delete">Delete</string>
<string name="action_delete_and_redraft">Delete and re-draft</string> <string name="action_delete_and_redraft">Delete and re-draft</string>
<string name="action_send">TOOT</string> <string name="action_send">TOOT</string>
@ -114,9 +116,12 @@
<string name="action_reject">Reject</string> <string name="action_reject">Reject</string>
<string name="action_search">Search</string> <string name="action_search">Search</string>
<string name="action_access_saved_toot">Drafts</string> <string name="action_access_saved_toot">Drafts</string>
<string name="action_access_scheduled_toot">Scheduled toots</string>
<string name="action_toggle_visibility">Toot visibility</string> <string name="action_toggle_visibility">Toot visibility</string>
<string name="action_content_warning">Content warning</string> <string name="action_content_warning">Content warning</string>
<string name="action_emoji_keyboard">Emoji keyboard</string> <string name="action_emoji_keyboard">Emoji keyboard</string>
<string name="action_schedule_toot">Schedule Toot</string>
<string name="action_reset_schedule">Reset</string>
<string name="action_add_tab">Add Tab</string> <string name="action_add_tab">Add Tab</string>
<string name="action_links">Links</string> <string name="action_links">Links</string>
<string name="action_mentions">Mentions</string> <string name="action_mentions">Mentions</string>
@ -152,6 +157,7 @@
<string name="hint_domain">Which instance?</string> <string name="hint_domain">Which instance?</string>
<string name="hint_compose">What\'s happening?</string> <string name="hint_compose">What\'s happening?</string>
<string name="hint_configure_scheduled_toot">Tap here to configure scheduled toot.</string>
<string name="hint_content_warning">Content warning</string> <string name="hint_content_warning">Content warning</string>
<string name="hint_display_name">Display name</string> <string name="hint_display_name">Display name</string>
<string name="hint_note">Bio</string> <string name="hint_note">Bio</string>