From c335651b6b78dc5d783adef25b26764050929e94 Mon Sep 17 00:00:00 2001 From: pandasoft0 Date: Sun, 9 Jun 2019 17:55:34 +0300 Subject: [PATCH] Redesign report activity (#1295) * Report activity core * Implement navigation * Implement navigation * Update strings * Revert manifest formatting * Implement Done page * Add landscape layout * Implement Note fragment * Create component * Implement simple status adapter * Format code * Add date/time to report statuses * Refactor status view holder * Refactor code * Refactor ViewPager * Replace MaterialButton with Button * Remove unneeded string * Update Text and Check views style * Remove old ReportActivity and rename Report2Activity to ReportActivity * Hide "report to remote instance" checkbox for local accounts * Add account, hashtag and links click handler * Add media preview * Add sensitive content support * Add status expand/collapse support * Update adapter to user adapterPosition instead of stored status * Updated checked change handling * Add polls support to report screen * Add copyright * Set buttonTint at CheckBox * Exclude reblogs from statuses for reports * Change final page check mark size * Update report note screen * Fix typos * Remove unused params from api endpoint * Replace .visibility with show()/hide() * Replace Date().time with System.currentTime... * Add line spacing * Fix close button tint issue * Updated status adapter --- app/build.gradle | 12 +- app/src/main/AndroidManifest.xml | 6 +- .../keylesspalace/tusky/ReportActivity.java | 215 ----------- .../keylesspalace/tusky/ViewTagActivity.java | 11 +- .../tusky/adapter/ReportAdapter.java | 138 ------- .../tusky/components/report/ReportActivity.kt | 162 ++++++++ .../components/report/ReportViewModel.kt | 224 +++++++++++ .../tusky/components/report/Screen.kt | 24 ++ .../report/adapter/AdapterHandler.kt | 28 ++ .../report/adapter/ReportPagerAdapter.kt | 36 ++ .../report/adapter/StatusViewHolder.kt | 166 ++++++++ .../report/adapter/StatusesAdapter.kt | 62 +++ .../report/adapter/StatusesDataSource.kt | 145 +++++++ .../adapter/StatusesDataSourceFactory.kt | 36 ++ .../report/adapter/StatusesRepository.kt | 61 +++ .../report/fragments/ReportDoneFragment.kt | 111 ++++++ .../report/fragments/ReportNoteFragment.kt | 141 +++++++ .../fragments/ReportStatusesFragment.kt | 216 +++++++++++ .../report/model/StatusViewState.kt | 36 ++ .../tusky/di/ActivitiesModule.kt | 6 +- .../tusky/di/FragmentBuildersModule.kt | 11 + .../tusky/di/ViewModelFactory.kt | 10 +- .../tusky/fragment/SFragment.java | 10 +- .../tusky/network/MastodonApi.java | 36 ++ .../com/keylesspalace/tusky/util/BiListing.kt | 38 ++ .../com/keylesspalace/tusky/util/Resource.kt | 3 +- .../tusky/util/StatusExtensions.kt | 23 ++ .../tusky/util/StatusViewHelper.kt | 314 ++++++++++++++++ .../tusky/view/NoSwipeViewPager.kt | 33 ++ .../drawable/report_success_background.xml | 4 + .../res/layout-land/fragment_report_done.xml | 109 ++++++ app/src/main/res/layout/activity_report.xml | 37 +- .../main/res/layout/fragment_report_done.xml | 108 ++++++ .../main/res/layout/fragment_report_note.xml | 129 +++++++ .../res/layout/fragment_report_statuses.xml | 72 ++++ .../main/res/layout/item_report_status.xml | 353 +++++++++++++++++- app/src/main/res/values/attrs.xml | 1 - app/src/main/res/values/dimens.xml | 1 + app/src/main/res/values/strings.xml | 14 +- 39 files changed, 2726 insertions(+), 416 deletions(-) delete mode 100644 app/src/main/java/com/keylesspalace/tusky/ReportActivity.java delete mode 100644 app/src/main/java/com/keylesspalace/tusky/adapter/ReportAdapter.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/report/Screen.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/report/adapter/AdapterHandler.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/report/adapter/ReportPagerAdapter.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSource.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSourceFactory.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesRepository.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportDoneFragment.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/report/model/StatusViewState.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/BiListing.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/StatusExtensions.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/view/NoSwipeViewPager.kt create mode 100644 app/src/main/res/drawable/report_success_background.xml create mode 100644 app/src/main/res/layout-land/fragment_report_done.xml create mode 100644 app/src/main/res/layout/fragment_report_done.xml create mode 100644 app/src/main/res/layout/fragment_report_note.xml create mode 100644 app/src/main/res/layout/fragment_report_statuses.xml diff --git a/app/build.gradle b/app/build.gradle index f0d7f9ab8..14aaa11a5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,7 +6,7 @@ apply plugin: 'kotlin-kapt' def getGitSha = { -> def stdout = new ByteArrayOutputStream() exec { - commandLine 'git', 'rev-parse', '--short' , 'HEAD' + commandLine 'git', 'rev-parse', '--short', 'HEAD' standardOutput = stdout } return stdout.toString().trim() @@ -35,15 +35,15 @@ android { shrinkResources true proguardFiles 'proguard-rules.pro' } - debug { } + debug {} } flavorDimensions "color" productFlavors { - blue { } + blue {} green { applicationIdSuffix ".test" - versionNameSuffix "-"+getGitSha() + versionNameSuffix "-" + getGitSha() } } @@ -124,6 +124,7 @@ dependencies { implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0' //room implementation 'androidx.room:room-runtime:2.0.0' + implementation 'androidx.legacy:legacy-support-v4:1.0.0' kapt 'androidx.room:room-compiler:2.0.0' implementation 'androidx.room:room-rxjava2:2.0.0' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" @@ -153,4 +154,7 @@ dependencies { //Glide implementation 'com.github.bumptech.glide:glide:4.9.0' implementation 'com.github.bumptech.glide:okhttp3-integration:4.9.0' + + //Add some useful extensions + implementation 'androidx.core:core-ktx:1.2.0-alpha01' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f08523f7f..07acb061a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -101,9 +101,7 @@ - + @@ -119,6 +117,8 @@ + diff --git a/app/src/main/java/com/keylesspalace/tusky/ReportActivity.java b/app/src/main/java/com/keylesspalace/tusky/ReportActivity.java deleted file mode 100644 index 319112122..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/ReportActivity.java +++ /dev/null @@ -1,215 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * 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; - -import android.content.Intent; -import android.os.Bundle; -import android.util.Log; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.widget.EditText; - -import com.google.android.material.snackbar.Snackbar; -import com.keylesspalace.tusky.adapter.ReportAdapter; -import com.keylesspalace.tusky.di.Injectable; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.network.MastodonApi; -import com.keylesspalace.tusky.util.HtmlUtils; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -import javax.inject.Inject; - -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.widget.Toolbar; -import androidx.recyclerview.widget.DividerItemDecoration; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import okhttp3.ResponseBody; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; - -public class ReportActivity extends BaseActivity implements Injectable { - private static final String TAG = "ReportActivity"; // logging tag - - @Inject - public MastodonApi mastodonApi; - - private View anyView; // what Snackbar will use to find the root view - private ReportAdapter adapter; - private boolean reportAlreadyInFlight; - private String accountId; - private EditText comment; - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_report); - - Intent intent = getIntent(); - accountId = intent.getStringExtra("account_id"); - String accountUsername = intent.getStringExtra("account_username"); - String statusId = intent.getStringExtra("status_id"); - String statusContent = intent.getStringExtra("status_content"); - - Toolbar toolbar = findViewById(R.id.toolbar); - setSupportActionBar(toolbar); - ActionBar bar = getSupportActionBar(); - if (bar != null) { - String title = String.format(getString(R.string.report_username_format), - accountUsername); - bar.setTitle(title); - bar.setDisplayHomeAsUpEnabled(true); - bar.setDisplayShowHomeEnabled(true); - } - anyView = toolbar; - - final RecyclerView recyclerView = findViewById(R.id.report_recycler_view); - recyclerView.setHasFixedSize(true); - LinearLayoutManager layoutManager = new LinearLayoutManager(this); - recyclerView.setLayoutManager(layoutManager); - adapter = new ReportAdapter(); - recyclerView.setAdapter(adapter); - - DividerItemDecoration divider = new DividerItemDecoration( - this, layoutManager.getOrientation()); - recyclerView.addItemDecoration(divider); - - ReportAdapter.ReportStatus reportStatus = new ReportAdapter.ReportStatus(statusId, - HtmlUtils.fromHtml(statusContent), true); - adapter.addItem(reportStatus); - - comment = findViewById(R.id.report_comment); - - reportAlreadyInFlight = false; - - fetchRecentStatuses(accountId); - } - - private void onClickSend() { - if (reportAlreadyInFlight) { - return; - } - - String[] statusIds = adapter.getCheckedStatusIds(); - - if (statusIds.length > 0) { - reportAlreadyInFlight = true; - sendReport(accountId, statusIds, comment.getText().toString()); - } else { - comment.setError(getString(R.string.error_report_too_few_statuses)); - } - } - - private void sendReport(final String accountId, final String[] statusIds, - final String comment) { - Callback callback = new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful()) { - onSendSuccess(); - } else { - onSendFailure(accountId, statusIds, comment); - } - } - - @Override - public void onFailure(Call call, Throwable t) { - onSendFailure(accountId, statusIds, comment); - } - }; - mastodonApi.report(accountId, Arrays.asList(statusIds), comment) - .enqueue(callback); - } - - private void onSendSuccess() { - Snackbar bar = Snackbar.make(anyView, getString(R.string.confirmation_reported), Snackbar.LENGTH_SHORT); - bar.show(); - finish(); - } - - private void onSendFailure(final String accountId, final String[] statusIds, - final String comment) { - Snackbar.make(anyView, R.string.error_generic, Snackbar.LENGTH_LONG) - .setAction(R.string.action_retry, new View.OnClickListener() { - @Override - public void onClick(View v) { - sendReport(accountId, statusIds, comment); - } - }) - .show(); - reportAlreadyInFlight = false; - } - - private void fetchRecentStatuses(String accountId) { - Callback> callback = new Callback>() { - @Override - public void onResponse(Call> call, Response> response) { - if (!response.isSuccessful()) { - onFetchStatusesFailure(new Exception(response.message())); - return; - } - List statusList = response.body(); - List itemList = new ArrayList<>(); - for (Status status : statusList) { - if (status.getReblog() == null) { - ReportAdapter.ReportStatus item = new ReportAdapter.ReportStatus( - status.getId(), status.getContent(), false); - itemList.add(item); - } - } - adapter.addItems(itemList); - } - - @Override - public void onFailure(Call> call, Throwable t) { - onFetchStatusesFailure((Exception) t); - } - }; - mastodonApi.accountStatuses(accountId, null, null, null, null, null, null) - .enqueue(callback); - } - - private void onFetchStatusesFailure(Exception exception) { - Log.e(TAG, "Failed to fetch recent statuses to report. " + exception.getMessage()); - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.report_toolbar, menu); - return super.onCreateOptionsMenu(menu); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: { - onBackPressed(); - return true; - } - case R.id.action_report: { - onClickSend(); - return true; - } - } - return super.onOptionsItemSelected(item); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java b/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java index d0ed9acfa..663dbc84f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java @@ -15,6 +15,8 @@ package com.keylesspalace.tusky; +import android.content.Context; +import android.content.Intent; import android.os.Bundle; import androidx.annotation.Nullable; @@ -34,16 +36,23 @@ import dagger.android.DispatchingAndroidInjector; import dagger.android.support.HasSupportFragmentInjector; public class ViewTagActivity extends BottomSheetActivity implements HasSupportFragmentInjector { + private static final String HASHTAG = "hashtag"; @Inject public DispatchingAndroidInjector dispatchingAndroidInjector; + public static Intent getIntent(Context context, String tag){ + Intent intent = new Intent(context,ViewTagActivity.class); + intent.putExtra(HASHTAG,tag); + return intent; + } + @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_view_tag); - String hashtag = getIntent().getStringExtra("hashtag"); + String hashtag = getIntent().getStringExtra(HASHTAG); Toolbar toolbar = findViewById(R.id.toolbar); setSupportActionBar(toolbar); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportAdapter.java deleted file mode 100644 index 0b0ac3fd9..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportAdapter.java +++ /dev/null @@ -1,138 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * 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 androidx.recyclerview.widget.RecyclerView; -import android.text.Spanned; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.CheckBox; -import android.widget.CompoundButton; -import android.widget.TextView; - -import com.keylesspalace.tusky.R; - -import java.util.ArrayList; -import java.util.List; - -public class ReportAdapter extends RecyclerView.Adapter { - public static class ReportStatus { - String id; - Spanned content; - boolean checked; - - public ReportStatus(String id, Spanned content, boolean checked) { - this.id = id; - this.content = content; - this.checked = checked; - } - - @Override - public int hashCode() { - return id.hashCode(); - } - - @Override - public boolean equals(Object other) { - if (this.id == null) { - return this == other; - } else if (!(other instanceof ReportStatus)) { - return false; - } - ReportStatus status = (ReportStatus) other; - return status.id.equals(this.id); - } - } - - private List statusList; - - public ReportAdapter() { - super(); - statusList = new ArrayList<>(); - } - - @Override - public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - View view = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.item_report_status, parent, false); - return new ReportStatusViewHolder(view); - } - - @Override - public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) { - ReportStatusViewHolder holder = (ReportStatusViewHolder) viewHolder; - ReportStatus status = statusList.get(position); - holder.setupWithStatus(status); - } - - @Override - public int getItemCount() { - return statusList.size(); - } - - public void addItem(ReportStatus status) { - int end = statusList.size(); - statusList.add(status); - notifyItemInserted(end); - } - - public void addItems(List newStatuses) { - int end = statusList.size(); - int added = 0; - for (ReportStatus status : newStatuses) { - if (!statusList.contains(status)) { - statusList.add(status); - added += 1; - } - } - if (added > 0) { - notifyItemRangeInserted(end, added); - } - } - - public String[] getCheckedStatusIds() { - List idList = new ArrayList<>(); - for (ReportStatus status : statusList) { - if (status.checked) { - idList.add(status.id); - } - } - return idList.toArray(new String[0]); - } - - private static class ReportStatusViewHolder extends RecyclerView.ViewHolder { - private TextView content; - private CheckBox checkBox; - - ReportStatusViewHolder(View view) { - super(view); - content = view.findViewById(R.id.report_status_content); - checkBox = view.findViewById(R.id.report_status_check_box); - } - - void setupWithStatus(final ReportStatus status) { - content.setText(status.content); - checkBox.setChecked(status.checked); - checkBox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { - @Override - public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { - status.checked = isChecked; - } - }); - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt new file mode 100644 index 000000000..170c108a4 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt @@ -0,0 +1,162 @@ +/* Copyright 2019 Joel Pyska + * + * 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.components.report + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.text.Spanned +import android.view.MenuItem +import androidx.appcompat.content.res.AppCompatResources +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import com.keylesspalace.tusky.BottomSheetActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.report.adapter.ReportPagerAdapter +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.util.HtmlUtils +import com.keylesspalace.tusky.util.ThemeUtils +import dagger.android.AndroidInjector +import dagger.android.DispatchingAndroidInjector +import dagger.android.support.HasSupportFragmentInjector +import kotlinx.android.synthetic.main.activity_report.* +import kotlinx.android.synthetic.main.toolbar_basic.* +import javax.inject.Inject + + +class ReportActivity : BottomSheetActivity(), HasSupportFragmentInjector { + + @Inject + lateinit var dispatchingFragmentInjector: DispatchingAndroidInjector + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private lateinit var viewModel: ReportViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel = ViewModelProviders.of(this, viewModelFactory)[ReportViewModel::class.java] + val accountId = intent?.getStringExtra(ACCOUNT_ID) + val accountUserName = intent?.getStringExtra(ACCOUNT_USERNAME) + if (accountId.isNullOrBlank() || accountUserName.isNullOrBlank()) { + throw IllegalStateException("accountId ($accountId) or accountUserName ($accountUserName) is null") + } + + viewModel.init(accountId, accountUserName, + intent?.getStringExtra(STATUS_ID), intent?.getStringExtra(STATUS_CONTENT)) + + + setContentView(R.layout.activity_report) + + setSupportActionBar(toolbar) + + val closeIcon = AppCompatResources.getDrawable(this, R.drawable.ic_close_24dp) + ThemeUtils.setDrawableTint(this, closeIcon!!, R.attr.compose_close_button_tint) + + supportActionBar?.apply { + title = getString(R.string.report_username_format, viewModel.accountUserName) + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + setHomeAsUpIndicator(closeIcon) + } + + initViewPager() + if (savedInstanceState == null) { + viewModel.navigateTo(Screen.Statuses) + } + subscribeObservables() + } + + private fun initViewPager() { + wizard.adapter = ReportPagerAdapter(supportFragmentManager) + } + + private fun subscribeObservables() { + viewModel.navigation.observe(this, Observer { screen -> + if (screen != null) { + viewModel.navigated() + when (screen) { + Screen.Statuses -> showStatusesPage() + Screen.Note -> showNotesPage() + Screen.Done -> showDonePage() + Screen.Back -> showPreviousScreen() + Screen.Finish -> closeScreen() + } + } + }) + + viewModel.checkUrl.observe(this, Observer { + if (!it.isNullOrBlank()) { + viewModel.urlChecked() + viewUrl(it) + } + }) + } + + private fun showPreviousScreen() { + when (wizard.currentItem) { + 0 -> closeScreen() + 1 -> showStatusesPage() + } + } + + private fun showDonePage() { + wizard.currentItem = 2 + } + + private fun showNotesPage() { + wizard.currentItem = 1 + } + + private fun closeScreen() { + finish() + } + + private fun showStatusesPage() { + wizard.currentItem = 0 + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + closeScreen() + return true + } + } + return super.onOptionsItemSelected(item) + } + + companion object { + private const val ACCOUNT_ID = "account_id" + private const val ACCOUNT_USERNAME = "account_username" + private const val STATUS_ID = "status_id" + private const val STATUS_CONTENT = "status_content" + + @JvmStatic + fun getIntent(context: Context, accountId: String, userName: String, statusId: String, statusContent: Spanned) = + Intent(context, ReportActivity::class.java) + .apply { + putExtra(ACCOUNT_ID, accountId) + putExtra(ACCOUNT_USERNAME, userName) + putExtra(STATUS_ID, statusId) + putExtra(STATUS_CONTENT, HtmlUtils.toHtml(statusContent)) + } + } + + override fun supportFragmentInjector(): AndroidInjector = dispatchingFragmentInjector +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt new file mode 100644 index 000000000..c772ff9b1 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt @@ -0,0 +1,224 @@ +/* Copyright 2019 Joel Pyska + * + * 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.components.report + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Transformations +import androidx.lifecycle.ViewModel +import androidx.paging.PagedList +import com.keylesspalace.tusky.components.report.adapter.StatusesRepository +import com.keylesspalace.tusky.components.report.model.StatusViewState +import com.keylesspalace.tusky.entity.Relationship +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.* +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.schedulers.Schedulers +import javax.inject.Inject + +class ReportViewModel @Inject constructor( + private val mastodonApi: MastodonApi, + private val statusesRepository: StatusesRepository) : ViewModel() { + private val disposables = CompositeDisposable() + + private val navigationMutable = MutableLiveData() + val navigation: LiveData = navigationMutable + + private val muteStateMutable = MutableLiveData>() + val muteState: LiveData> = muteStateMutable + + private val blockStateMutable = MutableLiveData>() + val blockState: LiveData> = blockStateMutable + + private val reportingStateMutable = MutableLiveData>() + var reportingState: LiveData> = reportingStateMutable + + private val checkUrlMutable = MutableLiveData() + val checkUrl: LiveData = checkUrlMutable + + private val repoResult = MutableLiveData>() + val statuses: LiveData> = Transformations.switchMap(repoResult) { it.pagedList } + val networkStateAfter: LiveData = Transformations.switchMap(repoResult) { it.networkStateAfter } + val networkStateBefore: LiveData = Transformations.switchMap(repoResult) { it.networkStateBefore } + val networkStateRefresh: LiveData = Transformations.switchMap(repoResult) { it.refreshState } + + private val selectedIds = HashSet() + val statusViewState = StatusViewState() + + var reportNote: String? = null + var isRemoteNotify = false + + private var statusContent: String? = null + private var statusId: String? = null + lateinit var accountUserName: String + lateinit var accountId: String + var isRemoteAccount: Boolean = false + var remoteServer: String? = null + + fun init(accountId: String, userName: String, statusId: String?, statusContent: String?) { + this.accountId = accountId + this.accountUserName = userName + this.statusId = statusId + statusId?.let { + selectedIds.add(it) + } + this.statusContent = statusContent + isRemoteAccount = userName.contains('@') + if (isRemoteAccount) { + remoteServer = userName.substring(userName.indexOf('@') + 1) + } + + obtainRelationship() + repoResult.value = statusesRepository.getStatuses(accountId, statusId, disposables) + } + + override fun onCleared() { + super.onCleared() + disposables.clear() + } + + fun navigateTo(screen: Screen) { + navigationMutable.value = screen + } + + fun navigated() { + navigationMutable.value = null + } + + + private fun obtainRelationship() { + val ids = listOf(accountId) + muteStateMutable.value = Loading() + blockStateMutable.value = Loading() + disposables.add( + mastodonApi.relationshipsObservable(ids) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { data -> + updateRelationship(data.getOrNull(0)) + + }, + { + updateRelationship(null) + } + )) + } + + + private fun updateRelationship(relationship: Relationship?) { + if (relationship != null) { + muteStateMutable.value = Success(relationship.muting) + blockStateMutable.value = Success(relationship.blocking) + } else { + muteStateMutable.value = Error(false) + blockStateMutable.value = Error(false) + } + } + + fun toggleMute() { + val single: Single = if (muteStateMutable.value?.data == true) { + mastodonApi.unmuteAccountObservable(accountId) + } else { + mastodonApi.muteAccountObservable(accountId) + } + muteStateMutable.value = Loading() + disposables.add( + single + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { relationship -> + muteStateMutable.value = Success(relationship?.muting == true) + }, + { error -> + muteStateMutable.value = Error(false, error.message) + } + )) + } + + fun toggleBlock() { + val single: Single = if (blockStateMutable.value?.data == true) { + mastodonApi.unblockAccountObservable(accountId) + } else { + mastodonApi.blockAccountObservable(accountId) + } + blockStateMutable.value = Loading() + disposables.add( + single + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { relationship -> + blockStateMutable.value = Success(relationship?.blocking == true) + }, + { error -> + blockStateMutable.value = Error(false, error.message) + } + )) + } + + fun doReport() { + reportingStateMutable.value = Loading() + disposables.add( + mastodonApi.reportObservable(accountId, selectedIds.toList(), reportNote, if (isRemoteAccount) isRemoteNotify else null) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + reportingStateMutable.value = Success(true) + }, + { error -> + reportingStateMutable.value = Error(cause = error) + } + ) + ) + } + + fun retryStatusLoad() { + repoResult.value?.retry?.invoke() + } + + fun refreshStatuses() { + repoResult.value?.refresh?.invoke() + } + + fun checkClickedUrl(url: String?) { + checkUrlMutable.value = url + } + + fun urlChecked() { + checkUrlMutable.value = null + } + + fun setStatusChecked(status: Status, checked: Boolean) { + if (checked) + selectedIds.add(status.id) + else + selectedIds.remove(status.id) + } + + fun isStatusChecked(id: String): Boolean { + return selectedIds.contains(id) + } + + fun isStatusesSelected(): Boolean { + return selectedIds.isNotEmpty() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/Screen.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/Screen.kt new file mode 100644 index 000000000..643c46c18 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/Screen.kt @@ -0,0 +1,24 @@ +/* Copyright 2019 Joel Pyska + * + * 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.components.report + +enum class Screen { + Statuses, + Note, + Done, + Back, + Finish +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/AdapterHandler.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/AdapterHandler.kt new file mode 100644 index 000000000..588cfef0e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/AdapterHandler.kt @@ -0,0 +1,28 @@ +/* Copyright 2019 Joel Pyska + * + * 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.components.report.adapter + +import android.view.View +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.interfaces.LinkListener +import java.util.ArrayList + +interface AdapterHandler: LinkListener { + fun showMedia(v: View?, status: Status?, idx: Int) + fun setStatusChecked(status: Status, isChecked: Boolean) + fun isStatusChecked(id: String): Boolean +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/ReportPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/ReportPagerAdapter.kt new file mode 100644 index 000000000..cf90fee1f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/ReportPagerAdapter.kt @@ -0,0 +1,36 @@ +/* Copyright 2019 Joel Pyska + * + * 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.components.report.adapter + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentPagerAdapter +import com.keylesspalace.tusky.components.report.fragments.ReportDoneFragment +import com.keylesspalace.tusky.components.report.fragments.ReportNoteFragment +import com.keylesspalace.tusky.components.report.fragments.ReportStatusesFragment + +class ReportPagerAdapter(manager: FragmentManager) : FragmentPagerAdapter(manager) { + override fun getItem(position: Int): Fragment { + return when (position) { + 0 -> ReportStatusesFragment.newInstance() + 1 -> ReportNoteFragment.newInstance() + 2 -> ReportDoneFragment.newInstance() + else -> throw IllegalArgumentException("Unknown page index: $position") + } + } + + override fun getCount(): Int = 3 +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt new file mode 100644 index 000000000..a3d919501 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt @@ -0,0 +1,166 @@ +/* Copyright 2019 Joel Pyska + * + * 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.components.report.adapter + +import android.text.Spanned +import android.text.TextUtils +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.report.model.StatusViewState +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.util.StatusViewHelper.Companion.COLLAPSE_INPUT_FILTER +import com.keylesspalace.tusky.util.StatusViewHelper.Companion.NO_INPUT_FILTER +import kotlinx.android.synthetic.main.item_report_status.view.* +import java.util.* + +class StatusViewHolder(itemView: View, + private val useAbsoluteTime: Boolean, + private val mediaPreviewEnabled: Boolean, + private val viewState: StatusViewState, + private val adapterHandler: AdapterHandler, + private val getStatusForPosition: (Int) -> Status?) : RecyclerView.ViewHolder(itemView) { + private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize(R.dimen.status_media_preview_height) + private val statusViewHelper = StatusViewHelper(itemView) + + private val previewListener = object : StatusViewHelper.MediaPreviewListener { + override fun onViewMedia(v: View?, idx: Int) { + status()?.let { status -> + adapterHandler.showMedia(v, status, idx) + } + } + + override fun onContentHiddenChange(isShowing: Boolean) { + status()?.id?.let { id -> + viewState.setMediaShow(id, isShowing) + } + } + } + + init { + itemView.statusSelection.setOnCheckedChangeListener { _, isChecked -> + status()?.let { status -> + adapterHandler.setStatusChecked(status, isChecked) + } + } + } + + fun bind(status: Status) { + itemView.statusSelection.isChecked = adapterHandler.isStatusChecked(status.id) + + updateTextView() + + val sensitive = status.sensitive + + statusViewHelper.setMediasPreview(mediaPreviewEnabled, status.attachments, sensitive, previewListener, + viewState.isMediaShow(status.id, status.sensitive), + mediaViewHeight) + + statusViewHelper.setupPollReadonly(status.poll, status.emojis, useAbsoluteTime) + setCreatedAt(status.createdAt) + } + + private fun updateTextView() { + status()?.let { status -> + setupCollapsedState(status.isCollapsible(), viewState.isCollapsed(status.id, true), + viewState.isContentShow(status.id, status.sensitive), status.spoilerText) + + if (status.spoilerText.isBlank()) { + setTextVisible(true, status.content, status.mentions, status.emojis, adapterHandler) + itemView.statusContentWarningButton.hide() + itemView.statusContentWarningDescription.hide() + } else { + val emojiSpoiler = CustomEmojiHelper.emojifyString(status.spoilerText, status.emojis, itemView.statusContentWarningDescription) + itemView.statusContentWarningDescription.text = emojiSpoiler + itemView.statusContentWarningDescription.show() + itemView.statusContentWarningButton.show() + itemView.statusContentWarningButton.isChecked = viewState.isContentShow(status.id, true) + itemView.statusContentWarningButton.setOnCheckedChangeListener { _, isViewChecked -> + status()?.let { status -> + itemView.statusContentWarningDescription.invalidate() + viewState.setContentShow(status.id, isViewChecked) + setTextVisible(isViewChecked, status.content, status.mentions, status.emojis, adapterHandler) + } + } + setTextVisible(viewState.isContentShow(status.id, true), status.content, status.mentions, status.emojis, adapterHandler) + } + } + } + + + private fun setTextVisible(expanded: Boolean, + content: Spanned, + mentions: Array?, + emojis: List, + listener: LinkListener) { + if (expanded) { + val emojifiedText = CustomEmojiHelper.emojifyText(content, emojis, itemView.statusContent) + LinkHelper.setClickableText(itemView.statusContent, emojifiedText, mentions, listener) + } else { + LinkHelper.setClickableMentions(itemView.statusContent, mentions, listener) + } + if (itemView.statusContent.text.isNullOrBlank()) { + itemView.statusContent.hide() + } else { + itemView.statusContent.show() + } + } + + private fun setCreatedAt(createdAt: Date?) { + if (useAbsoluteTime) { + itemView.timestampInfo.text = statusViewHelper.getAbsoluteTime(createdAt) + } else { + itemView.timestampInfo.text = if (createdAt != null) { + val then = createdAt.time + val now = System.currentTimeMillis() + DateUtils.getRelativeTimeSpanString(itemView.timestampInfo.context, then, now) + } else { + // unknown minutes~ + "?m" + } + } + } + + + private fun setupCollapsedState(collapsible: Boolean, collapsed: Boolean, expanded: Boolean, spoilerText: String) { + /* input filter for TextViews have to be set before text */ + if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) { + itemView.buttonToggleContent.setOnCheckedChangeListener { _, isChecked -> + status()?.let { status -> + viewState.setCollapsed(status.id, isChecked) + updateTextView() + } + } + + itemView.buttonToggleContent.show() + if (collapsed) { + itemView.buttonToggleContent.isChecked = true + itemView.statusContent.filters = COLLAPSE_INPUT_FILTER + } else { + itemView.buttonToggleContent.isChecked = false + itemView.statusContent.filters = NO_INPUT_FILTER + } + } else { + itemView.buttonToggleContent.hide() + itemView.statusContent.filters = NO_INPUT_FILTER + } + } + + private fun status() = getStatusForPosition(adapterPosition) +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt new file mode 100644 index 000000000..c5ecea09f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt @@ -0,0 +1,62 @@ +/* Copyright 2019 Joel Pyska + * + * 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.components.report.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.PagedListAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.report.model.StatusViewState +import com.keylesspalace.tusky.entity.Status + +class StatusesAdapter(private val useAbsoluteTime: Boolean, + private val mediaPreviewEnabled: Boolean, + private val statusViewState: StatusViewState, + private val adapterHandler: AdapterHandler) + : PagedListAdapter(STATUS_COMPARATOR) { + + private val statusForPosition: (Int) -> Status? = { position: Int -> + if (position != RecyclerView.NO_POSITION) getItem(position) else null + } + + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return StatusViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_report_status, parent, false), + useAbsoluteTime, mediaPreviewEnabled, statusViewState, adapterHandler, statusForPosition) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + getItem(position)?.let { status -> + (holder as? StatusViewHolder)?.bind(status) + } + + } + + companion object { + + val STATUS_COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areContentsTheSame(oldItem: Status, newItem: Status): Boolean = + oldItem == newItem + + override fun areItemsTheSame(oldItem: Status, newItem: Status): Boolean = + oldItem.id == newItem.id + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSource.kt new file mode 100644 index 000000000..8ecfbff21 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSource.kt @@ -0,0 +1,145 @@ +/* Copyright 2019 Joel Pyska + * + * 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.components.report.adapter + +import android.annotation.SuppressLint +import androidx.lifecycle.MutableLiveData +import androidx.paging.ItemKeyedDataSource +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.NetworkState +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.functions.BiFunction +import java.util.concurrent.Executor + +class StatusesDataSource(private val accountId: String, + private val mastodonApi: MastodonApi, + private val disposables: CompositeDisposable, + private val retryExecutor: Executor) : ItemKeyedDataSource() { + + val networkStateAfter = MutableLiveData() + val networkStateBefore = MutableLiveData() + + private var retryAfter: (() -> Any)? = null + private var retryBefore: (() -> Any)? = null + private var retryInitial: (() -> Any)? = null + + val initialLoad = MutableLiveData() + fun retryAllFailed() { + var prevRetry = retryInitial + retryInitial = null + prevRetry?.let { + retryExecutor.execute { + it.invoke() + } + } + + prevRetry = retryAfter + retryAfter = null + prevRetry?.let { + retryExecutor.execute { + it.invoke() + } + } + + prevRetry = retryBefore + retryBefore = null + prevRetry?.let { + retryExecutor.execute { + it.invoke() + } + } + } + + @SuppressLint("CheckResult") + override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback) { + networkStateAfter.postValue(NetworkState.LOADED) + networkStateBefore.postValue(NetworkState.LOADED) + retryAfter = null + retryBefore = null + retryInitial = null + initialLoad.postValue(NetworkState.LOADING) + mastodonApi.statusObservable(params.requestedInitialKey).zipWith( + mastodonApi.accountStatusesObservable(accountId, params.requestedInitialKey, null, params.requestedLoadSize - 1, true), + BiFunction { status: Status, list: List -> + val ret = ArrayList() + ret.add(status) + ret.addAll(list) + return@BiFunction ret + }) + .doOnSubscribe { + disposables.add(it) + } + .subscribe( + { + callback.onResult(it) + initialLoad.postValue(NetworkState.LOADED) + }, + { + retryInitial = { + loadInitial(params, callback) + } + initialLoad.postValue(NetworkState.error(it.message)) + } + ) + } + + @SuppressLint("CheckResult") + override fun loadAfter(params: LoadParams, callback: LoadCallback) { + networkStateAfter.postValue(NetworkState.LOADING) + retryAfter = null + mastodonApi.accountStatusesObservable(accountId, params.key, null, params.requestedLoadSize, true) + .doOnSubscribe { + disposables.add(it) + } + .subscribe( + { + callback.onResult(it) + networkStateAfter.postValue(NetworkState.LOADED) + }, + { + retryAfter = { + loadAfter(params, callback) + } + networkStateAfter.postValue(NetworkState.error(it.message)) + } + ) + } + + @SuppressLint("CheckResult") + override fun loadBefore(params: LoadParams, callback: LoadCallback) { + networkStateBefore.postValue(NetworkState.LOADING) + retryBefore = null + mastodonApi.accountStatusesObservable(accountId, null, params.key, params.requestedLoadSize, true) + .doOnSubscribe { + disposables.add(it) + } + .subscribe( + { + callback.onResult(it) + networkStateBefore.postValue(NetworkState.LOADED) + }, + { + retryBefore = { + loadBefore(params, callback) + } + networkStateBefore.postValue(NetworkState.error(it.message)) + } + ) + } + + override fun getKey(item: Status): String = item.id +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSourceFactory.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSourceFactory.kt new file mode 100644 index 000000000..4cf8ff1c3 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSourceFactory.kt @@ -0,0 +1,36 @@ +/* Copyright 2019 Joel Pyska + * + * 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.components.report.adapter + +import androidx.lifecycle.MutableLiveData +import androidx.paging.DataSource +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import io.reactivex.disposables.CompositeDisposable +import java.util.concurrent.Executor + +class StatusesDataSourceFactory( + private val accountId: String, + private val mastodonApi: MastodonApi, + private val disposables: CompositeDisposable, + private val retryExecutor: Executor) : DataSource.Factory() { + val sourceLiveData = MutableLiveData() + override fun create(): DataSource { + val source = StatusesDataSource(accountId, mastodonApi, disposables, retryExecutor) + sourceLiveData.postValue(source) + return source + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesRepository.kt new file mode 100644 index 000000000..852a07f96 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesRepository.kt @@ -0,0 +1,61 @@ +/* Copyright 2019 Joel Pyska + * + * 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.components.report.adapter + +import androidx.lifecycle.Transformations +import androidx.paging.Config +import androidx.paging.toLiveData +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.BiListing +import com.keylesspalace.tusky.util.Listing +import io.reactivex.disposables.CompositeDisposable +import java.util.concurrent.Executors +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class StatusesRepository @Inject constructor(private val mastodonApi: MastodonApi) { + + private val executor = Executors.newSingleThreadExecutor() + + fun getStatuses(accountId: String, initialStatus: String?, disposables: CompositeDisposable, pageSize: Int = 20): BiListing { + val sourceFactory = StatusesDataSourceFactory(accountId, mastodonApi, disposables, executor) + val livePagedList = sourceFactory.toLiveData( + config = Config(pageSize = pageSize, enablePlaceholders = false, initialLoadSizeHint = pageSize * 2), + fetchExecutor = executor, initialLoadKey = initialStatus + ) + return BiListing( + pagedList = livePagedList, + networkStateBefore = Transformations.switchMap(sourceFactory.sourceLiveData) { + it.networkStateBefore + }, + networkStateAfter = Transformations.switchMap(sourceFactory.sourceLiveData) { + it.networkStateAfter + }, + retry = { + sourceFactory.sourceLiveData.value?.retryAllFailed() + }, + refresh = { + sourceFactory.sourceLiveData.value?.invalidate() + }, + refreshState = Transformations.switchMap(sourceFactory.sourceLiveData) { + it.initialLoad + } + + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportDoneFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportDoneFragment.kt new file mode 100644 index 000000000..95bf73561 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportDoneFragment.kt @@ -0,0 +1,111 @@ +/* Copyright 2019 Joel Pyska + * + * 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.components.report.fragments + + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.report.ReportViewModel +import com.keylesspalace.tusky.components.report.Screen +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.util.Loading +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import kotlinx.android.synthetic.main.fragment_report_done.* +import javax.inject.Inject + + +class ReportDoneFragment : Fragment(), Injectable { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private lateinit var viewModel: ReportViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel = ViewModelProviders.of(requireActivity(), viewModelFactory)[ReportViewModel::class.java] + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + // Inflate the layout for this fragment + return inflater.inflate(R.layout.fragment_report_done, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + textReported.text = getString(R.string.report_sent_success, viewModel.accountUserName) + handleClicks() + subscribeObservables() + } + + private fun subscribeObservables() { + viewModel.muteState.observe(viewLifecycleOwner, Observer { + if (it !is Loading) { + buttonMute.show() + progressMute.show() + } else { + buttonMute.hide() + progressMute.hide() + } + + buttonMute.setText(when { + it.data == true -> R.string.action_unmute + else -> R.string.action_mute + }) + }) + + viewModel.blockState.observe(viewLifecycleOwner, Observer { + if (it !is Loading) { + buttonBlock.show() + progressBlock.show() + } + else{ + buttonBlock.hide() + progressBlock.hide() + } + buttonBlock.setText(when { + it.data == true -> R.string.action_unblock + else -> R.string.action_block + }) + }) + + } + + private fun handleClicks() { + buttonDone.setOnClickListener { + viewModel.navigateTo(Screen.Finish) + } + buttonBlock.setOnClickListener { + viewModel.toggleBlock() + } + buttonMute.setOnClickListener { + viewModel.toggleMute() + } + } + + companion object { + fun newInstance() = ReportDoneFragment() + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt new file mode 100644 index 000000000..216aa2575 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt @@ -0,0 +1,141 @@ +/* Copyright 2019 Joel Pyska + * + * 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.components.report.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.report.ReportViewModel +import com.keylesspalace.tusky.components.report.Screen +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.util.* +import kotlinx.android.synthetic.main.fragment_report_note.* +import java.io.IOException +import javax.inject.Inject + +class ReportNoteFragment : Fragment(), Injectable { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private lateinit var viewModel: ReportViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel = ViewModelProviders.of(requireActivity(), viewModelFactory)[ReportViewModel::class.java] + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + // Inflate the layout for this fragment + return inflater.inflate(R.layout.fragment_report_note, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + fillViews() + handleChanges() + handleClicks() + subscribeObservables() + } + + private fun handleChanges() { + editNote.doAfterTextChanged { + viewModel.reportNote = it?.toString() + } + checkIsNotifyRemote.setOnCheckedChangeListener { _, isChecked -> + viewModel.isRemoteNotify = isChecked + } + } + + private fun fillViews() { + editNote.setText(viewModel.reportNote) + + if (viewModel.isRemoteAccount){ + checkIsNotifyRemote.show() + reportDescriptionRemoteInstance.show() + } + else{ + checkIsNotifyRemote.hide() + reportDescriptionRemoteInstance.hide() + } + + if (viewModel.isRemoteAccount) + checkIsNotifyRemote.text = getString(R.string.report_remote_instance, viewModel.remoteServer) + checkIsNotifyRemote.isChecked = viewModel.isRemoteNotify + } + + private fun subscribeObservables() { + viewModel.reportingState.observe(viewLifecycleOwner, Observer { + when (it) { + is Success -> viewModel.navigateTo(Screen.Done) + is Loading -> showLoading() + is Error -> showError(it.cause) + + } + }) + } + + private fun showError(error: Throwable?) { + editNote.isEnabled = true + checkIsNotifyRemote.isEnabled = true + buttonReport.isEnabled = true + buttonBack.isEnabled = true + progressBar.hide() + + Snackbar.make(buttonBack, if (error is IOException) R.string.error_network else R.string.error_generic, Snackbar.LENGTH_LONG) + .apply { + setAction(R.string.action_retry) { + sendReport() + } + } + .show() + } + + private fun sendReport() { + viewModel.doReport() + } + + private fun showLoading() { + buttonReport.isEnabled = false + buttonBack.isEnabled = false + editNote.isEnabled = false + checkIsNotifyRemote.isEnabled = false + progressBar.show() + } + + private fun handleClicks() { + buttonBack.setOnClickListener { + viewModel.navigateTo(Screen.Back) + } + + buttonReport.setOnClickListener { + sendReport() + } + } + + companion object { + fun newInstance() = ReportNoteFragment() + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt new file mode 100644 index 000000000..13edcb44c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt @@ -0,0 +1,216 @@ +/* Copyright 2019 Joel Pyska + * + * 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.components.report.fragments + +import android.os.Bundle +import android.preference.PreferenceManager +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.app.ActivityOptionsCompat +import androidx.core.view.ViewCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import androidx.paging.PagedList +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.SimpleItemAnimator +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.AccountActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.ViewMediaActivity +import com.keylesspalace.tusky.ViewTagActivity +import com.keylesspalace.tusky.components.report.ReportViewModel +import com.keylesspalace.tusky.components.report.Screen +import com.keylesspalace.tusky.components.report.adapter.AdapterHandler +import com.keylesspalace.tusky.components.report.adapter.StatusesAdapter +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.viewdata.AttachmentViewData +import kotlinx.android.synthetic.main.fragment_report_statuses.* +import javax.inject.Inject + +class ReportStatusesFragment : Fragment(), Injectable, AdapterHandler { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + @Inject + lateinit var accountManager: AccountManager + + private lateinit var viewModel: ReportViewModel + + private lateinit var adapter: StatusesAdapter + private lateinit var layoutManager: LinearLayoutManager + + private var snackbarErrorRetry: Snackbar? = null + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel = ViewModelProviders.of(requireActivity(), viewModelFactory)[ReportViewModel::class.java] + } + + override fun showMedia(v: View?, status: Status?, idx: Int) { + status?.actionableStatus?.let { actionable -> + when (actionable.attachments[idx].type) { + Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE -> { + val attachments = AttachmentViewData.list(actionable) + val intent = ViewMediaActivity.newIntent(context, attachments, + idx) + if (v != null) { + val url = actionable.attachments[idx].url + ViewCompat.setTransitionName(v, url) + val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), + v, url) + startActivity(intent, options.toBundle()) + } else { + startActivity(intent) + } + } + Attachment.Type.UNKNOWN -> { + } + } + + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + // Inflate the layout for this fragment + return inflater.inflate(R.layout.fragment_report_statuses, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + handleClicks() + initStatusesView() + setupSwipeRefreshLayout() + } + + private fun setupSwipeRefreshLayout() { + swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + swipeRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(swipeRefreshLayout.context, android.R.attr.colorBackground)) + + swipeRefreshLayout.setOnRefreshListener { + snackbarErrorRetry?.dismiss() + viewModel.refreshStatuses() + } + } + + private fun initStatusesView() { + val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + + val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false) + + val account = accountManager.activeAccount + val mediaPreviewEnabled = account?.mediaPreviewEnabled ?: true + + + adapter = StatusesAdapter(useAbsoluteTime, mediaPreviewEnabled, viewModel.statusViewState, this) + + recyclerView.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) + layoutManager = LinearLayoutManager(requireContext()) + recyclerView.layoutManager = layoutManager + recyclerView.adapter = adapter + (recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + + viewModel.statuses.observe(viewLifecycleOwner, Observer> { + adapter.submitList(it) + }) + + viewModel.networkStateAfter.observe(viewLifecycleOwner, Observer { + if (it?.status == com.keylesspalace.tusky.util.Status.RUNNING) + progressBarBottom.show() + else + progressBarBottom.hide() + + if (it?.status == com.keylesspalace.tusky.util.Status.FAILED) + showError(it.msg) + }) + + viewModel.networkStateBefore.observe(viewLifecycleOwner, Observer { + if (it?.status == com.keylesspalace.tusky.util.Status.RUNNING) + progressBarTop.show() + else + progressBarTop.hide() + + if (it?.status == com.keylesspalace.tusky.util.Status.FAILED) + showError(it.msg) + }) + + viewModel.networkStateRefresh.observe(viewLifecycleOwner, Observer { + if (it?.status == com.keylesspalace.tusky.util.Status.RUNNING && !swipeRefreshLayout.isRefreshing) + progressBarLoading.show() + else + progressBarLoading.hide() + + if (it?.status != com.keylesspalace.tusky.util.Status.RUNNING) + swipeRefreshLayout.isRefreshing = false + if (it?.status == com.keylesspalace.tusky.util.Status.FAILED) + showError(it.msg) + }) + } + + private fun showError(@Suppress("UNUSED_PARAMETER") msg: String?) { + if (snackbarErrorRetry?.isShown != true) { + snackbarErrorRetry = Snackbar.make(swipeRefreshLayout, R.string.failed_fetch_statuses, Snackbar.LENGTH_INDEFINITE) + snackbarErrorRetry?.setAction(R.string.action_retry) { + viewModel.retryStatusLoad() + } + snackbarErrorRetry?.show() + } + } + + + private fun handleClicks() { + buttonCancel.setOnClickListener { + viewModel.navigateTo(Screen.Back) + } + + buttonContinue.setOnClickListener { + if (viewModel.isStatusesSelected()) { + viewModel.navigateTo(Screen.Note) + } else { + Snackbar.make(swipeRefreshLayout, R.string.error_report_too_few_statuses, Snackbar.LENGTH_LONG).show() + } + } + } + + override fun setStatusChecked(status: Status, isChecked: Boolean) { + viewModel.setStatusChecked(status, isChecked) + } + + override fun isStatusChecked(id: String): Boolean { + return viewModel.isStatusChecked(id) + } + + override fun onViewAccount(id: String) = startActivity(AccountActivity.getIntent(requireContext(), id)) + + override fun onViewTag(tag: String) = startActivity(ViewTagActivity.getIntent(requireContext(), tag)) + + override fun onViewUrl(url: String?) = viewModel.checkClickedUrl(url) + + companion object { + fun newInstance() = ReportStatusesFragment() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/model/StatusViewState.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/model/StatusViewState.kt new file mode 100644 index 000000000..664ddc6a5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/model/StatusViewState.kt @@ -0,0 +1,36 @@ +/* Copyright 2019 Joel Pyska + * + * 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.components.report.model + +class StatusViewState { + private val mediaShownState = HashMap() + private val contentShownState = HashMap() + private val longContentCollapsedState = HashMap() + + fun isMediaShow(id: String, isSensitive: Boolean): Boolean = isStateEnabled(mediaShownState, id, !isSensitive) + fun setMediaShow(id: String, isShow: Boolean) = setStateEnabled(mediaShownState, id, isShow) + + fun isContentShow(id: String, isSensitive: Boolean): Boolean = isStateEnabled(contentShownState, id, !isSensitive) + fun setContentShow(id: String, isShow: Boolean) = setStateEnabled(contentShownState, id, isShow) + + fun isCollapsed(id: String, isCollapsed: Boolean): Boolean = isStateEnabled(longContentCollapsedState, id, isCollapsed) + fun setCollapsed(id: String, isCollapsed: Boolean) = setStateEnabled(longContentCollapsedState, id, isCollapsed) + + private fun isStateEnabled(map: Map, id: String, def: Boolean): Boolean = map[id] + ?: def + + private fun setStateEnabled(map: MutableMap, id: String, state: Boolean) = map.put(id, state) +} \ No newline at end of file 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 9ad5e0863..f516ae16e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -16,6 +16,7 @@ package com.keylesspalace.tusky.di import com.keylesspalace.tusky.* +import com.keylesspalace.tusky.components.report.ReportActivity import dagger.Module import dagger.android.ContributesAndroidInjector @@ -71,9 +72,6 @@ abstract class ActivitiesModule { @ContributesAndroidInjector abstract fun contributesSplashActivity(): SplashActivity - @ContributesAndroidInjector - abstract fun contributesReportActivity(): ReportActivity - @ContributesAndroidInjector abstract fun contributesSavedTootActivity(): SavedTootActivity @@ -92,4 +90,6 @@ abstract class ActivitiesModule { @ContributesAndroidInjector abstract fun contributesFiltersActivity(): FiltersActivity + @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) + abstract fun contributesReportActivity(): ReportActivity } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt index 128bb1f5e..05f618a91 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -21,6 +21,9 @@ import com.keylesspalace.tusky.components.conversation.ConversationsFragment import com.keylesspalace.tusky.fragment.* import com.keylesspalace.tusky.fragment.preference.AccountPreferencesFragment import com.keylesspalace.tusky.fragment.preference.NotificationPreferencesFragment +import com.keylesspalace.tusky.components.report.fragments.ReportDoneFragment +import com.keylesspalace.tusky.components.report.fragments.ReportNoteFragment +import com.keylesspalace.tusky.components.report.fragments.ReportStatusesFragment import dagger.Module import dagger.android.ContributesAndroidInjector @@ -60,4 +63,12 @@ abstract class FragmentBuildersModule { @ContributesAndroidInjector abstract fun accountInListsFragment(): AccountsInListFragment + @ContributesAndroidInjector + abstract fun reportStatusesFragment(): ReportStatusesFragment + + @ContributesAndroidInjector + abstract fun reportNoteFragment(): ReportNoteFragment + + @ContributesAndroidInjector + abstract fun reportDoneFragment(): ReportDoneFragment } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index 175c5b363..2a3605a51 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -4,10 +4,9 @@ package com.keylesspalace.tusky.di import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import com.keylesspalace.tusky.viewmodel.AccountViewModel import com.keylesspalace.tusky.components.conversation.ConversationsViewModel -import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel -import com.keylesspalace.tusky.viewmodel.EditProfileViewModel +import com.keylesspalace.tusky.components.report.ReportViewModel +import com.keylesspalace.tusky.viewmodel.* import com.keylesspalace.tusky.viewmodel.ListsViewModel import dagger.Binds import dagger.MapKey @@ -61,5 +60,10 @@ abstract class ViewModelModule { @ViewModelKey(AccountsInListViewModel::class) internal abstract fun accountsInListViewModel(viewModel: AccountsInListViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(ReportViewModel::class) + internal abstract fun reportViewModel(viewModel: ReportViewModel): ViewModel + //Add more ViewModels here } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index a435e32fc..833a1e51d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -44,9 +44,9 @@ import com.keylesspalace.tusky.BottomSheetActivity; import com.keylesspalace.tusky.ComposeActivity; import com.keylesspalace.tusky.MainActivity; import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.ReportActivity; import com.keylesspalace.tusky.ViewMediaActivity; import com.keylesspalace.tusky.ViewTagActivity; +import com.keylesspalace.tusky.components.report.ReportActivity; import com.keylesspalace.tusky.db.AccountEntity; import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.di.Injectable; @@ -54,7 +54,6 @@ import com.keylesspalace.tusky.entity.Attachment; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.network.TimelineCases; -import com.keylesspalace.tusky.util.HtmlUtils; import com.keylesspalace.tusky.viewdata.AttachmentViewData; import java.util.LinkedHashSet; @@ -327,12 +326,7 @@ public abstract class SFragment extends BaseFragment implements Injectable { protected void openReportPage(String accountId, String accountUsername, String statusId, Spanned statusContent) { - Intent intent = new Intent(getContext(), ReportActivity.class); - intent.putExtra("account_id", accountId); - intent.putExtra("account_username", accountUsername); - intent.putExtra("status_id", statusId); - intent.putExtra("status_content", HtmlUtils.toHtml(statusContent)); - startActivity(intent); + startActivity(ReportActivity.getIntent(requireContext(),accountId,accountUsername,statusId,statusContent)); } protected void showConfirmDeleteDialog(final String id, final int position) { 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 9729d5d4e..c429e2727 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java @@ -385,4 +385,40 @@ public interface MastodonApi { @Path("id") String id, @Field("choices[]") List choices ); + + @POST("api/v1/accounts/{id}/block") + Single blockAccountObservable(@Path("id") String accountId); + + @POST("api/v1/accounts/{id}/unblock") + Single unblockAccountObservable(@Path("id") String accountId); + + @POST("api/v1/accounts/{id}/mute") + Single muteAccountObservable(@Path("id") String accountId); + + @POST("api/v1/accounts/{id}/unmute") + Single unmuteAccountObservable(@Path("id") String accountId); + + @GET("api/v1/accounts/relationships") + Single> relationshipsObservable(@Query("id[]") List accountIds); + + @FormUrlEncoded + @POST("api/v1/reports") + Single reportObservable( + @Field("account_id") String accountId, + @Field("status_ids[]") List statusIds, + @Field("comment") String comment, + @Field("forward") Boolean isNotifyRemote); + + @GET("api/v1/accounts/{id}/statuses") + Single> accountStatusesObservable( + @Path("id") String accountId, + @Query("max_id") String maxId, + @Query("since_id") String sinceId, + @Query("limit") Integer limit, + @Nullable @Query("exclude_reblogs") Boolean excludeReblogs); + + + @GET("api/v1/statuses/{id}") + Single statusObservable(@Path("id") String statusId); + } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/BiListing.kt b/app/src/main/java/com/keylesspalace/tusky/util/BiListing.kt new file mode 100644 index 000000000..dad6d552d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/BiListing.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.keylesspalace.tusky.util + +import androidx.lifecycle.LiveData +import androidx.paging.PagedList + +/** + * Data class that is necessary for a UI to show a listing and interact w/ the rest of the system + */ +data class BiListing( + // the LiveData of paged lists for the UI to observe + val pagedList: LiveData>, + // represents the network request status for load data before first to show to the user + val networkStateBefore: LiveData, + // represents the network request status for load data after last to show to the user + val networkStateAfter: LiveData, + // represents the refresh status to show to the user. Separate from networkState, this + // value is importantly only when refresh is requested. + val refreshState: LiveData, + // refreshes the whole data and fetches it from scratch. + val refresh: () -> Unit, + // retries any failed requests. + val retry: () -> Unit) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Resource.kt b/app/src/main/java/com/keylesspalace/tusky/util/Resource.kt index d6117c9b3..1f9f35d20 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/Resource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/Resource.kt @@ -8,5 +8,6 @@ class Success (override val data: T? = null) : Resource(data) class Error (override val data: T? = null, val errorMessage: String? = null, - var consumed: Boolean = false + var consumed: Boolean = false, + val cause: Throwable? = null ): Resource(data) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusExtensions.kt new file mode 100644 index 000000000..e0cc7b492 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusExtensions.kt @@ -0,0 +1,23 @@ +/* Copyright 2019 Joel Pyska + * + * 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 com.keylesspalace.tusky.entity.Status + +fun Status.isCollapsible(): Boolean { + return !SmartLengthInputFilter.hasBadRatio(content, SmartLengthInputFilter.LENGTH_DEFAULT) +} + diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt new file mode 100644 index 000000000..931f1a4b1 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt @@ -0,0 +1,314 @@ +/* Copyright 2019 Joel Pyska + * + * 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 android.content.Context +import android.text.InputFilter +import android.text.TextUtils +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.appcompat.content.res.AppCompatResources +import com.bumptech.glide.Glide +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.view.MediaPreviewImageView +import java.text.NumberFormat +import java.text.SimpleDateFormat +import java.util.* + +class StatusViewHelper(private val itemView: View) { + interface MediaPreviewListener { + fun onViewMedia(v: View?, idx: Int) + fun onContentHiddenChange(isShowing: Boolean) + } + + private val shortSdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + private val longSdf = SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()) + + fun setMediasPreview( + mediaPreviewEnabled: Boolean, + attachments: List, + sensitive: Boolean, + previewListener: MediaPreviewListener, + showingContent: Boolean, + mediaPreviewHeight: Int) { + + val context = itemView.context + val mediaPreviews = arrayOf( + itemView.findViewById(R.id.status_media_preview_0), + itemView.findViewById(R.id.status_media_preview_1), + itemView.findViewById(R.id.status_media_preview_2), + itemView.findViewById(R.id.status_media_preview_3)) + + val mediaOverlays = arrayOf( + itemView.findViewById(R.id.status_media_overlay_0), + itemView.findViewById(R.id.status_media_overlay_1), + itemView.findViewById(R.id.status_media_overlay_2), + itemView.findViewById(R.id.status_media_overlay_3)) + + val sensitiveMediaWarning = itemView.findViewById(R.id.status_sensitive_media_warning) + val sensitiveMediaShow = itemView.findViewById(R.id.status_sensitive_media_button) + val mediaLabel = itemView.findViewById(R.id.status_media_label) + if (mediaPreviewEnabled) { + // Hide the unused label. + mediaLabel.visibility = View.GONE + } else { + setMediaLabel(mediaLabel, attachments, sensitive, previewListener) + // Hide all unused views. + mediaPreviews[0].visibility = View.GONE + mediaPreviews[1].visibility = View.GONE + mediaPreviews[2].visibility = View.GONE + mediaPreviews[3].visibility = View.GONE + sensitiveMediaWarning.visibility = View.GONE + sensitiveMediaShow.visibility = View.GONE + return + } + + + val mediaPreviewUnloadedId = ThemeUtils.getDrawableId(context, R.attr.media_preview_unloaded_drawable, android.R.color.black) + + val n = Math.min(attachments.size, Status.MAX_MEDIA_ATTACHMENTS) + + for (i in 0 until n) { + val previewUrl = attachments[i].previewUrl + val description = attachments[i].description + + if (TextUtils.isEmpty(description)) { + mediaPreviews[i].contentDescription = context.getString(R.string.action_view_media) + } else { + mediaPreviews[i].contentDescription = description + } + + mediaPreviews[i].visibility = View.VISIBLE + + if (TextUtils.isEmpty(previewUrl)) { + Glide.with(mediaPreviews[i]) + .load(mediaPreviewUnloadedId) + .centerInside() + .into(mediaPreviews[i]) + } else { + val meta = attachments[i].meta + val focus = meta?.focus + + if (focus != null) { // If there is a focal point for this attachment: + mediaPreviews[i].setFocalPoint(focus) + + Glide.with(mediaPreviews[i]) + .load(previewUrl) + .placeholder(mediaPreviewUnloadedId) + .centerInside() + .addListener(mediaPreviews[i]) + .into(mediaPreviews[i]) + } else { + mediaPreviews[i].removeFocalPoint() + + Glide.with(mediaPreviews[i]) + .load(previewUrl) + .placeholder(mediaPreviewUnloadedId) + .centerInside() + .into(mediaPreviews[i]) + } + } + + val type = attachments[i].type + if ((type === Attachment.Type.VIDEO) or (type === Attachment.Type.GIFV)) { + mediaOverlays[i].visibility = View.VISIBLE + } else { + mediaOverlays[i].visibility = View.GONE + } + + mediaPreviews[i].setOnClickListener { v -> + previewListener.onViewMedia(v, i) + } + + if (n <= 2) { + mediaPreviews[0].layoutParams.height = mediaPreviewHeight * 2 + mediaPreviews[1].layoutParams.height = mediaPreviewHeight * 2 + } else { + mediaPreviews[0].layoutParams.height = mediaPreviewHeight + mediaPreviews[1].layoutParams.height = mediaPreviewHeight + mediaPreviews[2].layoutParams.height = mediaPreviewHeight + mediaPreviews[3].layoutParams.height = mediaPreviewHeight + } + } + if (attachments.isNullOrEmpty()) { + sensitiveMediaWarning.visibility = View.GONE + sensitiveMediaShow.visibility = View.GONE + } else { + + val hiddenContentText: String = if (sensitive) { + context.getString(R.string.status_sensitive_media_template, + context.getString(R.string.status_sensitive_media_title), + context.getString(R.string.status_sensitive_media_directions)) + } else { + context.getString(R.string.status_sensitive_media_template, + context.getString(R.string.status_media_hidden_title), + context.getString(R.string.status_sensitive_media_directions)) + } + + sensitiveMediaWarning.text = HtmlUtils.fromHtml(hiddenContentText) + + sensitiveMediaWarning.visibility = if (showingContent) View.GONE else View.VISIBLE + sensitiveMediaShow.visibility = if (showingContent) View.VISIBLE else View.GONE + sensitiveMediaShow.setOnClickListener { v -> + previewListener.onContentHiddenChange(false) + v.visibility = View.GONE + sensitiveMediaWarning.visibility = View.VISIBLE + } + sensitiveMediaWarning.setOnClickListener { v -> + previewListener.onContentHiddenChange(true) + v.visibility = View.GONE + sensitiveMediaShow.visibility = View.VISIBLE + } + } + + // Hide any of the placeholder previews beyond the ones set. + for (i in n until Status.MAX_MEDIA_ATTACHMENTS) { + mediaPreviews[i].visibility = View.GONE + } + } + + private fun setMediaLabel(mediaLabel: TextView, attachments: List, sensitive: Boolean, + listener: MediaPreviewListener) { + if (attachments.isEmpty()) { + mediaLabel.visibility = View.GONE + return + } + mediaLabel.visibility = View.VISIBLE + + // Set the label's text. + val context = mediaLabel.context + var labelText = getLabelTypeText(context, attachments[0].type) + if (sensitive) { + val sensitiveText = context.getString(R.string.status_sensitive_media_title) + labelText += String.format(" (%s)", sensitiveText) + } + mediaLabel.text = labelText + + // Set the icon next to the label. + val drawableId = getLabelIcon(attachments[0].type) + val drawable = AppCompatResources.getDrawable(context, drawableId) + ThemeUtils.setDrawableTint(context, drawable!!, android.R.attr.textColorTertiary) + mediaLabel.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null) + + mediaLabel.setOnClickListener { listener.onViewMedia(null, 0) } + } + + private fun getLabelTypeText(context: Context, type: Attachment.Type): String { + return when (type) { + Attachment.Type.IMAGE -> context.getString(R.string.status_media_images) + Attachment.Type.GIFV, Attachment.Type.VIDEO -> context.getString(R.string.status_media_video) + else -> context.getString(R.string.status_media_images) + } + } + + @DrawableRes + private fun getLabelIcon(type: Attachment.Type): Int { + return when (type) { + Attachment.Type.IMAGE -> R.drawable.ic_photo_24dp + Attachment.Type.GIFV, Attachment.Type.VIDEO -> R.drawable.ic_videocam_24dp + else -> R.drawable.ic_photo_24dp + } + } + + fun setupPollReadonly(poll: Poll?, emojis: List, useAbsoluteTime: Boolean) { + val pollResults = listOf( + itemView.findViewById(R.id.status_poll_option_result_0), + itemView.findViewById(R.id.status_poll_option_result_1), + itemView.findViewById(R.id.status_poll_option_result_2), + itemView.findViewById(R.id.status_poll_option_result_3)) + + val pollDescription = itemView.findViewById(R.id.status_poll_description) + + if (poll == null) { + for (pollResult in pollResults) { + pollResult.visibility = View.GONE + } + pollDescription.visibility = View.GONE + } else { + val timestamp = System.currentTimeMillis() + + + setupPollResult(poll, emojis, pollResults) + + pollDescription.visibility = View.VISIBLE + pollDescription.text = getPollInfoText(timestamp, poll, pollDescription, useAbsoluteTime) + } + } + + private fun getPollInfoText(timestamp: Long, poll: Poll, pollDescription: TextView, useAbsoluteTime: Boolean): CharSequence { + val context = pollDescription.context + val votes = NumberFormat.getNumberInstance().format(poll.votesCount.toLong()) + val votesText = context.resources.getQuantityString(R.plurals.poll_info_votes, poll.votesCount, votes) + val pollDurationInfo: CharSequence + if (poll.expired) { + pollDurationInfo = context.getString(R.string.poll_info_closed) + } else { + if (useAbsoluteTime) { + pollDurationInfo = context.getString(R.string.poll_info_time_absolute, getAbsoluteTime(poll.expiresAt)) + } else { + val pollDuration = DateUtils.formatPollDuration(context, poll.expiresAt!!.time, timestamp) + pollDurationInfo = context.getString(R.string.poll_info_time_relative, pollDuration) + } + } + + return context.getString(R.string.poll_info_format, votesText, pollDurationInfo) + } + + + private fun setupPollResult(poll: Poll, emojis: List, pollResults: List) { + val options = poll.options + + for (i in 0 until Status.MAX_POLL_OPTIONS) { + if (i < options.size) { + val percent = options[i].getPercent(poll.votesCount) + + val pollOptionText = pollResults[i].context.getString(R.string.poll_option_format, percent, options[i].title) + pollResults[i].text = CustomEmojiHelper.emojifyText(HtmlUtils.fromHtml(pollOptionText), emojis, pollResults[i]) + pollResults[i].visibility = View.VISIBLE + + val level = percent * 100 + + pollResults[i].background.level = level + + } else { + pollResults[i].visibility = View.GONE + } + } + } + + fun getAbsoluteTime(time: Date?): String { + return if (time != null) { + if (android.text.format.DateUtils.isToday(time.time)) { + shortSdf.format(time) + } else { + longSdf.format(time) + } + } else { + "??:??:??" + } + } + + companion object { + val COLLAPSE_INPUT_FILTER = arrayOf(SmartLengthInputFilter.INSTANCE) + val NO_INPUT_FILTER = arrayOfNulls(0) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/view/NoSwipeViewPager.kt b/app/src/main/java/com/keylesspalace/tusky/view/NoSwipeViewPager.kt new file mode 100644 index 000000000..8e4709125 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/NoSwipeViewPager.kt @@ -0,0 +1,33 @@ +/* Copyright 2019 Joel Pyska + * + * 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.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import androidx.viewpager.widget.ViewPager + +class NoSwipeViewPager @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : ViewPager(context, attrs) { + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + return false + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + return false + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/report_success_background.xml b/app/src/main/res/drawable/report_success_background.xml new file mode 100644 index 000000000..147e04857 --- /dev/null +++ b/app/src/main/res/drawable/report_success_background.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-land/fragment_report_done.xml b/app/src/main/res/layout-land/fragment_report_done.xml new file mode 100644 index 000000000..d9900a2a8 --- /dev/null +++ b/app/src/main/res/layout-land/fragment_report_done.xml @@ -0,0 +1,109 @@ + + + + + + + + + +