Skip to content

ADFA-3263 | Refactor FeedbackButtonManager and use position ratios#1086

Merged
jatezzz merged 3 commits intostagefrom
refactor/ADFA-3263-feedback-fab-manager
Mar 20, 2026
Merged

ADFA-3263 | Refactor FeedbackButtonManager and use position ratios#1086
jatezzz merged 3 commits intostagefrom
refactor/ADFA-3263-feedback-fab-manager

Conversation

@jatezzz
Copy link
Collaborator

@jatezzz jatezzz commented Mar 17, 2026

Description

Refactored FeedbackButtonManager to adhere to the Single Responsibility Principle (SRP). The "God Class" was split into smaller, modular components:

  • FabPositionCalculator: Handles safe dragging bounds and ratio math.
  • FabPositionRepository: Manages shared preferences storage.
  • DraggableTouchListener: Isolates the complex touch and gesture logic.

Additionally, updated the FAB to use normalized position ratios instead of absolute screen coordinates. This ensures the button maintains its relative position during layout changes, multi-window mode, or screen resizing (e.g., Samsung DeX).

Details

Before fix

document_5028666762355280055.mp4

After fix

document_5028666762355280054.mp4

Ticket

ADFA-3263

Observation

All extracted support classes (FabPositionCalculator, FabPositionRepository, DraggableTouchListener) were created with internal visibility to keep the module's public API surface clean.

@jatezzz jatezzz requested a review from a team March 17, 2026 18:55
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 17, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough
  • Refactor: FeedbackButtonManager converted from a companion-object/static-style design to an instance-based class to improve modularity and testability.
  • Persistence model: FAB position storage changed from absolute screen coordinates to normalized x/y ratios; FAB now restores relative position across:
    • Orientation changes
    • Multi-window / split-screen
    • Samsung DeX / desktop-like environments
    • Dynamic layout resizing and layout changes
  • SRP-driven extraction: support responsibilities split into internal components:
    • FabPositionCalculator — computes safe dragging bounds (system insets aware, RTL margin handling) and converts between absolute coordinates and normalized ratios (with guards for non-positive available space).
    • FabPositionRepository — SharedPreferences-backed persistence for x/y ratios (save + suspendable read on IO dispatcher).
    • DraggableTouchListener — isolated touch/gesture handling for the FAB: touch slop gating, drag clamping to safe bounds, long-press tooltip, and saving position on release.
  • Integration changes:
    • FeedbackButtonManager now uses FabPositionRepository and FabPositionCalculator to load/validate/restore FAB position and sets up DraggableTouchListener for drag handling.
    • Adds parent OnLayoutChangeListener to reload/adjust FAB position when parent size changes.
  • API surface: extracted helpers declared internal to keep the module public API minimal.

Improvements:

  • Better separation of concerns and easier unit testing of individual components.
  • More robust FAB placement across varied form factors and multi-window scenarios.
  • Reduced edge-case bugs from bounds/clamping and improved RTL margin handling.
  • Cleaner persistence via repository pattern; UI gesture code isolated from storage and geometry logic.

Risks / best-practice notes:

  • Breaking-change risk: switching to instance-based FeedbackButtonManager may require caller refactors where the prior static usage existed; no deprecation shim provided.
  • SharedPreferences usage: writes use apply() (async). High-frequency writes could still lead to IO contention; consider throttling or batching if position updates become frequent.
  • Layout-change listener: frequent layout churn could increase recalculation overhead; debounce or minimize work in the listener if needed.
  • Ratio/math edge cases: FabPositionCalculator guards against non-positive available space but unusual devices (foldables, extreme aspect ratios, multi-display scenarios) should be validated.
  • Internal visibility: marking helpers internal reduces cross-module reuse—confirm this aligns with intended module boundaries.

