Skip to content

Commit a8abd94

Browse files
authored
Напоминания для записей (#5)
* демо-верстка * Добавлены напоминания и обновлен план - Добавлены хранение и миграция напоминаний в базе - Добавлены создание и запуск одноразовых уведомлений - Обновлены экран создания и ViewModel для настройки напоминаний - Добавлены тесты для доменной логики, данных и сценариев сохранения - Актуализирован план работ по фактическому состоянию * Исправлены замечания ревью напоминаний - Убрано дублирование расчета fingerprint напоминаний - Переведено сохранение записи на единый путь saveItem - Добавлен показ предстоящего напоминания в деталях записи - Добавлены и обновлены тесты для формы и экрана деталей - Актуализирован план работ по фактическому состоянию * План рефактора * Добавлены микро-улучшения деталки - Добавлена защита от лишних обновлений состояния - Уточнена обработка повторных эмиссий потока - Добавлен тест на позднюю эмиссию после удаления - Обновлен план по фактическому состоянию * Добавлена проверка валидности напоминаний - Вынесена общая проверка валидности формы создания - Обновлена логика доступности кнопки сохранения - Добавлены unit-тесты на невалидные сценарии напоминаний * Обновлены разрешения и нейминг тестов - Добавлен runtime-запрос разрешения на уведомления - Добавлены policy и unit-тесты для permission-флоу - Переименованы тестовые методы в snake_case без кавычек - Обновлены правила нейминга тестов и план работ * Доработки Обновлены тесты и план напоминаний - Добавлен parser для intent открытия из уведомления - Добавлены unit и instrumentation тесты reminder-flow - Добавлены compose-тесты секции напоминаний - Сжат и актуализирован план по напоминаниям Исправлены UX и текст уведомлений - Обновлён заголовок уведомления из названия записи - Исправлена дата напоминания по умолчанию на плюс один день - Добавлен автоскролл к настройкам при включении тумблера - Обновлены тесты и актуализирован план доработок * Доработки - Разделена логика CreateEditScreenContent на небольшие функции - Упрощен saveItem и вынесены шаги сохранения в ViewModel - Заменен длинный список параметров на объект изменений - Обновлены unit-тесты под новую структуру Обновлены тесты и финализирован план - Добавлены тесты для валидации и сценариев напоминаний - Улучшена проверка ошибок в настройках напоминания - Сжат и актуализирован план работ по напоминаниям Добавлена проверка доступности уведомлений - Добавлена проверка включенности уведомлений и канала - Добавлен Snackbar с переходом в настройки уведомлений - Обновлены тесты для сценариев разрешений и доступности - Обновлена документация экрана Create/Edit и удален старый план Исправлена синхронизация уведомлений и рефактор - Исправлена проверка доступности уведомлений без ложного Snackbar - Добавлена синхронизация тоггла при возврате из системных настроек - Вынесены эффекты reminder в отдельный файл без нового suppress - Обновлены unit-тесты и актуализирован документ экрана 4.1 Обновлены версии Compose, Navigation, kotlin - Обновлен Compose BOM до 2026.04.01 - Обновлен Navigation Compose до 2.9.8 - Актуализирован каталог версий зависимостей Добавлено обновление напоминания при возврате - Добавлена проверка актуальности напоминания при ON_RESUME - Вынесено обновление reminder в отдельный метод ViewModel - Добавлен unit-тест на скрытие прошедшего напоминания Исправлен показ прошедшего напоминания в edit - Скрыто прошедшее напоминание при открытии редактирования - Добавлен unit-тест для сценария с прошедшим напоминанием - Добавлен провайдер времени для детерминированной проверки Исправлено обновление напоминания на экране деталей - Убран distinctUntilChanged из observeItem для получения свежего напоминания - Добавлен тест на переэмиссию item после изменения напоминания * gradle 9.4.1 -> 9.5.0 * Подготовка к релизу
1 parent a46b0eb commit a8abd94

102 files changed

Lines changed: 5259 additions & 907 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.serena/project.yml

Lines changed: 26 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@ project_name: "JetpackDays"
33

44

55
# list of languages for which language servers are started; choose from:
6-
# al bash clojure cpp csharp
7-
# csharp_omnisharp dart elixir elm erlang
8-
# fortran fsharp go groovy haskell
9-
# java julia kotlin lua markdown
10-
# matlab nix pascal perl php
11-
# php_phpactor powershell python python_jedi r
12-
# rego ruby ruby_solargraph rust scala
13-
# swift terraform toml typescript typescript_vts
14-
# vue yaml zig
6+
# al ansible bash clojure cpp
7+
# cpp_ccls crystal csharp csharp_omnisharp dart
8+
# elixir elm erlang fortran fsharp
9+
# go groovy haskell haxe hlsl
10+
# java json julia kotlin lean4
11+
# lua luau markdown matlab msl
12+
# nix ocaml pascal perl php
13+
# php_phpactor powershell python python_jedi python_ty
14+
# r rego ruby ruby_solargraph rust
15+
# scala solidity swift systemverilog terraform
16+
# toml typescript typescript_vts vue yaml
17+
# zig
1518
# (This list may be outdated. For the current list, see values of Language enum here:
1619
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
1720
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
@@ -65,53 +68,17 @@ read_only: false
6568

6669
# list of tool names to exclude.
6770
# This extends the existing exclusions (e.g. from the global configuration)
68-
#
69-
# Below is the complete list of tools for convenience.
70-
# To make sure you have the latest list of tools, and to view their descriptions,
71-
# execute `uv run scripts/print_tool_overview.py`.
72-
#
73-
# * `activate_project`: Activates a project by name.
74-
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
75-
# * `create_text_file`: Creates/overwrites a file in the project directory.
76-
# * `delete_lines`: Deletes a range of lines within a file.
77-
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
78-
# * `execute_shell_command`: Executes a shell command.
79-
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
80-
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
81-
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
82-
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
83-
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
84-
# * `initial_instructions`: Gets the initial instructions for the current project.
85-
# Should only be used in settings where the system prompt cannot be set,
86-
# e.g. in clients you have no control over, like Claude Desktop.
87-
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
88-
# * `insert_at_line`: Inserts content at a given line in a file.
89-
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
90-
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
91-
# * `list_memories`: Lists memories in Serena's project-specific memory store.
92-
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
93-
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
94-
# * `read_file`: Reads a file within the project directory.
95-
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
96-
# * `remove_project`: Removes a project from the Serena configuration.
97-
# * `replace_lines`: Replaces a range of lines within a file with new content.
98-
# * `replace_symbol_body`: Replaces the full definition of a symbol.
99-
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
100-
# * `search_for_pattern`: Performs a search for a pattern in the project.
101-
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
102-
# * `switch_modes`: Activates modes by providing a list of their names
103-
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
104-
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
105-
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
106-
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
71+
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
10772
excluded_tools: []
10873

10974
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
11075
# This extends the existing inclusions (e.g. from the global configuration).
76+
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
11177
included_optional_tools: []
11278

11379
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
11480
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
81+
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
11582
fixed_tools: []
11683

11784
# list of mode names to that are always to be included in the set of active modes
@@ -122,11 +89,14 @@ fixed_tools: []
12289
# Set this to a list of mode names to always include the respective modes for this project.
12390
base_modes:
12491

125-
# list of mode names that are to be activated by default.
126-
# The full set of modes to be activated is base_modes + default_modes.
127-
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
92+
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
93+
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
94+
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
12895
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
96+
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
97+
# for this project.
12998
# This setting can, in turn, be overridden by CLI parameters (--mode).
99+
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
130100
default_modes:
131101

132102
# initial prompt for the project. It will always be given to the LLM upon activating the project
@@ -150,3 +120,8 @@ read_only_memory_patterns: []
150120
# Extends the list from the global configuration, merging the two lists.
151121
# Example: ["_archive/.*", "_episodes/.*"]
152122
ignored_memory_patterns: []
123+
124+
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
125+
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
126+
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
127+
added_modes:

AGENTS.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ make test # All unit tests with report
3939
./gradlew test # Unit tests only
4040
./gradlew test --tests "com.dayscounter.domain.usecase.CalculateDaysDifferenceUseCaseTest" # Single test class
4141
./gradlew test --tests "*DaysDifferenceTest" # Pattern matching
42-
./gradlew test --tests "com.dayscounter.domain.usecase.CalculateDaysDifferenceUseCaseTest.calculate when same day then returns Today" # Single test method
42+
./gradlew test --tests "com.dayscounter.domain.usecase.CalculateDaysDifferenceUseCaseTest.calculate_when_same_day_then_returns_today" # Single test method
4343
make android-test # Instrumentation tests (requires device)
4444
```
4545

@@ -143,18 +143,21 @@ sealed class Screen(val route: String, val icon: ImageVector? = null, val titleR
143143

144144
```kotlin
145145
@Test
146-
fun functionName_whenCondition_thenExpectedResult() {
146+
fun function_name_when_condition_then_expected_result() {
147147
// Given
148148
// When
149149
// Then
150150
}
151151
```
152152

153+
- Используй `snake_case` для имен тестовых методов
154+
- Обратные кавычки в именах тестовых методов не использовать
155+
153156
### Test Example
154157

155158
```kotlin
156159
@Test
157-
fun `calculate when same day then returns Today`() {
160+
fun calculate_when_same_day_then_returns_today() {
158161
// Given
159162
val today = LocalDate.now()
160163
val timestamp = today.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# Счётчик дней
22

33
<!-- BEGIN_VERSIONS -->
4-
[<img alt="Kotlin Version" src="https://img.shields.io/badge/Kotlin_Version-2.3.20-purple">](https://kotlinlang.org/)
4+
[<img alt="Kotlin Version" src="https://img.shields.io/badge/Kotlin_Version-2.3.21-purple">](https://kotlinlang.org/)
55
[<img alt="Android SDK" src="https://img.shields.io/badge/Android_SDK-36-green">](https://developer.android.com/)
66
[<img alt="Min SDK" src="https://img.shields.io/badge/Min_SDK-26-informational">](https://developer.android.com/)
7-
[<img alt="Gradle" src="https://img.shields.io/badge/Gradle-9.4.1-blue">](https://gradle.org/)
7+
[<img alt="Gradle" src="https://img.shields.io/badge/Gradle-9.5.0-blue">](https://gradle.org/)
88
[<img alt="AGP" src="https://img.shields.io/badge/AGP-9.2.0-green">](https://developer.android.com/tools/releases/gradle-plugin)
99
<!-- END_VERSIONS -->
1010
[![GitMCP](https://img.shields.io/endpoint?url=https://gitmcp.io/badge/easydev991/Jetpack-Days)](https://gitmcp.io/easydev991/Jetpack-Days)
@@ -38,7 +38,7 @@ make help
3838

3939
| Список записей | Создание новой записи | Выбор опции отображения | Перед сохранением | Сортировка на главном экране |
4040
| --- | --- | --- | --- | --- |
41-
| <img src="./fastlane/metadata/android/ru-RU/images/phoneScreenshots/1-demoList_1776922671142.png" alt=""> | <img src="./fastlane/metadata/android/ru-RU/images/phoneScreenshots/2-chooseDate_1776922676468.png" alt=""> | <img src="./fastlane/metadata/android/ru-RU/images/phoneScreenshots/3-chooseDisplayOption_1776922677274.png" alt=""> | <img src="./fastlane/metadata/android/ru-RU/images/phoneScreenshots/4-beforeSave_1776922678970.png" alt=""> | <img src="./fastlane/metadata/android/ru-RU/images/phoneScreenshots/5-sortByDate_1776922681541.png" alt=""> |
41+
| <img src="./fastlane/metadata/android/ru-RU/images/phoneScreenshots/1-demoList_1777492307100.png" alt=""> | <img src="./fastlane/metadata/android/ru-RU/images/phoneScreenshots/2-chooseDate_1777492312447.png" alt=""> | <img src="./fastlane/metadata/android/ru-RU/images/phoneScreenshots/3-chooseDisplayOption_1777492313205.png" alt=""> | <img src="./fastlane/metadata/android/ru-RU/images/phoneScreenshots/4-beforeSave_1777492314955.png" alt=""> | <img src="./fastlane/metadata/android/ru-RU/images/phoneScreenshots/5-sortByDate_1777492317536.png" alt=""> |
4242

4343
### Релизный процесс
4444

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package com.dayscounter.reminder
2+
3+
import android.Manifest
4+
import android.app.NotificationManager
5+
import android.app.PendingIntent
6+
import android.content.Intent
7+
import android.os.Build
8+
import androidx.test.ext.junit.runners.AndroidJUnit4
9+
import androidx.test.platform.app.InstrumentationRegistry
10+
import org.junit.After
11+
import org.junit.Assert.assertNotNull
12+
import org.junit.Assert.assertNull
13+
import org.junit.Test
14+
import org.junit.runner.RunWith
15+
16+
@RunWith(AndroidJUnit4::class)
17+
class AlarmReminderSchedulerInstrumentedTest {
18+
private val context = InstrumentationRegistry.getInstrumentation().targetContext
19+
20+
@After
21+
fun tearDown() {
22+
context.getSystemService(NotificationManager::class.java).cancelAll()
23+
}
24+
25+
@Test
26+
fun cancel_whenScheduled_thenPendingIntentIsRemoved() {
27+
val scheduler = AlarmReminderScheduler(context)
28+
val itemId = 777L
29+
val reminder =
30+
com.dayscounter.domain.model.Reminder(
31+
itemId = itemId,
32+
mode = com.dayscounter.domain.model.ReminderMode.AT_DATE,
33+
targetEpochMillis = System.currentTimeMillis() + 60_000L,
34+
status = com.dayscounter.domain.model.ReminderStatus.ACTIVE,
35+
createdAt = System.currentTimeMillis(),
36+
updatedAt = System.currentTimeMillis()
37+
)
38+
39+
scheduler.schedule(reminder, "title")
40+
assertNotNull(findReminderPendingIntent(itemId))
41+
42+
scheduler.cancel(itemId)
43+
44+
assertNull(findReminderPendingIntent(itemId))
45+
}
46+
47+
@Test
48+
fun schedule_whenCalled_thenPendingIntentCanBeTriggered() {
49+
val scheduler = AlarmReminderScheduler(context)
50+
val itemId = 778L
51+
val reminder =
52+
com.dayscounter.domain.model.Reminder(
53+
itemId = itemId,
54+
mode = com.dayscounter.domain.model.ReminderMode.AT_DATE,
55+
targetEpochMillis = System.currentTimeMillis() + 60_000L,
56+
status = com.dayscounter.domain.model.ReminderStatus.ACTIVE,
57+
createdAt = System.currentTimeMillis(),
58+
updatedAt = System.currentTimeMillis()
59+
)
60+
61+
scheduler.schedule(reminder, "Проверка schedule")
62+
val pendingIntent = findReminderPendingIntent(itemId)
63+
assertNotNull(pendingIntent)
64+
65+
runWithNotificationPermission {
66+
checkNotNull(pendingIntent).send()
67+
}
68+
}
69+
70+
private fun findReminderPendingIntent(itemId: Long): PendingIntent? =
71+
PendingIntent.getBroadcast(
72+
context,
73+
ReminderIntentContract.requestCodeForItem(itemId),
74+
Intent(context, ReminderAlarmReceiver::class.java).apply {
75+
action = ReminderIntentContract.ACTION_FIRE_REMINDER
76+
},
77+
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
78+
)
79+
80+
private fun runWithNotificationPermission(block: () -> Unit) {
81+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
82+
block()
83+
return
84+
}
85+
86+
val uiAutomation = InstrumentationRegistry.getInstrumentation().uiAutomation
87+
uiAutomation.adoptShellPermissionIdentity(Manifest.permission.POST_NOTIFICATIONS)
88+
try {
89+
block()
90+
} finally {
91+
uiAutomation.dropShellPermissionIdentity()
92+
}
93+
}
94+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package com.dayscounter.reminder
2+
3+
import android.Manifest
4+
import android.app.NotificationManager
5+
import android.content.Intent
6+
import android.os.Build
7+
import androidx.test.ext.junit.runners.AndroidJUnit4
8+
import androidx.test.platform.app.InstrumentationRegistry
9+
import org.junit.After
10+
import org.junit.Assert.assertNotNull
11+
import org.junit.Test
12+
import org.junit.runner.RunWith
13+
14+
@RunWith(AndroidJUnit4::class)
15+
class ReminderAlarmReceiverInstrumentedTest {
16+
private val context = InstrumentationRegistry.getInstrumentation().targetContext
17+
18+
@After
19+
fun tearDown() {
20+
val notificationManager = context.getSystemService(NotificationManager::class.java)
21+
notificationManager.cancelAll()
22+
}
23+
24+
@Test
25+
fun onReceive_whenReminderIntentIsValid_thenPostsNotificationAndCreatesChannel() {
26+
runWithNotificationPermission {
27+
val receiver = ReminderAlarmReceiver()
28+
val intent =
29+
Intent(context, ReminderAlarmReceiver::class.java).apply {
30+
action = ReminderIntentContract.ACTION_FIRE_REMINDER
31+
putExtra(ReminderIntentContract.EXTRA_ITEM_ID, 77L)
32+
putExtra(ReminderIntentContract.EXTRA_ITEM_TITLE, "День рождения")
33+
}
34+
35+
receiver.onReceive(context, intent)
36+
37+
val notificationManager = context.getSystemService(NotificationManager::class.java)
38+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
39+
val channel = notificationManager.getNotificationChannel(ReminderIntentContract.CHANNEL_ID)
40+
assertNotNull(channel)
41+
}
42+
}
43+
}
44+
45+
@Test
46+
fun onReceive_whenItemIdInvalid_thenDoesNotPostNotification() {
47+
runWithNotificationPermission {
48+
val receiver = ReminderAlarmReceiver()
49+
val intent =
50+
Intent(context, ReminderAlarmReceiver::class.java).apply {
51+
action = ReminderIntentContract.ACTION_FIRE_REMINDER
52+
putExtra(ReminderIntentContract.EXTRA_ITEM_ID, -1L)
53+
}
54+
55+
receiver.onReceive(context, intent)
56+
}
57+
}
58+
59+
private fun runWithNotificationPermission(block: () -> Unit) {
60+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
61+
block()
62+
return
63+
}
64+
65+
val uiAutomation = InstrumentationRegistry.getInstrumentation().uiAutomation
66+
uiAutomation.adoptShellPermissionIdentity(Manifest.permission.POST_NOTIFICATIONS)
67+
try {
68+
block()
69+
} finally {
70+
uiAutomation.dropShellPermissionIdentity()
71+
}
72+
}
73+
}

0 commit comments

Comments
 (0)