Skip to content

Multi-connection envelopes + portfolio refactor + dashboard reorder#81

Merged
Crynners merged 26 commits into
mainfrom
feature/multi-connection-envelopes
Apr 12, 2026
Merged

Multi-connection envelopes + portfolio refactor + dashboard reorder#81
Crynners merged 26 commits into
mainfrom
feature/multi-connection-envelopes

Conversation

@nehasvit
Copy link
Copy Markdown
Collaborator

Summary

Bundles the multi-connection envelopes refactor with a portfolio screen rework, dashboard drag & drop, and a full round of review fixes.

Features

  • Multi-connection envelopes per exchange - multiple API credential sets per burza (e.g. Hlavní / Spoření), full Room migration v18→v19, connection-keyed credentials store (v3).
  • Portfolio refactor - per-plan chart pages with chip navigation, per-plan lines on aggregate view, 16 distinct colors for plan-metric combos, persistent chip selection, advanced legend with per-plan avg buy / accumulated and per-crypto totals.
  • Dashboard drag & drop reorder of DCA plans with drag handle and persistent displayOrder.
  • Optional plan name when creating a DCA plan.
  • Multi-plan warnings and fiat min-amount fix.

Review fixes (last commit)

  • @SerializedName on every Gson-serialized backup model + ExchangeCredentials - preventive protection against R8 field renaming in release builds.
  • DashboardViewModel.reorderPlans serialized via Mutex + distinctUntilChanged on the plan flow so collectLatest stops cancelling balance/price fetches on every drag swap.
  • PortfolioViewModel.loadChartData offloaded to Dispatchers.Default with cachedDbPlans (eliminates N+1 DB queries on chart navigation).
  • PlanDragState tracks plan ID instead of list index - immune to Flow emits mid-drag.
  • BackupDataRestorer post-restore integrity check for connections missing credentials after a half-completed restore.
  • PortfolioScreen pager re-aligns with selectedPageIndex on structural page list changes.
  • CredentialsStore.migrateV2ToV3ForEnv no longer latches the done-flag on partial failure.
  • DcaWorker raises a "missing credentials" notification (new template + Czech/English strings) with in-memory dedup instead of silently looping.
  • TransactionDao.getByExchangeOrderIdAndConnection scopes restore dedup to (orderId, connectionId).
  • PortfolioViewModel consumes deep-link args once and clears them from SavedStateHandle.
  • DashboardViewModel folds connection names into the main Flow combine.

Test plan

  • ./gradlew :app:assembleDebug - BUILD SUCCESSFUL
  • ./gradlew :app:assembleRelease - R8 minify + lint pass (packageRelease fails only on missing keystore, expected)
  • Installed debug APK on SM-G970F (Android 12)
  • Manual: drag & drop reorder on dashboard, multiple rapid drags
  • Manual: portfolio chart navigation (zoom, chip tap, scrub) with no main-thread jank
  • Manual: portfolio pager alignment after adding/removing/renaming a plan
  • Manual: multi-connection scenario - create "Hlavní" and "Spoření" envelopes on same exchange, verify independent credentials + balances
  • Manual: backup export (v2) + restore into fresh install - check no orphaned plans
  • Manual: Run a plan with its connection's credentials deleted - expect a notification within one worker tick

vitnehasil and others added 26 commits April 10, 2026 00:25
… fixes

Allows multiple credential sets ("envelopes") per exchange so users can run
e.g. two Coinmate sub-accounts as "Hlavní" and "Spoření". Connection is now a
first-class entity with its own credentials, balances, withdrawal thresholds
and notifications.

## Schema (Room v18 -> v19)

- New ExchangeConnectionEntity (id, exchange, name, createdAt, displayOrder)
  with unique index on (exchange, name) so the auto-created default envelope
  is unique per exchange and named envelopes can't collide.
- DcaPlanEntity, TransactionEntity, WithdrawalEntity, NotificationEntity:
  new connectionId column (NOT NULL on plans, nullable elsewhere so history
  survives connection deletion).
- WithdrawalThresholdEntity: PK changed from (crypto, exchange) to
  (crypto, connectionId). No DB-level FK; ExchangeConnectionRepository.delete
  cascades manually.
- ExchangeBalanceEntity: composite PK (connectionId, currency) so two
  envelopes on the same exchange keep separate balance caches.
- MIGRATION_18_19: auto-creates one default empty-named connection per
  Exchange enum referenced anywhere in user data, backfills connectionId
  on all child rows, recreates withdrawal_thresholds and exchange_balances
  with new PKs, post-migration sanity check on dca_plans.connectionId = 0.

## CredentialsStore (v2 -> v3 keying)

- Keys are now credentials_v3_{prod|sandbox}_{connectionId}; env prefix is
  required because prod and sandbox each have their own Room DB with
  independent autoincrement IDs.
- ensureMigrated(prodDb, sandboxDb) re-keys old credentials_{env}_{EXCHANGE}
  entries by looking up the corresponding connection in each DB. Idempotent
  via credentials_migration_v3_done flag. Called via runBlocking from
  AccBotApplication.onCreate so the migration completes before any
  background worker tries to load credentials by connectionId.
- @deprecated suspend shims keep legacy Exchange-based callers compiling
  during the gradual refactor.

## Repository / use case layer

- New ExchangeConnectionRepository with observe/get/create/rename/delete +
  manual cascade (plans, thresholds, balances, credentials). delete() now
  blocks with IllegalStateException when plans still reference the connection
  and deletePlans=false.
- ValidateAndSaveCredentialsUseCase is connection-aware: creates the
  connection up front, validates API keys, rolls back the connection on
  any failure (network, IO, business). Returns connectionId in Success.
- CreateDcaPlanUseCase takes optional connectionId and throws on missing
  connection instead of silently auto-creating an empty one.

## DcaWorker / NotificationService

- DcaWorker uses plan.connectionId for credentials lookup, threshold check,
  balance check and TransactionEntity inserts.
- NotificationService.show* are now suspend (was runBlocking + N+1 DAO
  query). Notification titles render "Coinmate - Spoření" when the
  connection has a non-empty name.
- NotificationEntity persists connectionId for the in-app notification
  history.

## Backup format v1 -> v2

- BackupPayload now carries a connections list, plus connectionId fields on
  plans/transactions/withdrawals/notifications/credentials/thresholds.
- BackupDataRestorer handles both v1 (legacy: auto-create one default
  envelope per exchange) and v2 (remap backup-local IDs to fresh local IDs
  via connectionIdMap, dedupe by (exchange, name)).
- Credentials are pre-validated before the DB transaction so a malformed
  backup aborts before any DB changes are made; race-safe insert pattern
  for default connections.

## UI

- ExchangeManagementScreen: lists individual connections (one tile per
  envelope), grouped by exchange. The "Available" section no longer filters
  out exchanges with credentials so users can add a 2nd connection. Settings
  card subtitle counts connections, not unique exchanges.
- AddExchangeScreen: optional "Connection name" input, becomes required when
  there's already 1+ connections on the same exchange. Validate button is
  disabled until the existing-connections lookup completes (race guard).
- AddPlanScreen: connection picker. When the selected exchange has 0
  connections -> credentials form (current behavior). 1 connection ->
  auto-selected, no picker needed. 2+ connections -> radio picker with
  envelope names plus a "Create new connection" option.
- DashboardScreen plan card shows "Coinmate - Spoření" label when the
  connection has a name (batch lookup in DashboardViewModel).

## Play Store warning fixes (bundled)

- enableEdgeToEdge() in MainActivity, removed deprecated statusBarColor /
  navigationBarColor from themes.xml + Theme.kt. Targets Android 15
  edge-to-edge requirements.
- DcaWorker.runFromAlarm: removed setExpedited() so the alarm-triggered
  WorkManager chain doesn't reach into a foreground service that would be
  flagged by Play Console as "FGS launched from BOOT_COMPLETED" on
  Android 15+ (alarm wakes the device, regular work runs immediately
  anyway).

## Critical migration bug fixed mid-rollout

The first install on a real device crashed because the v18->v19 migration
created withdrawal_thresholds with FOREIGN KEY ... ON DELETE CASCADE while
the entity declared no foreign keys. Room schema validation rolled back
the migration. Removed the FOREIGN KEY from the migration SQL; cascade is
now handled explicitly by ExchangeConnectionRepository.delete via
WithdrawalThresholdDao.deleteByConnection.

