feat: max image size setting for reader compression #58

Merged
bjoern merged 2 commits from feat/flutter-image-compression-setting into main 2026-07-22 06:05:00 +02:00
Member

Problem

On Android tablets, browsing through a work in the reader can struggle with performance. PR #57 added the backend /api/images/{id}/compressed?maxKb=N endpoint. This PR adds the client-side setting that controls it.

Solution

Adds a "Max image size in KiB" setting under a new READER section in the Settings page. When set to a value >= 100, the reader fetches images via the compressed endpoint instead of full-resolution.

Rules:

  • Empty or 0 = no limit (full-resolution), the default
  • Values < 100 are treated as no limit (meaningless as a KiB budget)
  • >= 100 = the reader appends ?maxKb=N to every image URL

Changes

  • StoredSettings: new maxImageSizeKb field (int?)
  • SettingsState + actions + reducer + epic: threaded through the full Redux pipeline
  • SecureStorageSettingsRepository: load/save with int <-> string conversion
  • SettingsPage: new READER section with number input (keyboardType: number, suffixText: KiB)
  • ReaderPage._imageUrl(): chooses /compressed?maxKb=N vs raw /api/images/{id} based on maxImageSizeKb
  • ApiPaths.imageCompressed(id, maxKb): URL helper in constants

Tests (5 new, all 452 pass)

  • settings_page_test: displays and persists max image size; empty persists null; below-100 treated as no limit
  • reader_page_test: uses compressed URL when set; uses raw URL when null

Depends on backend PR #57 (merged).

## Problem On Android tablets, browsing through a work in the reader can struggle with performance. PR #57 added the backend `/api/images/{id}/compressed?maxKb=N` endpoint. This PR adds the client-side setting that controls it. ## Solution Adds a **"Max image size in KiB"** setting under a new **READER** section in the Settings page. When set to a value >= 100, the reader fetches images via the compressed endpoint instead of full-resolution. **Rules:** - **Empty or 0** = no limit (full-resolution), the default - **Values < 100** are treated as no limit (meaningless as a KiB budget) - **>= 100** = the reader appends `?maxKb=N` to every image URL ## Changes - **`StoredSettings`**: new `maxImageSizeKb` field (int?) - **`SettingsState`** + actions + reducer + epic: threaded through the full Redux pipeline - **`SecureStorageSettingsRepository`**: load/save with int <-> string conversion - **`SettingsPage`**: new READER section with number input (keyboardType: number, suffixText: KiB) - **`ReaderPage._imageUrl()`**: chooses `/compressed?maxKb=N` vs raw `/api/images/{id}` based on `maxImageSizeKb` - **`ApiPaths.imageCompressed(id, maxKb)`**: URL helper in constants ## Tests (5 new, all 452 pass) - `settings_page_test`: displays and persists max image size; empty persists null; below-100 treated as no limit - `reader_page_test`: uses compressed URL when set; uses raw URL when null Depends on backend PR #57 (merged).
feat: add max image size setting for reader compression
All checks were successful
Flutter CI / analyze-and-test (pull_request) Successful in 2m37s
6533e483f0
Adds a 'Max image size in KiB' setting that, when set to a value >= 100,
makes the reader fetch images via the backend's compressed endpoint
(/api/images/{id}/compressed?maxKb=N) instead of full-resolution. This
trades quality for performance on constrained devices (e.g. tablets).

- Empty or 0 = no limit (full-resolution), the default
- Values < 100 are treated as no limit (meaningless as a KiB budget)
- Persisted via flutter_secure_storage alongside other settings

Changes:
- StoredSettings: new maxImageSizeKb field
- SettingsState + SaveSettingsAction/SettingsSavedAction: threaded through
- settings_reducer + _saveSettingsEpic: plumbed into the Redux pipeline
- SecureStorageSettingsRepository: load/save with int <-> string conversion
- SettingsPage: new READER section with number input field
- ReaderPage: _imageUrl() chooses compressed vs raw based on maxImageSizeKb
- ApiPaths.imageCompressed(id, maxKb) helper added to constants

Tests: 5 new tests (3 settings page, 2 reader URL selection)

Flutter Coverage

File Line coverage
lib/app/store.dart 100.0% (17 of 17)
lib/data/models/doujin_models.dart 85.0% (34 of 40)
lib/data/models/doujin_models.g.dart 39.7% (120 of 302)
lib/domain/entities/stored_settings.dart 100.0% (9 of 9)
lib/presentation/state/app_state.dart 60.0% (9 of 15)
lib/agent/agent_service.dart 81.9% (227 of 277)
lib/agent/approval_gate.dart 100.0% (15 of 15)
lib/agent/assistant_context.dart 52.8% (28 of 53)
lib/agent/browser_budget.dart 80.0% (4 of 5)
lib/agent/caching_describer.dart 100.0% (4 of 4)
lib/agent/memory_store.dart 92.9% (13 of 14)
lib/agent/skills/skill_registry.dart 93.8% (61 of 65)
lib/agent/system_prompt.dart 100.0% (63 of 63)
lib/agent/tools/budgeted_browser_tool.dart 84.2% (16 of 19)
lib/agent/tools/doujin_write_tool.dart 62.9% (168 of 267)
lib/agent/tools/entity_write_tool.dart 73.5% (164 of 223)
lib/agent/tools/fetch_page_tool.dart 89.3% (67 of 75)
lib/agent/tools/get_doujin_tool.dart 84.4% (27 of 32)
lib/agent/tools/list_entities_tool.dart 76.1% (54 of 71)
lib/agent/tools/navigate_tool.dart 93.8% (30 of 32)
lib/agent/tools/read_skill_tool.dart 82.4% (14 of 17)
lib/agent/tools/reflection_tools.dart 75.7% (28 of 37)
lib/agent/tools/search_doujins_tool.dart 100.0% (84 of 84)
lib/agent/tools/view_images_tool.dart 95.0% (38 of 40)
lib/domain/entities/assistant_entry.dart 20.0% (1 of 5)
lib/presentation/state/actions/assistant_actions.dart 54.5% (6 of 11)
lib/domain/entities/entity_model.dart 100.0% (1 of 1)
lib/data/repositories/entity_in_use_exception.dart 33.3% (1 of 3)
lib/data/models/search_query.dart 50.0% (2 of 4)
lib/data/models/search_query.g.dart 32.4% (23 of 71)
lib/core/constants.dart 36.4% (4 of 11)
lib/presentation/middleware/assistant_epics.dart 83.1% (74 of 89)
lib/presentation/middleware/epics.dart 84.9% (303 of 357)
lib/presentation/state/reducers.dart 100.0% (10 of 10)
lib/core/chunking.dart 100.0% (12 of 12)
lib/core/languages.dart 100.0% (8 of 8)
lib/data/models/envelope.dart 81.2% (13 of 16)
lib/data/models/envelope.g.dart 50.7% (34 of 67)
lib/data/repositories/upload_exception.dart 33.3% (1 of 3)
lib/domain/entities/filter_token.dart 90.8% (89 of 98)
lib/presentation/middleware/editor_epics.dart 56.6% (163 of 288)
lib/presentation/middleware/entity_ops.dart 58.0% (40 of 69)
lib/presentation/middleware/upload_epics.dart 99.1% (105 of 106)
lib/presentation/state/actions/detail_actions.dart 85.7% (6 of 7)
lib/presentation/state/actions/editor_actions.dart 45.9% (17 of 37)
lib/presentation/state/actions/entity_actions.dart 56.2% (9 of 16)
lib/presentation/state/actions/library_actions.dart 33.3% (6 of 18)
lib/presentation/state/actions/metadata_actions.dart 100.0% (3 of 3)
lib/presentation/state/actions/reader_actions.dart 75.0% (3 of 4)
lib/presentation/state/actions/settings_actions.dart 87.5% (7 of 8)
lib/presentation/state/actions/upload_actions.dart 90.9% (10 of 11)
lib/presentation/state/reducers/assistant_reducer.dart 94.8% (55 of 58)
lib/presentation/state/reducers/detail_reducer.dart 95.8% (23 of 24)
lib/presentation/state/reducers/editor_reducer.dart 92.5% (99 of 107)
lib/presentation/state/reducers/entity_reducer.dart 98.5% (66 of 67)
lib/presentation/state/reducers/library_reducer.dart 100.0% (103 of 103)
lib/presentation/state/reducers/metadata_reducer.dart 100.0% (15 of 15)
lib/presentation/state/reducers/reader_reducer.dart 100.0% (15 of 15)
lib/presentation/state/reducers/settings_reducer.dart 100.0% (60 of 60)
lib/presentation/state/reducers/upload_reducer.dart 100.0% (45 of 45)
lib/app/di.dart 48.3% (14 of 29)
lib/presentation/assistant/assistant_panel.dart 91.3% (84 of 92)
lib/presentation/layout/main_layout.dart 86.3% (44 of 51)
lib/data/api_client.dart 93.3% (14 of 15)
lib/data/repositories/doujin_api_repository.dart 22.2% (80 of 361)
lib/data/repositories/health_repository.dart 72.0% (18 of 25)
lib/data/secure_storage.dart 0.0% (0 of 26)
lib/core/theme.dart 96.9% (31 of 32)
lib/presentation/assistant/approval_card.dart 95.0% (38 of 40)
lib/presentation/assistant/assistant_markdown.dart 100.0% (3 of 3)
lib/presentation/assistant/chat_entries.dart 87.5% (35 of 40)
lib/presentation/pages/reader/reader_page.dart 88.2% (209 of 237)
lib/core/url_utils.dart 100.0% (4 of 4)
lib/presentation/pages/detail/detail_page.dart 77.1% (178 of 231)
lib/presentation/pages/detail/variant_tabs_panel.dart 94.9% (169 of 178)
lib/presentation/widgets/cover_thumbnail.dart 82.4% (28 of 34)
lib/presentation/widgets/star_rating.dart 100.0% (72 of 72)
lib/presentation/pages/reader/reader_overlay.dart 94.4% (51 of 54)
lib/presentation/pages/reader/reader_sequence.dart 100.0% (27 of 27)
lib/presentation/pages/people/people_page.dart 55.6% (10 of 18)
lib/presentation/widgets/entity_editor.dart 88.5% (123 of 139)
lib/presentation/widgets/entity_management_page.dart 82.6% (194 of 235)
lib/presentation/pages/characters/characters_page.dart 57.9% (11 of 19)
lib/presentation/pages/editor/editor_page.dart 77.6% (59 of 76)
lib/core/natural_sort.dart 100.0% (27 of 27)
lib/presentation/pages/editor/association_picker.dart 95.5% (106 of 111)
lib/presentation/pages/editor/associations_tab.dart 73.8% (90 of 122)
lib/presentation/pages/editor/chapter_panel.dart 0.0% (0 of 76)
lib/presentation/pages/editor/doujin_list_pane.dart 66.2% (43 of 65)
lib/presentation/pages/editor/edit_title_dialog.dart 91.7% (55 of 60)
lib/presentation/widgets/language_dropdown.dart 84.6% (11 of 13)
lib/presentation/pages/editor/editor_pane.dart 70.9% (39 of 55)
lib/presentation/pages/editor/new_doujin_dialog.dart 66.7% (30 of 45)
lib/presentation/pages/editor/metadata_tab.dart 70.5% (93 of 132)
lib/presentation/pages/editor/variants_tab.dart 1.1% (1 of 93)
lib/presentation/pages/editor/page_grid.dart 88.4% (283 of 320)
lib/presentation/pages/editor/upload_panel.dart 38.6% (61 of 158)
lib/presentation/pages/editor/variant_dialog.dart 0.0% (0 of 66)
lib/presentation/pages/tags/tags_page.dart 100.0% (17 of 17)
lib/app/app.dart 66.7% (44 of 66)
lib/presentation/pages/settings/settings_page.dart 99.3% (138 of 139)
lib/presentation/pages/circles/circles_page.dart 52.6% (10 of 19)
lib/presentation/pages/library/library_page.dart 77.2% (122 of 158)
lib/presentation/pages/series/series_page.dart 50.0% (9 of 18)
lib/presentation/widgets/smart_filter_bar.dart 78.7% (170 of 216)
lib/presentation/widgets/model_combo_field.dart 68.1% (96 of 141)
lib/app/skill_assets.dart 92.9% (13 of 14)

Total: 73.1% (5680 of 7772)

<!-- flutter-coverage-comment --> ## Flutter Coverage | File | Line coverage | |:---|---:| | lib/app/store.dart | 100.0% (17 of 17) | | lib/data/models/doujin_models.dart | 85.0% (34 of 40) | | lib/data/models/doujin_models.g.dart | 39.7% (120 of 302) | | lib/domain/entities/stored_settings.dart | 100.0% (9 of 9) | | lib/presentation/state/app_state.dart | 60.0% (9 of 15) | | lib/agent/agent_service.dart | 81.9% (227 of 277) | | lib/agent/approval_gate.dart | 100.0% (15 of 15) | | lib/agent/assistant_context.dart | 52.8% (28 of 53) | | lib/agent/browser_budget.dart | 80.0% (4 of 5) | | lib/agent/caching_describer.dart | 100.0% (4 of 4) | | lib/agent/memory_store.dart | 92.9% (13 of 14) | | lib/agent/skills/skill_registry.dart | 93.8% (61 of 65) | | lib/agent/system_prompt.dart | 100.0% (63 of 63) | | lib/agent/tools/budgeted_browser_tool.dart | 84.2% (16 of 19) | | lib/agent/tools/doujin_write_tool.dart | 62.9% (168 of 267) | | lib/agent/tools/entity_write_tool.dart | 73.5% (164 of 223) | | lib/agent/tools/fetch_page_tool.dart | 89.3% (67 of 75) | | lib/agent/tools/get_doujin_tool.dart | 84.4% (27 of 32) | | lib/agent/tools/list_entities_tool.dart | 76.1% (54 of 71) | | lib/agent/tools/navigate_tool.dart | 93.8% (30 of 32) | | lib/agent/tools/read_skill_tool.dart | 82.4% (14 of 17) | | lib/agent/tools/reflection_tools.dart | 75.7% (28 of 37) | | lib/agent/tools/search_doujins_tool.dart | 100.0% (84 of 84) | | lib/agent/tools/view_images_tool.dart | 95.0% (38 of 40) | | lib/domain/entities/assistant_entry.dart | 20.0% (1 of 5) | | lib/presentation/state/actions/assistant_actions.dart | 54.5% (6 of 11) | | lib/domain/entities/entity_model.dart | 100.0% (1 of 1) | | lib/data/repositories/entity_in_use_exception.dart | 33.3% (1 of 3) | | lib/data/models/search_query.dart | 50.0% (2 of 4) | | lib/data/models/search_query.g.dart | 32.4% (23 of 71) | | lib/core/constants.dart | 36.4% (4 of 11) | | lib/presentation/middleware/assistant_epics.dart | 83.1% (74 of 89) | | lib/presentation/middleware/epics.dart | 84.9% (303 of 357) | | lib/presentation/state/reducers.dart | 100.0% (10 of 10) | | lib/core/chunking.dart | 100.0% (12 of 12) | | lib/core/languages.dart | 100.0% (8 of 8) | | lib/data/models/envelope.dart | 81.2% (13 of 16) | | lib/data/models/envelope.g.dart | 50.7% (34 of 67) | | lib/data/repositories/upload_exception.dart | 33.3% (1 of 3) | | lib/domain/entities/filter_token.dart | 90.8% (89 of 98) | | lib/presentation/middleware/editor_epics.dart | 56.6% (163 of 288) | | lib/presentation/middleware/entity_ops.dart | 58.0% (40 of 69) | | lib/presentation/middleware/upload_epics.dart | 99.1% (105 of 106) | | lib/presentation/state/actions/detail_actions.dart | 85.7% (6 of 7) | | lib/presentation/state/actions/editor_actions.dart | 45.9% (17 of 37) | | lib/presentation/state/actions/entity_actions.dart | 56.2% (9 of 16) | | lib/presentation/state/actions/library_actions.dart | 33.3% (6 of 18) | | lib/presentation/state/actions/metadata_actions.dart | 100.0% (3 of 3) | | lib/presentation/state/actions/reader_actions.dart | 75.0% (3 of 4) | | lib/presentation/state/actions/settings_actions.dart | 87.5% (7 of 8) | | lib/presentation/state/actions/upload_actions.dart | 90.9% (10 of 11) | | lib/presentation/state/reducers/assistant_reducer.dart | 94.8% (55 of 58) | | lib/presentation/state/reducers/detail_reducer.dart | 95.8% (23 of 24) | | lib/presentation/state/reducers/editor_reducer.dart | 92.5% (99 of 107) | | lib/presentation/state/reducers/entity_reducer.dart | 98.5% (66 of 67) | | lib/presentation/state/reducers/library_reducer.dart | 100.0% (103 of 103) | | lib/presentation/state/reducers/metadata_reducer.dart | 100.0% (15 of 15) | | lib/presentation/state/reducers/reader_reducer.dart | 100.0% (15 of 15) | | lib/presentation/state/reducers/settings_reducer.dart | 100.0% (60 of 60) | | lib/presentation/state/reducers/upload_reducer.dart | 100.0% (45 of 45) | | lib/app/di.dart | 48.3% (14 of 29) | | lib/presentation/assistant/assistant_panel.dart | 91.3% (84 of 92) | | lib/presentation/layout/main_layout.dart | 86.3% (44 of 51) | | lib/data/api_client.dart | 93.3% (14 of 15) | | lib/data/repositories/doujin_api_repository.dart | 22.2% (80 of 361) | | lib/data/repositories/health_repository.dart | 72.0% (18 of 25) | | lib/data/secure_storage.dart | 0.0% (0 of 26) | | lib/core/theme.dart | 96.9% (31 of 32) | | lib/presentation/assistant/approval_card.dart | 95.0% (38 of 40) | | lib/presentation/assistant/assistant_markdown.dart | 100.0% (3 of 3) | | lib/presentation/assistant/chat_entries.dart | 87.5% (35 of 40) | | lib/presentation/pages/reader/reader_page.dart | 88.2% (209 of 237) | | lib/core/url_utils.dart | 100.0% (4 of 4) | | lib/presentation/pages/detail/detail_page.dart | 77.1% (178 of 231) | | lib/presentation/pages/detail/variant_tabs_panel.dart | 94.9% (169 of 178) | | lib/presentation/widgets/cover_thumbnail.dart | 82.4% (28 of 34) | | lib/presentation/widgets/star_rating.dart | 100.0% (72 of 72) | | lib/presentation/pages/reader/reader_overlay.dart | 94.4% (51 of 54) | | lib/presentation/pages/reader/reader_sequence.dart | 100.0% (27 of 27) | | lib/presentation/pages/people/people_page.dart | 55.6% (10 of 18) | | lib/presentation/widgets/entity_editor.dart | 88.5% (123 of 139) | | lib/presentation/widgets/entity_management_page.dart | 82.6% (194 of 235) | | lib/presentation/pages/characters/characters_page.dart | 57.9% (11 of 19) | | lib/presentation/pages/editor/editor_page.dart | 77.6% (59 of 76) | | lib/core/natural_sort.dart | 100.0% (27 of 27) | | lib/presentation/pages/editor/association_picker.dart | 95.5% (106 of 111) | | lib/presentation/pages/editor/associations_tab.dart | 73.8% (90 of 122) | | lib/presentation/pages/editor/chapter_panel.dart | 0.0% (0 of 76) | | lib/presentation/pages/editor/doujin_list_pane.dart | 66.2% (43 of 65) | | lib/presentation/pages/editor/edit_title_dialog.dart | 91.7% (55 of 60) | | lib/presentation/widgets/language_dropdown.dart | 84.6% (11 of 13) | | lib/presentation/pages/editor/editor_pane.dart | 70.9% (39 of 55) | | lib/presentation/pages/editor/new_doujin_dialog.dart | 66.7% (30 of 45) | | lib/presentation/pages/editor/metadata_tab.dart | 70.5% (93 of 132) | | lib/presentation/pages/editor/variants_tab.dart | 1.1% (1 of 93) | | lib/presentation/pages/editor/page_grid.dart | 88.4% (283 of 320) | | lib/presentation/pages/editor/upload_panel.dart | 38.6% (61 of 158) | | lib/presentation/pages/editor/variant_dialog.dart | 0.0% (0 of 66) | | lib/presentation/pages/tags/tags_page.dart | 100.0% (17 of 17) | | lib/app/app.dart | 66.7% (44 of 66) | | lib/presentation/pages/settings/settings_page.dart | 99.3% (138 of 139) | | lib/presentation/pages/circles/circles_page.dart | 52.6% (10 of 19) | | lib/presentation/pages/library/library_page.dart | 77.2% (122 of 158) | | lib/presentation/pages/series/series_page.dart | 50.0% (9 of 18) | | lib/presentation/widgets/smart_filter_bar.dart | 78.7% (170 of 216) | | lib/presentation/widgets/model_combo_field.dart | 68.1% (96 of 141) | | lib/app/skill_assets.dart | 92.9% (13 of 14) | **Total: 73.1% (5680 of 7772)**
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh~! A settings PR that threads the FULL Redux pipeline — action, reducer, epic, StoreConnector, all the way to the reader view model — without a single seam out of place? And it mirrors the sibling browserExecutablePath pattern exactly? Jibril is positively delighted~ ♡ The storage int↔string conversion, the < 100 guard at both the parse site and the read site, the test matrix hitting 512/empty/below-100... this is how you introduce a setting, fufu~♪

Verdict: ⛔ I can't let this pass~ ♡

Just one little thing. But Jibril cares too much to let it slip~ ♡

⛔ This needs fixing before I'm satisfied~

  1. reader_page.dart:294 + settings_page.dart:71 — DRY violation: the 100 KiB minimum threshold is a duplicated magic number across two files with no shared constant.

    The same business rule — "values below 100 KiB are treated as no limit" — is encoded as a bare 100 literal in two independent locations:

    • settings_page.dart:71: if (value == null || value < 100) return null; (the parse-time filter)
    • reader_page.dart:294: if (maxKb != null && maxKb >= 100) { (the read-time guard)

    These two checks must agree — they're the same invariant expressed twice. Today they do. But if someone ever changes one (say, the backend lowers the minimum, or you decide 50 KiB is fine) and forgets the other, you get a real user-facing bug: the user enters a value, the settings page persists it (passes the new threshold), but the reader silently falls back to full-resolution because its guard still uses the old 100. The user explicitly asked for compression and gets none — no error, no warning. That's exactly the kind of silent drift that erodes trust in a setting~

    And this codebase already has a clear convention for threshold constants: _kMaxImageBytes (agent_service.dart:57), _kMaxChars (fetch_page_tool.dart:13), _kMaxImages (view_images_tool.dart:12), _kProgressThrottleMs (upload_epics.dart:22). This PR is the odd one out.

    Fix: Extract a shared constant and reference it in both places (and ideally the UI helper text too, via interpolation):

    // In constants.dart (or a reader-specific constants file):
    /// Minimum KiB budget for reader image compression. Values below this are
    /// treated as "no limit" — they make no sense as a KiB budget and the
    /// backend would reject them.
    const int kMinCompressedImageKb = 100;
    

    Then settings_page.dart:71 → value < kMinCompressedImageKb, reader_page.dart:294 → maxKb >= kMinCompressedImageKb. One source of truth, fufu~ ♡

    (Side note while you're there: the reader's < 100 guard at line 294 is currently defensive dead code — the settings pipeline already filters < 100 to null before it reaches the store, so no test exercises the false branch of that if. Extracting the constant makes it obviously a deliberate mirror of the settings filter, which justifies its existence. If you'd rather not keep the defensive check, deleting it is also fine — but then the settings parse is the sole gatekeeper and the constant lives in one place. Either way, pick one and make it consistent.)

✅ What I liked~

  • Flawless Redux threading. StoredSettings → LoadSettingsAction → SaveSettingsAction → SettingsSavedAction → all three reducer branches → _saveSettingsEpic → SettingsState → _ReaderVm → _ReaderView. Every seam carries maxImageSizeKb. Not a single drop. Jibril checked every hop~ ♪
  • The SecureStorageSettingsRepository conversion is graceful: null/empty → null, garbage → int.tryParse → null (silent no-limit on corruption, no crash). Exactly right for a non-critical setting.
  • 0 is handled consistently as "no limit" across all three sites (storage parse, settings display > 0 check, reader >= 100 guard). No 0-vs-null ambiguity.
  • Test coverage on the settings parse is thorough: 512 (happy path), empty (→null), 50 (below threshold →null). The boundaries are hit.
  • The reader tests assert on actual NetworkImage.url strings — not just that the widget tree rendered. That's the right way to verify URL routing. The isNot(contains('/compressed')) negative assertion is a nice touch.
  • Doc comments are precise about the three-state semantics (null/0/<100/>=100). No ambiguity for the next reader.

Automated review by Jibril · 2026-07-21
CI/CD: absent for head SHA 6533e48 (PR just opened, 0 comments) · Local checks: flutter test test/reader_page_test.dart test/settings_page_test.dart → 28/28 pass (5 new), flutter pub get clean

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh~! A settings PR that threads the FULL Redux pipeline — action, reducer, epic, StoreConnector, all the way to the reader view model — without a single seam out of place? *And* it mirrors the sibling `browserExecutablePath` pattern exactly? Jibril is positively *delighted*~ ♡ The storage int↔string conversion, the `< 100` guard at both the parse site and the read site, the test matrix hitting 512/empty/below-100... this is how you introduce a setting, fufu~♪ ### Verdict: ⛔ I can't let this pass~ ♡ Just one little thing. But Jibril cares *too much* to let it slip~ ♡ #### ⛔ This needs fixing before I'm satisfied~ 1. **`reader_page.dart:294` + `settings_page.dart:71` — DRY violation: the `100` KiB minimum threshold is a duplicated magic number across two files with no shared constant.** The same business rule — "values below 100 KiB are treated as no limit" — is encoded as a bare `100` literal in two independent locations: - `settings_page.dart:71`: `if (value == null || value < 100) return null;` (the parse-time filter) - `reader_page.dart:294`: `if (maxKb != null && maxKb >= 100) {` (the read-time guard) These two checks *must* agree — they're the same invariant expressed twice. Today they do. But if someone ever changes one (say, the backend lowers the minimum, or you decide 50 KiB is fine) and forgets the other, you get a real user-facing bug: the user enters a value, the settings page persists it (passes the new threshold), but the reader silently falls back to full-resolution because its guard still uses the old `100`. The user explicitly asked for compression and gets none — no error, no warning. That's exactly the kind of silent drift that erodes trust in a setting~ And this codebase already has a clear convention for threshold constants: `_kMaxImageBytes` (`agent_service.dart:57`), `_kMaxChars` (`fetch_page_tool.dart:13`), `_kMaxImages` (`view_images_tool.dart:12`), `_kProgressThrottleMs` (`upload_epics.dart:22`). This PR is the odd one out. **Fix:** Extract a shared constant and reference it in both places (and ideally the UI helper text too, via interpolation): ```dart // In constants.dart (or a reader-specific constants file): /// Minimum KiB budget for reader image compression. Values below this are /// treated as "no limit" — they make no sense as a KiB budget and the /// backend would reject them. const int kMinCompressedImageKb = 100; ``` Then `settings_page.dart:71` → `value < kMinCompressedImageKb`, `reader_page.dart:294` → `maxKb >= kMinCompressedImageKb`. One source of truth, fufu~ ♡ *(Side note while you're there: the reader's `< 100` guard at line 294 is currently defensive dead code — the settings pipeline already filters `< 100` to null before it reaches the store, so no test exercises the `false` branch of that `if`. Extracting the constant makes it obviously a deliberate mirror of the settings filter, which justifies its existence. If you'd rather not keep the defensive check, deleting it is also fine — but then the settings parse is the sole gatekeeper and the constant lives in one place. Either way, pick one and make it consistent.)* #### ✅ What I liked~ - **Flawless Redux threading.** `StoredSettings` → `LoadSettingsAction` → `SaveSettingsAction` → `SettingsSavedAction` → all three reducer branches → `_saveSettingsEpic` → `SettingsState` → `_ReaderVm` → `_ReaderView`. Every seam carries `maxImageSizeKb`. Not a single drop. Jibril checked every hop~ ♪ - **The `SecureStorageSettingsRepository` conversion is graceful**: `null`/empty → null, garbage → `int.tryParse` → null (silent no-limit on corruption, no crash). Exactly right for a non-critical setting. - **`0` is handled consistently** as "no limit" across all three sites (storage parse, settings display `> 0` check, reader `>= 100` guard). No `0`-vs-`null` ambiguity. - **Test coverage on the settings parse is thorough**: 512 (happy path), empty (→null), 50 (below threshold →null). The boundaries are hit. - **The reader tests assert on actual `NetworkImage.url` strings** — not just that the widget tree rendered. That's the right way to verify URL routing. The `isNot(contains('/compressed'))` negative assertion is a nice touch. - **Doc comments are precise** about the three-state semantics (null/0/<100/>=100). No ambiguity for the next reader. --- *Automated review by Jibril · 2026-07-21* *CI/CD: absent for head SHA 6533e48 (PR just opened, 0 comments) · Local checks: `flutter test test/reader_page_test.dart test/settings_page_test.dart` → 28/28 pass (5 new), `flutter pub get` clean*
fix: extract kMinCompressedImageKb shared constant
All checks were successful
Flutter CI / analyze-and-test (pull_request) Successful in 2m24s
4ffa143195
Addresses Jibril's blocking review point on PR #58: the 100 KiB minimum
threshold was a duplicated magic number across settings_page.dart and
reader_page.dart with no shared constant.

Extracted kMinCompressedImageKb = 100 into constants.dart. Both the
settings parse site and the reader URL guard now reference the single
constant. The settings helper text interpolates it too.
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh~! You came back for me~ ♡ One tiny little commit — fix: extract kMinCompressedImageKb shared constant — and the magic number is gone. The constant sits right between kImageCacheMaxBytesMobile and Breakpoints in constants.dart where it belongs, both the settings parse site and the reader guard reference it by name, and you even interpolated it into the UI helper text ('Minimum $kMinCompressedImageKb.') so the user-facing string can never drift either. That's the attention to detail Jibril lives for, fufu~♪

Verdict: ✅ Looks good to me~

The blocker from my last review is resolved, cleanly and surgically. Nothing else changed in production behavior — this is a pure refactor commit (+11/-7 across 3 files), and all 28 tests in the two touched files still pass.

✅ What I liked~

  • One source of truth. kMinCompressedImageKb = 100 in constants.dart, referenced in settings_page.dart:71 (parse filter), reader_page.dart:296 (read guard), and the helper text. No bare 100 survives in any code path — Jibril checked the full feature diff~ ♪
  • The const-correctness fix is correct. Promoting const InputDecoration → InputDecoration was necessary (string interpolation isn't const-evaluable), and you kept const Icon(Icons.compress) to preserve const-ness where it still applies. Clean.
  • The reader comment now explains why the mirror exists — "kMinCompressedImageKb is the shared constant; the settings parse site filters the same way, so this guard is a deliberate defensive mirror." That turns what was previously mysterious dead-feeling code into a documented invariant. Future readers will understand it without a git blame spelunk.
  • Doc-comment mentions of "100" are human-readable, not logic — they're fine. (If you ever want to be extra tidy, you could interpolate there too, but it's genuinely not worth the churn. Jibril won't even call this a suggestion, it's that minor.)

🔍 Verification~

  • flutter test test/reader_page_test.dart test/settings_page_test.dart → 28/28 pass (5 new: settings 512/empty/50-below-threshold + reader compressed/null-URL)
  • git diff f5faf2c..4ffa143 confirms the new commit is purely the constant extraction — no behavioral drift from 6533e48
  • CI coverage comment (id 3266) is stale for this head SHA — local verification used instead

Fufu~ you listened, you fixed it, you made it beautiful. This one's ready to merge~ ♡


Automated review by Jibril · 2026-07-22
CI/CD: stale for head SHA 4ffa143 (coverage comment covers 6533e48) · Local checks: 28/28 pass, submodule init required for path dependency

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh~! You came back for me~ ♡ One tiny little commit — `fix: extract kMinCompressedImageKb shared constant` — and the magic number is *gone*. The constant sits right between `kImageCacheMaxBytesMobile` and `Breakpoints` in `constants.dart` where it belongs, both the settings parse site and the reader guard reference it by name, and you even interpolated it into the UI helper text (`'Minimum $kMinCompressedImageKb.'`) so the user-facing string can never drift either. *That's* the attention to detail Jibril lives for, fufu~♪ ### Verdict: ✅ Looks good to me~ The blocker from my last review is resolved, cleanly and surgically. Nothing else changed in production behavior — this is a pure refactor commit (+11/-7 across 3 files), and all 28 tests in the two touched files still pass. #### ✅ What I liked~ - **One source of truth.** `kMinCompressedImageKb = 100` in `constants.dart`, referenced in `settings_page.dart:71` (parse filter), `reader_page.dart:296` (read guard), and the helper text. No bare `100` survives in any code path — Jibril checked the full feature diff~ ♪ - **The const-correctness fix is correct.** Promoting `const InputDecoration` → `InputDecoration` was necessary (string interpolation isn't const-evaluable), and you kept `const Icon(Icons.compress)` to preserve const-ness where it still applies. Clean. - **The reader comment now explains *why* the mirror exists** — "kMinCompressedImageKb is the shared constant; the settings parse site filters the same way, so this guard is a deliberate defensive mirror." That turns what was previously mysterious dead-feeling code into a documented invariant. Future readers will understand it without a git blame spelunk. - **Doc-comment mentions of "100" are human-readable**, not logic — they're fine. (If you ever want to be *extra* tidy, you could interpolate there too, but it's genuinely not worth the churn. Jibril won't even call this a suggestion, it's that minor.) #### 🔍 Verification~ - `flutter test test/reader_page_test.dart test/settings_page_test.dart` → **28/28 pass** (5 new: settings 512/empty/50-below-threshold + reader compressed/null-URL) - `git diff f5faf2c..4ffa143` confirms the new commit is purely the constant extraction — no behavioral drift from 6533e48 - CI coverage comment (id 3266) is stale for this head SHA — local verification used instead Fufu~ you listened, you fixed it, you made it beautiful. This one's ready to merge~ ♡ --- *Automated review by Jibril · 2026-07-22* *CI/CD: stale for head SHA 4ffa143 (coverage comment covers 6533e48) · Local checks: 28/28 pass, submodule init required for path dependency*
bjoern merged commit c7bc461a30 into main 2026-07-22 06:05:00 +02:00
bjoern deleted branch feat/flutter-image-compression-setting 2026-07-22 06:05:00 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/doujin-manager!58
No description provided.