feat: indicators panel resize and toolbar#487
Conversation
Reviewer's GuideImplements resizable chart panel dividers for main and indicator panels on mobile and web, with persisted panel height fractions and indicator-specific IDs, plus supporting theme, dimensions, example wiring, and tests. Sequence diagram for resizable panel divider drag and persistencesequenceDiagram
actor User
participant ResizableChartDivider
participant _ChartState
participant PanelSizeRepository
participant SharedPreferences
User->>ResizableChartDivider: onVerticalDragUpdate
ResizableChartDivider->>_ChartState: _resizeCascadingPanels(orderedKeys,dividerIndex,deltaFraction,usableHeight)
_ChartState->>_ChartState: resizeCascadingFractions(_panelFractions,orderedKeys,dividerIndex,deltaFraction,usableHeight)
_ChartState->>_ChartState: setState()
User->>ResizableChartDivider: onVerticalDragEnd
ResizableChartDivider->>_ChartState: _persistPanelFractions()
_ChartState->>PanelSizeRepository: save(_panelFractions)
PanelSizeRepository->>SharedPreferences: setString(_storageKey,jsonEncode(fractions))
PanelSizeRepository->>PanelSizeRepository: notifyListeners()
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.OpenSSF Scorecard
Scanned Manifest Files |
There was a problem hiding this comment.
Hey - I've found 3 issues, and left some high level feedback:
- The naming between the top-level
syncPanelFractions/resizeCascadingFractionsand the_ChartStateinstance methods_syncPanelFractions/_resizeCascadingPanelsis quite close and easy to confuse; consider renaming one side or extracting the shared helpers into a separate utility to make the call-sites and responsibilities clearer. - In
panel_size_repository_test.dartthe test hard-codes the storage keypanelHeightsinstead of using a single source of truth; exposing the key (or a helper) fromPanelSizeRepositorywould avoid these tests silently drifting out of sync if the key changes.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The naming between the top-level `syncPanelFractions`/`resizeCascadingFractions` and the `_ChartState` instance methods `_syncPanelFractions`/`_resizeCascadingPanels` is quite close and easy to confuse; consider renaming one side or extracting the shared helpers into a separate utility to make the call-sites and responsibilities clearer.
- In `panel_size_repository_test.dart` the test hard-codes the storage key `panelHeights` instead of using a single source of truth; exposing the key (or a helper) from `PanelSizeRepository` would avoid these tests silently drifting out of sync if the key changes.
## Individual Comments
### Comment 1
<location path="lib/src/deriv_chart/chart/chart.dart" line_range="485-486" />
<code_context>
+ /// Height available for panel fractions once [dividerCount]
+ /// [ResizableChartDivider]s - which take up real space in the same
+ /// Column as the panels - have been subtracted from [totalHeight].
+ double _usableHeightFor(double totalHeight, int dividerCount) =>
+ (totalHeight - dividerCount * Dimens.chartPanelDividerHitHeight)
+ .clamp(0.0, double.infinity);
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Guard against zero or negative usableHeight to avoid division-by-zero and NaN/Infinity in resize math.
When `totalHeight` is less than `dividerCount * Dimens.chartPanelDividerHitHeight`, `_usableHeightFor` (now clamped) returns `0`. Callers like `_ChartStateMobile` and `_ChartStateWeb` then use `usableHeight` in:
- Drag math: `deltaPixels / usableHeight` → `Infinity`/`NaN` when `usableHeight == 0`.
- `resizeCascadingFractions`: `minFraction = indicatorTitleBarMinHeight / usableHeight` → also invalid for zero.
This is reachable on very small view heights or many indicators. In addition to clamping, please short‑circuit resizing when `usableHeight <= 0` (e.g., treat drag deltas as no‑ops or skip `_resizeCascadingPanels`) to avoid corrupting `_panelFractions` and keep the layout stable.
</issue_to_address>
### Comment 2
<location path="lib/src/deriv_chart/deriv_chart.dart" line_range="221" />
<code_context>
late AddOnsRepository<DrawingToolConfig> _drawingToolsRepo;
+ final PanelSizeRepository _panelSizeRepo = PanelSizeRepository();
+
final DrawingTools _drawingTools = DrawingTools();
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Dispose the internal PanelSizeRepository to avoid leaking a ChangeNotifier.
`_panelSizeRepo` is a `ChangeNotifier` but isn't disposed in `_DerivChartState.dispose`. Since `Chart` only removes its listener, the notifier itself lives for the lifetime of `DerivChartState` and can accumulate listeners when the widget is recreated.
To fix this, dispose `_panelSizeRepo` only when this widget owns it (i.e. when `widget.panelSizeRepo == null`):
```dart
@override
void dispose() {
if (widget.panelSizeRepo == null) {
_panelSizeRepo.dispose();
}
super.dispose();
}
```
This keeps lifecycle responsibility clear between host-supplied and internally-created repos.
Suggested implementation:
```
final PanelSizeRepository _panelSizeRepo = PanelSizeRepository();
final DrawingTools _drawingTools = DrawingTools();
late final InteractiveLayerBehaviour _interactiveLayerBehaviour;
```
```
@override
void dispose() {
if (widget.panelSizeRepo == null) {
_panelSizeRepo.dispose();
}
super.dispose();
}
```
I assumed a minimal `dispose` implementation (`super.dispose();`) for the `_DerivChartState` class. If your actual `dispose` method already removes listeners (e.g. from `panelSizeRepo`, `Chart`, or other `ChangeNotifier`s), make sure to keep that existing cleanup logic and only insert the conditional `_panelSizeRepo.dispose();` before `super.dispose();` as shown. The `SEARCH` block for `dispose` should be adjusted to match your current method body exactly.
</issue_to_address>
### Comment 3
<location path="lib/src/deriv_chart/chart/resizable_chart_divider.dart" line_range="61-65" />
<code_context>
+ setState(() => _isDragging = false);
+ widget.onDragEnd?.call();
+ },
+ onVerticalDragCancel: () => setState(() => _isDragging = false),
+ child: child,
+ ),
</code_context>
<issue_to_address>
**suggestion:** Align drag-cancel behavior with drag-end to ensure persistence hooks run consistently.
`onVerticalDragEnd` clears `_isDragging` and triggers `onDragEnd`, which persists the panel fractions, but `onVerticalDragCancel` only clears `_isDragging`. If a drag is cancelled after updates, the UI resets while the persisted fractions remain stale.
To keep cancellation behavior consistent with drag end, have `onVerticalDragCancel` call the same cleanup logic:
```dart
onVerticalDragEnd: (_) {
setState(() => _isDragging = false);
widget.onDragEnd?.call();
},
onVerticalDragCancel: () {
setState(() => _isDragging = false);
widget.onDragEnd?.call();
},
```
```suggestion
onVerticalDragEnd: (_) {
setState(() => _isDragging = false);
widget.onDragEnd?.call();
},
onVerticalDragCancel: () {
setState(() => _isDragging = false);
widget.onDragEnd?.call();
},
```
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| double _usableHeightFor(double totalHeight, int dividerCount) => | ||
| (totalHeight - dividerCount * Dimens.chartPanelDividerHitHeight) |
There was a problem hiding this comment.
issue (bug_risk): Guard against zero or negative usableHeight to avoid division-by-zero and NaN/Infinity in resize math.
When totalHeight is less than dividerCount * Dimens.chartPanelDividerHitHeight, _usableHeightFor (now clamped) returns 0. Callers like _ChartStateMobile and _ChartStateWeb then use usableHeight in:
- Drag math:
deltaPixels / usableHeight→Infinity/NaNwhenusableHeight == 0. resizeCascadingFractions:minFraction = indicatorTitleBarMinHeight / usableHeight→ also invalid for zero.
This is reachable on very small view heights or many indicators. In addition to clamping, please short‑circuit resizing when usableHeight <= 0 (e.g., treat drag deltas as no‑ops or skip _resizeCascadingPanels) to avoid corrupting _panelFractions and keep the layout stable.
|
|
||
| late AddOnsRepository<DrawingToolConfig> _drawingToolsRepo; | ||
|
|
||
| final PanelSizeRepository _panelSizeRepo = PanelSizeRepository(); |
There was a problem hiding this comment.
suggestion (bug_risk): Dispose the internal PanelSizeRepository to avoid leaking a ChangeNotifier.
_panelSizeRepo is a ChangeNotifier but isn't disposed in _DerivChartState.dispose. Since Chart only removes its listener, the notifier itself lives for the lifetime of DerivChartState and can accumulate listeners when the widget is recreated.
To fix this, dispose _panelSizeRepo only when this widget owns it (i.e. when widget.panelSizeRepo == null):
@override
void dispose() {
if (widget.panelSizeRepo == null) {
_panelSizeRepo.dispose();
}
super.dispose();
}This keeps lifecycle responsibility clear between host-supplied and internally-created repos.
Suggested implementation:
final PanelSizeRepository _panelSizeRepo = PanelSizeRepository();
final DrawingTools _drawingTools = DrawingTools();
late final InteractiveLayerBehaviour _interactiveLayerBehaviour;
@override
void dispose() {
if (widget.panelSizeRepo == null) {
_panelSizeRepo.dispose();
}
super.dispose();
}
I assumed a minimal dispose implementation (super.dispose();) for the _DerivChartState class. If your actual dispose method already removes listeners (e.g. from panelSizeRepo, Chart, or other ChangeNotifiers), make sure to keep that existing cleanup logic and only insert the conditional _panelSizeRepo.dispose(); before super.dispose(); as shown. The SEARCH block for dispose should be adjusted to match your current method body exactly.
| onVerticalDragEnd: (_) { | ||
| setState(() => _isDragging = false); | ||
| widget.onDragEnd?.call(); | ||
| }, | ||
| onVerticalDragCancel: () => setState(() => _isDragging = false), |
There was a problem hiding this comment.
suggestion: Align drag-cancel behavior with drag-end to ensure persistence hooks run consistently.
onVerticalDragEnd clears _isDragging and triggers onDragEnd, which persists the panel fractions, but onVerticalDragCancel only clears _isDragging. If a drag is cancelled after updates, the UI resets while the persisted fractions remain stale.
To keep cancellation behavior consistent with drag end, have onVerticalDragCancel call the same cleanup logic:
onVerticalDragEnd: (_) {
setState(() => _isDragging = false);
widget.onDragEnd?.call();
},
onVerticalDragCancel: () {
setState(() => _isDragging = false);
widget.onDragEnd?.call();
},| onVerticalDragEnd: (_) { | |
| setState(() => _isDragging = false); | |
| widget.onDragEnd?.call(); | |
| }, | |
| onVerticalDragCancel: () => setState(() => _isDragging = false), | |
| onVerticalDragEnd: (_) { | |
| setState(() => _isDragging = false); | |
| widget.onDragEnd?.call(); | |
| }, | |
| onVerticalDragCancel: () { | |
| setState(() => _isDragging = false); | |
| widget.onDragEnd?.call(); | |
| }, |
|
Claude finished @behnam-deriv's task in 2m 18s —— View job 🤖 Claude PR Review CompleteModel: Summary0 of 4 issues from the previous review have been resolved. The incremental diff since the last review adds Recommendation: REQUEST CHANGES 🟠 High Priority Issues🟠 1. `PanelSizeRepository` created in state but never disposed — memory leak —
|
| Severity | File | Lines |
|---|---|---|
| HIGH | lib/src/deriv_chart/deriv_chart.dart |
221 |
❌ Problematic Code:
class _DerivChartState extends State<DerivChart> {
// ...
final PanelSizeRepository _panelSizeRepo = PanelSizeRepository();
// ...
// No dispose() override anywhere in _DerivChartState📋 Issue: PanelSizeRepository extends ChangeNotifier. Flutter's ChangeNotifier.dispose() removes all registered listeners and signals that the notifier is no longer in use. Creating it inside state without calling dispose() means listeners accumulated over the widget's lifetime are never freed. The Chart widget's _ChartState.initState registers itself as a listener (widget.panelSizeRepo?.addListener(_onPanelSizeRepoChanged)), and _ChartState.dispose unregisters it correctly — but _panelSizeRepo itself is never torn down, which leaks its internal listener list.
DerivChart is mounted and unmounted (e.g., navigating away and back), and spurious callbacks can reach already-disposed objects in debug builds.
✅ Fix:
@override
void dispose() {
if (widget.panelSizeRepo == null) {
_panelSizeRepo.dispose();
}
super.dispose();
}💡 Explanation: ChangeNotifier.dispose() asserts no listeners remain (debug mode) and prevents further notifications after disposal. Since _DerivChartState owns _panelSizeRepo when widget.panelSizeRepo == null, it must dispose it.
🟡 Medium Priority Issues
🟡 2. Uncaught exceptions in `loadFromPrefs` can crash the chart on startup — lib/src/deriv_chart/chart/panel_size/panel_size_repository.dart:61-68
Details
| Severity | File | Lines |
|---|---|---|
| MEDIUM | lib/src/deriv_chart/chart/panel_size/panel_size_repository.dart |
61-68 |
❌ Problematic Code:
final Map<String, dynamic> decoded =
jsonDecode(encoded) as Map<String, dynamic>;
fractions = decoded.map(
(String key, dynamic value) => MapEntry<String, double>(
key,
(value as num).toDouble(),
),
);📋 Issue: Both jsonDecode(encoded) as Map<String, dynamic> and (value as num).toDouble() will throw unhandled exceptions if the stored value has been corrupted, manually edited, or written by a future schema change. The loadFromPrefs caller in _DerivChartState._loadPanelSizes is a fire-and-forget async method — any exception thrown here is silently swallowed. Compare with loadSavedIndicatorsAndDrawingTools, which wraps its load in a try/on Exception block.
'panelHeights' could experience a silent failure to load stored sizes, with no recovery path other than clearing storage.
✅ Fix:
void loadFromPrefs(SharedPreferences prefs) {
_prefs = prefs;
final String? encoded = prefs.getString(_storageKey);
if (encoded == null) {
fractions = <String, double>{};
_loadGeneration++;
notifyListeners();
return;
}
try {
final Map<String, dynamic> decoded =
jsonDecode(encoded) as Map<String, dynamic>;
fractions = decoded.map(
(String key, dynamic value) => MapEntry<String, double>(
key,
(value as num).toDouble(),
),
);
} on Exception {
// Corrupted or schema-incompatible data — start fresh.
fractions = <String, double>{};
_prefs?.remove(_storageKey);
}
_loadGeneration++;
notifyListeners();
}💡 Explanation: Graceful degradation (fall back to empty fractions) is better than a silent crash, and clearing the stale entry prevents the same error on every subsequent restart.
🟢 Low Priority Issues
🟢 3. Millisecond timestamp as `configId` risks collision — lib/src/add_ons/indicators_ui/indicators_dialog.dart:162-164
Details
| Severity | File | Lines |
|---|---|---|
| LOW | lib/src/add_ons/indicators_ui/indicators_dialog.dart |
162-164 |
❌ Problematic Code:
repo.add(config.copyWith(
number: postFixNumber,
configId:
DateTime.now().millisecondsSinceEpoch.toString(),
));📋 Issue: Two indicators added in the same millisecond (e.g., from a batch add or a fast double-tap) will share the same configId, causing them to map to the same panel-size key. The second one silently overwrites the first's persisted size.
PanelSizeRepository entry; resizing one unexpectedly sets the other's size on the next app launch.
✅ Fix:
import 'dart:math' as math;
String _generateConfigId() {
final int ms = DateTime.now().millisecondsSinceEpoch;
final int rand = math.Random().nextInt(0xFFFFFF);
return '${ms}_$rand';
}
// In the add handler:
repo.add(config.copyWith(
number: postFixNumber,
configId: _generateConfigId(),
));Or, if the uuid package is already available, simply use const Uuid().v4().
💡 Explanation: Appending a random suffix makes same-millisecond collisions astronomically unlikely without adding a package dependency.
🟢 4. `context.read<ChartTheme>()` in `build()` won't react to theme changes — lib/src/deriv_chart/chart/resizable_chart_divider.dart:52
Details
| Severity | File | Lines |
|---|---|---|
| LOW | lib/src/deriv_chart/chart/resizable_chart_divider.dart |
52 |
❌ Problematic Code:
ChartTheme get _theme => context.read<ChartTheme>();This getter is called from within build() at lines 72–74.
📋 Issue: context.read does not subscribe to the Provider — the widget will not rebuild when ChartTheme changes (e.g., system dark/light mode switch). The provider package's own documentation explicitly warns against calling read inside build.
✅ Fix:
// Remove the getter and use context.watch directly in build():
@override
Widget build(BuildContext context) {
final ChartTheme theme = context.watch<ChartTheme>();
final Color color = _isDragging
? theme.panelDividerActiveColor
: theme.crosshairLineDesktopColor;
// ...
}💡 Explanation: context.watch subscribes to the provided value and triggers a rebuild when it changes, which is the correct pattern for values consumed in build.
Summary Table
| Priority | Count | Categories |
|---|---|---|
| 🔴 Critical | 0 | — |
| 🟠 High | 1 | Missing ChangeNotifier.dispose() in _DerivChartState |
| 🟡 Medium | 1 | No error handling in PanelSizeRepository.loadFromPrefs |
| 🟢 Low | 2 | Millisecond-based configId collision; context.read in build |
Recommendations
- Add
dispose()to_DerivChartState— one-liner fix, high impact; should block merge. - Wrap
loadFromPrefsin a try/catch — keeps chart working even on schema mismatch. - Improve
configIduniqueness — add a random suffix or use a UUID. - Switch to
context.watchinResizableChartDivider.build— aligns withproviderbest practices.
Auto Fix Claude Reviews
| Action | Open Dashboard |
|---|
🤖 Claude PR Review CompleteModel: Summary0 of 4 issues from the previous review have been resolved. The incremental diff since the last review adds Recommendation: REQUEST CHANGES 🟠 High Priority Issues🟠 1. `PanelSizeRepository` created in state but never disposed — memory leak —
|
| Severity | File | Lines |
|---|---|---|
| HIGH | lib/src/deriv_chart/deriv_chart.dart |
221 |
❌ Problematic Code:
class _DerivChartState extends State<DerivChart> {
// ...
final PanelSizeRepository _panelSizeRepo = PanelSizeRepository();
// ...
// No dispose() override anywhere in _DerivChartState📋 Issue: PanelSizeRepository extends ChangeNotifier. Flutter's ChangeNotifier.dispose() removes all registered listeners and signals that the notifier is no longer in use. Creating it inside state without calling dispose() means listeners accumulated over the widget's lifetime are never freed. The Chart widget's _ChartState.initState registers itself as a listener (widget.panelSizeRepo?.addListener(_onPanelSizeRepoChanged)), and _ChartState.dispose unregisters it correctly — but _panelSizeRepo itself is never torn down, which leaks its internal listener list.
DerivChart is mounted and unmounted (e.g., navigating away and back), and spurious callbacks can reach already-disposed objects in debug builds.
✅ Fix:
@override
void dispose() {
if (widget.panelSizeRepo == null) {
_panelSizeRepo.dispose();
}
super.dispose();
}💡 Explanation: ChangeNotifier.dispose() asserts no listeners remain (debug mode) and prevents further notifications after disposal. Since _DerivChartState owns _panelSizeRepo when widget.panelSizeRepo == null, it must dispose it.
🟡 Medium Priority Issues
🟡 2. Uncaught exceptions in `loadFromPrefs` can crash the chart on startup — lib/src/deriv_chart/chart/panel_size/panel_size_repository.dart:61-68
Details
| Severity | File | Lines |
|---|---|---|
| MEDIUM | lib/src/deriv_chart/chart/panel_size/panel_size_repository.dart |
61-68 |
❌ Problematic Code:
final Map<String, dynamic> decoded =
jsonDecode(encoded) as Map<String, dynamic>;
fractions = decoded.map(
(String key, dynamic value) => MapEntry<String, double>(
key,
(value as num).toDouble(),
),
);📋 Issue: Both jsonDecode(encoded) as Map<String, dynamic> and (value as num).toDouble() will throw unhandled exceptions if the stored value has been corrupted, manually edited, or written by a future schema change. The loadFromPrefs caller in _DerivChartState._loadPanelSizes is a fire-and-forget async method — any exception thrown here is silently swallowed. Compare with loadSavedIndicatorsAndDrawingTools, which wraps its load in a try/on Exception block.
'panelHeights' could experience a silent failure to load stored sizes, with no recovery path other than clearing storage.
✅ Fix:
void loadFromPrefs(SharedPreferences prefs) {
_prefs = prefs;
final String? encoded = prefs.getString(_storageKey);
if (encoded == null) {
fractions = <String, double>{};
_loadGeneration++;
notifyListeners();
return;
}
try {
final Map<String, dynamic> decoded =
jsonDecode(encoded) as Map<String, dynamic>;
fractions = decoded.map(
(String key, dynamic value) => MapEntry<String, double>(
key,
(value as num).toDouble(),
),
);
} on Exception {
// Corrupted or schema-incompatible data — start fresh.
fractions = <String, double>{};
_prefs?.remove(_storageKey);
}
_loadGeneration++;
notifyListeners();
}💡 Explanation: Graceful degradation (fall back to empty fractions) is better than a silent crash, and clearing the stale entry prevents the same error on every subsequent restart.
🟢 Low Priority Issues
🟢 3. Millisecond timestamp as `configId` risks collision — lib/src/add_ons/indicators_ui/indicators_dialog.dart:162-164
Details
| Severity | File | Lines |
|---|---|---|
| LOW | lib/src/add_ons/indicators_ui/indicators_dialog.dart |
162-164 |
❌ Problematic Code:
repo.add(config.copyWith(
number: postFixNumber,
configId:
DateTime.now().millisecondsSinceEpoch.toString(),
));📋 Issue: Two indicators added in the same millisecond (e.g., from a batch add or a fast double-tap) will share the same configId, causing them to map to the same panel-size key. The second one silently overwrites the first's persisted size.
PanelSizeRepository entry; resizing one unexpectedly sets the other's size on the next app launch.
✅ Fix:
import 'dart:math' as math;
String _generateConfigId() {
final int ms = DateTime.now().millisecondsSinceEpoch;
final int rand = math.Random().nextInt(0xFFFFFF);
return '${ms}_$rand';
}
// In the add handler:
repo.add(config.copyWith(
number: postFixNumber,
configId: _generateConfigId(),
));Or, if the uuid package is already available, simply use const Uuid().v4().
💡 Explanation: Appending a random suffix makes same-millisecond collisions astronomically unlikely without adding a package dependency.
🟢 4. `context.read<ChartTheme>()` in `build()` won't react to theme changes — lib/src/deriv_chart/chart/resizable_chart_divider.dart:52
Details
| Severity | File | Lines |
|---|---|---|
| LOW | lib/src/deriv_chart/chart/resizable_chart_divider.dart |
52 |
❌ Problematic Code:
ChartTheme get _theme => context.read<ChartTheme>();This getter is called from within build() at lines 72–74.
📋 Issue: context.read does not subscribe to the Provider — the widget will not rebuild when ChartTheme changes (e.g., system dark/light mode switch). The provider package's own documentation explicitly warns against calling read inside build.
✅ Fix:
// Remove the getter and use context.watch directly in build():
@override
Widget build(BuildContext context) {
final ChartTheme theme = context.watch<ChartTheme>();
final Color color = _isDragging
? theme.panelDividerActiveColor
: theme.crosshairLineDesktopColor;
// ...
}💡 Explanation: context.watch subscribes to the provided value and triggers a rebuild when it changes, which is the correct pattern for values consumed in build.
Summary Table
| Priority | Count | Categories |
|---|---|---|
| 🔴 Critical | 0 | — |
| 🟠 High | 1 | Missing ChangeNotifier.dispose() in _DerivChartState |
| 🟡 Medium | 1 | No error handling in PanelSizeRepository.loadFromPrefs |
| 🟢 Low | 2 | Millisecond-based configId collision; context.read in build |
Recommendations
- Add
dispose()to_DerivChartState— one-liner fix, high impact; should block merge. - Wrap
loadFromPrefsin a try/catch — keeps chart working even on schema mismatch. - Improve
configIduniqueness — add a random suffix or use a UUID. - Switch to
context.watchinResizableChartDivider.build— aligns withproviderbest practices.
Auto Fix Claude Reviews
| Action | Open Dashboard |
|---|
Clickup link:
Fixes issue: #
This PR contains the following changes:
Developers Note (Optional)
Pre-launch Checklist (For PR creator)
As a creator of this PR:
Reviewers
Pre-launch Checklist (For Reviewers)
As a reviewer I ensure that:
Pre-launch Checklist (For QA)
Pre-launch Checklist (For Maintainer)
Summary by Sourcery
Add persistent, resizable layout for main chart and bottom indicator panels across mobile and web, including unique per-indicator panel identities and themeable divider UI.
Enhancements:
Tests: