Введние
Знакомо ощущение, когда уже перед сном в голове проносится мысль: «А я зашел сегодня забрать ежедневные награды или 90 гемов только что сгорели?».
В поисках решения этой проблемы натыкаешься на целый кластер разных помощников. Функционал таких приложений‑компаньонов (так проще их назвать) заключался в отслеживании ресурсов и накоплений, учета круток в баннерах, помощи в сборке персонажей и так далее. Чаще всего они фокусируются на конкретной игре и нескольких инструментах для нее.
Идея
Я загорелся идеей создать проект, который объединит все популярные (а может, и не только) игры и соберёт воедино удобные инструменты в одном лёгком приложении. И всё это — с полным отсутствием рекламы, платных подписок и требований к интернет‑соединению.
Так родился мой pet‑проект GachaPoint — локальный компаньон для аниме‑RPG (пока только для Genshin Impact, Honkai: Star Rail и Zenless Zone Zero, но в дальнейшем планирую добавить поддержку и других игр).
Реализация
Основная идея любого такого приложения начинается с предоставления определенного инструментария, облегчающего времяпрепровождение игроков в игре: расширенные карты игрового мира, планировщик ресурсов, книга гайдов, учет круток и тому подобное. Я отобрал самые популярные и реализовал их в своем приложении, добавив функционал отслеживания ежедневных наград. В первую версию приложения вошли: отслеживание ежедневных наград (календарь подписок), счетчик круток и копилка гемов. В дальнейшем я планирую расширять возможности приложения. Разберем, как все работает под капотом:
Календарь подписок

Календарь ‑ то, с чего начался проект, решение собственной паранойи и просто удобный способ следить за ежедневными наградами. Смысл прост: забрал награды, зашел в приложение, открыл календарь нужной игры и нажал кнопку «Отметка».
Календарь отражает дни цветами: зеленый — награды собраны, красный — награды пропущены, голубой — предстоящие дни, белый — дни без наград (подписки не активны).
Вся информация хранится в локальной базе данных на SQLite (используя библиотеку Room). Под календарь выделена таблица calendar:
CREATE TABLE calendar ( id INTEGER PRIMARY KEY, day INTEGER, day_of_year INTEGER, day_of_week INTEGER, month INTEGER, year INTEGER, status_genshin INTEGER, moon_days_remaining INTEGER, status_hsr INTEGER, express_pass_days_remaining INTEGER, status_zzz INTEGER, interknot_days_remaining INTEGER );
Каждая строка — это день с указанием номера дня в году и неделе, номера месяца, номера года, статусом отметок и оставшихся активных дней подписки в играх. Все значения представлены в числовом виде. Для упрощения и ускорения взаимодействия, для календаря, используется «плоская» таблица без отношений. «Плоская» таблица упрощает и ускоряет один единственный SQL‑запрос для отрисовки всего месяца за один раз без JOIN‑операций и сложных связей, что критично для быстрых локальных выборок.
Через метод getMonthCalendarData из БД получаем массив‑месяц указанного года. Этот массив выводится на экран в виде календаря при помощи метода renderCalendarGrid и кастомного элемента интерфейса CalendarGrid на основе GridLayout.
public void getMonthCalendarData(int year, int month, GameType gameType, Callback<List<Date>> callback) { AppDatabase.getExecutor().execute(() -> { List<CalendarEntity> entities = db.calendarDao().getCalendarForMonth(year, month); List<Date> dates = new ArrayList<>(entities.size()); for (CalendarEntity entity : entities) { dates.add(entity.toDateModel(gameType)); } AppDatabase.postToMain(() -> callback.onResult(dates)); }); }
Выбор обычного GridLayout с явным пересчетом LayoutParams в calendarGrid.post(...), а не стандартного RecyclerView с GridLayoutManager обусловлен несколькими причинами:
Количество ячеек строго ограничено (максимум 42 элемента для 6 недель). В переиспользовании View (
ViewHolder) при таком малом объеме нет необходимости.Чтобы сетка выглядела аккуратно на любых диагоналях и плотностях пикселей (dpi), ширина ячейки рассчитывается динамически исходя из реальной ширины
calendarGrid, после чего высота приравнивается к ширине.В зависимости от месяца (5 или 6 недель) нижний ряд либо скрывается, либо отображается. Через
animateGridHeightмы плавно анимируем высоту самого контейнера, что сGridLayoutделается буквально в пару строк и без багов с задержкой перерисовки списка.
private void renderCalendarGrid(List<CalendarCellUiModel> cells) { if (cells == null || cells.size() < 42) return; int activeRows = IntStream.range(35, 42).anyMatch(i -> cells.get(i).isVisible) ? 6 : 5; for (int i = 0; i < 42; i++) { TextView cellView = cellViewsPool.get(i); CalendarCellUiModel model = cells.get(i); if (i >= 35 && activeRows == 5) { cellView.setVisibility(View.GONE); cellView.setText(""); cellView.setBackground(null); continue; } if (!model.isVisible) { cellView.setVisibility(View.INVISIBLE); cellView.setText(""); cellView.setBackground(null); } else { cellView.setVisibility(View.VISIBLE); cellView.setText(String.valueOf(model.dayOfMonth)); cellView.setBackgroundResource(model.backgroundRes); cellView.setTextColor(ContextCompat.getColor(requireContext(), model.textColorRes)); } } calendarGrid.post(() -> { int gridWidth = calendarGrid.getWidth(); if (gridWidth == 0 || !isAdded()) return; float density = getResources().getDisplayMetrics().density; int marginPx = (int) (4 * density); int cellSidePx = (gridWidth - (marginPx * 2 * 7)) / 7; for (int i = 0; i < 42; i++) { TextView cell = cellViewsPool.get(i); GridLayout.LayoutParams params = (GridLayout.LayoutParams) cell.getLayoutParams(); if (params != null) { params.width = cellSidePx; params.height = cellSidePx; params.setMargins(marginPx, marginPx, marginPx, marginPx); cell.setLayoutParams(params); } } int targetHeight = (cellSidePx + (marginPx * 2)) * activeRows; animateGridHeight(targetHeight); }); }
Добавление подписки или отметки напрямую обновляет нужную строку в таблице.
Вызвав метод добавления подписки, мы проверяем лимит, прибавляем 1 в счетчик и в цикле добавляем значения оставшихся дней по убыванию с сегодняшнего дня (+ 30 сегодня, + 29 завтра, + 28 послезавтра и так далее).
При вызове метода отметки приложение проверяет возможность отметки (активна одна или более подписка в выбранной игре и отметки на дне еще нет) переписывает статус и обновляет ячейку календаря. Для ведения статистики мы рассчитываем количество пропусков и сборов на активные подписки, а после при помощи констант, рассчитываем данные по простым формулам:
Полученные гемы: собранные дни умножаем на ежедневную награду,
Полученные крутки: гемы умножаем на стоимость круток,
Пропущенные гемы: пропущенные дни на ежедневную награду,
Пропущенные крутки: пропущенные гемы на стоимость круток,
Гемы, которые еще получим: общий сбор с одной подписки умножим на количество подписок и вычтем собранные и пропущенные гемы,
Крутки, которые еще получим: гемы, которые еще получим, поделим на стоимость круток.
Счетчик круток