Build: ./gradlew :app:assembleDebug succeeds; androidTest sources also
compile after fixing the test fixtures (CredentialsStore now needs the
ExchangeConnectionDao and credentials operations are suspend).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- ExchangeManagement: subtitle unified to connection name or "Výchozí"
  for unnamed connections (was "Připojeno" which was redundant in the
  "Connected" section)
- ExchangeDetail: inline rename via pencil icon under avatar. Tapping
  opens a text field; save calls connectionRepository.rename(). Top bar
  title includes connection name when set.
- AddPlan picker: accent-colored selection (green / sandbox orange at
  15% alpha) instead of Material3 default purple primaryContainer.
  Radio dot and text also use accent color for consistency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Schema (Room v19 -> v20):
- DcaPlanEntity gains `name TEXT DEFAULT ''` and `displayOrder INTEGER
  DEFAULT 0` columns. Simple ALTER TABLE migration, no table recreate.

Dashboard:
- Plans sorted by displayOrder ASC, then createdAt DESC (was only createdAt).
- DcaPlanCard shows custom plan name above "BTC/EUR" in accent color when
  set.
- Arrow-up / arrow-down buttons on each card for manual reorder (swap
  displayOrder values). First plan hides up arrow, last hides down.

PlanDetailsScreen:
- Inline editable plan name in the header card. If no name is set, a subtle
  "Add plan name" link appears. Tapping opens a text field with save button.
- renamePlan() in PlanDetailsViewModel persists via DcaPlanDao.renamePlan().

DashboardViewModel:
- reorderPlan(fromIndex, toIndex) swaps displayOrder between two plans.

Connection label fix:
- "Výchozí" changed to "Výchozí připojení" / "Default connection" for
  unnamed exchange connections (clearer when shown alongside named ones).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Import warning:
- ImportConfigDialog shows a warning card when the connection has other DCA
  plans, explaining that import fetches ALL trades from the exchange account
  (not just this plan's). Duplicate transactions are filtered automatically
  but the user should be aware of the shared-account nature.
- PlanDetailsViewModel computes otherPlansOnSameConnection count from
  DcaPlanDao.countPlansByConnection.

AddPlan multi-plan warning:
- When user picks a connection that already has plans, a similar warning card
  appears above the plan form in AddPlanScreen.
- CredentialFormDelegate.selectExistingConnection loads plan count via
  DcaPlanDao (new optional constructor parameter).

Fiat min-amount bug fix:
- PlanFormDelegate.selectFiat now bumps the amount to the new fiat's static
  minimum if the current amount is below it. Previously switching EUR->CZK
  kept the amount at 2 (EUR minimum) even though CZK minimum is 50.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces all em-dash characters (U+2014) with plain hyphens in code
comments, string resources and Kotlin string literals across all files
changed by the multi-connection feature. Also fixes two Czech warning
strings (import_api_multi_plan_warning, add_plan_multi_plan_warning)
that were missing diacritics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace arrow-button reordering with long-press drag handle on each
DcaPlanCard. PlanDragState tracks drag offset and triggers reorder when
the card crosses the midpoint of a neighbor. Both portrait and landscape
LazyColumn layouts are updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tedState

Also: hide drag handle until long-press activates drag, apply
detectDragGesturesAfterLongPress on entire card instead of small icon.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…oggles

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…nd Plan pages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…disabled plans

- When toggling a plan OFF, show confirmation dialog before disabling
- When plan is disabled, show "Paused"/"Pozastaveno" instead of next execution time
- Re-enabling a plan works immediately without confirmation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…isabled plans

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reverting to prepare for a different approach: per-plan lines within
per-pair views instead of separate per-plan pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When viewing a SinglePair page (e.g. BTC/EUR) with 2+ DCA plans,
the chart now shows individual plan portfolio-value lines alongside
the pair total. Each plan line is toggleable via a dedicated legend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…an lines on aggregate

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…e totals

- Plan pages no longer show redundant per-plan lines
- Aggregate pages show value+invested per plan in legend, toggleable independently
- Invested line uses same plan color with 0.4 alpha
- Main legend on aggregate shows "Total value" / "Total invested" (Celkem)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Stable page identifier (agg:<fiat> or plan:<id>) survives plan add/remove/reorder.
Explicit deep-link navigation from dashboard still takes priority.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…n Now sheet

- Portfolio: tapping a chip now scrolls the HorizontalPager to that page so KPI card updates
- Run Now sheet: show custom plan name above crypto/fiat pair when set

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… tap

Previously, tapping a chip to jump 2+ pages triggered intermediate
currentPage updates during animation, which caused the reverse-sync
LaunchedEffect to overwrite the target page mid-animation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…cumulated and per-crypto totals

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…okročilé" to "Další metriky"

- Each plan-metric combination (value/invested/avg buy/accumulated) gets its
  own distinct color from a 16-color palette. Plan 0 uses colors 0..3, Plan 1
  uses 4..7, etc. Cycles modulo 16 after 4 plans.
- Legend labels use the same assignment so chart and legend colors match.
- Section toggle renamed: "Pokročilé" -> "Další metriky" / "More metrics"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…vestováno)

