From 9e4c19a47e29719ff73f82ca166cb7c4101f9e9b Mon Sep 17 00:00:00 2001 From: kyori19 Date: Thu, 3 Oct 2019 04:28:12 +0900 Subject: [PATCH] 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 --- app/build.gradle | 2 +- app/src/main/AndroidManifest.xml | 1 + .../keylesspalace/tusky/ComposeActivity.java | 126 +++++++++--- .../com/keylesspalace/tusky/MainActivity.java | 30 +-- .../tusky/ScheduledTootActivity.kt | 166 ++++++++++++++++ .../tusky/adapter/ScheduledTootAdapter.java | 125 ++++++++++++ .../keylesspalace/tusky/appstore/Events.kt | 1 + .../tusky/di/ActivitiesModule.kt | 3 + .../keylesspalace/tusky/entity/NewStatus.kt | 1 + .../tusky/entity/ScheduledStatus.kt | 25 +++ .../tusky/entity/StatusParams.kt | 26 +++ .../tusky/fragment/TimePickerFragment.java | 53 +++++ .../tusky/network/MastodonApi.kt | 22 +-- .../receiver/SendStatusBroadcastReceiver.kt | 3 +- .../tusky/service/SendTootService.kt | 12 +- .../tusky/util/VersionUtils.java | 42 ++++ .../tusky/view/ComposeScheduleView.java | 187 ++++++++++++++++++ .../res/drawable-anydpi/ic_access_time.xml | 9 + app/src/main/res/layout/activity_compose.xml | 25 +++ .../res/layout/activity_scheduled_toot.xml | 53 +++++ .../main/res/layout/item_scheduled_toot.xml | 40 ++++ .../main/res/layout/view_compose_schedule.xml | 31 +++ app/src/main/res/values/strings.xml | 6 + 23 files changed, 933 insertions(+), 56 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/ScheduledTootActivity.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/adapter/ScheduledTootAdapter.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/entity/StatusParams.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/fragment/TimePickerFragment.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/VersionUtils.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/view/ComposeScheduleView.java create mode 100644 app/src/main/res/drawable-anydpi/ic_access_time.xml create mode 100644 app/src/main/res/layout/activity_scheduled_toot.xml create mode 100644 app/src/main/res/layout/item_scheduled_toot.xml create mode 100644 app/src/main/res/layout/view_compose_schedule.xml 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 @@ + + +