Testing recommendations:

  • Verify FAB persistence and correct restoration across process kill/restart, configuration changes, multi-window, and DeX.
  • Test on varied form factors (phones, tablets, foldables, DeX/desktop) and RTL locales.
  • Stress-test frequent position updates to detect SharedPreferences contention or UI jank.
  • Validate behavior under rapid layout changes to ensure the OnLayoutChangeListener does not cause thrashing.

Walkthrough

Adds modular draggable FAB support: a touch listener for drag/long-press, a calculator to compute safe bounds and ratio conversions, a repository to persist FAB position ratios, and a refactor of FeedbackButtonManager to use these components and instance-based lifecycle.

Changes

Cohort / File(s) Summary
Drag handler
common-ui/src/main/java/com/itsaky/androidide/DraggableTouchListener.kt
New internal View.OnTouchListener implementing drag, long-press detection (via GestureDetector), clamping to safe bounds, and saving/restoring behavior on up/cancel.
Position calculations
common-ui/src/main/java/com/itsaky/androidide/FabPositionCalculator.kt
New internal calculator computing safe dragging Rect, validating/clamping positions, and converting between absolute coords and normalized ratios.
Persistence
common-ui/src/main/java/com/itsaky/androidide/FabPositionRepository.kt
New internal repository persisting/loading FAB x/y ratios to SharedPreferences; read is suspend on IO dispatcher.
Manager integration
common-ui/src/main/java/com/itsaky/androidide/FeedbackButtonManager.kt
Refactored to instance-based manager (constructor props now private), integrates the new calculator, repository, and draggable listener; moves persistence to ratio-based storage and reloads on parent layout changes.

Sequence Diagram

sequenceDiagram
    participant User as User Touch
    participant Listener as DraggableTouchListener
    participant Calc as FabPositionCalculator
    participant Repo as FabPositionRepository
    participant FAB as FloatingActionButton
    participant Prefs as SharedPreferences

    User->>Listener: ACTION_DOWN (capture start coords)
    User->>Listener: ACTION_MOVE (exceeds touchSlop)
    Listener->>Calc: getSafeDraggingBounds(parent, fab)
    Calc-->>Listener: safe bounds Rect
    Listener->>FAB: update x/y (coerceIn bounds)
    User->>Listener: ACTION_UP
    Listener->>Calc: validateAndCorrectPosition(x,y,parent,fab)
    Calc-->>Listener: validated x,y
    Listener->>Calc: toRatio(validatedX,...)
    Calc-->>Listener: xRatio,yRatio
    Listener->>Repo: savePositionRatios(xRatio,yRatio)
    Repo->>Prefs: apply() (persist ratios)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • itsaky-adfa
  • Daniel-ADFA
  • jomen-adfa

Poem

🐰 I nudged a floating moon so spry,

hopped it safe beneath the sky,
ratios tucked in tiny stacks,
stored in prefs for future tracks,
a gentle hop, a saved sky.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main refactoring work: splitting FeedbackButtonManager and adopting position ratios for FAB persistence.
Description check ✅ Passed The description is directly related to the changeset, explaining the refactoring rationale (SRP), the new components created, and the shift from absolute to ratio-based positioning.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/ADFA-3263-feedback-fab-manager
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
common-ui/src/main/java/com/itsaky/androidide/FabPositionRepository.kt (2)

15-16: Consider caching the SharedPreferences instance.

The prefs computed property calls getSharedPreferences on every access. While Android internally caches the returned instance, using a lazy delegate would be slightly more efficient and explicit.