Игроки аниме‑RPG игр любят вести учет круток несмотря на то, что в играх есть официальная история. Причина этому — лимиты и отсутствие общей статистики. Записи в игре ограничены промежутком времени, после которого они перезаписываются. Такой инструмент тоже есть в моем приложении. С обновлениями я планирую добавить обширную статистику за всю историю круток и максимально простой экспорт истории из игры.
Как вообще работает? Крутишь баннер — открыл приложение, выбрал тип и добавляешь крутки. Можно сразу десятками, а можно по одной.
Десятками ты указываешь название последней (10 по счету за раз) крутки и ее редкость. Нажав на запись, ее можно отредактировать. По умолчанию для записи указывается редкость 3 звезды и название предмета «Неизвестно». Ведется счетчик гаранта, который можно сбросить, выбрав соответствующий чекбокс в редактировании или создании записи. Последующие записи начнут отсчет с нуля.
В базе данных есть таблица с учетом круток из всех игр:
CREATE TABLE pulls ( id INTEGER PRIMARY KEY AUTOINCREMENT, game_type INTEGER, date_time TEXT, drop_rare TEXT, drop_type TEXT, banner_type TEXT DEFAULT 'event', is_reset_pity INTEGER DEFAULT 0 );
Таблица содержит в себе идентификатор игры (подписки), список круток с датой, редкостью, дропом, типом баннера и флагом на сброс гаранта. Записи из нужной таблицы фильтруются и выводятся в RecyclerView.
Добавление происходит сразу в таблицу БД и обновляет вид RecyclerView. Метод addPulls через Room записывает новые строки таблицы пачкой, а метод updatePull обновляет запись точечно при редактировании.
public void addPulls(String dateTime, String dropType, String dropRare, int count, GameType gameType, String bannerType, boolean isResetPity, Runnable onComplete) { AppDatabase.getExecutor().execute(() -> { db.pullDao().addPullsBatch(dateTime, dropType, dropRare, count, gameType.getCode(), bannerType, isResetPity); if (onComplete != null) { AppDatabase.postToMain(onComplete); } }); } public void updatePull(int id, String dateTime, String dropType, String dropRare, GameType gameType, String bannerType, boolean isResetPity, Runnable onComplete) { AppDatabase.getExecutor().execute(() -> { db.pullDao().updatePullFields(id, dateTime, dropType, dropRare, bannerType, isResetPity); if (onComplete != null) { AppDatabase.postToMain(onComplete); } }); }
На основе выбранного типа игры (subType) происходит выборка значений, которые выводятся в массив Wishes, привязанный к отображению в RecyclerView.
Копилка гемов

Последний и самый простой инструмент — копилка. Принцип прост: мы указываем цель (+ 1 гарант, + 2 гаранта или + 6 гарантов к цели соответствует 77, 154 и 462 круткам к текущей цели) и приложение сохраняет прогресс в цифрах. Прогресс наглядно показан на прогресс‑баре. Мы можем собирать награды с луны, они сразу учтутся в прогрессе или добавить собранные гемы вручную (например, с зачистки карты). Добавить можно сразу 10 круток или 1 крутку за раз.
В Preferences записываются значения цели, сбора с подписки и ручного сбора. Запрос прогресса возвращает сложенные значения сбора с подписки и ручного сбора.
Ручное добавление просто прибавляет крутки к ручному сбору, а добавление из статистики перезаписывает значение сбора с подписки, дабы избежать постоянных лишних прибавлений при любом обновлении статистики. В методе расчета статистики просто выполняется команда перезаписи значения сбора с подписки в Preferences. А при ручном добавлении идет прибавление в ручной сбор.
Цель же выступает пределом добавления круток, если он достигнут, больше сбор не продолжится (добавленные крутки не прибавят счетчик) до сброса и установки новой цели.
Напоминания
Приложение каждый день будет напоминать о сборе наград в играх. Это тоже то, с чего в целом я и начинал задумку. Если выдано разрешение на уведомления и включены оповещения в настройках, то в указанное время (по умолчанию 12:00) придет оповещение о сборе наград. Реализовано это при помощи WorkManager. Это избавляет нас от лишних хлопот и бэкенда, а также одобряется Google. Начиная с Android 12, системы энергосбережения (Doze Mode, вендорские оптимизации Xiaomi/Samsung) агрессивно убивают фоновые задачи.
Так как регулярный интервал PeriodicWorkRequest составляет строго 24 часа, для точного срабатывания в выбранный пользователем час вычисляется стартовая задержка (initialDelay):
LocalDateTime now = LocalDateTime.now(); LocalDateTime targetTime = now.withHour(targetHour).withMinute(targetMinute).withSecond(0).withNano(0); if (!targetTime.isAfter(now)) { targetTime = targetTime.plusDays(1); } long initialDelayMinutes = Duration.between(now, targetTime).toMinutes();
Для предотвращения дублирования задач при повторном вызове метода используется политика ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE.
Учтены жесткие требования современности (начиная с Android 13) по работе с уведомлениями и разрешениями:
Soft Check: Воркер проверяет пользовательский флаг ALLOW_NOTIFICATIONS из SharedPreferences. Если пользователь выключил тумблер в интерфейсе приложения, задание возвращает
Result.success(), избегая ненужной работы.Hard Check (Android 12+): проверяется системное разрешение POST_NOTIFICATIONS. Если пользователь отозвал его на уровне системы, воркер возвращает
Result.failure(), сигналя о нестираемой ошибке доступа.
Ключевые фишки приложения
Календарь подписок: наглядно отслеживайте дни сбора ежедневных наград (Снабжение, Луна и др.), анализируйте полученные гемы и планируйте будущие поступления.
Счётчик и история круток: надёжно сохраняйте историю молитв/прыжков с быстрым доступом к личной статистике и гарантам.
Менеджер накоплений: рассчитывайте текущие ресурсы с автоматическим учётом доходов от активных подписок и ивентов.
Ежедневные напоминания: настраиваемые уведомления, которые не дадут забыть зайти в игру и забрать ежедневные награды.
Конфиденциальность и офлайн‑режим: вся ваша база данных и персональная статистика хранятся исключительно локально на устройстве.
Итог

Проект я создавал в первую очередь для себя, но решил, что неплохо развивать его дальше. Сейчас мне очень важен фидбек читателей Хабра:
По коду и архитектуре: буду рад критике и предложениям по улучшению Room‑запросов и UI‑логики (исходники открыты на GitHub (ссылка) ).
По UX/UI: если вы играете в гача‑игры и решите протестировать приложение — поделитесь в комментариях, насколько удобен текущий формат отслеживания и каких инструментов вам не хватает.
Спасибо за внимание! Буду рад ответить на любые вопросы в комментариях.

