From b1624293c1a8b35f48507beb552d4eef45f3a521 Mon Sep 17 00:00:00 2001 From: kyori19 Date: Fri, 25 Jan 2019 21:45:38 +0900 Subject: [PATCH] [scheduled] Scheduled toot --- app/src/main/AndroidManifest.xml | 1 + .../keylesspalace/tusky/ComposeActivity.java | 95 +++++++++- .../com/keylesspalace/tusky/MainActivity.java | 4 + .../tusky/ScheduledTootActivity.kt | 169 ++++++++++++++++++ .../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 | 10 ++ .../tusky/entity/StatusParams.kt | 11 ++ .../tusky/fragment/DatePickerFragment.java | 40 +++++ .../tusky/fragment/TimePickerFragment.java | 38 ++++ .../tusky/network/MastodonApi.java | 7 + .../receiver/SendStatusBroadcastReceiver.kt | 1 + .../tusky/service/SendTootService.kt | 12 +- .../tusky/util/VersionUtils.java | 27 +++ .../tusky/view/ComposeScheduleView.java | 169 ++++++++++++++++++ .../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 | 46 +++++ app/src/main/res/values/strings.xml | 6 + 23 files changed, 886 insertions(+), 7 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/DatePickerFragment.java 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/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bee9f4d6f..8647c702b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -136,6 +136,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()); @@ -584,6 +601,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)) { @@ -601,6 +623,7 @@ public final class ComposeActivity setStatusVisibility(startingVisibility); updateHideMediaToggle(); + updateScheduleButton(); updateVisibleCharactersLeft(); // Setup the main text field. @@ -868,11 +891,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); } @@ -881,6 +915,7 @@ public final class ComposeActivity visibilityButton.setClickable(true); emojiButton.setClickable(true); hideMediaToggle.setClickable(true); + scheduleButton.setClickable(true); tootButton.setEnabled(true); } @@ -935,12 +970,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) { @@ -952,7 +998,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); } @@ -981,7 +1027,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); } @@ -1184,7 +1230,8 @@ public final class ComposeActivity } 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), @@ -1842,10 +1889,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; } @@ -2054,6 +2103,10 @@ public final class ComposeActivity maxPollOptionLength = instance.getPollLimits().getMaxOptionChars(); } + if (!new VersionUtils(instance.getVersion()).supportsScheduledToots()) { + scheduleButton.setVisibility(View.GONE); + } + cacheInstanceMetadata(accountManager.getActiveAccount()); } } @@ -2150,6 +2203,26 @@ public final class ComposeActivity } } + @Override + public void onDateSet(DatePicker view, int year, int month, int dayOfMonth) { + scheduleView.onDateSet(year, month, dayOfMonth); + updateScheduleButton(); + scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); + } + + @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; @@ -2180,6 +2253,8 @@ public final class ComposeActivity @Nullable private ArrayList mediaAttachments; @Nullable + private String scheduledAt; + @Nullable private Boolean sensitive; @Nullable private NewPoll poll; @@ -2256,6 +2331,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; @@ -2317,6 +2397,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 59dc58002..13037abc9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java @@ -105,6 +105,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 @@ -425,6 +426,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)); @@ -467,6 +469,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..015fda0ce --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/ScheduledTootActivity.kt @@ -0,0 +1,169 @@ +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.google.gson.Gson +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 + + val gson = Gson() + + 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 e3eb84c4b..caabf2590 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 060845dc8..eaed7188d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -99,6 +99,9 @@ abstract class ActivitiesModule { @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) abstract fun contributesInstanceListActivity(): InstanceListActivity + @ContributesAndroidInjector + abstract fun contributesScheduledTootActivity(): ScheduledTootActivity + @ContributesAndroidInjector abstract fun contributesAccessTokenLoginActivity(): AccessTokenLoginActivity } 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 d8799621c..72f69f6d4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt @@ -27,6 +27,7 @@ data class NewStatus( val sensitive: Boolean, @SerializedName("media_ids") val mediaIds: List?, val poll: NewPoll?, + @SerializedName("scheduled_at") val scheduledAt: String?, @SerializedName("quote_id") val quoteId: String? ) 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..1bee1dee7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt @@ -0,0 +1,10 @@ +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..3f4d905ee --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/StatusParams.kt @@ -0,0 +1,11 @@ +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/DatePickerFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/DatePickerFragment.java new file mode 100644 index 000000000..11424710b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/DatePickerFragment.java @@ -0,0 +1,40 @@ +package com.keylesspalace.tusky.fragment; + +import android.app.DatePickerDialog; +import android.app.Dialog; +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 DatePickerFragment extends DialogFragment { + + public static final String PICKER_TIME_YEAR = "picker_time_year"; + public static final String PICKER_TIME_MONTH = "picker_time_month"; + public static final String PICKER_TIME_DAY = "picker_time_day"; + + @Override + @NonNull + public Dialog onCreateDialog(Bundle savedInstanceState) { + Bundle args = getArguments(); + Calendar calendar = Calendar.getInstance(TimeZone.getDefault()); + if (args != null) { + calendar.set(args.getInt(PICKER_TIME_YEAR), + args.getInt(PICKER_TIME_MONTH), + args.getInt(PICKER_TIME_DAY)); + } + + return new DatePickerDialog(getContext(), + android.R.style.Theme_DeviceDefault_Dialog, + (ComposeActivity) getActivity(), + calendar.get(Calendar.YEAR), + calendar.get(Calendar.MONTH), + calendar.get(Calendar.DAY_OF_MONTH)); + } + +} 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..0ab1af312 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimePickerFragment.java @@ -0,0 +1,38 @@ +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.java b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java index d2bcc64b3..5555e9293 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java @@ -31,6 +31,7 @@ import com.keylesspalace.tusky.entity.NewStatus; import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Poll; import com.keylesspalace.tusky.entity.Relationship; +import com.keylesspalace.tusky.entity.ScheduledStatus; import com.keylesspalace.tusky.entity.SearchResults; import com.keylesspalace.tusky.entity.SearchResults2; import com.keylesspalace.tusky.entity.Status; @@ -174,6 +175,12 @@ public interface MastodonApi { @POST("api/v1/statuses/{id}/unpin") Single unpinStatus(@Path("id") String statusId); + @GET("api/v1/scheduled_statuses") + Call> scheduledStatuses(); + + @DELETE("api/v1/scheduled_statuses/{id}") + Call deleteScheduledStatus(@Path("id") String scheduledStatusId); + @GET("api/v1/accounts/verify_credentials") Single accountVerifyCredentials(); 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 321494e34..b769a98a8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -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 8f500772c..c41245b11 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 @@ -141,6 +142,7 @@ class SendTootService : Service(), Injectable { tootToSend.sensitive, tootToSend.mediaIds, tootToSend.poll, + tootToSend.scheduledAt, tootToSend.quoteId ) @@ -157,6 +159,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) { @@ -165,7 +168,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) @@ -285,6 +292,7 @@ class SendTootService : Service(), Injectable { mediaIds: List, mediaUris: List, mediaDescriptions: List, + scheduledAt: String?, inReplyToId: String?, poll: NewPoll?, replyingStatusContent: String?, @@ -305,6 +313,7 @@ class SendTootService : Service(), Injectable { mediaIds, mediaUris.map { it.toString() }, mediaDescriptions, + scheduledAt, inReplyToId, poll, replyingStatusContent, @@ -349,6 +358,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..83b4f07b9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/VersionUtils.java @@ -0,0 +1,27 @@ +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; + } + +} 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..dfba71b77 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/ComposeScheduleView.java @@ -0,0 +1,169 @@ +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.keylesspalace.tusky.R; +import com.keylesspalace.tusky.fragment.DatePickerFragment; +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 scheduledDateView; + private TextView scheduledTimeView; + + 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); + scheduledDateView = findViewById(R.id.scheduledDate); + scheduledTimeView = findViewById(R.id.scheduledTime); + + scheduledDateView.setOnClickListener(v -> openPickDateDialog()); + scheduledTimeView.setOnClickListener(v -> openPickTimeDialog()); + + scheduleDateTime = null; + + setScheduledDateTime(); + + setEditIcons(); + } + + private void setScheduledDateTime() { + if (scheduleDateTime == null) { + scheduledDateView.setText(R.string.hint_configure_scheduled_toot); + scheduledTimeView.setText(R.string.hint_configure_scheduled_toot); + } else { + scheduledDateView.setText(dateFormat.format(scheduleDateTime.getTime())); + scheduledTimeView.setText(timeFormat.format(scheduleDateTime.getTime())); + } + } + + private void setEditIcons() { + final int size = scheduledDateView.getLineHeight(); + + Drawable icon = getContext().getDrawable(R.drawable.ic_create_24dp); + if (icon == null) { + return; + } + + icon.setBounds(0, 0, size, size); + + scheduledDateView.setCompoundDrawables(null, null, icon, null); + scheduledTimeView.setCompoundDrawables(null, null, icon, null); + } + + public void setResetOnClickListener(OnClickListener listener) { + resetScheduleButton.setOnClickListener(listener); + } + + public void resetSchedule() { + scheduleDateTime = null; + setScheduledDateTime(); + } + + private void openPickDateDialog() { + DatePickerFragment picker = new DatePickerFragment(); + if (scheduleDateTime != null) { + Bundle args = new Bundle(); + args.putInt(DatePickerFragment.PICKER_TIME_YEAR, scheduleDateTime.get(Calendar.YEAR)); + args.putInt(DatePickerFragment.PICKER_TIME_MONTH, scheduleDateTime.get(Calendar.MONTH)); + args.putInt(DatePickerFragment.PICKER_TIME_DAY, scheduleDateTime.get(Calendar.DAY_OF_MONTH)); + picker.setArguments(args); + } + 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(); + } + + public void onDateSet(int year, int month, int dayOfMonth) { + if (scheduleDateTime == null) { + scheduleDateTime = Calendar.getInstance(TimeZone.getDefault()); + } + scheduleDateTime.set(year, month, dayOfMonth); + setScheduledDateTime(); + } + + 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..c0ecd8dce --- /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 3780cac5f..a4be93bd3 100644 --- a/app/src/main/res/layout/activity_compose.xml +++ b/app/src/main/res/layout/activity_compose.xml @@ -271,6 +271,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..5d7e2bbd5 --- /dev/null +++ b/app/src/main/res/layout/view_compose_schedule.xml @@ -0,0 +1,46 @@ + + +