Previous guard prevented the last visible series from being hidden, which
made "Celkem investováno" untoggleable if "Celkem hodnota" was already off.
With plan lines and crypto group lines available on aggregate view, an empty
base series set is valid.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Shown in AddPlan and FirstPlan screens (onboarding). EditPlan keeps the
existing separate rename dialog, so showNameField=false there.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Code review pass over the feature/multi-connection-envelopes branch surfaced
a handful of concurrency, performance and resilience issues. This commit
addresses all of them:

Critical
- Add @SerializedName to every Gson-serialized backup model and
  ExchangeCredentials. Release buildu with R8 field renaming was only safe
  because the enclosing packages are in proguard-rules.pro - belt-and-
  suspenders so future refactors can't silently break backup restore.
- DashboardViewModel.reorderPlans: Mutex serializes display-order writes and
  the plan flow is distinctUntilChanged on content (ignoring displayOrder) so
  collectLatest no longer cancels in-flight balance/price fetches on every
  drag swap.
- PortfolioViewModel.loadChartData: cache cachedDbPlans (no more N+1 DB
  queries on chart navigation) and run the heavy per-plan/per-crypto chart
  computation on Dispatchers.Default instead of the main thread.

High
- DashboardScreen PlanDragState now tracks plan ID instead of list index and
  resolves the live index on every drag event - immune to Flow emits
  reshuffling the list mid-drag.
- BackupDataRestorer runs a post-restore integrity check and surfaces
  connections that ended up with plans but no credentials, so DcaWorker no
  longer silently loops forever on a half-restored backup.
- PortfolioScreen adds a LaunchedEffect keyed on the structural page list
  that re-aligns the pager with selectedPageIndex when plans are added,
  removed or renamed.

Medium
- CredentialsStore.migrateV2ToV3ForEnv returns a success flag so partial
  failures no longer latch KEY_MIGRATION_V3_DONE; remaining exchanges get
  another chance on next launch.
- DcaWorker now raises a "missing credentials" notification (new
  NotificationTemplateArgs.MissingCredentials template + strings) when a
  plan's connection has no stored API keys, with in-memory dedup so the
  user gets one notification per plan per worker lifetime.

Low / bundle
- TransactionDao.getByExchangeOrderIdAndConnection dedupes restore by
  (exchangeOrderId, connectionId) - two connections on the same exchange
  that share an orderId no longer collapse into one row.
- PortfolioViewModel consumes deep-link initialCrypto/initialFiat once and
  clears them from SavedStateHandle so a process death restore no longer
  overrides the persisted chip selection.
- DashboardViewModel folds exchange connection names into the main combine
  flow instead of making per-emit per-id getById lookups.

Verified with ./gradlew :app:assembleDebug and ./gradlew :app:assembleRelease
(R8 minify + lint pass; only packageRelease fails on the missing signing
keystore, which is expected).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@Crynners Crynners merged commit 260f4ed into main Apr 12, 2026
1 of 2 checks passed
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.

3 participants