diff --git a/app/build.gradle b/app/build.gradle
index 27f4916e6..ae6dda088 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -100,7 +100,7 @@ dependencies {
implementation 'androidx.browser:browser:1.0.0'
implementation 'androidx.recyclerview:recyclerview: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.cardview:cardview:1.0.0'
implementation 'androidx.preference:preference:1.1.0-alpha04'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0dd19c4c4..edc4856e4 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -135,6 +135,7 @@
android:name=".components.report.ReportActivity"
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
+
onContentWarningChanged());
emojiButton.setOnClickListener(v -> showEmojis());
hideMediaToggle.setOnClickListener(v -> toggleHideMedia());
+ scheduleButton.setOnClickListener(v -> showScheduleView());
+ scheduleView.setResetOnClickListener(v -> resetSchedule());
atButton.setOnClickListener(v -> atButtonClicked());
hashButton.setOnClickListener(v -> hashButtonClicked());
@@ -521,6 +538,11 @@ public final class ComposeActivity
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);
if(intent.hasExtra(POLL_EXTRA) && (mediaAttachments == null || mediaAttachments.size() == 0)) {
@@ -536,6 +558,7 @@ public final class ComposeActivity
setStatusVisibility(startingVisibility);
updateHideMediaToggle();
+ updateScheduleButton();
updateVisibleCharactersLeft();
// 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() {
pickButton.setClickable(false);
visibilityButton.setClickable(false);
emojiButton.setClickable(false);
hideMediaToggle.setClickable(false);
+ scheduleButton.setClickable(false);
tootButton.setEnabled(false);
}
@@ -812,6 +846,7 @@ public final class ComposeActivity
visibilityButton.setClickable(true);
emojiButton.setClickable(true);
hideMediaToggle.setClickable(true);
+ scheduleButton.setClickable(true);
tootButton.setEnabled(true);
}
@@ -859,12 +894,23 @@ public final class ComposeActivity
composeOptionsBehavior.setState(BottomSheetBehavior.STATE_EXPANDED);
addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
-
+ scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
} else {
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() {
if (emojiView.getAdapter() != null) {
@@ -876,7 +922,7 @@ public final class ComposeActivity
emojiBehavior.setState(BottomSheetBehavior.STATE_EXPANDED);
composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
-
+ scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
} else {
emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
}
@@ -891,7 +937,7 @@ public final class ComposeActivity
addMediaBehavior.setState(BottomSheetBehavior.STATE_EXPANDED);
composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
-
+ scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
} else {
addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
}
@@ -1084,7 +1130,8 @@ public final class ComposeActivity
}
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_AUTHOR_USERNAME_EXTRA),
getIntent().getStringExtra(SAVED_JSON_URLS_EXTRA),
@@ -1744,10 +1791,12 @@ public final class ComposeActivity
// Acting like a teen: deliberately ignoring parent.
if (composeOptionsBehavior.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);
addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
+ scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
return;
}
@@ -1947,6 +1996,10 @@ public final class ComposeActivity
updateVisibleCharactersLeft();
}
+ if (!new VersionUtils(instance.getVersion()).supportsScheduledToots()) {
+ scheduleButton.setVisibility(View.GONE);
+ }
+
if (instance.getPollLimits() != null) {
maxPollOptions = instance.getPollLimits().getMaxOptions();
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 {
@Nullable
private Integer savedTootUid;
@@ -2074,6 +2140,8 @@ public final class ComposeActivity
@Nullable
private ArrayList mediaAttachments;
@Nullable
+ private String scheduledAt;
+ @Nullable
private Boolean sensitive;
@Nullable
private NewPoll poll;
@@ -2138,6 +2206,11 @@ public final class ComposeActivity
return this;
}
+ public IntentBuilder scheduledAt(String scheduledAt) {
+ this.scheduledAt = scheduledAt;
+ return this;
+ }
+
public IntentBuilder sensitive(boolean sensitive) {
this.sensitive = sensitive;
return this;
@@ -2188,6 +2261,9 @@ public final class ComposeActivity
if (mediaAttachments != null) {
intent.putParcelableArrayListExtra(MEDIA_ATTACHMENTS_EXTRA, mediaAttachments);
}
+ if (scheduledAt != null) {
+ intent.putExtra(SCHEDULED_AT_EXTRA, scheduledAt);
+ }
if (sensitive != null) {
intent.putExtra(SENSITIVE_EXTRA, sensitive);
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java
index de12e4e35..c10bbff84 100644
--- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java
+++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java
@@ -15,26 +15,11 @@
package com.keylesspalace.tusky;
-import androidx.lifecycle.Lifecycle;
-
import android.content.Intent;
import android.graphics.Color;
import android.graphics.drawable.Drawable;
import android.net.Uri;
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.preference.PreferenceManager;
import android.util.Log;
@@ -42,6 +27,17 @@ import android.view.KeyEvent;
import android.widget.ImageButton;
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.EventHub;
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_LOG_OUT = 8;
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";
@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_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_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 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));
@@ -433,6 +431,8 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut
} else if (drawerItemIdentifier == DRAWER_ITEM_SAVED_TOOT) {
Intent intent = new Intent(MainActivity.this, SavedTootActivity.class);
startActivityWithSlideInAnimation(intent);
+ } else if (drawerItemIdentifier == DRAWER_ITEM_SCHEDULED_TOOT) {
+ startActivityWithSlideInAnimation(ScheduledTootActivity.newIntent(this));
} else if (drawerItemIdentifier == DRAWER_ITEM_LISTS) {
startActivityWithSlideInAnimation(ListsActivity.newIntent(this));
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/ScheduledTootActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ScheduledTootActivity.kt
new file mode 100644
index 000000000..a7de7a2bf
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/ScheduledTootActivity.kt
@@ -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(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> {
+ override fun onResponse(call: Call>, response: Response>) {
+ 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>, 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> {
+ override fun onResponse(call: Call>, response: Response>) {
+ 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>, t: Throwable) {
+ swipe_refresh_layout.isRefreshing = false
+ }
+ })
+ }
+
+ fun show(statuses: List) {
+ 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 {
+ override fun onResponse(call: Call, response: Response) {
+ adapter.removeItem(position)
+ }
+
+ override fun onFailure(call: Call, t: Throwable) {
+
+ }
+ })
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ScheduledTootAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/ScheduledTootAdapter.java
new file mode 100644
index 000000000..6698dd371
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ScheduledTootAdapter.java
@@ -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 . */
+
+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 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 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);
+ });
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt
index d7d92d49f..32deb0044 100644
--- a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt
@@ -12,6 +12,7 @@ data class BlockEvent(val accountId: String) : Dispatchable
data class MuteEvent(val accountId: String) : Dispatchable
data class StatusDeletedEvent(val statusId: String) : Dispatchable
data class StatusComposedEvent(val status: Status) : Dispatchable
+data class StatusScheduledEvent(val status: Status) : Dispatchable
data class ProfileEditedEvent(val newProfileData: Account) : Dispatchable
data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable
data class MainTabsChangedEvent(val newTabs: List) : Dispatchable
diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt
index ed4151b22..c107e0c62 100644
--- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt
@@ -97,4 +97,7 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesInstanceListActivity(): InstanceListActivity
+
+ @ContributesAndroidInjector
+ abstract fun contributesScheduledTootActivity(): ScheduledTootActivity
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt
index 1a3226fad..ebc979f36 100644
--- a/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt
@@ -26,6 +26,7 @@ data class NewStatus(
val visibility: String,
val sensitive: Boolean,
@SerializedName("media_ids") val mediaIds: List?,
+ @SerializedName("scheduled_at") val scheduledAt: String?,
val poll: NewPoll?
)
diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt
new file mode 100644
index 000000000..2621bd5ed
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt
@@ -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 . */
+
+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
+)
diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/StatusParams.kt b/app/src/main/java/com/keylesspalace/tusky/entity/StatusParams.kt
new file mode 100644
index 000000000..0e25e6c16
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/entity/StatusParams.kt
@@ -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 . */
+
+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?
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimePickerFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimePickerFragment.java
new file mode 100644
index 000000000..e4b20dda5
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimePickerFragment.java
@@ -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 . */
+
+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);
+ }
+
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt
index 460b86e53..01813a3cd 100644
--- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt
@@ -23,20 +23,8 @@ import okhttp3.RequestBody
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.Response
-import retrofit2.http.Body
-import retrofit2.http.DELETE
+import retrofit2.http.*
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/
@@ -202,6 +190,14 @@ interface MastodonApi {
@Path("id") statusId: String
): Single
+ @GET("api/v1/scheduled_statuses")
+ fun scheduledStatuses(): Call>
+
+ @DELETE("api/v1/scheduled_statuses/{id}")
+ fun deleteScheduledStatus(
+ @Path("id") scheduledStatusId: String
+ ): Call
+
@GET("api/v1/accounts/verify_credentials")
fun accountVerifyCredentials(): Single
diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt
index 7ae1f6dff..5bf8c76d7 100644
--- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt
@@ -18,11 +18,11 @@ package com.keylesspalace.tusky.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
+import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat
-import android.util.Log
import com.keylesspalace.tusky.ComposeActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.db.AccountManager
@@ -92,6 +92,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
emptyList(),
emptyList(),
emptyList(),
+ null,
citedStatusId,
null,
null,
diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt
index ba4d0b982..97aac1a86 100644
--- a/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt
@@ -18,6 +18,7 @@ import androidx.core.content.ContextCompat
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.StatusComposedEvent
+import com.keylesspalace.tusky.appstore.StatusScheduledEvent
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
@@ -140,6 +141,7 @@ class SendTootService : Service(), Injectable {
tootToSend.visibility,
tootToSend.sensitive,
tootToSend.mediaIds,
+ tootToSend.scheduledAt,
tootToSend.poll
)
@@ -156,6 +158,7 @@ class SendTootService : Service(), Injectable {
val callback = object : Callback {
override fun onResponse(call: Call, response: Response) {
+ val scheduled = !tootToSend.scheduledAt.isNullOrEmpty()
tootsToSend.remove(tootId)
if (response.isSuccessful) {
@@ -164,7 +167,11 @@ class SendTootService : Service(), Injectable {
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)
@@ -284,6 +291,7 @@ class SendTootService : Service(), Injectable {
mediaIds: List,
mediaUris: List,
mediaDescriptions: List,
+ scheduledAt: String?,
inReplyToId: String?,
poll: NewPoll?,
replyingStatusContent: String?,
@@ -303,6 +311,7 @@ class SendTootService : Service(), Injectable {
mediaIds,
mediaUris.map { it.toString() },
mediaDescriptions,
+ scheduledAt,
inReplyToId,
poll,
replyingStatusContent,
@@ -346,6 +355,7 @@ data class TootToSend(val text: String,
val mediaIds: List,
val mediaUris: List,
val mediaDescriptions: List,
+ val scheduledAt: String?,
val inReplyToId: String?,
val poll: NewPoll?,
val replyingStatusContent: String?,
diff --git a/app/src/main/java/com/keylesspalace/tusky/util/VersionUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/VersionUtils.java
new file mode 100644
index 000000000..76dcd4510
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/util/VersionUtils.java
@@ -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 . */
+
+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);
+ }
+
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ComposeScheduleView.java b/app/src/main/java/com/keylesspalace/tusky/view/ComposeScheduleView.java
new file mode 100644
index 000000000..8efe68639
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/view/ComposeScheduleView.java
@@ -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 . */
+
+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 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());
+ }
+}
diff --git a/app/src/main/res/drawable-anydpi/ic_access_time.xml b/app/src/main/res/drawable-anydpi/ic_access_time.xml
new file mode 100644
index 000000000..2239a4f45
--- /dev/null
+++ b/app/src/main/res/drawable-anydpi/ic_access_time.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/layout/activity_compose.xml b/app/src/main/res/layout/activity_compose.xml
index b0b5efab3..410d04db4 100644
--- a/app/src/main/res/layout/activity_compose.xml
+++ b/app/src/main/res/layout/activity_compose.xml
@@ -231,6 +231,20 @@
app:behavior_peekHeight="0dp"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_scheduled_toot.xml b/app/src/main/res/layout/item_scheduled_toot.xml
new file mode 100644
index 000000000..ef935c2a8
--- /dev/null
+++ b/app/src/main/res/layout/item_scheduled_toot.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/view_compose_schedule.xml b/app/src/main/res/layout/view_compose_schedule.xml
new file mode 100644
index 000000000..dde00743d
--- /dev/null
+++ b/app/src/main/res/layout/view_compose_schedule.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f46c2f6c0..f71993680 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -40,6 +40,7 @@
Follow Requests
Edit your profile
Drafts
+ Scheduled toots
Licenses
\@%s
@@ -80,6 +81,7 @@
Hide boosts
Show boosts
Report
+ Edit
Delete
Delete and re-draft
TOOT
@@ -114,9 +116,12 @@
Reject
Search
Drafts
+ Scheduled toots
Toot visibility
Content warning
Emoji keyboard
+ Schedule Toot
+ Reset
Add Tab
Links
Mentions
@@ -152,6 +157,7 @@
Which instance?
What\'s happening?
+ Tap here to configure scheduled toot.
Content warning
Display name
Bio