♻️ Optional: Cache the SharedPreferences instance
-    private val prefs: SharedPreferences
-        get() = context.getSharedPreferences(FAB_PREFS, Context.MODE_PRIVATE)
+    private val prefs: SharedPreferences by lazy {
+        context.getSharedPreferences(FAB_PREFS, Context.MODE_PRIVATE)
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@common-ui/src/main/java/com/itsaky/androidide/FabPositionRepository.kt`
around lines 15 - 16, The prefs computed property in FabPositionRepository
currently calls context.getSharedPreferences(FAB_PREFS, Context.MODE_PRIVATE) on
every access; change it to cache the SharedPreferences instance (e.g., use a
lazy-initialized property) so subsequent accesses reuse the same object. Update
the prefs declaration in the FabPositionRepository class to initialize once
(using lazy or an explicit val assigned in the constructor/init) while still
calling context.getSharedPreferences(FAB_PREFS, Context.MODE_PRIVATE) to obtain
the instance.

18-24: Minor: Naming collision between Kotlin's apply and SharedPreferences' apply().

The code is correct, but using Kotlin's apply {} scope function alongside the Editor's apply() method can be confusing at first glance. Consider using chained calls or the AndroidX Core KTX edit {} extension for clarity.

♻️ Optional: Clearer alternatives

Option 1 - Chained calls:

     fun savePositionRatios(xRatio: Float, yRatio: Float) {
-        prefs.edit().apply {
-            putFloat(KEY_FAB_X_RATIO, xRatio)
-            putFloat(KEY_FAB_Y_RATIO, yRatio)
-            apply()
-        }
+        prefs.edit()
+            .putFloat(KEY_FAB_X_RATIO, xRatio)
+            .putFloat(KEY_FAB_Y_RATIO, yRatio)
+            .apply()
     }

Option 2 - AndroidX KTX extension (if androidx.core:core-ktx is available):

fun savePositionRatios(xRatio: Float, yRatio: Float) {
    prefs.edit {
        putFloat(KEY_FAB_X_RATIO, xRatio)
        putFloat(KEY_FAB_Y_RATIO, yRatio)
    }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@common-ui/src/main/java/com/itsaky/androidide/FabPositionRepository.kt`
around lines 18 - 24, The savePositionRatios function uses Kotlin's apply { }
scope together with the SharedPreferences.Editor.apply() method which can be
confusing; replace the block so the Editor's methods are called without nesting
Kotlin's apply scope — e.g., avoid prefs.edit().apply { ... apply() } and
instead use a clear pattern such as chaining
(prefs.edit().putFloat(...).putFloat(...).apply()) or the AndroidX Core KTX edit
{ } extension (prefs.edit { putFloat(...); putFloat(...) }) to remove the naming
collision and improve readability; update references in savePositionRatios and
any similar methods that call prefs.edit().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@common-ui/src/main/java/com/itsaky/androidide/DraggableTouchListener.kt`:
- Around line 48-53: The touch handler's when-branch currently treats canceled
gestures as else -> false and doesn't reset state; add an explicit
MotionEvent.ACTION_CANCEL case in the same when where ACTION_DOWN/MOVE/UP are
handled and call a new or existing cleanup routine (e.g., implement
handleActionCancel(fab) or reuse handleActionUp semantics) to set isDragging =
false and isLongPressed = false and perform any visual/state cleanup, then
return false; reference the when block handling MotionEvent.ACTION_DOWN,
ACTION_MOVE, ACTION_UP and the isDragging/isLongPressed flags so the cancel
logic is colocated with the other action handlers.

In `@common-ui/src/main/java/com/itsaky/androidide/FabPositionCalculator.kt`:
- Around line 57-65: The fallback default coordinates for the FAB (computed as
defaultX = marginStart and defaultY = parentView.height - fabView.height -
marginBottom in FabPositionCalculator) can still fall outside the allowed
safeBounds (e.g., very small parent height); clamp defaultX and defaultY to the
safeBounds Rect before returning so the restored position is always within
bounds. Locate the block computing marginStart/marginBottom and
defaultX/defaultY and replace the raw return with clampedX =
safeBounds.coerceInX(defaultX) and clampedY = safeBounds.coerceInY(defaultY)
(use the existing safeBounds utilities or inline min/max) so the method returns
the coordinates guaranteed to lie inside safeBounds.
- Around line 23-34: The getSafeDraggingBounds in FabPositionCalculator
currently only applies the systemBarsInsets.top; update it to extract all inset
components (left, top, right, bottom) from systemBarsInsets and use them to
compute safe bounds: subtract left inset + start/marginStart and fabMarginPx
from bounds.left, subtract right inset + fab width + marginEnd from
bounds.right, and subtract bottom inset + bottomMargin + fabMarginPx from
bounds.bottom, while keeping bounds.top based on top inset + topMargin +
fabMarginPx; reference variables/methods: getSafeDraggingBounds,
systemBarsInsets, bounds, fabMarginPx, fabView, parentView and the FAB layout
params (topMargin/marginStart/bottomMargin) to ensure consistent margin handling
across all edges.

In `@common-ui/src/main/java/com/itsaky/androidide/FeedbackButtonManager.kt`:
- Around line 70-74: The layout listener is comparing raw coordinates
(right/bottom) to stored dimensions (lastWidth/lastHeight), causing false
positives when the view moves; in the parentView.addOnLayoutChangeListener
callback compute currentWidth = right - left and currentHeight = bottom - top,
compare those to lastWidth/lastHeight, update lastWidth/lastHeight from those
computed dimensions, and only call loadFabPosition() when the computed
width/height actually changed.

---

Nitpick comments:
In `@common-ui/src/main/java/com/itsaky/androidide/FabPositionRepository.kt`:
- Around line 15-16: The prefs computed property in FabPositionRepository
currently calls context.getSharedPreferences(FAB_PREFS, Context.MODE_PRIVATE) on
every access; change it to cache the SharedPreferences instance (e.g., use a
lazy-initialized property) so subsequent accesses reuse the same object. Update
the prefs declaration in the FabPositionRepository class to initialize once
(using lazy or an explicit val assigned in the constructor/init) while still
calling context.getSharedPreferences(FAB_PREFS, Context.MODE_PRIVATE) to obtain
the instance.
- Around line 18-24: The savePositionRatios function uses Kotlin's apply { }
scope together with the SharedPreferences.Editor.apply() method which can be
confusing; replace the block so the Editor's methods are called without nesting
Kotlin's apply scope — e.g., avoid prefs.edit().apply { ... apply() } and
instead use a clear pattern such as chaining
(prefs.edit().putFloat(...).putFloat(...).apply()) or the AndroidX Core KTX edit
{ } extension (prefs.edit { putFloat(...); putFloat(...) }) to remove the naming
collision and improve readability; update references in savePositionRatios and
any similar methods that call prefs.edit().

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c09b9ad4-f5c4-4c60-b925-3d8ec6e87153

📥 Commits

Reviewing files that changed from the base of the PR and between 1061433 and a21451c.

📒 Files selected for processing (4)
  • common-ui/src/main/java/com/itsaky/androidide/DraggableTouchListener.kt
  • common-ui/src/main/java/com/itsaky/androidide/FabPositionCalculator.kt
  • common-ui/src/main/java/com/itsaky/androidide/FabPositionRepository.kt
  • common-ui/src/main/java/com/itsaky/androidide/FeedbackButtonManager.kt

@jatezzz jatezzz force-pushed the refactor/ADFA-3263-feedback-fab-manager branch from c0d97a2 to 62da823 Compare March 18, 2026 14:58
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
common-ui/src/main/java/com/itsaky/androidide/FeedbackButtonManager.kt (1)

38-42: Serialize restore work so concurrent loads cannot reapply stale ratios.

loadFabPosition() is hit from more than one path, and each call does an async read followed by a posted apply. That makes it possible for an older restore to land after a newer drag/save and snap the FAB back to stale coordinates. Consider either making this hot-path read synchronous or dropping stale restores with a generation/job check. Please verify by resizing or resuming the activity and dragging immediately afterward.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@common-ui/src/main/java/com/itsaky/androidide/FeedbackButtonManager.kt`
around lines 38 - 42, loadFabPosition() performs an async read
(repository.readPositionRatios()) and then posts applySavedPosition(fab, xRatio,
yRatio), allowing older restores to overwrite newer positions; fix by
serializing/resting stale restores: either make the read synchronous on the
UI/thread or add a generation/token check or single-job guard in
FeedbackButtonManager — increment a private generation counter or set a single
Coroutine Job before launching from activity.lifecycleScope.launch, capture the
current token/job in the launched coroutine, and before calling fab.post {
applySavedPosition(...) } verify the token/job still matches (or that the
previous job completed); apply this check around repository.readPositionRatios()
and applySavedPosition(fab,...) to drop stale restores when a newer
load/save/drag occurs.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@common-ui/src/main/java/com/itsaky/androidide/FabPositionCalculator.kt`:
- Around line 22-25: The RTL bug: in FabPositionCalculator resolve logical
margins (marginStart/marginEnd) to physical left/right before creating the
bounds Rect by checking layout direction with
ViewCompat.getLayoutDirection(view); if RTL swap them (physicalLeft = marginEnd,
physicalRight = marginStart) otherwise physicalLeft = marginStart, physicalRight
= marginEnd, then use physicalLeft/physicalRight (and marginTop/marginBottom as
before) when building the Rect; apply the same change to the other occurrence
around lines 37–41 so both Rect constructions use resolved physical margins.

In `@common-ui/src/main/java/com/itsaky/androidide/FeedbackButtonManager.kt`:
- Around line 68-75: The layout-change listener only reacts to size changes, but
FabPositionCalculator.getSafeDraggingBounds() also depends on system window
insets, so add a window-insets change listener on the same parent view (e.g.,
via setOnApplyWindowInsetsListener or ViewCompat.setOnApplyWindowInsetsListener)
and call loadFabPosition() from that listener as well; ensure you keep the
existing parentView.addOnLayoutChangeListener and reference loadFabPosition() so
the FAB is repositioned when insets (navigation/taskbar) change.

---

Nitpick comments:
In `@common-ui/src/main/java/com/itsaky/androidide/FeedbackButtonManager.kt`:
- Around line 38-42: loadFabPosition() performs an async read
(repository.readPositionRatios()) and then posts applySavedPosition(fab, xRatio,
yRatio), allowing older restores to overwrite newer positions; fix by
serializing/resting stale restores: either make the read synchronous on the
UI/thread or add a generation/token check or single-job guard in
FeedbackButtonManager — increment a private generation counter or set a single
Coroutine Job before launching from activity.lifecycleScope.launch, capture the
current token/job in the launched coroutine, and before calling fab.post {
applySavedPosition(...) } verify the token/job still matches (or that the
previous job completed); apply this check around repository.readPositionRatios()
and applySavedPosition(fab,...) to drop stale restores when a newer
load/save/drag occurs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d66bbd1f-a5bb-47a6-9613-4d3223a22e63

📥 Commits

Reviewing files that changed from the base of the PR and between a21451c and 62da823.

📒 Files selected for processing (4)
  • common-ui/src/main/java/com/itsaky/androidide/DraggableTouchListener.kt
  • common-ui/src/main/java/com/itsaky/androidide/FabPositionCalculator.kt
  • common-ui/src/main/java/com/itsaky/androidide/FabPositionRepository.kt
  • common-ui/src/main/java/com/itsaky/androidide/FeedbackButtonManager.kt

jatezzz added 3 commits March 20, 2026 08:34
Split FeedbackButtonManager into separate calculator, repository, and touch listener components.
Handle touch cancellation, respect all system bar insets, and reload only on real size changes
@jatezzz jatezzz force-pushed the refactor/ADFA-3263-feedback-fab-manager branch from 8cf674d to 5e1fc60 Compare March 20, 2026 13:35
@jatezzz jatezzz merged commit 1053d93 into stage Mar 20, 2026
2 checks passed
@jatezzz jatezzz deleted the refactor/ADFA-3263-feedback-fab-manager branch March 20, 2026 13:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants