diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b8e82ee..751349a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,6 +19,7 @@ android:name=".CardActivity" android:parentActivityName=".MainActivity" android:exported="false" + android:configChanges="orientation|screenSize" > + android:exported="true" + > diff --git a/app/src/main/java/org/mmu/tinkoffkinolab/CardActivity.java b/app/src/main/java/org/mmu/tinkoffkinolab/CardActivity.java index 1e0c53c..5a40a59 100644 --- a/app/src/main/java/org/mmu/tinkoffkinolab/CardActivity.java +++ b/app/src/main/java/org/mmu/tinkoffkinolab/CardActivity.java @@ -1,18 +1,31 @@ package org.mmu.tinkoffkinolab; +import static org.mmu.tinkoffkinolab.Constants.ADAPTER_FILM_ID; +import static org.mmu.tinkoffkinolab.Constants.ADAPTER_TITLE; +import static org.mmu.tinkoffkinolab.Constants.LOG_TAG; + +import android.content.res.Configuration; +import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; +import android.os.Debug; import android.util.Log; import android.view.MenuItem; import android.view.View; import android.widget.ImageView; +import android.widget.ScrollView; import android.widget.TextView; +import android.widget.Toast; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; import com.google.android.material.appbar.MaterialToolbar; import com.google.android.material.snackbar.Snackbar; +import com.squareup.picasso.Callback; import com.squareup.picasso.Picasso; import java.util.AbstractMap; @@ -28,110 +41,9 @@ public class CardActivity extends AppCompatActivity { - //region 'Поля и константы' - private static final Map _cardData = new HashMap<>(); - private ImageView imgPoster; private String filmId; - private WebDataDownloadTask downloadTask; - private TextView txtHeader; - private TextView txtContent; - private View androidContentView; - - //endregion 'Поля и константы' - - - - //region 'Типы' - - private class WebDataDownloadTask extends AsyncTask - { - private final FilmsApi filmsApi; - private Map.Entry error; - - public Map.Entry getError() - { - return error; - } - - public WebDataDownloadTask(FilmsApi engine) - { - filmsApi = engine; - } - - @Override - protected void onPreExecute() - { - super.onPreExecute(); - var progBar = findViewById(R.id.progress_bar); - progBar.setVisibility(View.VISIBLE); - Log.d(Constants.LOG_TAG, "Начало загрузки веб-ресурса..."); - } - - @Override - protected Void doInBackground(String... request) - { - try - { - final var response = filmsApi.apiV22FilmsIdGet(Integer.parseInt(filmId)); - _cardData.put(Constants.ADAPTER_TITLE, response.getNameRu()); - final var geners = "\n\n Жанры: " + response.getGenres().stream() - .map(g -> g.getGenre() + ", ") - .collect(Collectors.joining()) - .replaceFirst(",\\s*$", ""); - final var countries = "\n\n Страны: " + response.getCountries().stream() - .map(country -> country.getCountry() + ", ") - .collect(Collectors.joining()).replaceFirst(",\\s*$", ""); - final var res = response.getDescription() + geners + countries; - _cardData.put(Constants.ADAPTER_CONTENT, res); - _cardData.put(Constants.ADAPTER_POSTER_PREVIEW_URL, response.getPosterUrl()); - } - catch (RuntimeException ex) - { - var mes = Objects.requireNonNullElse(ex.getMessage(), ""); - error = new AbstractMap.SimpleEntry<>(ex, mes); - if (ex instanceof ApiException) - { - final var apiEx = (ApiException)ex; - final var headers = apiEx.getResponseHeaders(); - final var headersText = headers == null ? "" : headers.entrySet().stream() - .map(entry -> entry.getKey() + ": " + String.join(" \n", entry.getValue())) - .collect(Collectors.joining()); - mes += String.format(Locale.ROOT, " %s (ErrorCode: %d), ResponseHeaders: \n%s\n ResponseBody: \n%s\n", - Constants.KINO_API_ERROR_MES, apiEx.getCode(), headersText, apiEx.getResponseBody()); - } - Log.e(Constants.LOG_TAG, mes.isEmpty() ? Constants.UNKNOWN_WEB_ERROR_MES : mes, ex); - } - return null; - } - - /** - * @apiNote Этот метод выполняется в потоке интерфейса - * - * @param unused The result of the operation computed by {@link #doInBackground}. - */ - @Override - protected void onPostExecute(Void unused) - { - super.onPostExecute(unused); - var progBar = findViewById(R.id.progress_bar); - progBar.setVisibility(View.GONE); - if (downloadTask.getError() != null) - { - final var mes = downloadTask.getError().getValue(); - showSnackBar(Constants.UNKNOWN_WEB_ERROR_MES); - } - else - { - Log.d(Constants.LOG_TAG, "Загрузка Веб-ресурса завершена успешно"); - fillCardUI(); - } - } - } - - //endregion 'Типы' - - + //region 'Обработчики' @@ -148,13 +60,34 @@ protected void onCreate(Bundle savedInstanceState) Objects.requireNonNull(getSupportActionBar()).setDisplayHomeAsUpEnabled(true); getSupportActionBar().setDisplayShowHomeEnabled(true); - imgPoster = findViewById(R.id.poster_image_view); - txtHeader = findViewById(R.id.card_title); - txtContent = findViewById(R.id.card_content); - androidContentView = findViewById(android.R.id.content); - filmId = getIntent().getStringExtra(Constants.ADAPTER_FILM_ID); - getFilmDataAsync(); + + getSupportFragmentManager().registerFragmentLifecycleCallbacks(new FragmentManager.FragmentLifecycleCallbacks() + { + private CardFragment cardFragment; + @Override + public void onFragmentViewCreated(@NonNull FragmentManager fm, @NonNull Fragment f, + @NonNull View v, @Nullable Bundle savedInstanceState) + { + super.onFragmentViewCreated(fm, f, v, savedInstanceState); + cardFragment = ((CardFragment)f); + cardFragment.setDataLoadListener(() -> onDataLoaded_Handler()); + cardFragment.getFilmDataAsync(filmId, customToolBar.getTitle().toString()); + } + + @Override + public void onFragmentDestroyed(@NonNull FragmentManager fm, @NonNull Fragment f) + { + super.onFragmentDestroyed(fm, f); + cardFragment.removeDataLoadListener(); + } + }, false); + + } + + public void onDataLoaded_Handler() + { + Objects.requireNonNull(getSupportActionBar()).setDisplayShowTitleEnabled(false); } /** @@ -181,42 +114,6 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) - //region 'Методы' - - /** - * Метод заполнения UI карточки фильма из скачанных данных - */ - private void fillCardUI() - { - // параметры fit() и centerCrop() сильно замедляют загрузку. - Picasso.get().load(_cardData.get(Constants.ADAPTER_POSTER_PREVIEW_URL)).fit().centerCrop().into(imgPoster); - txtHeader.setText(_cardData.get(Constants.ADAPTER_TITLE)); - Objects.requireNonNull(getSupportActionBar()).setDisplayShowTitleEnabled(false); - txtContent.setText(_cardData.get(Constants.ADAPTER_CONTENT)); - } - - /** - * Метод отображения всплывющей подсказки - */ - private void showSnackBar(String message) - { - var popup = Snackbar.make(androidContentView, message, Snackbar.LENGTH_INDEFINITE); - popup.setAction(R.string.repeat_button_caption, view -> { - getFilmDataAsync(); - popup.dismiss(); - }); - popup.show(); - } - - private void getFilmDataAsync() - { - if (downloadTask != null && !downloadTask.isCancelled() && (downloadTask.getStatus() == AsyncTask.Status.RUNNING)) - { - downloadTask.cancel(true); - } - downloadTask = (WebDataDownloadTask)new WebDataDownloadTask(FilmsApiHelper.getFilmsApi()).execute(); - } - - //endregion 'Методы' + } \ No newline at end of file diff --git a/app/src/main/java/org/mmu/tinkoffkinolab/CardFragment.java b/app/src/main/java/org/mmu/tinkoffkinolab/CardFragment.java new file mode 100644 index 0000000..1f24741 --- /dev/null +++ b/app/src/main/java/org/mmu/tinkoffkinolab/CardFragment.java @@ -0,0 +1,289 @@ +package org.mmu.tinkoffkinolab; + +import static org.mmu.tinkoffkinolab.Constants.LOG_TAG; + +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.ScrollView; +import android.widget.TextView; + +import com.google.android.material.snackbar.Snackbar; +import com.squareup.picasso.Callback; +import com.squareup.picasso.Picasso; + +import java.util.AbstractMap; +import java.util.EventListener; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import io.swagger.client.ApiException; +import io.swagger.client.api.FilmsApi; + +/** + * A simple {@link Fragment} subclass. + * create an instance of this fragment. + */ +public class CardFragment extends Fragment +{ + + + //region 'Типы' + + private class WebDataDownloadTask extends AsyncTask + { + private final FilmsApi filmsApi; + private Map.Entry error; + + public Map.Entry getError() + { + return error; + } + + public WebDataDownloadTask(FilmsApi engine) + { + filmsApi = engine; + } + + @Override + protected void onPreExecute() + { + super.onPreExecute(); + progBar.setVisibility(View.VISIBLE); + Log.d(LOG_TAG, "Начало загрузки веб-ресурса..."); + } + + @Override + protected Void doInBackground(String... filmId) + { + try + { + final var response = filmsApi.apiV22FilmsIdGet(Integer.parseInt(filmId[0])); + final var engName = Objects.requireNonNullElse(response.getNameEn(), ""); + _cardData.put(Constants.ADAPTER_TITLE, response.getNameRu() + (!engName.isBlank() ? + System.lineSeparator() + "[" + engName + "]" : "")); + final var genres = "\n\n Жанры: " + response.getGenres().stream() + .map(g -> g.getGenre() + ", ") + .collect(Collectors.joining()) + .replaceFirst(",\\s*$", ""); + final var countries = "\n\n Страны: " + response.getCountries().stream() + .map(country -> country.getCountry() + ", ") + .collect(Collectors.joining()).replaceFirst(",\\s*$", ""); + final var res = Objects.requireNonNullElse(response.getDescription(), "") + + genres + countries; + _cardData.put(Constants.ADAPTER_CONTENT, res); + _cardData.put(Constants.ADAPTER_POSTER_PREVIEW_URL, response.getPosterUrl()); + } + catch (RuntimeException ex) + { + var mes = Objects.requireNonNullElse(ex.getMessage(), ""); + error = new AbstractMap.SimpleEntry<>(ex, mes); + if (ex instanceof ApiException) + { + final var apiEx = (ApiException) ex; + final var headers = apiEx.getResponseHeaders(); + final var headersText = headers == null ? "" : headers.entrySet().stream() + .map(entry -> entry.getKey() + ": " + String.join(" \n", entry.getValue())) + .collect(Collectors.joining()); + mes += String.format(Locale.ROOT, " %s (ErrorCode: %d), ResponseHeaders: \n%s\n ResponseBody: \n%s\n", + Constants.KINO_API_ERROR_MES, apiEx.getCode(), headersText, apiEx.getResponseBody()); + } + Log.e(LOG_TAG, mes.isEmpty() ? Constants.UNKNOWN_WEB_ERROR_MES : mes, ex); + } + return null; + } + + /** + * @param unused The result of the operation computed by {@link #doInBackground}. + * @apiNote Этот метод выполняется в потоке интерфейса + */ + @Override + protected void onPostExecute(Void unused) + { + super.onPostExecute(unused); + progBar.setVisibility(View.GONE); + if (downloadTask.getError() != null) + { + final var mes = downloadTask.getError().getValue(); + showSnackBar(Constants.UNKNOWN_WEB_ERROR_MES); + } + else + { + Log.d(LOG_TAG, "Загрузка Веб-ресурса завершена успешно"); + fillCardUI(); + } + } + } + + //endregion 'Типы' + + + //region 'Поля и константы' + + private static final Map _cardData = new HashMap<>(); + private ImageView imgPoster; + + private WebDataDownloadTask downloadTask; + private TextView txtHeader; + private TextView txtContent; + private View progBar; + + private String filmId_param; + private String filmTitle_param; + + //endregion 'Поля и константы' + + + //region 'События' + + interface DataLoadSuccessListener extends EventListener + { + void onFilmDataLoaded(); + } + + public DataLoadSuccessListener getDataLoadListener() + { + return dataLoadListener; + } + + public void setDataLoadListener(DataLoadSuccessListener dataLoadListener) + { + this.dataLoadListener = dataLoadListener; + } + + public void removeDataLoadListener() + { + this.dataLoadListener = null; + } + + private DataLoadSuccessListener dataLoadListener; + + //endregion 'События' + + + // Required empty public constructor + public CardFragment() + { + } + + + + //region 'Обработчики' + + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) + { + return inflater.inflate(R.layout.fragment_card, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) + { + super.onViewCreated(view, savedInstanceState); + imgPoster = view.findViewById(R.id.poster_image_view); + txtHeader = view.findViewById(R.id.card_title); + txtContent = view.findViewById(R.id.card_content); + progBar = view.findViewById(R.id.progress_bar); + } + + //endregion 'Обработчики' + + + //region 'Методы' + + /** + * Метод заполнения UI карточки фильма из скачанных данных + */ + private void fillCardUI() + { + progBar.setVisibility(View.VISIBLE); + // N.B.: параметры fit() и centerCrop() могут сильно замедлять загрузку! + final var posterUrl = _cardData.get(Constants.ADAPTER_POSTER_PREVIEW_URL); + Picasso.get().load(posterUrl).into(imgPoster, new Callback() + { + @Override + public void onSuccess() + { + progBar.setVisibility(View.GONE); + if (dataLoadListener != null) + { + dataLoadListener.onFilmDataLoaded(); + } + txtHeader.setText(Objects.requireNonNullElse(_cardData.get(Constants.ADAPTER_TITLE), + filmTitle_param)); + final var textContent = _cardData.get(Constants.ADAPTER_CONTENT); + txtContent.setText(Objects.requireNonNullElse(textContent, "")); + imgPoster.setOnClickListener(v1 -> + Utils.showFullScreenPhoto(Uri.parse(posterUrl), v1.getContext()) + ); + final ScrollView sv = requireActivity().findViewById(R.id.fragment_scroll); + sv.postDelayed(() -> { + if (!Utils.isViewOnScreen(txtContent)) + { + sv.smoothScrollTo(0, imgPoster.getHeight() / 2); + } + }, 500); + } + + @Override + public void onError(Exception e) + { + progBar.setVisibility(View.GONE); + Log.e(LOG_TAG, "Ошибка загрузки большого постера", e); + } + }); + } + + /** + * Метод отображения всплывющей подсказки + */ + private void showSnackBar(String message) + { + var popup = Snackbar.make(this.imgPoster, message, Snackbar.LENGTH_INDEFINITE); + popup.setAction(R.string.repeat_button_caption, view -> { + getFilmDataAsync(); + popup.dismiss(); + }); + popup.show(); + } + + private void getFilmDataAsync() + { + getFilmDataAsync(filmId_param, filmTitle_param); + } + + public void getFilmDataAsync(String id, String title) + { + clearView(); + filmId_param = id; + filmTitle_param = title; + if (downloadTask != null && !downloadTask.isCancelled() && (downloadTask.getStatus() == AsyncTask.Status.RUNNING)) + { + downloadTask.cancel(true); + } + downloadTask = (WebDataDownloadTask)new WebDataDownloadTask(FilmsApiHelper.getFilmsApi()).execute(id); + } + + private void clearView() + { + imgPoster.setImageDrawable(null); + txtContent.setText(""); + txtHeader.setText(""); + } + + //endregion 'Методы' +} \ No newline at end of file diff --git a/app/src/main/java/org/mmu/tinkoffkinolab/Constants.java b/app/src/main/java/org/mmu/tinkoffkinolab/Constants.java index 18695c2..8b2ee7c 100644 --- a/app/src/main/java/org/mmu/tinkoffkinolab/Constants.java +++ b/app/src/main/java/org/mmu/tinkoffkinolab/Constants.java @@ -1,6 +1,8 @@ package org.mmu.tinkoffkinolab; +import jp.wasabeef.picasso.transformations.RoundedCornersTransformation; + public class Constants { public static final String ADAPTER_FILM_ID = "ID"; @@ -13,6 +15,7 @@ public class Constants public static final String KEY_VALUE_SEPARATOR = "="; public static final String FILM_START_TOKEN = "{"; public static final String FILM_END_TOKEN = "}"; + public static final RoundedCornersTransformation ROUNDED_CORNERS_TRANSFORMATION = new RoundedCornersTransformation(30, 10); /** * Демо-ключ неофициального API Кинопоиска * @@ -24,10 +27,11 @@ public class Constants * @implSpec В качестве альтернативы вы можете зарегистрироваться самостоятельно и получить * собственный ключ, но тогда будет действовать ограничение в 500 запросов в день. */ - static final String KINO_DEMO_API_KEY = "e30ffed0-76ab-4dd6-b41f-4c9da2b2735b"; - static final String ADAPTER_POSTER_PREVIEW_URL = "ImageUrl"; - static final String UNKNOWN_WEB_ERROR_MES = "Ошибка загрузки данных по сети:"; - static final String KINO_API_ERROR_MES = "Ошибка API KinoPoisk"; + public static final String KINO_DEMO_API_KEY = "e30ffed0-76ab-4dd6-b41f-4c9da2b2735b"; + public static final String ADAPTER_POSTER_PREVIEW_URL = "ImageUrl"; + public static final String UNKNOWN_WEB_ERROR_MES = "Ошибка загрузки данных по сети:"; + public static final String KINO_API_ERROR_MES = "Ошибка API KinoPoisk"; + public static final String SCROLL_POS = "scroll_pos"; enum TopFilmsType { diff --git a/app/src/main/java/org/mmu/tinkoffkinolab/FilmsApiHelper.java b/app/src/main/java/org/mmu/tinkoffkinolab/FilmsApiHelper.java index 116f566..9a0d5b9 100644 --- a/app/src/main/java/org/mmu/tinkoffkinolab/FilmsApiHelper.java +++ b/app/src/main/java/org/mmu/tinkoffkinolab/FilmsApiHelper.java @@ -11,7 +11,11 @@ public class FilmsApiHelper public static FilmsApi getFilmsApi() { - return Objects.requireNonNullElse(filmsApi, createFilmsApi()); + if (filmsApi == null) + { + filmsApi = createFilmsApi(); + } + return filmsApi; } private static FilmsApi createFilmsApi() diff --git a/app/src/main/java/org/mmu/tinkoffkinolab/MainActivity.java b/app/src/main/java/org/mmu/tinkoffkinolab/MainActivity.java index 340ac80..5000ff1 100644 --- a/app/src/main/java/org/mmu/tinkoffkinolab/MainActivity.java +++ b/app/src/main/java/org/mmu/tinkoffkinolab/MainActivity.java @@ -6,9 +6,12 @@ import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.cardview.widget.CardView; +import androidx.fragment.app.FragmentContainerView; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; +import android.annotation.SuppressLint; import android.content.Intent; +import android.content.res.Configuration; import android.graphics.Bitmap; import android.graphics.drawable.Drawable; import android.net.Uri; @@ -32,32 +35,20 @@ import com.google.android.material.appbar.MaterialToolbar; import com.google.android.material.snackbar.Snackbar; import com.google.android.material.textfield.TextInputEditText; +import com.squareup.picasso.Callback; import com.squareup.picasso.Picasso; -import java.io.BufferedReader; -import java.io.BufferedWriter; -import java.io.File; -import java.io.FileOutputStream; -import java.io.FileReader; -import java.io.FileWriter; -import java.io.IOException; -import java.util.AbstractMap; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; +import java.io.*; +import java.util.*; import java.util.stream.Collectors; +import java.util.stream.Stream; import io.swagger.client.ApiException; import io.swagger.client.api.FilmsApi; -import jp.wasabeef.picasso.transformations.RoundedCornersTransformation; public class MainActivity extends AppCompatActivity { + //region 'Типы' @@ -71,11 +62,13 @@ private enum ViewMode private class WebDataDownloadTask extends AsyncTask { - private static final int FILMS_COUNT_PER_PAGE = 20; - private final FilmsApi filmsApi; - private Map.Entry error; private static final String UNKNOWN_WEB_ERROR_MES = "Ошибка загрузки данных по сети:"; private static final String KINO_API_ERROR_MES = "Ошибка API KinoPoisk"; + public static final int FILMS_COUNT_PER_PAGE = 20; + private final FilmsApi filmsApi; + private final ProgressBar refreshProgBar = findViewById(R.id.progress_bar_bottom); + private Map.Entry error; + public WebDataDownloadTask(FilmsApi engine) @@ -87,30 +80,82 @@ public WebDataDownloadTask(FilmsApi engine) protected void onPreExecute() { super.onPreExecute(); - progBar.setVisibility(View.VISIBLE); - progBar.bringToFront(); + if (_nextPageNumber == 1) + { + progBar.setVisibility(View.VISIBLE); + progBar.bringToFront(); + } + else + { + refreshProgBar.setVisibility(View.VISIBLE); + } Log.d(LOG_TAG, "Начало загрузки веб-ресурса..."); } @Override protected Integer doInBackground(Integer... pageNumber) + { + return downloadPopularFilmList(pageNumber[0]); + } + + @Override + protected void onPostExecute(Integer pagesCount) + { + super.onPostExecute(pagesCount); + progBar.setVisibility(View.GONE); + refreshProgBar.setVisibility(View.GONE); + swipeRefreshContainer.setRefreshing(false); + if (error != null) + { + lastSnackBar = showErrorSnackBar(UNKNOWN_WEB_ERROR_MES); + } + else + { + Log.d(LOG_TAG, "Загрузка Веб-ресурса завершена успешно"); + fillCardListUIFrom((_nextPageNumber - 1) * FILMS_COUNT_PER_PAGE, _cardList); + _topFilmsPagesCount = pagesCount; + // наращивать номер страницы можно ТОЛЬКО после успешной загрузки, иначе можно накрутить номер + if (_nextPageNumber < _topFilmsPagesCount) + { + _nextPageNumber++; + } + if (lastSnackBar != null && lastSnackBar.isShown()) + { + lastSnackBar.dismiss(); + } + } + } + + /** + * Метод загрузки списка популярных Фильмов + * + * @param pageNumber Номер страницы, с которой надо начинать загрузку + * + * @return Общее Кол-во страниц на сервере или 0 (если операция была прервана пользователем) + * + * @apiNote Не выбрасывает исключений + */ + private int downloadPopularFilmList(int pageNumber) { int res = 0; try { - final var response = filmsApi.apiV22FilmsTopGet(Constants.TopFilmsType.TOP_100_POPULAR_FILMS.name(), pageNumber[0]); + final var response = filmsApi.apiV22FilmsTopGet( + TopFilmsType.TOP_100_POPULAR_FILMS.name(), + pageNumber); res = response.getPagesCount(); for (var filmData : response.getFilms()) { if (isCancelled()) { - return null; + return 0; } final var id = String.valueOf(filmData.getFilmId()); Optional name = Optional.ofNullable(isRus ? filmData.getNameRu() : filmData.getNameEn()); _cardList.add(Map.of(Constants.ADAPTER_FILM_ID, id, Constants.ADAPTER_TITLE, name.orElse(id), - Constants.ADAPTER_CONTENT, filmData.getGenres().get(0).getGenre() + " (" + filmData.getYear() + ")", + Constants.ADAPTER_CONTENT, filmData.getGenres().get(0).getGenre() + + " (" + filmData.getYear() + ")", Constants.ADAPTER_POSTER_PREVIEW_URL, filmData.getPosterUrlPreview()) ); } @@ -133,88 +178,62 @@ protected Integer doInBackground(Integer... pageNumber) } return res; } - - @Override - protected void onPostExecute(Integer pagesCount) - { - super.onPostExecute(pagesCount); - progBar.setVisibility(View.GONE); - swipeRefreshContainer.setRefreshing(false); - if (error != null) - { - _lastSnackBar = showErrorSnackBar(UNKNOWN_WEB_ERROR_MES); - } - else - { - Log.d(LOG_TAG, "Загрузка Веб-ресурса завершена успешно"); - fillCardListUIFrom((_currentPageNumber - 1) * FILMS_COUNT_PER_PAGE, _cardList); - _topFilmsPagesCount = pagesCount; - if (_currentPageNumber < _topFilmsPagesCount) - { - _currentPageNumber++; - } - if (_lastSnackBar != null && _lastSnackBar.isShown()) - { - _lastSnackBar.dismiss(); - } - } - } + + } //endregion 'Типы' - //region 'Поля и константы' private static final List> _cardList = new ArrayList<>(); - public TextWatcher _textWatcher; - public File _favouritesListFilePath; - private File _imageCacheDirPath; - - private boolean isRus; - private MaterialToolbar customToolBar; - private Snackbar _lastSnackBar; - private ProgressBar progBar; - private CardView inputPanel; - private SwipeRefreshLayout swipeRefreshContainer; - private Integer _topFilmsPagesCount = 1; + private static ViewMode _currentViewMode; /** * Содержит номер страницы, которая будет запрошена * * @implSpec НЕ изменять - управляется классом {@link WebDataDownloadTask} */ - private int _currentPageNumber = 1; + private static int _nextPageNumber = 1; + private static int _topFilmsPagesCount = 1; + public File favouritesListFilePath; + private File imageCacheDirPath; + private boolean isRus; + private MaterialToolbar customToolBar; + private Snackbar lastSnackBar; + private ProgressBar progBar; + private CardView inputPanel; + private SwipeRefreshLayout swipeRefreshContainer; /** - * Обязательно пересоздавать задачу перед каждым вызовом! + * @apiNote Обязательно пересоздавать задачу перед каждым вызовом! */ private AsyncTask downloadTask; private TextInputEditText txtQuery; - private View androidContentView; + private View coordinatorView; private LinearLayout cardsContainer; - private ViewMode _currentViewMode; - private LayoutInflater _layoutInflater; - private boolean _isFiltered; - private int _lastListViewPos; - private int _lastListViewPos2; - + private LayoutInflater layoutInflater; + private boolean isFiltered; + private int lastListViewPos; + private int lastListViewPos2; private View scroller; + private boolean isLandscape; + private CardFragment detailsFragment; + private LinearLayout horizontalContainer; //endregion 'Поля и константы' - //region 'Свойства' - private Map> favouritesMap; + private static Map> favouritesMap; /** * Возвращает список избранных фильмов * * @implSpec ВНИМАНИЕ: ИД фильма должен идти первой строчкой, иначе метод загрузки из файла не будет - * работать корректно ({@link #loadFavouritesList()}) + * работать корректно ({@link #loadFavouritesList()}) */ public Map> getFavouritesMap() { @@ -235,16 +254,56 @@ public AlertDialog getConfirmClearDialog() .setIcon(R.drawable.round_warning_amber_24) .setTitle(R.string.confirm_dialog_title).setMessage(R.string.confirm_clear_dialog_text) .setNegativeButton(android.R.string.no, null) - .setPositiveButton(android.R.string.yes, (dialog, which) -> { - clearList(false); - }) + .setPositiveButton(android.R.string.yes, (dialog, which) -> + clearList(false) + ) .create(); } return confirmClearDialog; } - //endregion 'Свойства' + private TextWatcher searchTextChangeWatcher; + + /** + * Обработчик изменения текста в виджете Поиска + */ + @NonNull + private TextWatcher getSearchTextChangeWatcher() + { + if (this.searchTextChangeWatcher == null) + { + this.searchTextChangeWatcher = new TextWatcher() + { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) + { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) + { + } + + @Override + public void afterTextChanged(Editable s) + { + final var query = s.toString(); + if (query.isBlank()) + { + showFilmCardsUI(); + } + else + { + filterCardListUI(s.toString(), getCurrentFilmListStream()); + } + } + }; + } + return searchTextChangeWatcher; + } + + //endregion 'Свойства' @@ -253,6 +312,8 @@ public AlertDialog getConfirmClearDialog() /** * При создании Экрана навешиваем обработчик обновления списка свайпом * + * @apiNote Вызывается при запуске программы, а также, если процесс был вытеснен из памяти + * * @param savedInstanceState If the activity is being re-initialized after * previously being shut down then this Bundle contains the data it most * recently supplied in {@link #onSaveInstanceState}. Note: Otherwise it is null. @@ -261,38 +322,146 @@ public AlertDialog getConfirmClearDialog() protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - Log.w(LOG_TAG, "---------------- Start of onCreate() method"); + Log.d(LOG_TAG, "---------------- In the onCreate() method"); setContentView(R.layout.activity_main); customToolBar = findViewById(R.id.top_toolbar); this.setSupportActionBar(customToolBar); - _favouritesListFilePath = new File(this.getFilesDir(), FAVOURITES_LIST_FILE_NAME); - if (_favouritesListFilePath.exists()) + // восстанавливаем список избранного из файла + this.favouritesListFilePath = new File(this.getFilesDir(), FAVOURITES_LIST_FILE_NAME); + if (getFavouritesMap().isEmpty() && this.favouritesListFilePath.exists()) { loadFavouritesList(); + Log.d(LOG_TAG, "Список избранного загружен из файла:\n " + this.favouritesListFilePath); } + // закруглённые углы для верхней панели + Utils.setRoundedBottomToolbarStyle(customToolBar, 25); if (Debug.isDebuggerConnected()) { Picasso.get().setIndicatorsEnabled(true); - Picasso.get().setLoggingEnabled(true); + //Picasso.get().setLoggingEnabled(true); } - isRus = Locale.getDefault().getLanguage().equalsIgnoreCase("ru"); - - _layoutInflater = getLayoutInflater(); - androidContentView = findViewById(android.R.id.content); - progBar = findViewById(R.id.progress_bar); - progBar.setVisibility(View.GONE); - txtQuery = findViewById(R.id.txt_input); - cardsContainer = findViewById(R.id.card_linear_lyaout); - inputPanel = findViewById(R.id.input_panel); - swipeRefreshContainer = findViewById(R.id.film_list_swipe_refresh_container); - swipeRefreshContainer.setColorSchemeResources(R.color.biz, R.color.neo, R.color.neo_dark, R.color.purple_light); - _imageCacheDirPath = new File(this.getCacheDir(), Constants.FAVOURITES_CASH_DIR_NAME); - scroller = findViewById(R.id.card_scroller); - - this._initEventHandlers(); + this.isRus = Locale.getDefault().getLanguage().equalsIgnoreCase("ru"); + + this.layoutInflater = getLayoutInflater(); + this.coordinatorView = findViewById(R.id.root_container); + this.progBar = findViewById(R.id.progress_bar); + this.progBar.setVisibility(View.GONE); + this.txtQuery = findViewById(R.id.txt_input); + this.cardsContainer = findViewById(R.id.card_linear_lyaout); + this.inputPanel = findViewById(R.id.input_panel); + //inputPanel.setBackgroundResource(R.drawable.rounded_bottom_shape); + this.swipeRefreshContainer = findViewById(R.id.film_list_swipe_refresh_container); + this.swipeRefreshContainer.setColorSchemeResources(R.color.biz, R.color.neo, R.color.neo_dark, R.color.purple_light); + this.imageCacheDirPath = new File(this.getCacheDir(), Constants.FAVOURITES_CASH_DIR_NAME); + this.scroller = findViewById(R.id.card_scroller); + this.isLandscape = this.getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; + this.horizontalContainer = findViewById(R.id.horizontal_view); + + this.setEventHandlers(); - switchUIToPopularFilmsAsync(true, true); - Log.w(LOG_TAG, "End of onCreate() method ---------------------"); + if (_cardList.isEmpty()) + { + switchUIToPopularFilmsAsync(true, true); + } + Log.d(LOG_TAG, "Exit of onCreate() method ---------------------"); + } + + /** + * @apiNote Этот метод всегда вызывается после {@link #onCreate(Bundle)}, а также после + * {@link #onRestart()}, например после нажатия кнопки назад на Activity вызванной из этой. + */ + @Override + protected void onStart() + { + super.onStart(); + Log.d(LOG_TAG, "---------------- In the onStart() method"); + this.horizontalContainer.setWeightSum(1); + Log.w(LOG_TAG, "Exit of onStart() method ---------------------"); + } + + /** + * Метод восстановления состояния Активити - Вызывается после {@link #onStart()} + * + * @param savedInstanceState the data most recently supplied in {@link #onSaveInstanceState}. + * + */ + @Override + protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) + { + super.onRestoreInstanceState(savedInstanceState); + // обновляем заголовок в Тулбаре + List> sourceList = null; + if (_currentViewMode == ViewMode.FAVOURITES) + { + switchUIToFavouriteFilms(false); + sourceList = new ArrayList<>(getFavouritesMap().values()); + } + else if (_currentViewMode == ViewMode.POPULAR) + { + switchUIToPopularFilmsAsync(false, false); + sourceList = _cardList; + } + fillCardListUIFrom((_nextPageNumber - 2) * WebDataDownloadTask.FILMS_COUNT_PER_PAGE, + Objects.requireNonNull(sourceList)); + final String query = Objects.requireNonNull(txtQuery.getText()).toString(); + if (!query.isBlank()) + { + filterCardListUI(query, this.getCurrentFilmListStream()); + } + Log.d(LOG_TAG, "---------------------- состояние восстановлено! ----------------"); + } + + /** + * Обработчик поворота экрана + * + * @param newConfig The new device configuration. + */ + @Override + public void onConfigurationChanged(@NonNull Configuration newConfig) + { + super.onConfigurationChanged(newConfig); + if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) + { + isLandscape = true; + if (Debug.isDebuggerConnected()) + { + Toast.makeText(this, "landscape", Toast.LENGTH_SHORT).show(); + } + } + else if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) + { + isLandscape = false; + if (Debug.isDebuggerConnected()) + { + Toast.makeText(this, "portrait", Toast.LENGTH_SHORT).show(); + } + } + onScreenRotate(); + } + + @Override + protected void onResume() + { + super.onResume(); + { + onScreenRotate(); + } + } + + private void onScreenRotate() + { + final var isBlank = Objects.requireNonNull(txtQuery.getText()).toString().isBlank(); + this.inputPanel.setVisibility(this.isLandscape && isBlank ? View.GONE : View.VISIBLE); + FragmentContainerView fw = findViewById(R.id.details_view); + if (!this.isLandscape) + { + for (var fragment : getSupportFragmentManager().getFragments()) { + if (fragment instanceof CardFragment) { + getSupportFragmentManager().beginTransaction().remove(fragment).commit(); + } + } + this.horizontalContainer.setWeightSum(1); + } } /** @@ -304,13 +473,17 @@ protected void onCreate(Bundle savedInstanceState) protected void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); + // TODO: Нужно как-то определять, когда происходит выход из программы. а не поворот, чтобы + // не перезаписывать файл при каждом повороте - если же это невозможно, то лучше наверно будет + // обновлять файл избранного не при переключении Экранов, а при изменении списка Избранного, + // т.к. по идее это должно происходить реже. if (favouritesMap == null || favouritesMap.isEmpty()) { this.deleteFile(Constants.FAVOURITES_LIST_FILE_NAME); - if (_imageCacheDirPath.exists()) + if (this.imageCacheDirPath.exists()) { //noinspection ResultOfMethodCallIgnored - Arrays.stream(Objects.requireNonNull(_imageCacheDirPath.listFiles())).parallel() + Arrays.stream(Objects.requireNonNull(this.imageCacheDirPath.listFiles())).parallel() .forEach(File::delete); } } @@ -321,6 +494,7 @@ protected void onSaveInstanceState(@NonNull Bundle outState) Log.d(LOG_TAG, "-------------------------- состояние сохранено! ----------------"); } + @Override public boolean onCreateOptionsMenu(Menu menu) { @@ -351,6 +525,8 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) } case R.id.action_go_to_top: { + // N.B. этот код достаточно надёжен, т.к. внутри SwipeRefreshContainer может быть только + // что-то типа ScrollView swipeRefreshContainer.getChildAt(0).scrollTo(0, 0); break; } @@ -366,9 +542,15 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) } break; } + case R.id.action_show_search_bar: + { + this.inputPanel.setVisibility(this.inputPanel.getVisibility() == View.VISIBLE ? + View.GONE : View.VISIBLE); + break; + } default: { - Log.w(LOG_TAG, "Неизвестная команда меню!"); + Log.w(LOG_TAG, "Неизвестная команда меню - действие не назначено!"); break; } } @@ -403,109 +585,132 @@ private View.OnClickListener getOnLikeButtonClickListener(Map ca /** * Обработчик прокрутки списка фильмов до конца - подгружает новую "страницу" в конец списка топов */ - private void onScrollChange(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) + private void onScrollChange(@NonNull View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) { - boolean isBottomReached = cardsContainer.getBottom() - v.getBottom() - scrollY == 0; - if (isBottomReached) + final boolean isBottomReached = cardsContainer.getBottom() - v.getBottom() - scrollY == 0; + if (isBottomReached && !isFiltered) { - if (_currentViewMode == ViewMode.POPULAR && _currentPageNumber < _topFilmsPagesCount) + if (_currentViewMode == ViewMode.POPULAR && _nextPageNumber < _topFilmsPagesCount) { switchUIToPopularFilmsAsync(false, true); } } else if (scrollY > 20) { - inputPanel.setCardElevation(10 * getResources().getDisplayMetrics().density); + this.inputPanel.setCardElevation(10 * getResources().getDisplayMetrics().density); } else if (scrollY == 0) { - inputPanel.setCardElevation(0); + this.inputPanel.setCardElevation(0); } } + + /** - * Обработчик изменения текста в виджете Поиска + * @return Возвращает нужный поток Фильмов в зависимости от выбранной вкладки UI + */ + private Stream> getCurrentFilmListStream() + { + return _currentViewMode == ViewMode.FAVOURITES ? + getFavouritesMap().values().stream() : _currentViewMode == ViewMode.POPULAR ? + _cardList.stream() : Stream.empty(); + } + + /** + * Обработчик клика на элемент списка (карточку Фильма) - открывает окно с подробным описанием Фильма. + * + * @param id ИД Фильма в API Kinopoisk + * @param title Название (будет отображаться в заголовке карточки до тех пор, пока не будет загружено подробное описание) */ @NonNull - private TextWatcher getSearchTextChangeWatcher() + private View.OnClickListener getOnListItemClickListener(String id, String title) { - if (_textWatcher == null) - { - _textWatcher = new TextWatcher() + return (View v) -> { + if (this.isLandscape) { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) + if (this.detailsFragment == null) { + this.detailsFragment = new CardFragment(); + getSupportFragmentManager().beginTransaction().add(R.id.details_view, this.detailsFragment) + .setCustomAnimations(android.R.animator.fade_in, android.R.animator.fade_out) + .runOnCommit(() -> + detailsFragment.getFilmDataAsync(id, title)).commit(); } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) + else { + detailsFragment.getFilmDataAsync(id, title); } + horizontalContainer.setWeightSum(2); + } + else + { + showFilmCardActivity(id, title); + } + }; + } - @Override - public void afterTextChanged(Editable s) - { - final var query = s.toString(); - if (query.isBlank()) - { - showFilmCardsUI(); - } - else - { - filterFilmCardsUI(s.toString()); - } - } - }; + /** + * Обработчик скрытия панели Поиска - очищает поле ввода поискового запроса + */ + private void onSearchBarVisibleChanged() + { + if (this.inputPanel.getVisibility() == View.GONE) + { + Log.d(LOG_TAG, "Поле поиска скрыто - очищаю ввод!"); + Objects.requireNonNull(txtQuery.getText()).clear(); + this.customToolBar.setElevation(10 * getResources().getDisplayMetrics().density); + } + else + { + this.customToolBar.setElevation(0); } - return _textWatcher; } //endregion 'Обработчики' - //region 'Методы' /** * Метод настройки событий виджетов */ - private void _initEventHandlers() + private void setEventHandlers() { // обновляем страницу свайпом сверху - swipeRefreshContainer.setOnRefreshListener(this::refreshUIContent); + this.swipeRefreshContainer.setOnRefreshListener(this::refreshUIContent); // ищем текст по кнопке ВВОД на клавиатуре - txtQuery.setOnEditorActionListener((v, actionId, event) -> { - // N.B. Похоже, только для действия Done можно реализовать автоскрытие клавиатуры - при остальных клава остаётся на экране после клика - if (actionId == EditorInfo.IME_ACTION_DONE || actionId == EditorInfo.IME_ACTION_GO || actionId == EditorInfo.IME_ACTION_SEARCH) - { - return false; - } - return true; + this.txtQuery.setOnEditorActionListener((v, actionId, event) -> { + // N.B. Похоже, только для действия Done можно реализовать авто скрытие клавиатуры - при + // остальных клава остаётся на экране после клика + return actionId != EditorInfo.IME_ACTION_DONE && actionId != EditorInfo.IME_ACTION_GO && + actionId != EditorInfo.IME_ACTION_SEARCH; }); // Обработчик изменения текста в поисковом контроле - txtQuery.addTextChangedListener(getSearchTextChangeWatcher()); + this.txtQuery.addTextChangedListener(getSearchTextChangeWatcher()); // при прокрутке списка фильмов до конца подгружаем следующую страницу результатов (если есть) - scroller.setOnScrollChangeListener(this::onScrollChange); + this.scroller.setOnScrollChangeListener(this::onScrollChange); + // при скрытии панели Поиска очищаем текст фильтра + this.inputPanel.getViewTreeObserver().addOnGlobalLayoutListener(this::onSearchBarVisibleChanged); } /** * Метод показа (вывода из невидимости) всех карточек фильмов * - * @apiNote Вызов имеет смысл, только если перед этим был вызов {@link #filterFilmCardsUI(String)} - * @implNote Сбрасывает признак фильтрации списка {@link #_isFiltered} + * @apiNote Вызов имеет смысл, только если перед этим был вызов {@link #filterCardListUI(String, Stream)} )} + * @implNote Сбрасывает признак фильтрации списка {@link #isFiltered} */ private void showFilmCardsUI() { - if (_isFiltered) + if (isFiltered) { for (int i = 0; i < cardsContainer.getChildCount(); i++) { cardsContainer.getChildAt(i).setVisibility(View.VISIBLE); } } - _isFiltered = false; + isFiltered = false; } /** @@ -515,13 +720,13 @@ private void showFilmCardsUI() * @apiNote Метод не выдаёт совпадения для слов короче 3-х символов (для исключения предлогов) * @implSpec При пустом query не делает НИЧЕГО - для отмены фильтра используйте {@link #showFilmCardsUI()} */ - private void filterFilmCardsUI(String query) + private void filterCardListUI(String query, Stream> cardStream) { if (query.isBlank()) { return; } - final var foundFilms = _cardList.stream().filter(x -> { + final var foundFilms = cardStream.filter(x -> { final var words = Arrays.stream(Objects.requireNonNull(x.get(Constants.ADAPTER_TITLE)).split(" ")); return words.anyMatch(t -> t.length() > 2 && t.toLowerCase().startsWith(query.strip().toLowerCase())); }) @@ -548,7 +753,7 @@ private void filterFilmCardsUI(String query) } } } - _isFiltered = true; + isFiltered = true; } /** @@ -556,14 +761,16 @@ private void filterFilmCardsUI(String query) */ private void refreshUIContent() { - if (this._currentViewMode == ViewMode.POPULAR) + Objects.requireNonNull(txtQuery.getText()).clear(); + if (_currentViewMode == ViewMode.POPULAR) { switchUIToPopularFilmsAsync(true, true); } - else if (this._currentViewMode == ViewMode.FAVOURITES) + else if (_currentViewMode == ViewMode.FAVOURITES) { cardsContainer.setVisibility(View.INVISIBLE); switchUIToFavouriteFilms(true); + // добавляем задержку, чтобы юзер мог четко определить замену списка на новый cardsContainer.postDelayed(() -> { swipeRefreshContainer.setRefreshing(false); cardsContainer.setVisibility(View.VISIBLE); @@ -580,7 +787,8 @@ private void fillCardListUIFrom(int startItemIndex, List> ca { for (int i = startItemIndex; i < cardList.size(); i++) { - final var listItem = _layoutInflater.inflate(R.layout.list_item, null); + @SuppressLint("InflateParams") + final var listItem = layoutInflater.inflate(R.layout.list_item, null); final var cardData = cardList.get(i); final var id = cardData.get(Constants.ADAPTER_FILM_ID); ((TextView) listItem.findViewById(R.id.film_id_holder)).setText(id); @@ -589,31 +797,39 @@ private void fillCardListUIFrom(int startItemIndex, List> ca ((TextView) listItem.findViewById(R.id.card_content)).setText(cardData.get(ADAPTER_CONTENT)); final var imgView = ((ImageView) listItem.findViewById(R.id.poster_preview)); final var imageUrl = cardData.get(Constants.ADAPTER_POSTER_PREVIEW_URL); - final var cachedImageFilePath = cardData.getOrDefault(ADAPTER_IMAGE_PREVIEW_FILE_PATH, ""); - //noinspection ConstantConditions - if (cachedImageFilePath.isEmpty()) + final var cachedImageFilePath = cardData.get(ADAPTER_IMAGE_PREVIEW_FILE_PATH); + File previewImageFile = null; + if (cachedImageFilePath != null && !cachedImageFilePath.isBlank()) + { + previewImageFile = new File(cachedImageFilePath); + } + if (previewImageFile == null) { - Picasso.get().load(imageUrl).transform(new RoundedCornersTransformation(30, 10)).into(imgView); + Picasso.get().load(imageUrl).transform(ROUNDED_CORNERS_TRANSFORMATION).into(imgView); } else { - final var previewImageFile = new File(cachedImageFilePath); if (previewImageFile.exists()) { imgView.setImageURI(Uri.fromFile(previewImageFile)); - //imgView.setImageDrawable(RoundedBitmapDrawable.createFromPath(cachedImageFilePath)); Log.d(LOG_TAG, "Картинка загружена из файла: " + cachedImageFilePath); } + else + { + restoreImageCacheFromURL(imgView, imageUrl, cachedImageFilePath); + Log.d(LOG_TAG, "Промах кэша - видимо файл был удалён - картинка будет загружена из Сети:\n " + + cachedImageFilePath); + } } cardsContainer.addView(listItem); - listItem.setOnClickListener(v -> showFilmCardActivity(id, title)); + listItem.setOnClickListener(this.getOnListItemClickListener(id, title)); final ImageView imgViewLike = listItem.findViewById(R.id.like_image_view); // TODO: возможно фильм нужно искать по названию, а не по ИД, на случай, если ИД поменяется? if (getFavouritesMap().containsKey(id)) { imgViewLike.setImageResource(R.drawable.baseline_favorite_24); } - final var likeButtonClickHandler = getOnLikeButtonClickListener( + final var likeButtonClickHandler = this.getOnLikeButtonClickListener( cardData, id, imgView, imgViewLike); imgViewLike.setOnClickListener(likeButtonClickHandler); // Требование ТЗ: "При длительном клике на карточку, фильм помещается в избранное" @@ -624,6 +840,27 @@ private void fillCardListUIFrom(int startItemIndex, List> ca } } + private void restoreImageCacheFromURL(ImageView imgView, String imageUrl, String cachedImageFilePath) + { + Picasso.get().load(imageUrl).transform(ROUNDED_CORNERS_TRANSFORMATION) + .into(imgView, new Callback() + { + @Override + public void onSuccess() + { + //TODO: без задержки, картинки не успевают прорисоваться, но слишком большая задержка, + // тоже опасна - юзер уже мог переключить представление (хотя объект не должен уничтожаться). + imgView.postDelayed(() -> Utils.extractImageToDiskCache(imgView, cachedImageFilePath), 300); + } + + @Override + public void onError(Exception e) + { + Log.e(LOG_TAG, "Ошибка загрузки мини постера фильма по адресу:\n " + imageUrl, e); + } + }); + } + /** * Метод добавления фильма в список Избранного (или удаления из него) * @@ -635,11 +872,11 @@ private void fillCardListUIFrom(int startItemIndex, List> ca */ private boolean addToOrRemoveFromFavourites(String id, Map cardData, Drawable image) { - if (!_imageCacheDirPath.exists() && !_imageCacheDirPath.mkdir()) + if (!this.imageCacheDirPath.exists() && !this.imageCacheDirPath.mkdir()) { Log.w(LOG_TAG, "Ошибка создания подкаталога для кэша постеров к фильмам"); } - final var imgPreviewFilePath = new File(_imageCacheDirPath, "preview_" + id + ".webp"); + final var imgPreviewFilePath = new File(this.imageCacheDirPath, "preview_" + id + ".webp"); if (this.getFavouritesMap().containsKey(id)) { @@ -673,10 +910,10 @@ private boolean addToOrRemoveFromFavourites(String id, Map cardD */ private Snackbar showErrorSnackBar(String message) { - var popup = Snackbar.make(this.androidContentView, message, Snackbar.LENGTH_INDEFINITE); + var popup = Snackbar.make(coordinatorView, message, Snackbar.LENGTH_INDEFINITE); popup.setAction(R.string.repeat_button_caption, view -> { var text = Objects.requireNonNull(txtQuery.getText()).toString().replace("null", ""); - this.startTopFilmsDownloadTask(_currentPageNumber); + this.startTopFilmsDownloadTask(_nextPageNumber); popup.dismiss(); }); popup.show(); @@ -708,40 +945,47 @@ private void startTopFilmsDownloadTask(int pageNumber) /** * Метод показа ТОП-100 популярных фильмов - асинхронно загружает список фильмов из Сети * - * @param ifBeginFromPageOne если True, текущий список очищается и показ начинается с первой страницы + * @param isBeginFromPageOne если True, текущий список очищается и показ начинается с первой страницы */ - private void switchUIToPopularFilmsAsync(boolean ifBeginFromPageOne, boolean isDownloadNew) + private void switchUIToPopularFilmsAsync(boolean isBeginFromPageOne, boolean isDownloadNew) { + customToolBar.setTitle(R.string.action_popular_title); if (_currentViewMode == ViewMode.POPULAR && !isDownloadNew) { return; } _currentViewMode = ViewMode.POPULAR; - customToolBar.setTitle(R.string.action_popular_title); - - _lastListViewPos2 = scroller.getScrollY(); + lastListViewPos2 = scroller.getScrollY(); - if (ifBeginFromPageOne && isDownloadNew) + if (isBeginFromPageOne && isDownloadNew) { clearList(false); } else if (!isDownloadNew) { - clearList(!ifBeginFromPageOne); + clearList(!isBeginFromPageOne); } - if (ifBeginFromPageOne) + if (isBeginFromPageOne) { - _currentPageNumber = 1; + _nextPageNumber = 1; } if (isDownloadNew) { - this.startTopFilmsDownloadTask(_currentPageNumber); + this.startTopFilmsDownloadTask(_nextPageNumber); } else { fillCardListUIFrom(0, _cardList); - // на основе кода отсюда: https://stackoverflow.com/a/3263540/2323972 - scroller.post(() -> scroller.scrollTo(0, _lastListViewPos)); + final var query = Objects.requireNonNull(txtQuery.getText()).toString(); + if (query.isBlank() && lastListViewPos > 0) + { + // на основе кода отсюда: https://stackoverflow.com/a/3263540/2323972 + scroller.post(() -> scroller.scrollTo(0, lastListViewPos)); + } + else + { + filterCardListUI(query, _cardList.stream()); + } } } @@ -749,28 +993,34 @@ private void switchUIToFavouriteFilms() { switchUIToFavouriteFilms(false); } + /** * Метода показа Избранных фильмов */ private void switchUIToFavouriteFilms(boolean forceRefresh) { + customToolBar.setTitle(R.string.action_favourites_title); if (!forceRefresh && _currentViewMode == ViewMode.FAVOURITES) { return; } if (!forceRefresh) { - _lastListViewPos = scroller.getScrollY(); + lastListViewPos = scroller.getScrollY(); } _currentViewMode = ViewMode.FAVOURITES; clearList(true); - customToolBar.setTitle(R.string.action_favourites_title); if (!getFavouritesMap().isEmpty()) { fillCardListUIFrom(0, new ArrayList<>(getFavouritesMap().values())); - if (!forceRefresh) + final var query = Objects.requireNonNull(txtQuery.getText()).toString(); + if (!query.isBlank()) { - scroller.post(() -> scroller.scrollTo(0, _lastListViewPos2)); + filterCardListUI(query, getFavouritesMap().values().stream()); + } + else if (!forceRefresh) + { + scroller.post(() -> scroller.scrollTo(0, lastListViewPos2)); } } } @@ -797,13 +1047,15 @@ else if (_currentViewMode == ViewMode.POPULAR) * @param kinoApiFilmId ИД фильма в API кинопоиска * @param cardTitle Желаемый заголовок карточки */ - @NonNull private void showFilmCardActivity(String kinoApiFilmId, String cardTitle) { - var switchActivityIntent = new Intent(getApplicationContext(), CardActivity.class); - switchActivityIntent.putExtra(Constants.ADAPTER_TITLE, cardTitle); - switchActivityIntent.putExtra(Constants.ADAPTER_FILM_ID, kinoApiFilmId); - startActivity(switchActivityIntent); + final var switchToCardActivityIntent = new Intent(getApplicationContext(), CardActivity.class); + switchToCardActivityIntent.putExtra(Constants.ADAPTER_TITLE, cardTitle); + switchToCardActivityIntent.putExtra(Constants.ADAPTER_FILM_ID, kinoApiFilmId); + final var extraData = new Bundle(2); + extraData.clear(); + extraData.putString(Constants.ADAPTER_TITLE, cardTitle); + startActivity(switchToCardActivityIntent); } /** @@ -812,7 +1064,7 @@ private void showFilmCardActivity(String kinoApiFilmId, String cardTitle) private void loadFavouritesList() { final var tempValuesMap = new HashMap(); - try (var fr = new BufferedReader(new FileReader(_favouritesListFilePath)); + try (var fr = new BufferedReader(new FileReader(this.favouritesListFilePath)); var fileLines = fr.lines()) { fileLines.forEach(line -> { @@ -830,8 +1082,8 @@ else if (!line.equalsIgnoreCase(FILM_START_TOKEN)) } catch (IOException | RuntimeException e) { - Log.e(LOG_TAG,"Ошибка чтения файла: " + FAVOURITES_LIST_FILE_NAME, e); - Toast.makeText(this,"Не удалось прочитать файл Избранного!", Toast.LENGTH_SHORT).show(); + Log.e(LOG_TAG, "Ошибка чтения файла: " + FAVOURITES_LIST_FILE_NAME, e); + Toast.makeText(this, "Не удалось прочитать файл Избранного!", Toast.LENGTH_SHORT).show(); } } @@ -840,7 +1092,7 @@ else if (!line.equalsIgnoreCase(FILM_START_TOKEN)) */ private void saveFavouritesList() { - try (BufferedWriter fw = new BufferedWriter(new FileWriter(_favouritesListFilePath))) + try (BufferedWriter fw = new BufferedWriter(new FileWriter(this.favouritesListFilePath))) { favouritesMap.forEach((id, filmData) -> { try @@ -865,7 +1117,7 @@ private void saveFavouritesList() } catch (IOException | RuntimeException e) { - Log.e(LOG_TAG,"Ошибка записи файла: " + _favouritesListFilePath.toString(), e); + Log.e(LOG_TAG, "Ошибка записи файла: " + this.favouritesListFilePath.toString(), e); } } diff --git a/app/src/main/java/org/mmu/tinkoffkinolab/ProgramState.java b/app/src/main/java/org/mmu/tinkoffkinolab/ProgramState.java new file mode 100644 index 0000000..ff753bb --- /dev/null +++ b/app/src/main/java/org/mmu/tinkoffkinolab/ProgramState.java @@ -0,0 +1,17 @@ +package org.mmu.tinkoffkinolab; + +public class ProgramState +{ + private static boolean isFirstLaunch = true; + + + public static boolean getIsFirstLaunch() + { + return isFirstLaunch; + } + + public static void setIsFirstLaunch(boolean value) + { + ProgramState.isFirstLaunch = value; + } +} diff --git a/app/src/main/java/org/mmu/tinkoffkinolab/Utils.java b/app/src/main/java/org/mmu/tinkoffkinolab/Utils.java index 407d375..e97c9dd 100644 --- a/app/src/main/java/org/mmu/tinkoffkinolab/Utils.java +++ b/app/src/main/java/org/mmu/tinkoffkinolab/Utils.java @@ -1,13 +1,29 @@ package org.mmu.tinkoffkinolab; +import static org.mmu.tinkoffkinolab.Constants.LOG_TAG; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Canvas; +import android.graphics.Rect; import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Build; import android.util.Log; +import android.view.View; import android.widget.ImageView; +import com.google.android.material.appbar.MaterialToolbar; +import com.google.android.material.shape.CornerFamily; +import com.google.android.material.shape.MaterialShapeDrawable; + import java.io.BufferedInputStream; +import java.io.FileOutputStream; +import java.io.IOException; import java.io.InputStream; import java.net.URL; import java.net.URLConnection; @@ -53,4 +69,95 @@ public static Bitmap convertDrawableToBitmap(Drawable pd) pd.draw(canvas); return bm; } + + public static void extractImageToDiskCache(ImageView imgViewSource, String cachedImageFilePath) + { + try (var outStream = new FileOutputStream(cachedImageFilePath)) + { + Utils.convertDrawableToBitmap(imgViewSource.getDrawable()).compress( + Bitmap.CompressFormat.WEBP, 80, outStream); + } + catch (IOException e) + { + Log.e(LOG_TAG, "Ошибка записи в файл:\n " + cachedImageFilePath, e); + } + } + + public static void switchToFullScreen(Activity context) + { + // BEGIN_INCLUDE (get_current_ui_flags) + // The UI options currently enabled are represented by a bitfield. + // getSystemUiVisibility() gives us that bitfield. + int uiOptions = context.getWindow().getDecorView().getSystemUiVisibility(); + int newUiOptions = uiOptions; + // END_INCLUDE (get_current_ui_flags) + // BEGIN_INCLUDE (toggle_ui_flags) + boolean isImmersiveModeEnabled = ((uiOptions | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY) == uiOptions); + if (isImmersiveModeEnabled) + { + Log.d(LOG_TAG, "Turning immersive mode mode off. "); + } + else + { + Log.d(LOG_TAG, "Turning immersive mode mode on."); + } + + // Navigation bar hiding: Backwards compatible to ICS. + newUiOptions ^= View.SYSTEM_UI_FLAG_HIDE_NAVIGATION; + + // Status bar hiding: Backwards compatible to Jellybean + newUiOptions ^= View.SYSTEM_UI_FLAG_FULLSCREEN; + + // Immersive mode: Backward compatible to KitKat. + // Note that this flag doesn't do anything by itself, it only augments the behavior + // of HIDE_NAVIGATION and FLAG_FULLSCREEN. For the purposes of this sample + // all three flags are being toggled together. + // Note that there are two immersive mode UI flags, one of which is referred to as "sticky". + // Sticky immersive mode differs in that it makes the navigation and status bars + // semi-transparent, and the UI flag does not get cleared when the user interacts with + // the screen. + newUiOptions ^= View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; + + context.getWindow().getDecorView().setSystemUiVisibility(newUiOptions); + //END_INCLUDE (set_ui_flags) + } + + public static void showFullScreenPhoto(Uri photoUri, Activity parentActivity) + { + showFullScreenPhoto(photoUri, parentActivity.getBaseContext()); + } + + public static void showFullScreenPhoto(Uri photoUri, Context context) + { + Intent intent = new Intent(); + intent.setAction(Intent.ACTION_VIEW); + intent.setDataAndType(photoUri, "image/*"); + context.startActivity(intent); + } + + public static boolean isViewOnScreen(View target) + { + if (!target.isShown()) + { + return false; + } + final var actualPosition = new Rect(); + final var isGlobalVisible = target.getGlobalVisibleRect(actualPosition); + final var screenWidth = Resources.getSystem().getDisplayMetrics().widthPixels; + final var screenHeight = Resources.getSystem().getDisplayMetrics().heightPixels; + final var screen = new Rect(0, 0, screenWidth, screenHeight); + return isGlobalVisible && Rect.intersects(actualPosition, screen); + } + + public static void setRoundedBottomToolbarStyle(MaterialToolbar customToolBar, int cornerRadius) + { + final var toolbarBackground = (MaterialShapeDrawable) customToolBar.getBackground(); + toolbarBackground.setShapeAppearanceModel( + toolbarBackground.getShapeAppearanceModel() + .toBuilder() + .setBottomRightCorner(CornerFamily.ROUNDED, cornerRadius) + .setBottomLeftCorner(CornerFamily.ROUNDED, cornerRadius) + .build() + ); + } } diff --git a/app/src/main/res/drawable/rounded_bottom_shape.xml b/app/src/main/res/drawable/rounded_bottom_shape.xml new file mode 100644 index 0000000..bd286ef --- /dev/null +++ b/app/src/main/res/drawable/rounded_bottom_shape.xml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_card.xml b/app/src/main/res/layout/activity_card.xml index 05a43c8..198794c 100644 --- a/app/src/main/res/layout/activity_card.xml +++ b/app/src/main/res/layout/activity_card.xml @@ -6,54 +6,14 @@ android:layout_height="match_parent" tools:context=".CardActivity"> - - - - - - - - - - + - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 83395cf..52efac6 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,98 +1,136 @@ - + tools:context=".MainActivity" + android:id="@+id/root_container" + > - + - + + - + - + - + app:cardBackgroundColor="?colorOnPrimary" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/top_toolbar"> - - tools:ignore="VisualLintTextFieldSize,TextContrastCheck" /> - - + + tools:ignore="VisualLintTextFieldSize,TextContrastCheck" /> + + - + android:layout_height="0dp" + android:orientation="horizontal" + app:layout_constraintBottom_toTopOf="@+id/progress_bar_bottom" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/input_panel" + android:weightSum="2"> - - - + + + + + + + + - \ No newline at end of file + + diff --git a/app/src/main/res/layout/fragment_card.xml b/app/src/main/res/layout/fragment_card.xml new file mode 100644 index 0000000..3cef52d --- /dev/null +++ b/app/src/main/res/layout/fragment_card.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/toolbar_menu.xml b/app/src/main/res/menu/toolbar_menu.xml index 72eff4c..7078676 100644 --- a/app/src/main/res/menu/toolbar_menu.xml +++ b/app/src/main/res/menu/toolbar_menu.xml @@ -12,6 +12,12 @@ android:icon="@drawable/baseline_favorite_24" android:title="@string/action_popular_title" app:showAsAction="ifRoom" /> + + app:showAsAction="never"/> @drawable/back_outlined_24px ?colorOnPrimary + @color/black \ No newline at end of file diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index ed1c40b..c374711 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -10,4 +10,5 @@ Очистить список Вы уверены, что хотите удалить ВСЕ записи из списка? Подтверждение + Показать панель поиска \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a7216bc..1b0ca27 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,4 +10,6 @@ Clear All Are you sure want to delete all items? Confirmation + + Show search bar \ No newline at end of file