Compare commits

..

66 Commits

Author SHA1 Message Date
Kleidukos 567ddea46a chore(infra): update compose configurations, mqtt health bridge, and export scripts 2026-09-01 17:38:57 +02:00
Kleidukos 2fe8cc0dca fix(assets): safeguard against null InstrumentCategory during ETF/Fund scans 2026-09-01 17:38:46 +02:00
Kleidukos d161efb370 fix(simulation,technicals): fix EMA 200 warmup distortion, intrabar execution path, and zero slippage 2026-09-01 17:38:34 +02:00
Kleidukos cb8a169043 feat(technicals,engine): add V2 multi-timeframe scoring, SMC patterns, and COS V2 engine 2026-09-01 17:38:13 +02:00
Kleidukos c5d7d359ba feat(notify): add FinlyticNotify push notification microservice and test suite 2026-09-01 17:38:01 +02:00
Kleidukos 6a0f9af3f8 docs: add comprehensive platform STATE.md and diagnostic PROBLEMS.md 2026-09-01 17:37:49 +02:00
Kleidukos 2b7d59f40d chore: remove deprecated microservices, update solution and docker compose configurations 2026-08-24 21:37:57 +02:00
Kleidukos 0894c40f07 feat(app): add evaluation history, bot control panel, simulation visualizer, and vendored signalr_core 2026-08-24 21:37:43 +02:00
Kleidukos 676496b77d feat(backend): add admin evaluation history, engine, simulation, bot API controllers and global exception middleware 2026-08-24 21:37:32 +02:00
Kleidukos 6974b2075b refactor(assets): update asset entity mappings, database migrations, and MQTT RPC handlers 2026-08-24 21:37:21 +02:00
Kleidukos 7060f0f7b1 refactor(bot): update paper trading models, broker integration, background services, and test project 2026-08-24 21:37:10 +02:00
Kleidukos 5c95dd182c feat(engine): add FinlyticEngine microservice with trade lifecycle, AI reasoning gate, composite scoring, and unit tests 2026-08-24 21:37:05 +02:00
Kleidukos a4959658a2 feat(simulation): add quant simulation microservice with virtual backtest broker and replay engine 2026-08-24 21:36:20 +02:00
Kleidukos f43ce2b7e9 feat(technicals): add technical analysis microservice with indicator engines, pattern detectors, and strategies 2026-08-24 21:36:05 +02:00
Kleidukos 12e7b57b16 feat(sentiment): add persistent sentiment entities, summaries, SentimentDbService and MQTT RPC refactoring 2026-08-24 21:35:48 +02:00
Kleidukos 600ccf299e feat(fundamentals): add KeyedLockPool for concurrent scraping synchronization and update MQTT RPC handlers 2026-08-24 21:35:43 +02:00
Kleidukos 8112598602 feat(news): add scraper adapters, article deduplication, blocklist service, and remove tracked publish artifacts 2026-08-24 21:35:33 +02:00
Kleidukos 44b161d509 feat(core): add shared DTOs, MqttTopics constants, DatabaseBootstrapper, and ManagedMqttClient extensions 2026-08-24 21:35:24 +02:00
Kleidukos 6ab84fe1de chore: update .gitignore and add developer documentation 2026-08-24 21:35:11 +02:00
Kleidukos 5497cc5de7 feat(bot): add FinlyticBot autonomous paper trading microservice with Alpaca Markets API integration 2026-08-17 16:32:47 +02:00
Kleidukos 3972507cb0 feat(settings): add dynamic N8N webhook URL configuration for News, Sentiment and Analyzer services 2026-08-17 16:10:31 +02:00
Kleidukos 75a2510b39 feat(frontend): make baseUrl dynamic for external network access and clear default login inputs 2026-08-16 16:59:36 +02:00
Kleidukos aaef272f4e fix(backend): resolve TR json serialization, news concurrency and asset index locks 2026-08-16 16:57:34 +02:00
Kleidukos d8ba28a810 fix(news): normalize URL and clear change tracker on insert conflict to prevent concurrency exception 2026-08-16 14:16:02 +02:00
Kleidukos b5cc70c08c fix(news): use CreateAsyncScope for Playwright service and gracefully handle DbUpdateException on duplicate URLs 2026-08-16 14:11:18 +02:00
Kleidukos 2ba54e8057 feat(fundamentals): multi-ticker DB caching, parallel html scraper, and frontend mappings 2026-08-16 14:05:57 +02:00
Kleidukos b0f8d4b78b feat(app): live terminal log console widget and service dynamic settings management 2026-08-15 21:31:00 +02:00
Kleidukos f18f75c1ab feat(backend): generic settings RPC bridge, LogStreamHub SignalR, and log ringbuffer 2026-08-15 21:30:56 +02:00
Kleidukos 1f9d66405a feat(assets): dynamic settings, IFinlyticLogger, live log streaming, and EF migration 2026-08-15 21:30:46 +02:00
Kleidukos 57554a9582 feat(trades): dynamic settings, IFinlyticLogger, live log streaming, and EF migration 2026-08-15 21:30:19 +02:00
Kleidukos 0d370d09e7 feat(analyzer): dynamic settings, IFinlyticLogger, live log streaming, and EF migration 2026-08-15 21:30:16 +02:00
Kleidukos 62e030e2cf feat(sentiment): dynamic settings, IFinlyticLogger, live log streaming, and EF migration 2026-08-15 21:30:12 +02:00
Kleidukos 1522c3480f feat(ta): dynamic settings, IFinlyticLogger, live log streaming, and EF migration 2026-08-15 21:30:05 +02:00
Kleidukos a94c36a878 feat(news): dynamic settings, IFinlyticLogger, live log streaming, and EF migration 2026-08-15 21:30:01 +02:00
Kleidukos a1f2b888f6 feat(fundamentals): dynamic settings, IFinlyticLogger, live log streaming, and EF migration 2026-08-15 21:29:50 +02:00
Kleidukos 3dbee36ca0 feat(core): dynamic settings service, IFinlyticLogger, log broadcaster, and persistent Yahoo auth 2026-08-15 21:29:38 +02:00
Kleidukos 34fa774cbf feat(trades): add live execution cockpit, closing cockpit, calculation cards and precision trade settings 2026-08-15 19:30:25 +02:00
Kleidukos 882d24a316 feat(derivatives): fix Trade Republic derivative pagination streaming and picker modal 2026-08-15 19:30:15 +02:00
Kleidukos 7e18257e3e feat(fundamentals): preserve ticker selection and add deterministic key executives sorting 2026-08-15 19:30:04 +02:00
Kleidukos 960a4bdbd6 feat(news,calendar): improve corporate calendar filters and news sentiment dialogs 2026-08-15 19:30:00 +02:00
Kleidukos 067f1bfdd5 feat(core): add internal crypto isin resolution and subtitle mapping 2026-08-15 19:29:32 +02:00
Kleidukos 5a6a50a609 feat(chart): interactive zoom/pan controls, pattern overlays, predictions, individual pattern filtering and landscape fullscreen mode 2026-08-15 01:03:57 +02:00
Kleidukos 1ccb6b613f feat(asset_detail): modular fundamentals sections, executive salaries and logo resolution 2026-08-15 01:03:30 +02:00
Kleidukos 15f8f7896e feat(app): clean architecture with typed repositories, DTO models and calendar event logos 2026-08-15 01:03:22 +02:00
Kleidukos f08fecde23 feat(backend): support calendar image routing and fundamentals executive salary mapping 2026-08-15 01:03:07 +02:00
Kleidukos a3f9e55a7e chore(docker): update docker compose configuration 2026-08-14 23:57:14 +02:00
Kleidukos 1d244b338a feat(app): responsive asset detail layout, full width chart, reactive hero header, shimmer loaders and enriched fundamentals 2026-08-14 23:57:03 +02:00
Kleidukos f94e3b8164 feat(backend): add analysis and calendar endpoints 2026-08-14 23:56:57 +02:00
Kleidukos 5c4b3165ba feat(technical,sentiment,news): add pending migrations and DbContext updates 2026-08-14 23:56:36 +02:00
Kleidukos c496651dd1 feat(analyzer): update MQTT handler, active trade monitoring, and dynamic settings 2026-08-14 23:55:52 +02:00
Kleidukos 4d5ab09bbd feat(assets): update scanner background service, derivative entities, and dynamic settings 2026-08-14 23:55:47 +02:00
Kleidukos 2151fd89f0 feat(trades): align TradeProposal and TradeAcceptance DTOs, update lifecycle and migrations 2026-08-14 23:55:29 +02:00
Kleidukos 1447f0aa4c feat(fundamentals): refactor entities, add Yahoo modules scraper, 52W metrics and migrations 2026-08-14 23:55:15 +02:00
Kleidukos 4f733bf8c3 feat(core): update DTOs, Trade Republic client, Yahoo scrapers, and dynamic settings 2026-08-14 23:55:02 +02:00
Kleidukos 3d8af3940b refactor: save current workspace state including FinlyticAnalyzer fixes, FinlyticApp trade route alignment, and DTO audit documentation 2026-08-12 18:30:42 +02:00
Kleidukos a9553e9fbf feat(Backend): update API gateway and websocket hubs 2026-08-09 21:32:52 +02:00
Kleidukos fdf4b6efcb chore: update root config, scripts, and remaining files 2026-08-09 21:01:48 +02:00
Kleidukos a708d2977c feat(App): update Finlytic Flutter app UI and blocs 2026-08-09 21:01:46 +02:00
Kleidukos e7427b7464 feat(Trades): refactor trades MQTT client and DTOs 2026-08-09 21:01:42 +02:00
Kleidukos c74a4456af feat(TA): update technical analysis service 2026-08-09 21:01:42 +02:00
Kleidukos 05d55a324c feat(Sentiment): update sentiment service 2026-08-09 21:01:42 +02:00
Kleidukos aff831cf58 feat(News): refactor news service and add sentiment tracking 2026-08-09 21:01:41 +02:00
Kleidukos 605e41cfeb feat(Fundamentals): add fundamentals service 2026-08-09 21:01:39 +02:00
Kleidukos e0778b88ea feat(Assets): update asset services and background workers 2026-08-09 21:01:39 +02:00
Kleidukos b57cc9894c feat(Analyzer): refactor analyzer and implement auto mode 2026-08-09 21:01:38 +02:00
Kleidukos 5475c3ac51 feat(Core): update DTOs and shared models 2026-08-09 21:01:38 +02:00
655 changed files with 300872 additions and 2639 deletions
+14 -1
View File
@@ -22,4 +22,17 @@
**/secrets.dev.yaml **/secrets.dev.yaml
**/values.dev.yaml **/values.dev.yaml
LICENSE LICENSE
README.md README.md
## Exported Docker image archives — never needed inside a build context (~2.7 GB)
Docker/
**/*.tar
## Flutter client — not referenced by any Dockerfile (~450 MB).
## The compiled web bundle ships via FinlyticBackend/wwwroot instead.
FinlyticApp/
**/.dart_tool/
**/build/
## dotnet publish output on the host (final stage copies from the publish stage)
**/publish/
+14 -2
View File
@@ -1,6 +1,8 @@
## Build outputs ## Build outputs
**/bin/ **/bin/
**/obj/ **/obj/
**/publish/
TestResults/
## Rider / JetBrains / VS Code / Visual Studio ## Rider / JetBrains / VS Code / Visual Studio
.idea/ .idea/
@@ -22,11 +24,21 @@ assets/
## Secrets & local environment files ## Secrets & local environment files
.env .env
.env.*
.env.bak*
*.env.local *.env.local
appsettings.Development.json appsettings.Development.json
## User-specific files ## User-specific files
*.user *.user
*.suo *.suo
*.userosscache ## Flutter & Dart
*.sln.docstates **/.dart_tool/
**/build/
*.log
## Temporary data & scratch
Yahoo finance data/
*.tmp
*.tar
FinlyticApp/lib/tickers_grep.json
+137
View File
@@ -0,0 +1,137 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
Finlytic is a personal financial analysis/trading platform: a fleet of .NET 10 microservices communicating
over MQTT, one HTTP/WebSocket gateway (`FinlyticBackend`), and a Flutter app (`FinlyticApp`) as the only
client. There is no CI/multi-developer setup — this is a single-developer repo backed by one shared
PostgreSQL instance ("OmniDB") and one MQTT broker, both external to this repo.
## Commands
### Backend (.NET)
```
dotnet build Finlytic.sln # build everything
dotnet build FinlyticEngine # build a single project
dotnet test FinlyticEngine.Tests # run one test project
dotnet test FinlyticEngine.Tests --filter "FullyQualifiedName~TradeLifecycleServiceTests"
```
- Solution-wide `dotnet test` will also try to run `FinlyticEngine.Tests/_Verify/PostgresVerificationTests.cs`,
which connects to a real, throwaway local Postgres container on `localhost:55987` and has no `[Skip]`/trait
to exclude it. It fails outside that one-off setup — run test projects individually, or filter it out with
`--filter "FullyQualifiedName!~_Verify"`, rather than running the whole solution's tests.
- `FinlyticBot.Tests` currently has no test files (scaffolding only).
- EF Core migrations are created via the CLI only (never hand-written) from inside the owning service project,
e.g.: `dotnet ef migrations add <Name> --project FinlyticAssets`.
### Frontend (Flutter, `FinlyticApp/`)
```
flutter pub get
flutter run # or -d chrome for the web dashboard
flutter test
flutter analyze
```
### Docker Compose (`compose.yaml`)
The compose file only defines this repo's own services — Postgres and the MQTT broker are external
infrastructure (see the comment block at the top of `compose.yaml` for required env vars: `DB_HOST`,
`DB_PORT`, `DB_PASSWORD`, `MQTT_HOST`, `MQTT_PORT`, plus per-service secrets like `JWT_SECRET_KEY`,
`ADMIN_DEFAULT_PASSWORD`, `ALPACA_KEY_ID`/`ALPACA_SECRET_KEY`). Set these in a local `.env` file.
`FinlyticNews` and `FinlyticFundamentals` build from a shared Playwright base image that must exist first:
```
docker compose --profile build-base build finlytic-playwright-base
docker compose build
```
Keep the `PLAYWRIGHT_VERSION` build arg in `compose.yaml` in sync with the `Microsoft.Playwright` NuGet
package version in `FinlyticCore/FinlyticCore.csproj`.
## Architecture
### Service topology
Nine .NET projects share one solution (`Finlytic.sln`): `FinlyticCore` (shared library, no entry point) plus
eight deployable services — `FinlyticAssets`, `FinlyticNews`, `FinlyticFundamentals`, `FinlyticSentiment`,
`FinlyticTechnicals`, `FinlyticEngine`, `FinlyticSimulation`, `FinlyticBot`, and `FinlyticBackend`. Each
non-Core service is its own Docker image, has its own PostgreSQL database (`finlytic_assets`,
`finlytic_news`, ... one per service — never shared tables across services), and runs its own EF Core
migrations independently at startup via `DbContext.MigrateWithBootstrapAsync` (`FinlyticCore/Database/DatabaseBootstrapper.cs`).
- **`FinlyticBackend`** is the *only* service allowed to host Kestrel/HTTP/WebSocket endpoints. It is a thin
aggregation gateway for `FinlyticApp`: its controllers and SignalR hubs (`Hubs/`) largely proxy to the
backing microservices over MQTT RPC and rebroadcast their MQTT events to connected clients via SignalR
(`BackendMqttBridge`). All other services are `Microsoft.NET.Sdk.Worker` background services with no HTTP
surface at all — this is an enforced rule (`Rules.md` §5), not a stylistic default. See the
`compose.yaml` comment block explaining why only `finlyticbackend` has a Docker `HEALTHCHECK`.
- Every other service follows the same internal shape: `Database/` (DbContext + migrations),
`Entities/` (EF entities), `Services/` (business logic + the service's MQTT client), `Util/`, and a
`Program.cs` that wires DI, registers the service's MQTT client as a hosted service, and migrates the DB
at startup.
### Inter-service communication (MQTT-only)
All cross-service calls go over MQTT — never direct HTTP between backend services. `FinlyticCore/Util/MqttTopics.cs`
is the single source of truth for every topic and RPC channel name; add new channels there rather than
inlining topic strings at call sites. Two patterns are used:
1. **RPC** (`services/request/{channel}/{correlationId}``services/response/{channel}/{correlationId}`),
implemented by `FinlyticCore/Util/ManagedMqttClient.cs` (`SendRpcRequestAsync` / `SubscribeRpcAsync`).
Used for synchronous-style query/command calls, e.g. `FinlyticBackend` asking `FinlyticAssets` to resolve
an ISIN, or `FinlyticEngine` asking `FinlyticSentiment` for a sentiment summary.
2. **Pub/sub event streams** (e.g. `finlytic/news/stream/{isin}`, `finlytic/engine/proposals/created`,
`finlytic/bot/trades/stream`, `finlytic/logs/{service}`) for fire-and-forget notifications. `FinlyticBackend`'s
`BackendMqttBridge` subscribes to the wildcard form of most of these (`*Wildcard` constants) purely to
relay them onward to SignalR clients.
Every service also implements a shared `health_Ping` RPC channel and exposes runtime-configurable settings
via a `{service}_settings_GetAll` / `{service}_settings_Update` channel pair (backed by `ISettingsService`
and `IOptionsMonitor`-style dynamic config — see `FinlyticCore/Services/Settings`) — changing a setting does
not require a service restart, and channel-level logging enable/disable follows the same mechanism (see
`IFinlyticLogger<T>` / `FinlyticLogBroadcaster` in `FinlyticCore/Services/FinlyticLogger`).
### Domain flow (roughly upstream → downstream)
`FinlyticAssets` (asset/ISIN resolution, Trade Republic price feed) and `FinlyticNews`/`FinlyticFundamentals`
(scraped/ingested data, using Playwright) feed `FinlyticSentiment` (FinBERT analysis) and `FinlyticTechnicals`
(indicators/strategy setups), which feed `FinlyticEngine` (trade proposal generation, AI-assisted validation
via an n8n webhook, trade lifecycle management) and `FinlyticSimulation` (backtesting/strategy reliability
scoring). `FinlyticBot` executes accepted proposals as paper trades (via Alpaca or a synthetic ledger).
`FinlyticBackend` sits above all of them as the gateway `FinlyticApp` talks to.
### Flutter app (`FinlyticApp/`)
Feature-folder structure under `lib/features/*` (auth, dashboard, discovery, trades, bot, simulation, news,
calendar, favorites, search, asset_detail, proposals, admin), with `lib/core/` for cross-cutting network
(Dio + interceptors), theme, and services, and `lib/shared/` for shared widgets. State management is
`flutter_bloc`. Every outgoing request must carry the JWT via a central Dio interceptor, and a 401/403 must
trigger an immediate client-side logout — this is an enforced rule (`Rules.md` §8), not optional handling.
## Project-wide rules (`Rules.md`)
`Rules.md` is a binding rules document for this repo, not a style guide — treat every rule as an
architectural constraint to actively check against, not background reading. Full text is in `Rules.md`;
key points, since they shape most non-trivial changes:
- Every service interface + implementation lives in one file, named after the implementation class.
- Every method (any visibility) needs XML doc comments in English; implementations of interface/base
methods use `/// <inheritdoc />` (custom helper methods still need their own explicit docs).
- Strongly-typed data classes only for state passing, API payloads, and MQTT messages — no
`Dictionary<string, object>`, `dynamic`, or raw `JObject`/`JsonDocument` in internal logic. Shared
DTOs/entities/enums used by more than one service belong in `FinlyticCore`; service-specific models
(e.g. raw third-party API shapes) stay in that service.
- No mock/demo/fallback data anywhere, backend or frontend. Empty results are either a real empty set or an
explicit exception — the Flutter UI must show an explicit empty state, never placeholder content.
- Async all the way (no `.Result`/`.Wait()`/`.GetAwaiter().GetResult()`); async DB/MQTT/network methods take
a trailing `CancellationToken` and pass it through.
- Every `FinlyticBackend` HTTP/WebSocket endpoint requires `[Authorize]` unless explicitly `[AllowAnonymous]`
(only `/api/v1/auth/login`-style endpoints and the `/health` check are exempt).
- No hardcoded secrets; bind config via `IOptions<T>`/`IOptionsMonitor<T>`, and `FinlyticBackend` fails fast
at startup if `JWT_SECRET_KEY`/`ADMIN_DEFAULT_PASSWORD` are missing or weak (see `Program.cs`).
+128
View File
@@ -0,0 +1,128 @@
<#
.SYNOPSIS
Exports all required Finlytic Docker images to .tar archives and saves/transfers them directly to the network share.
.DESCRIPTION
Checks all 9 Finlytic microservice images, verifies network path availability,
exports each image directly (or with copy) to \\SONA\appdata\finlytic\images,
and displays progress and total transferred size.
#>
param (
[string]$DestinationPath = "\\SONA\appdata\finlytic\images",
[switch]$BuildFirst = $false
)
$ErrorActionPreference = "Stop"
$images = @(
"finlyticassets",
"finlyticnews",
"finlyticfundamentals",
"finlyticsentiment",
"finlytictechnicals",
"finlyticengine",
"finlyticsimulation",
"finlyticbot",
"finlyticnotify",
"finlyticbackend"
)
Write-Host "============================================================" -ForegroundColor Cyan
Write-Host " Finlytic Docker Images Export & Server Transfer" -ForegroundColor Cyan
Write-Host "============================================================" -ForegroundColor Cyan
Write-Host "Target Server Share : $DestinationPath" -ForegroundColor Yellow
Write-Host ""
# 1. Check destination share connectivity
if (-not (Test-Path -Path $DestinationPath)) {
Write-Host "[INFO] Target directory does not exist. Attempting to create it..." -ForegroundColor Gray
try {
New-Item -ItemType Directory -Path $DestinationPath -Force | Out-Null
Write-Host "[OK] Destination folder successfully created." -ForegroundColor Green
}
catch {
Write-Host "[ERROR] Could not access or create network share: $DestinationPath" -ForegroundColor Red
Write-Host "Please make sure \\SONA is online and credentials/permissions are valid." -ForegroundColor Red
exit 1
}
} else {
Write-Host "[OK] Target server share is accessible." -ForegroundColor Green
}
# 2. Optional: Build images first
if ($BuildFirst) {
Write-Host ""
Write-Host "[BUILD] Building all Docker images from compose.yaml..." -ForegroundColor Cyan
docker compose -f (Join-Path $PSScriptRoot "..\compose.yaml") build
if ($LASTEXITCODE -ne 0) {
Write-Host "[ERROR] Docker build failed. Aborting export." -ForegroundColor Red
exit 1
}
}
Write-Host ""
Write-Host "Starting export of $($images.Count) service images..." -ForegroundColor Cyan
Write-Host "------------------------------------------------------------" -ForegroundColor Gray
$exported = 0
$failed = @()
$missing = @()
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
foreach ($img in $images) {
$ref = "$img`:latest"
$targetTar = Join-Path $DestinationPath "$img.tar"
# Verify if image exists locally in Docker
docker image inspect $ref *> $null
if ($LASTEXITCODE -ne 0) {
Write-Host "[SKIP] Image '$ref' not found locally in Docker." -ForegroundColor Yellow
$missing += $img
continue
}
$imgWatch = [System.Diagnostics.Stopwatch]::StartNew()
Write-Host "[EXPORT] Saving $ref -> $targetTar ... " -NoNewline -ForegroundColor White
try {
# Export directly to network share
docker save -o $targetTar $ref
$imgWatch.Stop()
if ($LASTEXITCODE -eq 0 -and (Test-Path $targetTar)) {
$fileSizeMB = [math]::Round((Get-Item $targetTar).Length / 1MB, 2)
Write-Host "DONE! ($fileSizeMB MB in $($imgWatch.Elapsed.ToString('mm\:ss')))" -ForegroundColor Green
$exported++
} else {
Write-Host "FAILED!" -ForegroundColor Red
$failed += $img
}
}
catch {
Write-Host "ERROR: $_" -ForegroundColor Red
$failed += $img
}
}
$stopwatch.Stop()
Write-Host "------------------------------------------------------------" -ForegroundColor Gray
Write-Host ""
Write-Host "======================= SUMMARY ============================" -ForegroundColor Cyan
Write-Host "Successfully Exported : $exported / $($images.Count)" -ForegroundColor Green
if ($missing.Count -gt 0) {
Write-Host "Missing locally : $($missing -join ', ')" -ForegroundColor Yellow
Write-Host " -> Tip: Run 'docker compose build' to build all images." -ForegroundColor DarkGray
}
if ($failed.Count -gt 0) {
Write-Host "Failed to Export : $($failed -join ', ')" -ForegroundColor Red
}
Write-Host "Total Duration : $($stopwatch.Elapsed.ToString('mm\:ss'))" -ForegroundColor Cyan
Write-Host "============================================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "To load these images on your server, run on the server:" -ForegroundColor White
Write-Host ' for f in /pfad/zu/appdata/finlytic/images/*.tar; do docker load -i "$f"; done' -ForegroundColor Yellow
Write-Host ""
+177
View File
@@ -11,23 +11,200 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticCore", "FinlyticCor
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticNews", "FinlyticNews\FinlyticNews.csproj", "{03B4D920-6173-44E8-A1E2-8945D8393CEA}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticNews", "FinlyticNews\FinlyticNews.csproj", "{03B4D920-6173-44E8-A1E2-8945D8393CEA}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticFundamentals", "FinlyticFundamentals\FinlyticFundamentals.csproj", "{D458A1B3-16CF-45E8-859D-87542A4A83A7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticSentiment", "FinlyticSentiment\FinlyticSentiment.csproj", "{9C3BB705-86AD-4A89-AA0F-A52C87A4950B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticTechnicals", "FinlyticTechnicals\FinlyticTechnicals.csproj", "{A1C82F63-4482-4E99-9231-1184FA2E001F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticBackend", "FinlyticBackend\FinlyticBackend.csproj", "{C1A924B8-904E-436D-B07E-4E621F51C1AA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticEngine", "FinlyticEngine\FinlyticEngine.csproj", "{8112DE84-695D-489B-9568-C531B34C63F8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticSimulation", "FinlyticSimulation\FinlyticSimulation.csproj", "{1407B23D-3B7F-4673-9548-AA2AFF2D8011}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticBot", "FinlyticBot\FinlyticBot.csproj", "{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticEngine.Tests", "FinlyticEngine.Tests\FinlyticEngine.Tests.csproj", "{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticBot.Tests", "FinlyticBot.Tests\FinlyticBot.Tests.csproj", "{1E282E4D-C63E-49E6-879D-DDEEDA530E47}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticNotify", "FinlyticNotify\FinlyticNotify.csproj", "{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticNotify.Tests", "FinlyticNotify.Tests\FinlyticNotify.Tests.csproj", "{6E54FE48-A814-469C-B2E4-67C0EB575A9E}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{B5AC2C29-44D5-4538-815F-F02CDDE9D01F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B5AC2C29-44D5-4538-815F-F02CDDE9D01F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B5AC2C29-44D5-4538-815F-F02CDDE9D01F}.Debug|Any CPU.Build.0 = Debug|Any CPU {B5AC2C29-44D5-4538-815F-F02CDDE9D01F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B5AC2C29-44D5-4538-815F-F02CDDE9D01F}.Debug|x64.ActiveCfg = Debug|Any CPU
{B5AC2C29-44D5-4538-815F-F02CDDE9D01F}.Debug|x64.Build.0 = Debug|Any CPU
{B5AC2C29-44D5-4538-815F-F02CDDE9D01F}.Debug|x86.ActiveCfg = Debug|Any CPU
{B5AC2C29-44D5-4538-815F-F02CDDE9D01F}.Debug|x86.Build.0 = Debug|Any CPU
{B5AC2C29-44D5-4538-815F-F02CDDE9D01F}.Release|Any CPU.ActiveCfg = Release|Any CPU {B5AC2C29-44D5-4538-815F-F02CDDE9D01F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B5AC2C29-44D5-4538-815F-F02CDDE9D01F}.Release|Any CPU.Build.0 = Release|Any CPU {B5AC2C29-44D5-4538-815F-F02CDDE9D01F}.Release|Any CPU.Build.0 = Release|Any CPU
{B5AC2C29-44D5-4538-815F-F02CDDE9D01F}.Release|x64.ActiveCfg = Release|Any CPU
{B5AC2C29-44D5-4538-815F-F02CDDE9D01F}.Release|x64.Build.0 = Release|Any CPU
{B5AC2C29-44D5-4538-815F-F02CDDE9D01F}.Release|x86.ActiveCfg = Release|Any CPU
{B5AC2C29-44D5-4538-815F-F02CDDE9D01F}.Release|x86.Build.0 = Release|Any CPU
{03E2ECBB-0FF9-43E4-994E-F6A522860AD5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {03E2ECBB-0FF9-43E4-994E-F6A522860AD5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{03E2ECBB-0FF9-43E4-994E-F6A522860AD5}.Debug|Any CPU.Build.0 = Debug|Any CPU {03E2ECBB-0FF9-43E4-994E-F6A522860AD5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{03E2ECBB-0FF9-43E4-994E-F6A522860AD5}.Debug|x64.ActiveCfg = Debug|Any CPU
{03E2ECBB-0FF9-43E4-994E-F6A522860AD5}.Debug|x64.Build.0 = Debug|Any CPU
{03E2ECBB-0FF9-43E4-994E-F6A522860AD5}.Debug|x86.ActiveCfg = Debug|Any CPU
{03E2ECBB-0FF9-43E4-994E-F6A522860AD5}.Debug|x86.Build.0 = Debug|Any CPU
{03E2ECBB-0FF9-43E4-994E-F6A522860AD5}.Release|Any CPU.ActiveCfg = Release|Any CPU {03E2ECBB-0FF9-43E4-994E-F6A522860AD5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{03E2ECBB-0FF9-43E4-994E-F6A522860AD5}.Release|Any CPU.Build.0 = Release|Any CPU {03E2ECBB-0FF9-43E4-994E-F6A522860AD5}.Release|Any CPU.Build.0 = Release|Any CPU
{03E2ECBB-0FF9-43E4-994E-F6A522860AD5}.Release|x64.ActiveCfg = Release|Any CPU
{03E2ECBB-0FF9-43E4-994E-F6A522860AD5}.Release|x64.Build.0 = Release|Any CPU
{03E2ECBB-0FF9-43E4-994E-F6A522860AD5}.Release|x86.ActiveCfg = Release|Any CPU
{03E2ECBB-0FF9-43E4-994E-F6A522860AD5}.Release|x86.Build.0 = Release|Any CPU
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {03B4D920-6173-44E8-A1E2-8945D8393CEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Debug|Any CPU.Build.0 = Debug|Any CPU {03B4D920-6173-44E8-A1E2-8945D8393CEA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Debug|x64.ActiveCfg = Debug|Any CPU
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Debug|x64.Build.0 = Debug|Any CPU
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Debug|x86.ActiveCfg = Debug|Any CPU
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Debug|x86.Build.0 = Debug|Any CPU
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Release|Any CPU.ActiveCfg = Release|Any CPU {03B4D920-6173-44E8-A1E2-8945D8393CEA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Release|Any CPU.Build.0 = Release|Any CPU {03B4D920-6173-44E8-A1E2-8945D8393CEA}.Release|Any CPU.Build.0 = Release|Any CPU
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Release|x64.ActiveCfg = Release|Any CPU
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Release|x64.Build.0 = Release|Any CPU
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Release|x86.ActiveCfg = Release|Any CPU
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Release|x86.Build.0 = Release|Any CPU
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Debug|x64.ActiveCfg = Debug|Any CPU
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Debug|x64.Build.0 = Debug|Any CPU
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Debug|x86.ActiveCfg = Debug|Any CPU
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Debug|x86.Build.0 = Debug|Any CPU
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Release|Any CPU.Build.0 = Release|Any CPU
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Release|x64.ActiveCfg = Release|Any CPU
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Release|x64.Build.0 = Release|Any CPU
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Release|x86.ActiveCfg = Release|Any CPU
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Release|x86.Build.0 = Release|Any CPU
{9C3BB705-86AD-4A89-AA0F-A52C87A4950B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9C3BB705-86AD-4A89-AA0F-A52C87A4950B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9C3BB705-86AD-4A89-AA0F-A52C87A4950B}.Debug|x64.ActiveCfg = Debug|Any CPU
{9C3BB705-86AD-4A89-AA0F-A52C87A4950B}.Debug|x64.Build.0 = Debug|Any CPU
{9C3BB705-86AD-4A89-AA0F-A52C87A4950B}.Debug|x86.ActiveCfg = Debug|Any CPU
{9C3BB705-86AD-4A89-AA0F-A52C87A4950B}.Debug|x86.Build.0 = Debug|Any CPU
{9C3BB705-86AD-4A89-AA0F-A52C87A4950B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9C3BB705-86AD-4A89-AA0F-A52C87A4950B}.Release|Any CPU.Build.0 = Release|Any CPU
{9C3BB705-86AD-4A89-AA0F-A52C87A4950B}.Release|x64.ActiveCfg = Release|Any CPU
{9C3BB705-86AD-4A89-AA0F-A52C87A4950B}.Release|x64.Build.0 = Release|Any CPU
{9C3BB705-86AD-4A89-AA0F-A52C87A4950B}.Release|x86.ActiveCfg = Release|Any CPU
{9C3BB705-86AD-4A89-AA0F-A52C87A4950B}.Release|x86.Build.0 = Release|Any CPU
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|x64.ActiveCfg = Debug|Any CPU
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|x64.Build.0 = Debug|Any CPU
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|x86.Build.0 = Debug|Any CPU
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Release|Any CPU.Build.0 = Release|Any CPU
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Release|x64.ActiveCfg = Release|Any CPU
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Release|x64.Build.0 = Release|Any CPU
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Release|x86.ActiveCfg = Release|Any CPU
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Release|x86.Build.0 = Release|Any CPU
{C1A924B8-904E-436D-B07E-4E621F51C1AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C1A924B8-904E-436D-B07E-4E621F51C1AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C1A924B8-904E-436D-B07E-4E621F51C1AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C1A924B8-904E-436D-B07E-4E621F51C1AA}.Release|Any CPU.Build.0 = Release|Any CPU
{8112DE84-695D-489B-9568-C531B34C63F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8112DE84-695D-489B-9568-C531B34C63F8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8112DE84-695D-489B-9568-C531B34C63F8}.Debug|x64.ActiveCfg = Debug|Any CPU
{8112DE84-695D-489B-9568-C531B34C63F8}.Debug|x64.Build.0 = Debug|Any CPU
{8112DE84-695D-489B-9568-C531B34C63F8}.Debug|x86.ActiveCfg = Debug|Any CPU
{8112DE84-695D-489B-9568-C531B34C63F8}.Debug|x86.Build.0 = Debug|Any CPU
{8112DE84-695D-489B-9568-C531B34C63F8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8112DE84-695D-489B-9568-C531B34C63F8}.Release|Any CPU.Build.0 = Release|Any CPU
{8112DE84-695D-489B-9568-C531B34C63F8}.Release|x64.ActiveCfg = Release|Any CPU
{8112DE84-695D-489B-9568-C531B34C63F8}.Release|x64.Build.0 = Release|Any CPU
{8112DE84-695D-489B-9568-C531B34C63F8}.Release|x86.ActiveCfg = Release|Any CPU
{8112DE84-695D-489B-9568-C531B34C63F8}.Release|x86.Build.0 = Release|Any CPU
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Debug|x64.ActiveCfg = Debug|Any CPU
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Debug|x64.Build.0 = Debug|Any CPU
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Debug|x86.ActiveCfg = Debug|Any CPU
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Debug|x86.Build.0 = Debug|Any CPU
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Release|Any CPU.Build.0 = Release|Any CPU
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Release|x64.ActiveCfg = Release|Any CPU
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Release|x64.Build.0 = Release|Any CPU
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Release|x86.ActiveCfg = Release|Any CPU
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Release|x86.Build.0 = Release|Any CPU
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Debug|x64.ActiveCfg = Debug|Any CPU
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Debug|x64.Build.0 = Debug|Any CPU
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Debug|x86.ActiveCfg = Debug|Any CPU
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Debug|x86.Build.0 = Debug|Any CPU
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Release|Any CPU.Build.0 = Release|Any CPU
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Release|x64.ActiveCfg = Release|Any CPU
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Release|x64.Build.0 = Release|Any CPU
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Release|x86.ActiveCfg = Release|Any CPU
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Release|x86.Build.0 = Release|Any CPU
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Debug|x64.ActiveCfg = Debug|Any CPU
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Debug|x64.Build.0 = Debug|Any CPU
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Debug|x86.ActiveCfg = Debug|Any CPU
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Debug|x86.Build.0 = Debug|Any CPU
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Release|Any CPU.Build.0 = Release|Any CPU
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Release|x64.ActiveCfg = Release|Any CPU
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Release|x64.Build.0 = Release|Any CPU
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Release|x86.ActiveCfg = Release|Any CPU
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Release|x86.Build.0 = Release|Any CPU
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Debug|x64.ActiveCfg = Debug|Any CPU
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Debug|x64.Build.0 = Debug|Any CPU
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Debug|x86.ActiveCfg = Debug|Any CPU
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Debug|x86.Build.0 = Debug|Any CPU
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|Any CPU.Build.0 = Release|Any CPU
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|x64.ActiveCfg = Release|Any CPU
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|x64.Build.0 = Release|Any CPU
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|x86.ActiveCfg = Release|Any CPU
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|x86.Build.0 = Release|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|x64.ActiveCfg = Debug|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|x64.Build.0 = Debug|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|x86.ActiveCfg = Debug|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|x86.Build.0 = Debug|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|Any CPU.Build.0 = Release|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|x64.ActiveCfg = Release|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|x64.Build.0 = Release|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|x86.ActiveCfg = Release|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|x86.Build.0 = Release|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|x64.ActiveCfg = Debug|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|x64.Build.0 = Debug|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|x86.ActiveCfg = Debug|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|x86.Build.0 = Debug|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|Any CPU.Build.0 = Release|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|x64.ActiveCfg = Release|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|x64.Build.0 = Release|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|x86.ActiveCfg = Release|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal
File diff suppressed because one or more lines are too long
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
@@ -0,0 +1,66 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../services/secure_storage_service.dart';
/// Central HTTP ApiClient backed by Dio with automatic 401 Unauthorized handling.
class ApiClient {
final SecureStorageService _storageService;
late final Dio _dio;
Function()? onUnauthorized;
static String get baseUrl {
if (kIsWeb) {//todo on release
final origin = Uri.base.origin;
if (origin.isNotEmpty && !origin.contains('null') && !origin.startsWith('file:')) {
return origin;
}
}
return const String.fromEnvironment('BACKEND_URL', defaultValue: 'http://localhost:5000');
}
ApiClient(this._storageService) {
_dio = Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
headers: {'Content-Type': 'application/json'},
),
);
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await _storageService.getToken();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
return handler.next(options);
},
onError: (DioException error, handler) async {
if (error.response?.statusCode == 401 || error.response?.statusCode == 403) {
await _storageService.clearAll();
onUnauthorized?.call();
}
return handler.next(error);
},
),
);
}
Future<Response> get(String path, {Map<String, dynamic>? queryParameters}) async {
return await _dio.get(path, queryParameters: queryParameters);
}
Future<Response> post(String path, {dynamic data}) async {
return await _dio.post(path, data: data);
}
Future<Response> put(String path, {dynamic data}) async {
return await _dio.put(path, data: data);
}
Future<Response> delete(String path) async {
return await _dio.delete(path);
}
}
@@ -0,0 +1,266 @@
import 'dart:async';
import 'package:finlytic_app/core/network/api_client.dart';
import 'package:flutter/foundation.dart';
import 'package:signalr_core/signalr_core.dart';
import '../services/secure_storage_service.dart';
/// Central Real-Time WebSocket Service utilizing SignalR (`signalr_core`).
/// Connects persistently to `/hubs/health`, `/hubs/favorites-prices`, `/hubs/trade-stream`, `/hubs/logs`, and `/hubs/news`.
class SignalRService extends ChangeNotifier {
final SecureStorageService storageService;
HubConnection? _healthConnection;
HubConnection? _favoritesConnection;
HubConnection? _tradeStreamConnection;
HubConnection? _logsConnection;
HubConnection? _newsConnection;
bool _isConnected = false;
final _statusController = StreamController<bool>.broadcast();
final _healthController = StreamController<List<Map<String, dynamic>>>.broadcast();
final _favoritePricesController = StreamController<Map<String, dynamic>>.broadcast();
final _tradeProposalController = StreamController<Map<String, dynamic>>.broadcast();
final _tradeUpdateController = StreamController<Map<String, dynamic>>.broadcast();
final _botPositionController = StreamController<Map<String, dynamic>>.broadcast();
final _portfolioSummaryController = StreamController<Map<String, dynamic>>.broadcast();
final _logMessageController = StreamController<Map<String, dynamic>>.broadcast();
final _newsArticleController = StreamController<Map<String, dynamic>>.broadcast();
bool get isConnected => _isConnected;
Stream<bool> get connectionStream => _statusController.stream;
Stream<List<Map<String, dynamic>>> get healthStream => _healthController.stream;
Stream<Map<String, dynamic>> get favoritePricesStream => _favoritePricesController.stream;
Stream<Map<String, dynamic>> get tradeProposalStream => _tradeProposalController.stream;
Stream<Map<String, dynamic>> get tradeUpdateStream => _tradeUpdateController.stream;
Stream<Map<String, dynamic>> get botPositionStream => _botPositionController.stream;
Stream<Map<String, dynamic>> get portfolioSummaryStream => _portfolioSummaryController.stream;
Stream<Map<String, dynamic>> get logMessageStream => _logMessageController.stream;
Stream<Map<String, dynamic>> get newsArticleStream => _newsArticleController.stream;
static String get baseUrl => ApiClient.baseUrl;
SignalRService(this.storageService);
Future<void> initSignalR() async {
if (_isConnected) return;
try {
// Reads the token fresh from secure storage on every connection attempt
// (initial connect AND every automatic reconnect), so a token refreshed
// mid-session is always picked up instead of being pinned to the value
// read at initSignalR() time.
Future<String?> tokenFactory() => storageService.getToken();
// 1. Connect SystemHealthHub over WebSockets
_healthConnection = HubConnectionBuilder()
.withUrl(
'$baseUrl/hubs/health',
HttpConnectionOptions(
accessTokenFactory: tokenFactory,
transport: HttpTransportType.webSockets,
logging: (level, message) {
if (kDebugMode) debugPrint('[SignalR Health WS] $message');
},
),
)
.withAutomaticReconnect()
.build();
_healthConnection!.on('ReceiveSystemHealth', (arguments) {
if (arguments != null && arguments.isNotEmpty) {
try {
final List<dynamic> list = arguments.first as List<dynamic>;
final mappedList = list.map((item) => Map<String, dynamic>.from(item as Map)).toList();
_healthController.add(mappedList);
} catch (e) {
if (kDebugMode) debugPrint('[SignalR Health Parsing Error] $e');
}
}
});
// 2. Connect FavoritesPriceHub over WebSockets
_favoritesConnection = HubConnectionBuilder()
.withUrl(
'$baseUrl/hubs/favorites-prices',
HttpConnectionOptions(
accessTokenFactory: tokenFactory,
transport: HttpTransportType.webSockets,
logging: (level, message) {
if (kDebugMode) debugPrint('[SignalR Favorites WS] $message');
},
),
)
.withAutomaticReconnect()
.build();
_favoritesConnection!.on('ReceiveFavoritePrices', (arguments) {
if (arguments != null && arguments.isNotEmpty) {
try {
final Map<String, dynamic> priceMap = Map<String, dynamic>.from(arguments.first as Map);
_favoritePricesController.add(priceMap);
} catch (e) {
if (kDebugMode) debugPrint('[SignalR Favorites Parsing Error] $e');
}
}
});
// 3. Connect TradeStreamHub over WebSockets (Engine & Bot Real-Time Streams)
_tradeStreamConnection = HubConnectionBuilder()
.withUrl(
'$baseUrl/hubs/trade-stream',
HttpConnectionOptions(
accessTokenFactory: tokenFactory,
transport: HttpTransportType.webSockets,
logging: (level, message) {
if (kDebugMode) debugPrint('[SignalR TradeStream WS] $message');
},
),
)
.withAutomaticReconnect()
.build();
_tradeStreamConnection!.on('ReceiveTradeProposal', (arguments) {
if (arguments != null && arguments.isNotEmpty) {
try {
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
_tradeProposalController.add(map);
} catch (e) {
if (kDebugMode) debugPrint('[SignalR Proposal Parsing Error] $e');
}
}
});
_tradeStreamConnection!.on('ReceiveTradeUpdate', (arguments) {
if (arguments != null && arguments.isNotEmpty) {
try {
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
_tradeUpdateController.add(map);
} catch (e) {
if (kDebugMode) debugPrint('[SignalR TradeUpdate Parsing Error] $e');
}
}
});
_tradeStreamConnection!.on('ReceiveBotPositionUpdate', (arguments) {
if (arguments != null && arguments.isNotEmpty) {
try {
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
_botPositionController.add(map);
} catch (e) {
if (kDebugMode) debugPrint('[SignalR BotPosition Parsing Error] $e');
}
}
});
_tradeStreamConnection!.on('ReceivePortfolioSummary', (arguments) {
if (arguments != null && arguments.isNotEmpty) {
try {
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
_portfolioSummaryController.add(map);
} catch (e) {
if (kDebugMode) debugPrint('[SignalR PortfolioSummary Parsing Error] $e');
}
}
});
// 4. Connect LogStreamHub over WebSockets
_logsConnection = HubConnectionBuilder()
.withUrl(
'$baseUrl/hubs/logs',
HttpConnectionOptions(
accessTokenFactory: tokenFactory,
transport: HttpTransportType.webSockets,
logging: (level, message) {
if (kDebugMode) debugPrint('[SignalR Logs WS] $message');
},
),
)
.withAutomaticReconnect()
.build();
_logsConnection!.on('ReceiveLogMessage', (arguments) {
if (arguments != null && arguments.isNotEmpty) {
try {
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
_logMessageController.add(map);
} catch (e) {
if (kDebugMode) debugPrint('[SignalR Log Parsing Error] $e');
}
}
});
// 5. Connect NewsHub over WebSockets (Live News Feed)
_newsConnection = HubConnectionBuilder()
.withUrl(
'$baseUrl/hubs/news',
HttpConnectionOptions(
accessTokenFactory: tokenFactory,
transport: HttpTransportType.webSockets,
logging: (level, message) {
if (kDebugMode) debugPrint('[SignalR News WS] $message');
},
),
)
.withAutomaticReconnect()
.build();
_newsConnection!.on('ReceiveNewArticle', (arguments) {
if (arguments != null && arguments.isNotEmpty) {
try {
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
_newsArticleController.add(map);
} catch (e) {
if (kDebugMode) debugPrint('[SignalR News Parsing Error] $e');
}
}
});
await Future.wait([
_healthConnection!.start() ?? Future.value(),
_favoritesConnection!.start() ?? Future.value(),
_tradeStreamConnection!.start() ?? Future.value(),
_logsConnection!.start() ?? Future.value(),
_newsConnection!.start() ?? Future.value(),
]);
if (kDebugMode) debugPrint('[SignalR WS] All 5 Real-Time WebSockets successfully connected.');
_isConnected = true;
_statusController.add(true);
notifyListeners();
} catch (e) {
if (kDebugMode) debugPrint('[SignalR WebSocket Connection Error] $e');
_isConnected = false;
_statusController.add(false);
notifyListeners();
}
}
void disconnect() async {
try {
await _healthConnection?.stop();
await _favoritesConnection?.stop();
await _tradeStreamConnection?.stop();
await _logsConnection?.stop();
await _newsConnection?.stop();
} catch (_) {}
_isConnected = false;
_statusController.add(false);
notifyListeners();
}
@override
void dispose() {
disconnect();
_statusController.close();
_healthController.close();
_favoritePricesController.close();
_tradeProposalController.close();
_tradeUpdateController.close();
_botPositionController.close();
_portfolioSummaryController.close();
_logMessageController.close();
_newsArticleController.close();
super.dispose();
}
}
@@ -0,0 +1,37 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Secure token and session storage service.
class SecureStorageService {
final _storage = const FlutterSecureStorage();
static const String _keyToken = 'jwt_token';
static const String _keyUserEmail = 'user_email';
Future<void> saveToken(String token) async {
await _storage.write(key: _keyToken, value: token);
}
Future<String?> getToken() async {
return await _storage.read(key: _keyToken);
}
Future<void> saveUserEmail(String email) async {
await _storage.write(key: _keyUserEmail, value: email);
}
Future<String?> getUserEmail() async {
return await _storage.read(key: _keyUserEmail);
}
Future<void> deleteToken() async {
await _storage.delete(key: _keyToken);
}
Future<void> deleteUserEmail() async {
await _storage.delete(key: _keyUserEmail);
}
Future<void> clearAll() async {
await _storage.deleteAll();
}
}
+232
View File
@@ -0,0 +1,232 @@
import 'package:flutter/material.dart';
class ThemePreset {
final String id;
final String name;
final Brightness brightness;
final Color darkBackground;
final Color cardSurface;
final Color glassSurface;
final Color glassBorder;
final Color primaryColor;
final Color accentColor;
final Color accentRed;
final Color textPrimary;
final Color textSecondary;
final Color textMuted;
final double borderRadius;
final List<BoxShadow> boxShadows;
const ThemePreset({
required this.id,
required this.name,
required this.brightness,
required this.darkBackground,
required this.cardSurface,
required this.glassSurface,
required this.glassBorder,
required this.primaryColor,
required this.accentColor,
required this.accentRed,
required this.textPrimary,
required this.textSecondary,
required this.textMuted,
required this.borderRadius,
required this.boxShadows,
});
ThemeData toThemeData() {
final isDark = brightness == Brightness.dark;
return (isDark ? ThemeData.dark() : ThemeData.light()).copyWith(
scaffoldBackgroundColor: darkBackground,
primaryColor: primaryColor,
colorScheme: isDark
? ColorScheme.dark(
primary: primaryColor,
secondary: accentColor,
surface: cardSurface,
error: accentRed,
)
: ColorScheme.light(
primary: primaryColor,
secondary: accentColor,
surface: cardSurface,
error: accentRed,
),
appBarTheme: AppBarTheme(
backgroundColor: cardSurface,
elevation: isDark ? 0 : 1,
centerTitle: false,
iconTheme: IconThemeData(color: textPrimary),
titleTextStyle: TextStyle(color: textPrimary, fontSize: 18, fontWeight: FontWeight.bold),
),
cardTheme: CardThemeData(
color: cardSurface,
elevation: isDark ? 0 : 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(borderRadius),
side: BorderSide(color: glassBorder, width: 1),
),
),
dialogTheme: DialogThemeData(
backgroundColor: cardSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(borderRadius + 2),
side: BorderSide(color: glassBorder, width: 1),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: glassSurface,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(borderRadius),
borderSide: BorderSide(color: glassBorder),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(borderRadius),
borderSide: BorderSide(color: glassBorder),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(borderRadius),
borderSide: BorderSide(color: primaryColor, width: 1.5),
),
),
);
}
}
/// Dynamic Finlytic Multi-Theme Presets & Tokens Engine.
class AppTheme {
static const ThemePreset darkClassic = ThemePreset(
id: 'dark_classic',
name: 'Dark',
brightness: Brightness.dark,
darkBackground: Color(0xFF0A0E17),
cardSurface: Color(0xFF12182B),
glassSurface: Color(0x2A1A233A),
glassBorder: Color(0x33425980),
primaryColor: Color(0xFF00E676),
accentColor: Color(0xFF00E5FF),
accentRed: Color(0xFFFF5252),
textPrimary: Colors.white,
textSecondary: Color(0xFFB0BEC5),
textMuted: Color(0xFF607D8B),
borderRadius: 14.0,
boxShadows: [
BoxShadow(color: Color(0x1F000000), blurRadius: 10, offset: Offset(0, 4)),
],
);
static const ThemePreset darkCyberNeon = ThemePreset(
id: 'dark_cyber_neon',
name: 'Alternative Dark (Cyber Neon)',
brightness: Brightness.dark,
darkBackground: Color(0xFF0D0B1E),
cardSurface: Color(0xFF161233),
glassSurface: Color(0x3A261D52),
glassBorder: Color(0x5500FFA3),
primaryColor: Color(0xFF00FFA3),
accentColor: Color(0xFFFF007A),
accentRed: Color(0xFFFF2E93),
textPrimary: Color(0xFFF5F3FF),
textSecondary: Color(0xFFC4B5FD),
textMuted: Color(0xFF8B5CF6),
borderRadius: 8.0,
boxShadows: [
BoxShadow(color: Color(0x3300FFA3), blurRadius: 12, spreadRadius: -2),
],
);
static const ThemePreset lightClassic = ThemePreset(
id: 'light_classic',
name: 'Light',
brightness: Brightness.light,
darkBackground: Color(0xFFF8FAFC),
cardSurface: Color(0xFFFFFFFF),
glassSurface: Color(0xFFF1F5F9),
glassBorder: Color(0xFFE2E8F0),
primaryColor: Color(0xFF059669),
accentColor: Color(0xFF0284C7),
accentRed: Color(0xFFDC2626),
textPrimary: Color(0xFF0F172A),
textSecondary: Color(0xFF475569),
textMuted: Color(0xFF94A3B8),
borderRadius: 16.0,
boxShadows: [
BoxShadow(color: Color(0x0F000000), blurRadius: 12, offset: Offset(0, 4)),
],
);
static const ThemePreset lightWarmSand = ThemePreset(
id: 'light_warm_sand',
name: 'Alternative Light (Warm Paper)',
brightness: Brightness.light,
darkBackground: Color(0xFFF5F2EB),
cardSurface: Color(0xFFFFFDF9),
glassSurface: Color(0xFFEFEADF),
glassBorder: Color(0xFFE2D9C8),
primaryColor: Color(0xFFD97706),
accentColor: Color(0xFF2563EB),
accentRed: Color(0xFFE11D48),
textPrimary: Color(0xFF272522),
textSecondary: Color(0xFF57534E),
textMuted: Color(0xFFA8A29E),
borderRadius: 12.0,
boxShadows: [
BoxShadow(color: Color(0x14443422), blurRadius: 8, offset: Offset(0, 3)),
],
);
static const ThemePreset nordicMint = ThemePreset(
id: 'nordic_mint',
name: 'Nordic Mint',
brightness: Brightness.light,
darkBackground: Color(0xFFEFF6F5),
cardSurface: Color(0xFFFFFFFF),
glassSurface: Color(0xFFE0F2FE),
glassBorder: Color(0xFFCCFBF1),
primaryColor: Color(0xFF0D9488),
accentColor: Color(0xFF0284C7),
accentRed: Color(0xFFF43F5E),
textPrimary: Color(0xFF111827),
textSecondary: Color(0xFF374151),
textMuted: Color(0xFF6B7280),
borderRadius: 20.0,
boxShadows: [
BoxShadow(color: Color(0x0D0F766E), blurRadius: 14, offset: Offset(0, 4)),
],
);
static const List<ThemePreset> allPresets = [
darkClassic,
darkCyberNeon,
lightClassic,
lightWarmSand,
nordicMint,
];
static ThemePreset getPresetById(String? id) {
return allPresets.firstWhere(
(p) => p.id.toLowerCase() == id?.toLowerCase(),
orElse: () => darkClassic,
);
}
// Legacy static color mappings for backward compatibility
static Color get darkBackground => activePreset.darkBackground;
static Color get cardSurface => activePreset.cardSurface;
static Color get surfaceDark => activePreset.cardSurface;
static Color get glassSurface => activePreset.glassSurface;
static Color get glassBorder => activePreset.glassBorder;
static Color get primaryEmerald => activePreset.primaryColor;
static Color get accentCyan => activePreset.accentColor;
static Color get accentRed => activePreset.accentRed;
static Color get textPrimary => activePreset.textPrimary;
static Color get textSecondary => activePreset.textSecondary;
static Color get textMuted => activePreset.textMuted;
static ThemePreset activePreset = darkClassic;
static ThemeData get darkTheme => activePreset.toThemeData();
}
@@ -0,0 +1,16 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
/// Global scroll behavior enabling smooth mouse dragging, mouse wheel, and touch scrolling
/// across all platforms (especially Flutter Web on Desktop without requiring Shift-key).
class CustomAppScrollBehavior extends MaterialScrollBehavior {
const CustomAppScrollBehavior();
@override
Set<PointerDeviceKind> get dragDevices => {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.stylus,
PointerDeviceKind.trackpad,
};
}
@@ -0,0 +1,49 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../network/api_client.dart';
import 'app_theme.dart';
class ThemeState {
final ThemePreset preset;
final bool isLoading;
const ThemeState({required this.preset, this.isLoading = false});
}
class ThemeCubit extends Cubit<ThemeState> {
final ApiClient? apiClient;
ThemeCubit({this.apiClient}) : super(ThemeState(preset: AppTheme.activePreset));
Future<void> fetchUserThemePreference() async {
if (apiClient == null) return;
try {
final res = await apiClient!.get('/api/v1/user/preferences');
if (res.statusCode == 200 && res.data is Map) {
final themeId = res.data['themePreference']?.toString();
if (themeId != null && themeId.isNotEmpty) {
final preset = AppTheme.getPresetById(themeId);
AppTheme.activePreset = preset;
emit(ThemeState(preset: preset));
}
}
} catch (_) {
// Keep active preset on network failure
}
}
Future<void> setTheme(ThemePreset newPreset) async {
AppTheme.activePreset = newPreset;
emit(ThemeState(preset: newPreset));
if (apiClient != null) {
try {
await apiClient!.put(
'/api/v1/user/preferences/theme',
data: {'themeId': newPreset.id},
);
} catch (_) {
// Silently handle offline preference update
}
}
}
}
@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'app_theme.dart';
import 'theme_cubit.dart';
/// Modal dialog allowing the user to select and preview theme presets.
class ThemePickerDialog extends StatelessWidget {
const ThemePickerDialog({super.key});
@override
Widget build(BuildContext context) {
final activeTheme = AppTheme.activePreset;
return Dialog(
backgroundColor: activeTheme.cardSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(activeTheme.borderRadius + 2),
side: BorderSide(color: activeTheme.glassBorder, width: 1),
),
child: Container(
width: 480,
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.palette_outlined, color: activeTheme.primaryColor),
const SizedBox(width: 10),
const Text('Design & Theme Auswählen', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
const SizedBox(height: 16),
Flexible(
child: SingleChildScrollView(
child: Column(
children: AppTheme.allPresets.map((preset) {
final isSelected = activeTheme.id == preset.id;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: InkWell(
onTap: () {
context.read<ThemeCubit>().setTheme(preset);
Navigator.pop(context);
},
borderRadius: BorderRadius.circular(preset.borderRadius),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: preset.glassSurface,
borderRadius: BorderRadius.circular(preset.borderRadius),
border: Border.all(
color: isSelected ? preset.primaryColor : preset.glassBorder,
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
// Palette Color Preview Dots
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: preset.darkBackground,
shape: BoxShape.circle,
border: Border.all(color: preset.glassBorder),
),
child: Center(
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: preset.primaryColor,
shape: BoxShape.circle,
),
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
preset.name,
style: TextStyle(
color: preset.textPrimary,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
'Ecken: ${preset.borderRadius.toInt()}px • ${preset.brightness == Brightness.dark ? "Dunkel" : "Hell"}',
style: TextStyle(color: preset.textMuted, fontSize: 11),
),
],
),
),
if (isSelected)
Icon(Icons.check_circle_rounded, color: preset.primaryColor, size: 22),
],
),
),
),
);
}).toList(),
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,43 @@
import 'package:intl/intl.dart';
/// Formatting utility helpers for currency, percentages, dates, and numbers.
class Formatters {
static final NumberFormat _currencyFormat = NumberFormat.currency(symbol: '\$', decimalDigits: 2);
static final NumberFormat _percentFormat = NumberFormat.decimalPercentPattern(decimalDigits: 2);
/// Formats currency values (e.g., $1,234.56).
static String formatCurrency(double? value, {String symbol = '\$'}) {
if (value == null) return '-';
if (symbol == '\$') return _currencyFormat.format(value);
return NumberFormat.currency(symbol: symbol, decimalDigits: 2).format(value);
}
/// Formats percentage value with sign (e.g. +3.45%).
static String formatPercent(double? value) {
if (value == null) return '0.00%';
final formatted = _percentFormat.format(value / 100);
return value >= 0 ? '+$formatted' : formatted;
}
/// Formats compact numbers (e.g. 1.2M, 3.4B).
static String formatCompactNumber(double? value) {
if (value == null) return '-';
return NumberFormat.compact().format(value);
}
/// Formats ISO date time string to readable short date.
static String formatDate(String? isoDate) {
if (isoDate == null || isoDate.isEmpty) return '-';
try {
final dt = DateTime.parse(isoDate).toLocal();
return DateFormat('dd.MM.yyyy HH:mm').format(dt);
} catch (_) {
return isoDate;
}
}
/// Formats date to day-month format.
static String formatShortDate(DateTime dt) {
return DateFormat('dd. MMM yyyy').format(dt);
}
}
@@ -0,0 +1,59 @@
/// Universal time formatting helper to format relative timestamps cleanly for UI displays.
class TimeUtils {
/// Converts ISO-8601 or DateTime inputs to localized, readable relative time strings.
///
/// Examples:
/// - Under 60 minutes: "Vor 15 Min.", "Vor 42 Min."
/// - 1 hour or more: "Vor 1 Std.", "Vor 1 Std. 15 Min.", "Vor 3 Std. 45 Min."
/// - Yesterday: "Gestern"
/// - Older: "22.07.2026"
static String formatRelativeTime(dynamic rawDate) {
if (rawDate == null) return '';
final str = rawDate.toString().trim();
if (str.isEmpty) return '';
DateTime dt;
try {
dt = DateTime.parse(str).toLocal();
} catch (_) {
// Return raw string if already formatted or non-date string
return str;
}
final now = DateTime.now();
final rawDiff = now.difference(dt);
// If the timestamp is in the future (e.g. slight clock skew or scraper timezone issues), cap it to 0
final duration = rawDiff.isNegative ? Duration.zero : rawDiff;
final totalMinutes = duration.inMinutes;
if (totalMinutes < 1) {
return 'Vor 1 Min.';
}
if (totalMinutes < 60) {
return 'Vor $totalMinutes Min.';
}
final hours = duration.inHours;
if (hours < 24) {
final remainingMinutes = totalMinutes % 60;
if (remainingMinutes == 0) {
return 'Vor $hours Std.';
} else {
return 'Vor $hours Std. $remainingMinutes Min.';
}
}
final days = duration.inDays;
if (days == 1) {
return 'Gestern';
} else if (days < 7) {
return 'Vor $days Tagen';
}
final dayStr = dt.day.toString().padLeft(2, '0');
final monthStr = dt.month.toString().padLeft(2, '0');
return '$dayStr.$monthStr.${dt.year}';
}
}
@@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../network/api_client.dart';
import '../theme/app_theme.dart';
/// Reusable performance-optimized Asset Logo Widget supporting SVG, PNG, gradient fallbacks, and Hero transitions.
class AssetLogoWidget extends StatelessWidget {
static final Set<String> _failedUrls = {};
final String symbolOrName;
final String? imageUrl;
final double size;
final bool enableHero;
const AssetLogoWidget({
super.key,
required this.symbolOrName,
required this.imageUrl,
this.size = 32,
this.enableHero = true,
});
@override
Widget build(BuildContext context) {
// Resolve relative URLs (e.g. /api/v1/logo/...) to include host and port (e.g. http://localhost:5000)
String? resolveUrl(String? url) {
if (url == null || url.isEmpty) {
final clean = symbolOrName.trim();
if (RegExp(r'^[A-Z]{2}[A-Z0-9]{9}[0-9]$').hasMatch(clean)) {
return '${ApiClient.baseUrl}/api/v1/logo/$clean';
}
return null;
}
if (url.startsWith('http://') || url.startsWith('https://')) return url;
return url.startsWith('/') ? '${ApiClient.baseUrl}$url' : '${ApiClient.baseUrl}/$url';
}
final image = resolveUrl(imageUrl);
final initial = symbolOrName.isNotEmpty ? symbolOrName[0].toUpperCase() : 'A';
final colors = _getGradientColors(initial);
Widget content;
if (image != null && image.isNotEmpty && !_failedUrls.contains(image)) {
final isSvg = image.toLowerCase().endsWith('.svg') ||
image.contains('/api/v1/logo/');
content = ClipRRect(
borderRadius: BorderRadius.circular(size * 0.3),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(size * 0.3),
color: AppTheme.activePreset.glassSurface,
border: Border.all(
color: AppTheme.activePreset.glassBorder,
width: 1,
),
),
padding: EdgeInsets.all(size * 0.1),
child: isSvg
? SvgPicture.network(
image,
width: size,
height: size,
fit: BoxFit.contain,
placeholderBuilder: (context) => _buildFallback(initial, colors),
errorBuilder: (context, error, stackTrace) {
_failedUrls.add(image);
return _buildFallback(initial, colors);
},
)
: CachedNetworkImage(
imageUrl: image,
width: size,
height: size,
fit: BoxFit.contain,
placeholder: (context, url) => _buildFallback(initial, colors),
errorWidget: (context, url, error) {
_failedUrls.add(image);
return _buildFallback(initial, colors);
},
),
),
);
} else {
content = _buildFallback(initial, colors);
}
if (enableHero && symbolOrName.isNotEmpty) {
return Hero(
tag: 'asset_logo_${symbolOrName}_$size',
child: content,
);
}
return content;
}
Widget _buildFallback(String initial, List<Color> colors) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(size * 0.3),
gradient: LinearGradient(
colors: colors,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Center(
child: Text(
initial,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: size * 0.45,
decoration: TextDecoration.none,
),
),
),
);
}
List<Color> _getGradientColors(String char) {
final code = char.codeUnitAt(0);
switch (code % 5) {
case 0:
return [AppTheme.activePreset.primaryColor, AppTheme.activePreset.accentColor];
case 1:
return [const Color(0xFF6366F1), const Color(0xFFA855F7)];
case 2:
return [const Color(0xFFEC4899), const Color(0xFFF43F5E)];
case 3:
return [const Color(0xFFF59E0B), const Color(0xFFEF4444)];
default:
return [AppTheme.activePreset.accentColor, const Color(0xFF3B82F6)];
}
}
}
@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// Reusable glassmorphic container widget adapting to active ThemePreset.
class GlassContainer extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
final double? width;
final double? height;
final double? borderRadius;
final VoidCallback? onTap;
const GlassContainer({
super.key,
required this.child,
this.padding = const EdgeInsets.all(16),
this.margin,
this.width,
this.height,
this.borderRadius,
this.onTap,
});
@override
Widget build(BuildContext context) {
final activeTheme = AppTheme.activePreset;
final effectiveRadius = borderRadius ?? activeTheme.borderRadius;
final body = AnimatedContainer(
duration: const Duration(milliseconds: 250),
width: width,
height: height,
margin: margin,
padding: padding,
decoration: BoxDecoration(
color: activeTheme.glassSurface,
borderRadius: BorderRadius.circular(effectiveRadius),
border: Border.all(color: activeTheme.glassBorder, width: 1),
boxShadow: activeTheme.boxShadows,
),
child: child,
);
if (onTap != null) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(effectiveRadius),
child: body,
);
}
return body;
}
}
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// Smooth Shimmer Loading Skeleton Effect Widget.
class ShimmerLoading extends StatefulWidget {
final double width;
final double height;
final double? borderRadius;
const ShimmerLoading({
super.key,
required this.width,
required this.height,
this.borderRadius,
});
@override
State<ShimmerLoading> createState() => _ShimmerLoadingState();
}
class _ShimmerLoadingState extends State<ShimmerLoading> with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final activeTheme = AppTheme.activePreset;
final radius = widget.borderRadius ?? activeTheme.borderRadius;
final baseColor = activeTheme.glassSurface;
final highlightColor = activeTheme.glassBorder.withValues(alpha: 0.5);
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(radius),
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
baseColor,
highlightColor,
baseColor,
],
stops: [
(_controller.value - 0.3).clamp(0.0, 1.0),
_controller.value,
(_controller.value + 0.3).clamp(0.0, 1.0),
],
),
),
);
},
);
}
}
@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// Reusable pill status badge widget for sentiment, trades, or user roles.
class StatusBadge extends StatelessWidget {
final String label;
final Color color;
final IconData? icon;
const StatusBadge({
super.key,
required this.label,
required this.color,
this.icon,
});
factory StatusBadge.sentiment(String status, {double? score}) {
final sUpper = status.trim().toUpperCase();
Color bg;
if (sUpper.contains('POS')) {
bg = AppTheme.primaryEmerald;
} else if (sUpper.contains('NEG')) {
bg = AppTheme.accentRed;
} else if (sUpper.contains('NEU')) {
bg = AppTheme.accentCyan;
} else if (score != null) {
if (score > 0.15) {
bg = AppTheme.primaryEmerald;
} else if (score < -0.15) {
bg = AppTheme.accentRed;
} else {
bg = AppTheme.accentCyan;
}
} else {
bg = AppTheme.textMuted;
}
return StatusBadge(label: sUpper.isNotEmpty ? sUpper : 'NEUTRAL', color: bg);
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: color.withValues(alpha: 0.4), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 12, color: color),
const SizedBox(width: 4),
],
Text(
label,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
],
),
);
}
}
@@ -0,0 +1,47 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../repositories/admin_repository.dart';
import 'admin_event.dart';
import 'admin_state.dart';
export 'admin_event.dart';
export 'admin_state.dart';
class AdminBloc extends Bloc<AdminEvent, AdminState> {
final AdminRepository repository;
AdminBloc({required this.repository}) : super(AdminInitial()) {
on<FetchAdminUsers>(_onFetchUsers);
on<CreateAdminUser>(_onCreateUser);
on<UpdateAdminUser>(_onUpdateUser);
}
Future<void> _onFetchUsers(FetchAdminUsers event, Emitter<AdminState> emit) async {
emit(AdminLoading());
try {
final users = await repository.fetchUsers();
emit(AdminLoaded(users));
} catch (e) {
emit(const AdminError("Fehler beim Laden der Admin-Nutzer."));
}
}
Future<void> _onCreateUser(CreateAdminUser event, Emitter<AdminState> emit) async {
try {
await repository.createUser(event.dto);
add(FetchAdminUsers());
} catch (e) {
emit(AdminError(e.toString()));
}
}
Future<void> _onUpdateUser(UpdateAdminUser event, Emitter<AdminState> emit) async {
try {
await repository.updateUser(event.id, event.dto);
add(FetchAdminUsers());
} catch (e) {
emit(const AdminError("Nutzer konnte nicht aktualisiert werden."));
}
}
}
@@ -0,0 +1,34 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../repositories/admin_repository.dart';
import 'admin_evaluation_history_event.dart';
import 'admin_evaluation_history_state.dart';
export 'admin_evaluation_history_event.dart';
export 'admin_evaluation_history_state.dart';
class AdminEvaluationHistoryBloc extends Bloc<AdminEvaluationHistoryEvent, AdminEvaluationHistoryState> {
final AdminRepository repository;
AdminEvaluationHistoryBloc({required this.repository}) : super(AdminEvaluationHistoryInitial()) {
on<FetchEvaluationHistory>(_onFetch);
}
Future<void> _onFetch(FetchEvaluationHistory event, Emitter<AdminEvaluationHistoryState> emit) async {
emit(AdminEvaluationHistoryLoading());
try {
final response = await repository.fetchEvaluationHistory(
fromUtc: event.fromUtc,
toUtc: event.toUtc,
outcome: event.outcome,
triggerSource: event.triggerSource,
search: event.search,
page: event.page,
pageSize: event.pageSize,
);
emit(AdminEvaluationHistoryLoaded(response: response, page: event.page, pageSize: event.pageSize));
} catch (e) {
emit(AdminEvaluationHistoryError(e.toString().replaceFirst('Exception: ', '')));
}
}
}
@@ -0,0 +1,36 @@
import 'package:equatable/equatable.dart';
import '../models/evaluation_history_enums.dart';
abstract class AdminEvaluationHistoryEvent extends Equatable {
const AdminEvaluationHistoryEvent();
@override
List<Object?> get props => [];
}
/// Fetches (or re-fetches) one page of evaluation history for the given filter
/// set. There is deliberately no separate "change page" event — every fetch is
/// a full filter snapshot, so the bloc never has to guess which filters were
/// active on a previously-loaded page when the caller asks for the next one.
class FetchEvaluationHistory extends AdminEvaluationHistoryEvent {
final DateTime? fromUtc;
final DateTime? toUtc;
final OutcomeReason? outcome;
final TriggerSource? triggerSource;
final String? search;
final int page;
final int pageSize;
const FetchEvaluationHistory({
this.fromUtc,
this.toUtc,
this.outcome,
this.triggerSource,
this.search,
this.page = 1,
this.pageSize = 50,
});
@override
List<Object?> get props => [fromUtc, toUtc, outcome, triggerSource, search, page, pageSize];
}
@@ -0,0 +1,48 @@
import 'package:equatable/equatable.dart';
import '../models/evaluation_history_response_model.dart';
abstract class AdminEvaluationHistoryState extends Equatable {
const AdminEvaluationHistoryState();
@override
List<Object?> get props => [];
}
class AdminEvaluationHistoryInitial extends AdminEvaluationHistoryState {}
class AdminEvaluationHistoryLoading extends AdminEvaluationHistoryState {}
class AdminEvaluationHistoryLoaded extends AdminEvaluationHistoryState {
final EvaluationHistoryResponseModel response;
final int page;
final int pageSize;
const AdminEvaluationHistoryLoaded({
required this.response,
required this.page,
required this.pageSize,
});
bool get hasPreviousPage => page > 1;
bool get hasNextPage => page * pageSize < response.totalCount;
int get rangeStart => response.totalCount == 0 ? 0 : (page - 1) * pageSize + 1;
int get rangeEnd {
final end = page * pageSize;
return end > response.totalCount ? response.totalCount : end;
}
@override
List<Object?> get props => [response, page, pageSize];
}
class AdminEvaluationHistoryError extends AdminEvaluationHistoryState {
final String message;
const AdminEvaluationHistoryError(this.message);
@override
List<Object?> get props => [message];
}
@@ -0,0 +1,31 @@
import 'package:equatable/equatable.dart';
import '../models/admin_create_user_request_dto.dart';
import '../models/admin_update_user_request_dto.dart';
abstract class AdminEvent extends Equatable {
const AdminEvent();
@override
List<Object?> get props => [];
}
class FetchAdminUsers extends AdminEvent {}
class CreateAdminUser extends AdminEvent {
final AdminCreateUserRequestDto dto;
const CreateAdminUser(this.dto);
@override
List<Object?> get props => [dto];
}
class UpdateAdminUser extends AdminEvent {
final String id;
final AdminUpdateUserRequestDto dto;
const UpdateAdminUser(this.id, this.dto);
@override
List<Object?> get props => [id, dto];
}
@@ -0,0 +1,31 @@
import 'package:equatable/equatable.dart';
import 'package:finlytic_app/features/admin/models/admin_user_model.dart';
abstract class AdminState extends Equatable {
const AdminState();
@override
List<Object?> get props => [];
}
class AdminInitial extends AdminState {}
class AdminLoading extends AdminState {}
class AdminLoaded extends AdminState {
final List<AdminUserModel> users;
const AdminLoaded(this.users);
@override
List<Object?> get props => [users];
}
class AdminError extends AdminState {
final String message;
const AdminError(this.message);
@override
List<Object?> get props => [message];
}
@@ -0,0 +1,22 @@
class AdminCreateUserRequestDto {
final String email;
final String password;
final String fullName;
final String role;
AdminCreateUserRequestDto({
required this.email,
required this.password,
required this.fullName,
required this.role,
});
Map<String, dynamic> toJson() {
return {
'email': email,
'password': password,
'fullName': fullName,
'role': role,
};
}
}
@@ -0,0 +1,16 @@
class AdminUpdateUserRequestDto {
final String role;
final bool isActive;
AdminUpdateUserRequestDto({
required this.role,
required this.isActive,
});
Map<String, dynamic> toJson() {
return {
'role': role,
'isActive': isActive,
};
}
}
@@ -0,0 +1,40 @@
import 'package:equatable/equatable.dart';
class AdminUserModel extends Equatable {
final String id;
final String email;
final String fullName;
final String role;
final bool isActive;
const AdminUserModel({
required this.id,
required this.email,
required this.fullName,
required this.role,
required this.isActive,
});
factory AdminUserModel.fromJson(Map<String, dynamic> json) {
return AdminUserModel(
id: json['id']?.toString() ?? '',
email: json['email']?.toString() ?? '',
fullName: json['fullName']?.toString() ?? json['name']?.toString() ?? '',
role: json['role']?.toString() ?? 'User',
isActive: json['isActive'] == true || json['IsActive'] == true,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'fullName': fullName,
'role': role,
'isActive': isActive,
};
}
@override
List<Object?> get props => [id, email, fullName, role, isActive];
}
@@ -0,0 +1,124 @@
import 'package:equatable/equatable.dart';
import 'evaluation_history_enums.dart';
/// Typed mirror of `FinlyticCore.Dtos.Trading.EvaluationHistoryEntryDto` — one row
/// of `GET /api/v1/admin/evaluations`. Every score field is the real,
/// already-computed value the server persisted (including the honest 0/default
/// values recorded for [OutcomeReason.noTechnicalSetups]) — nothing here is
/// fabricated client-side (Rules.md §4).
class EvaluationHistoryEntryModel extends Equatable {
final String id;
final String isin;
final String symbol;
final double technicalScore;
final double sentimentScore;
final double fundamentalScore;
final double compositeOpportunityScore;
final double reliabilityBonus;
final bool passedEarningsLockout;
final int? daysToNextEarnings;
final bool passedDividendGate;
final int? daysToNextExDividend;
final UniverseSource? universeSource;
final DateTime? universeEnteredAtUtc;
final bool passedSimulationVeto;
final bool passedAiValidation;
final String aiThesisSummary;
final OutcomeReason outcomeReason;
final TriggerSource triggerSource;
final String? triggeredByUserId;
final String? proposalId;
final DateTime evaluatedAtUtc;
const EvaluationHistoryEntryModel({
required this.id,
required this.isin,
required this.symbol,
required this.technicalScore,
required this.sentimentScore,
required this.fundamentalScore,
required this.compositeOpportunityScore,
required this.reliabilityBonus,
required this.passedEarningsLockout,
this.daysToNextEarnings,
required this.passedDividendGate,
this.daysToNextExDividend,
this.universeSource,
this.universeEnteredAtUtc,
required this.passedSimulationVeto,
required this.passedAiValidation,
required this.aiThesisSummary,
required this.outcomeReason,
required this.triggerSource,
this.triggeredByUserId,
this.proposalId,
required this.evaluatedAtUtc,
});
/// True exactly when this evaluation resulted in a trade proposal.
bool get hasProposal => proposalId != null && proposalId!.isNotEmpty;
factory EvaluationHistoryEntryModel.fromJson(Map<String, dynamic> json) {
double parseDbl(dynamic val) {
if (val == null) return 0.0;
if (val is num) return val.toDouble();
return double.tryParse(val.toString()) ?? 0.0;
}
DateTime parseDate(dynamic val) {
if (val == null) return DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
return DateTime.tryParse(val.toString())?.toUtc() ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
}
return EvaluationHistoryEntryModel(
id: json['id']?.toString() ?? '',
isin: json['isin']?.toString() ?? '',
symbol: json['symbol']?.toString() ?? '',
technicalScore: parseDbl(json['technicalScore']),
sentimentScore: parseDbl(json['sentimentScore']),
fundamentalScore: parseDbl(json['fundamentalScore']),
compositeOpportunityScore: parseDbl(json['compositeOpportunityScore']),
reliabilityBonus: parseDbl(json['reliabilityBonus']),
passedEarningsLockout: json['passedEarningsLockout'] == true,
daysToNextEarnings: json['daysToNextEarnings'] is num ? (json['daysToNextEarnings'] as num).toInt() : null,
passedDividendGate: json['passedDividendGate'] == true,
daysToNextExDividend: json['daysToNextExDividend'] is num ? (json['daysToNextExDividend'] as num).toInt() : null,
universeSource: UniverseSource.fromJson(json['universeSource']?.toString()),
universeEnteredAtUtc: json['universeEnteredAtUtc'] == null ? null : parseDate(json['universeEnteredAtUtc']),
passedSimulationVeto: json['passedSimulationVeto'] == true,
passedAiValidation: json['passedAiValidation'] == true,
aiThesisSummary: json['aiThesisSummary']?.toString() ?? '',
outcomeReason: OutcomeReason.fromJson(json['outcomeReason']?.toString()),
triggerSource: TriggerSource.fromJson(json['triggerSource']?.toString()),
triggeredByUserId: json['triggeredByUserId']?.toString(),
proposalId: json['proposalId']?.toString(),
evaluatedAtUtc: parseDate(json['evaluatedAtUtc']),
);
}
@override
List<Object?> get props => [
id,
isin,
symbol,
technicalScore,
sentimentScore,
fundamentalScore,
compositeOpportunityScore,
reliabilityBonus,
passedEarningsLockout,
daysToNextEarnings,
passedDividendGate,
daysToNextExDividend,
universeSource,
universeEnteredAtUtc,
passedSimulationVeto,
passedAiValidation,
aiThesisSummary,
outcomeReason,
triggerSource,
triggeredByUserId,
proposalId,
evaluatedAtUtc,
];
}
@@ -0,0 +1,204 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
/// Mirrors `FinlyticCore.Dtos.Trading.OutcomeReason` (`TradeEnums.cs`), which the
/// `/api/v1/admin/evaluations` endpoint serializes as a `JsonStringEnumConverter`
/// string using the exact C# member name (e.g. `"Approved"`, `"BelowScoreThreshold"`).
///
/// [unknown] is the fallback both for the server's own `Unknown = 0` default (an
/// honest "we don't know" rather than a fabricated reason, Rules.md §4) and for any
/// future server-side member this client doesn't recognize yet.
enum OutcomeReason {
unknown,
approved,
belowScoreThreshold,
earningsLockout,
simulationVeto,
aiRejected,
noTechnicalSetups,
duplicateActiveProposal,
dividendGate;
static OutcomeReason fromJson(String? raw) {
switch (raw) {
case 'Approved':
return OutcomeReason.approved;
case 'BelowScoreThreshold':
return OutcomeReason.belowScoreThreshold;
case 'EarningsLockout':
return OutcomeReason.earningsLockout;
case 'SimulationVeto':
return OutcomeReason.simulationVeto;
case 'AiRejected':
return OutcomeReason.aiRejected;
case 'NoTechnicalSetups':
return OutcomeReason.noTechnicalSetups;
case 'DuplicateActiveProposal':
return OutcomeReason.duplicateActiveProposal;
case 'DividendGate':
return OutcomeReason.dividendGate;
case 'Unknown':
default:
return OutcomeReason.unknown;
}
}
/// Exact server-side enum member name. `[FromQuery] OutcomeReason?` on
/// `AdminEvaluationHistoryController` model-binds a bare enum member name from
/// the query string (ASP.NET Core's default `Enum.TryParse`-based binder), not a
/// JSON string — so this is what must be sent back as the `outcome` filter value.
String toApiValue() {
switch (this) {
case OutcomeReason.approved:
return 'Approved';
case OutcomeReason.belowScoreThreshold:
return 'BelowScoreThreshold';
case OutcomeReason.earningsLockout:
return 'EarningsLockout';
case OutcomeReason.simulationVeto:
return 'SimulationVeto';
case OutcomeReason.aiRejected:
return 'AiRejected';
case OutcomeReason.noTechnicalSetups:
return 'NoTechnicalSetups';
case OutcomeReason.duplicateActiveProposal:
return 'DuplicateActiveProposal';
case OutcomeReason.dividendGate:
return 'DividendGate';
case OutcomeReason.unknown:
return 'Unknown';
}
}
String get label {
switch (this) {
case OutcomeReason.approved:
return 'Freigegeben';
case OutcomeReason.belowScoreThreshold:
return 'Score zu niedrig';
case OutcomeReason.earningsLockout:
return 'Earnings-Sperre';
case OutcomeReason.simulationVeto:
return 'Simulation-Veto';
case OutcomeReason.aiRejected:
return 'KI abgelehnt';
case OutcomeReason.noTechnicalSetups:
return 'Kein Setup';
case OutcomeReason.duplicateActiveProposal:
return 'Bereits aktiver Vorschlag';
case OutcomeReason.dividendGate:
return 'Dividend-Sperre';
case OutcomeReason.unknown:
return 'Unbekannt';
}
}
/// Color-coding for the history-list badge, reusing only colors already
/// established elsewhere in the app (`AppTheme.primaryEmerald`/`accentRed` plus
/// the `Colors.amber`/`Colors.purpleAccent` already used by
/// `EvaluationScoreBreakdownSheet`) rather than introducing a new palette.
Color get color {
switch (this) {
case OutcomeReason.approved:
return AppTheme.primaryEmerald;
case OutcomeReason.aiRejected:
case OutcomeReason.simulationVeto:
return AppTheme.accentRed;
case OutcomeReason.belowScoreThreshold:
case OutcomeReason.earningsLockout:
case OutcomeReason.duplicateActiveProposal:
case OutcomeReason.dividendGate:
return Colors.amber;
case OutcomeReason.noTechnicalSetups:
case OutcomeReason.unknown:
return AppTheme.textMuted;
}
}
}
/// Mirrors `FinlyticCore.Dtos.Trading.TriggerSource`.
enum TriggerSource {
unknown,
automatic,
manual;
static TriggerSource fromJson(String? raw) {
switch (raw) {
case 'Automatic':
return TriggerSource.automatic;
case 'Manual':
return TriggerSource.manual;
case 'Unknown':
default:
return TriggerSource.unknown;
}
}
String toApiValue() {
switch (this) {
case TriggerSource.automatic:
return 'Automatic';
case TriggerSource.manual:
return 'Manual';
case TriggerSource.unknown:
return 'Unknown';
}
}
String get label {
switch (this) {
case TriggerSource.automatic:
return 'Automatisch';
case TriggerSource.manual:
return 'Manuell';
case TriggerSource.unknown:
return 'Unbekannt';
}
}
Color get color {
switch (this) {
case TriggerSource.automatic:
return AppTheme.accentCyan;
case TriggerSource.manual:
return Colors.purpleAccent;
case TriggerSource.unknown:
return AppTheme.textMuted;
}
}
}
/// Mirrors `FinlyticCore.Dtos.TechnicalAnalysis.UniverseSource` - which recurring
/// FinlyticTechnicals selection mechanism added the ISIN to the continuously
/// scanned universe before this evaluation ran. `null` on the Dart side (not
/// modeled as its own enum value here) means the evaluation happened outside
/// that universe entirely (e.g. a manual "Analyze now" call).
enum UniverseSource {
sentimentSpike,
userFavorite,
discovery;
static UniverseSource? fromJson(String? raw) {
switch (raw) {
case 'SentimentSpike':
return UniverseSource.sentimentSpike;
case 'UserFavorite':
return UniverseSource.userFavorite;
case 'Discovery':
return UniverseSource.discovery;
default:
return null;
}
}
String get label {
switch (this) {
case UniverseSource.sentimentSpike:
return 'Sentiment-Spike';
case UniverseSource.userFavorite:
return 'Nutzer-Favorit';
case UniverseSource.discovery:
return 'Discovery-Liste';
}
}
}
@@ -0,0 +1,44 @@
import 'package:equatable/equatable.dart';
import 'evaluation_history_entry_model.dart';
import 'evaluation_history_summary_model.dart';
/// Typed mirror of `FinlyticCore.Dtos.Trading.GetEvaluationHistoryResponse` — the
/// full response body of `GET /api/v1/admin/evaluations`.
class EvaluationHistoryResponseModel extends Equatable {
final int totalCount;
final List<EvaluationHistoryEntryModel> entries;
final EvaluationHistorySummaryModel summary;
const EvaluationHistoryResponseModel({
required this.totalCount,
required this.entries,
required this.summary,
});
factory EvaluationHistoryResponseModel.empty() => EvaluationHistoryResponseModel(
totalCount: 0,
entries: const [],
summary: EvaluationHistorySummaryModel.empty(),
);
factory EvaluationHistoryResponseModel.fromJson(Map<String, dynamic> json) {
final rawEntries = json['entries'];
final entries = rawEntries is List
? rawEntries.whereType<Map<String, dynamic>>().map(EvaluationHistoryEntryModel.fromJson).toList()
: <EvaluationHistoryEntryModel>[];
final rawSummary = json['summary'];
final summary = rawSummary is Map<String, dynamic>
? EvaluationHistorySummaryModel.fromJson(rawSummary)
: EvaluationHistorySummaryModel.empty();
return EvaluationHistoryResponseModel(
totalCount: json['totalCount'] is num ? (json['totalCount'] as num).toInt() : 0,
entries: entries,
summary: summary,
);
}
@override
List<Object?> get props => [totalCount, entries, summary];
}
@@ -0,0 +1,84 @@
import 'package:equatable/equatable.dart';
import 'evaluation_history_enums.dart';
/// Typed mirror of `FinlyticCore.Dtos.Trading.OutcomeReasonCountDto`.
class OutcomeReasonCountModel extends Equatable {
final OutcomeReason outcomeReason;
final int count;
const OutcomeReasonCountModel({required this.outcomeReason, required this.count});
factory OutcomeReasonCountModel.fromJson(Map<String, dynamic> json) {
return OutcomeReasonCountModel(
outcomeReason: OutcomeReason.fromJson(json['outcomeReason']?.toString()),
count: json['count'] is num ? (json['count'] as num).toInt() : 0,
);
}
@override
List<Object?> get props => [outcomeReason, count];
}
/// Typed mirror of `FinlyticCore.Dtos.Trading.EvaluationHistorySummaryDto` — the
/// pre-aggregated headline numbers for the admin evaluation-history tab. Every
/// field except [lastProposalCreatedAtUtc] is scoped to the same filters as the
/// paginated entry list it accompanies; [lastProposalCreatedAtUtc] deliberately
/// ignores the from/to filters (see the server-side DTO doc comment) so the admin
/// always sees "how long since the last real proposal" regardless of which
/// historical window is currently selected.
class EvaluationHistorySummaryModel extends Equatable {
final int totalEvaluations;
final List<OutcomeReasonCountModel> countsByOutcome;
final double averageCompositeScore;
final int proposalsCreated;
final DateTime? lastProposalCreatedAtUtc;
const EvaluationHistorySummaryModel({
required this.totalEvaluations,
required this.countsByOutcome,
required this.averageCompositeScore,
required this.proposalsCreated,
this.lastProposalCreatedAtUtc,
});
int countFor(OutcomeReason reason) {
for (final c in countsByOutcome) {
if (c.outcomeReason == reason) return c.count;
}
return 0;
}
factory EvaluationHistorySummaryModel.empty() => const EvaluationHistorySummaryModel(
totalEvaluations: 0,
countsByOutcome: [],
averageCompositeScore: 0,
proposalsCreated: 0,
lastProposalCreatedAtUtc: null,
);
factory EvaluationHistorySummaryModel.fromJson(Map<String, dynamic> json) {
double parseDbl(dynamic val) {
if (val == null) return 0.0;
if (val is num) return val.toDouble();
return double.tryParse(val.toString()) ?? 0.0;
}
final rawCounts = json['countsByOutcome'];
final counts = rawCounts is List
? rawCounts.whereType<Map<String, dynamic>>().map(OutcomeReasonCountModel.fromJson).toList()
: <OutcomeReasonCountModel>[];
final rawLast = json['lastProposalCreatedAtUtc'];
return EvaluationHistorySummaryModel(
totalEvaluations: json['totalEvaluations'] is num ? (json['totalEvaluations'] as num).toInt() : 0,
countsByOutcome: counts,
averageCompositeScore: parseDbl(json['averageCompositeScore']),
proposalsCreated: json['proposalsCreated'] is num ? (json['proposalsCreated'] as num).toInt() : 0,
lastProposalCreatedAtUtc: rawLast != null ? DateTime.tryParse(rawLast.toString())?.toUtc() : null,
);
}
@override
List<Object?> get props => [totalEvaluations, countsByOutcome, averageCompositeScore, proposalsCreated, lastProposalCreatedAtUtc];
}
@@ -0,0 +1,44 @@
class LogMessageDto {
final DateTime timestamp;
final String serviceName;
final String channel;
final String level; // 'Information', 'Warning', 'Error', 'Debug', 'Trace'
final String message;
final String? exception;
const LogMessageDto({
required this.timestamp,
required this.serviceName,
required this.channel,
required this.level,
required this.message,
this.exception,
});
factory LogMessageDto.fromJson(Map<String, dynamic> json) {
DateTime parsedTime = DateTime.now();
if (json['timestamp'] != null) {
parsedTime = DateTime.tryParse(json['timestamp'].toString()) ?? DateTime.now();
}
return LogMessageDto(
timestamp: parsedTime.toLocal(),
serviceName: json['serviceName']?.toString() ?? '',
channel: json['channel']?.toString() ?? '',
level: json['level']?.toString() ?? 'Information',
message: json['message']?.toString() ?? '',
exception: json['exception']?.toString(),
);
}
Map<String, dynamic> toJson() {
return {
'timestamp': timestamp.toUtc().toIso8601String(),
'serviceName': serviceName,
'channel': channel,
'level': level,
'message': message,
'exception': exception,
};
}
}
@@ -0,0 +1,42 @@
import 'package:equatable/equatable.dart';
/// Typed mirror of the fields the admin UI needs from
/// `FinlyticCore.Dtos.TechnicalAnalysis.StrategyResultDto`, as returned by
/// `GET /api/v1/admin/evaluations/watchlist/{isin}/history` — the last N
/// technical-analysis setups computed for one ISIN, most recent first, so the
/// score trend (improving/worsening, and whether it ever cleared the engine's
/// top-pick bar) is visible even for setups too weak to ever reach the engine.
class RecentSetupModel extends Equatable {
final String strategyName;
final double qualityScore;
final bool isTopPick;
final String rating;
final DateTime createdAt;
const RecentSetupModel({
required this.strategyName,
required this.qualityScore,
required this.isTopPick,
required this.rating,
required this.createdAt,
});
factory RecentSetupModel.fromJson(Map<String, dynamic> json) {
double parseDbl(dynamic val) {
if (val == null) return 0.0;
if (val is num) return val.toDouble();
return double.tryParse(val.toString()) ?? 0.0;
}
return RecentSetupModel(
strategyName: json['strategyName']?.toString() ?? '',
qualityScore: parseDbl(json['qualityScore']),
isTopPick: json['isTopPick'] == true,
rating: json['rating']?.toString() ?? '',
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? '')?.toUtc() ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
);
}
@override
List<Object?> get props => [strategyName, qualityScore, isTopPick, rating, createdAt];
}
@@ -0,0 +1,31 @@
class ServiceSettingDto {
final String key;
final String value;
final String type; // 'bool', 'int', 'double', 'string'
final String description;
const ServiceSettingDto({
required this.key,
required this.value,
this.type = 'string',
this.description = '',
});
factory ServiceSettingDto.fromJson(Map<String, dynamic> json) {
return ServiceSettingDto(
key: json['key']?.toString() ?? '',
value: json['value']?.toString() ?? '',
type: json['dataType']?.toString() ?? json['type']?.toString() ?? 'string',
description: json['description']?.toString() ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'key': key,
'value': value,
'type': type,
'description': description,
};
}
}
@@ -0,0 +1,42 @@
import 'package:equatable/equatable.dart';
import 'evaluation_history_enums.dart';
/// Typed mirror of `FinlyticCore.Dtos.TechnicalAnalysis.WatchlistEntryDto` — one
/// row of `GET /api/v1/admin/evaluations/watchlist`: an asset FinlyticTechnicals'
/// background scanner is actually evaluating every cycle, independent of
/// whether it has produced any evaluation the engine ever saw.
class WatchlistEntryModel extends Equatable {
final String isin;
final String? symbol;
final UniverseSource? source;
final int priority;
final DateTime addedAtUtc;
final DateTime? expiresAtUtc;
const WatchlistEntryModel({
required this.isin,
this.symbol,
this.source,
required this.priority,
required this.addedAtUtc,
this.expiresAtUtc,
});
factory WatchlistEntryModel.fromJson(Map<String, dynamic> json) {
DateTime parseDate(dynamic val) {
return DateTime.tryParse(val?.toString() ?? '')?.toUtc() ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
}
return WatchlistEntryModel(
isin: json['isin']?.toString() ?? '',
symbol: json['symbol']?.toString(),
source: UniverseSource.fromJson(json['source']?.toString()),
priority: json['priority'] is num ? (json['priority'] as num).toInt() : 0,
addedAtUtc: parseDate(json['addedAtUtc']),
expiresAtUtc: json['expiresAtUtc'] == null ? null : parseDate(json['expiresAtUtc']),
);
}
@override
List<Object?> get props => [isin, symbol, source, priority, addedAtUtc, expiresAtUtc];
}
@@ -0,0 +1,150 @@
import 'package:dio/dio.dart';
import 'package:finlytic_app/core/network/api_client.dart';
import 'package:finlytic_app/features/admin/models/admin_user_model.dart';
import 'package:finlytic_app/features/admin/models/admin_create_user_request_dto.dart';
import 'package:finlytic_app/features/admin/models/admin_update_user_request_dto.dart';
import 'package:finlytic_app/features/admin/models/evaluation_history_enums.dart';
import 'package:finlytic_app/features/admin/models/evaluation_history_response_model.dart';
import 'package:finlytic_app/features/admin/models/recent_setup_model.dart';
import 'package:finlytic_app/features/admin/models/service_setting_dto.dart';
import 'package:finlytic_app/features/admin/models/watchlist_entry_model.dart';
class AdminRepository {
final ApiClient apiClient;
const AdminRepository({required this.apiClient});
Future<List<AdminUserModel>> fetchUsers() async {
try {
final res = await apiClient.get('/api/v1/admin/users');
if (res.statusCode == 200 && res.data != null) {
final List<dynamic> data = res.data;
return data.map((json) => AdminUserModel.fromJson(json)).toList();
}
return [];
} catch (e) {
throw Exception('Nutzer konnten nicht geladen werden: $e');
}
}
Future<void> createUser(AdminCreateUserRequestDto dto) async {
final res = await apiClient.post('/api/v1/admin/users', data: dto.toJson());
if (res.statusCode != 200 && res.statusCode != 201) {
throw Exception('Erstellen fehlgeschlagen');
}
}
Future<void> updateUser(String id, AdminUpdateUserRequestDto dto) async {
final res = await apiClient.put('/api/v1/admin/users/$id', data: dto.toJson());
if (res.statusCode != 200) {
throw Exception('Aktualisieren fehlgeschlagen');
}
}
Future<Map<String, List<ServiceSettingDto>>> fetchSettings() async {
final res = await apiClient.get('/api/v1/admin/settings');
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
final map = res.data as Map<String, dynamic>;
final result = <String, List<ServiceSettingDto>>{};
map.forEach((k, v) {
if (v is List) {
result[k] = v
.whereType<Map<String, dynamic>>()
.map((item) => ServiceSettingDto.fromJson(item))
.toList();
}
});
return result;
}
return {};
}
Future<void> updateServiceSettings(String serviceName, Map<String, dynamic> settings) async {
final res = await apiClient.put('/api/v1/admin/settings/$serviceName', data: settings);
if (res.statusCode != 200 && res.statusCode != 204) {
throw Exception('Einstellungen konnten nicht gespeichert werden');
}
}
/// Fetches a filtered, paginated page of the evaluation history plus its
/// accompanying summary from `GET /api/v1/admin/evaluations`
/// (`AdminEvaluationHistoryController`). All filter parameters are optional —
/// omitting one means "do not filter on this field", mirroring the server
/// contract exactly (`GetEvaluationHistoryRequest`).
///
/// `[Authorize(Roles = "Admin")]` on the server means a non-admin caller (or an
/// expired/invalid token) gets a `401`/`403`, which `ApiClient`'s interceptor
/// already turns into an auto-logout (Rules.md §8) before this method's
/// `catch` even runs — this method only has to turn the remaining
/// error responses (engine unreachable `503`, RPC timeout `502`, unexpected
/// `500` — all `ProblemDetails` bodies per the controller) into a readable
/// message instead of letting a raw `DioException` reach the UI.
Future<EvaluationHistoryResponseModel> fetchEvaluationHistory({
DateTime? fromUtc,
DateTime? toUtc,
OutcomeReason? outcome,
TriggerSource? triggerSource,
String? search,
int page = 1,
int pageSize = 50,
}) async {
final query = <String, dynamic>{
'page': page,
'pageSize': pageSize,
};
if (fromUtc != null) query['fromUtc'] = fromUtc.toUtc().toIso8601String();
if (toUtc != null) query['toUtc'] = toUtc.toUtc().toIso8601String();
if (outcome != null) query['outcome'] = outcome.toApiValue();
if (triggerSource != null) query['triggerSource'] = triggerSource.toApiValue();
if (search != null && search.trim().isNotEmpty) query['search'] = search.trim();
try {
final res = await apiClient.get('/api/v1/admin/evaluations', queryParameters: query);
if (res.data is Map<String, dynamic>) {
return EvaluationHistoryResponseModel.fromJson(res.data as Map<String, dynamic>);
}
return EvaluationHistoryResponseModel.empty();
} on DioException catch (e) {
final data = e.response?.data;
final title = (data is Map && data['title'] is String) ? data['title'] as String : null;
throw Exception(title ?? 'Evaluierungs-Historie konnte nicht geladen werden.');
}
}
/// Fetches FinlyticTechnicals' current scan universe ("watchlist") from
/// `GET /api/v1/admin/evaluations/watchlist` — the assets actually being
/// evaluated every cycle in the background, independent of the (filtered)
/// evaluation history above.
Future<List<WatchlistEntryModel>> fetchWatchlist() async {
try {
final res = await apiClient.get('/api/v1/admin/evaluations/watchlist');
if (res.data is List) {
return (res.data as List).whereType<Map<String, dynamic>>().map(WatchlistEntryModel.fromJson).toList();
}
return [];
} on DioException catch (e) {
final data = e.response?.data;
final title = (data is Map && data['title'] is String) ? data['title'] as String : null;
throw Exception(title ?? 'Watchlist konnte nicht geladen werden.');
}
}
/// Fetches the last [limit] technical-analysis setups computed for [isin]
/// (most recent first) from `GET /api/v1/admin/evaluations/watchlist/{isin}/history`.
Future<List<RecentSetupModel>> fetchWatchlistEntryHistory(String isin, {int limit = 8}) async {
try {
final res = await apiClient.get(
'/api/v1/admin/evaluations/watchlist/${Uri.encodeComponent(isin)}/history',
queryParameters: {'limit': limit},
);
if (res.data is List) {
return (res.data as List).whereType<Map<String, dynamic>>().map(RecentSetupModel.fromJson).toList();
}
return [];
} on DioException catch (e) {
final data = e.response?.data;
final title = (data is Map && data['title'] is String) ? data['title'] as String : null;
throw Exception(title ?? 'Score-Verlauf konnte nicht geladen werden.');
}
}
}
@@ -0,0 +1,324 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../shared/widgets/evaluation_score_breakdown_sheet.dart';
import '../bloc/admin_evaluation_history_bloc.dart';
import '../models/evaluation_history_entry_model.dart';
import '../models/evaluation_history_enums.dart';
import '../models/evaluation_history_summary_model.dart';
import '../repositories/admin_repository.dart';
import '../widgets/evaluation_history_filter_bar.dart';
import '../widgets/evaluation_history_kpi_header.dart';
import '../widgets/evaluation_history_list_item.dart';
/// Admin-only tab showing the full history of every asset evaluation the
/// engine ever ran — approved or not, automatic or manual — so an admin can
/// see directly *why* no new trade proposal appeared instead of having to
/// query the database by hand. Backed by `GET /api/v1/admin/evaluations`
/// (`AdminEvaluationHistoryController`, `[Authorize(Roles = "Admin")]`).
///
/// This screen is only ever mounted from `ResponsiveScaffold` behind an
/// `if (widget.user.isAdmin)` guard, same as the Bot Panel/Backtest/Admin
/// Panel tabs — that guard is UX only, not a security boundary. The real
/// boundary is the server-side `[Authorize(Roles = "Admin")]`: if a non-admin
/// (or an expired-token admin) somehow still reaches this screen, the 401/403
/// response is caught by `ApiClient`'s central interceptor, which clears the
/// stored token and triggers auto-logout (Rules.md §8) — the bloc below just
/// has to not crash on the `AdminEvaluationHistoryError` that results in the
/// meantime, which it doesn't (it renders a normal retryable error state).
class AdminEvaluationHistoryScreen extends StatelessWidget {
final ApiClient apiClient;
const AdminEvaluationHistoryScreen({super.key, required this.apiClient});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => AdminEvaluationHistoryBloc(
repository: AdminRepository(apiClient: apiClient),
)..add(const FetchEvaluationHistory()),
child: _AdminEvaluationHistoryScreenContent(apiClient: apiClient),
);
}
}
class _AdminEvaluationHistoryScreenContent extends StatefulWidget {
final ApiClient apiClient;
const _AdminEvaluationHistoryScreenContent({required this.apiClient});
@override
State<_AdminEvaluationHistoryScreenContent> createState() => _AdminEvaluationHistoryScreenContentState();
}
class _AdminEvaluationHistoryScreenContentState extends State<_AdminEvaluationHistoryScreenContent> {
static const int _pageSize = 50;
DateTime? _fromUtc;
DateTime? _toUtc;
OutcomeReason? _outcome;
TriggerSource? _triggerSource;
String _search = '';
int _page = 1;
void _fetch() {
context.read<AdminEvaluationHistoryBloc>().add(FetchEvaluationHistory(
fromUtc: _fromUtc,
toUtc: _toUtc,
outcome: _outcome,
triggerSource: _triggerSource,
search: _search,
page: _page,
pageSize: _pageSize,
));
}
void _onFilterChanged({
required DateTime? fromUtc,
required DateTime? toUtc,
required OutcomeReason? outcome,
required TriggerSource? triggerSource,
required String search,
}) {
setState(() {
_fromUtc = fromUtc;
_toUtc = toUtc;
_outcome = outcome;
_triggerSource = triggerSource;
_search = search;
_page = 1;
});
_fetch();
}
void _goToPage(int page) {
setState(() => _page = page);
_fetch();
}
void _showDetail(EvaluationHistoryEntryModel entry) {
final approvedLike = entry.outcomeReason == OutcomeReason.approved || entry.passedAiValidation;
EvaluationScoreBreakdownSheet.show(
context,
title: '${entry.symbol.isNotEmpty ? entry.symbol : entry.isin} · ${entry.outcomeReason.label}',
subtitle: 'Evaluiert am ${_formatFullTimestamp(entry.evaluatedAtUtc)} · Ausgelöst: ${entry.triggerSource.label}.',
headerIcon: approvedLike ? Icons.psychology_outlined : Icons.block_outlined,
headerColor: approvedLike ? AppTheme.primaryEmerald : entry.outcomeReason.color,
compositeScore: entry.compositeOpportunityScore,
technicalScore: entry.technicalScore,
sentimentScore: entry.sentimentScore,
fundamentalScore: entry.fundamentalScore,
reliabilityBonus: entry.reliabilityBonus,
passedEarningsLockout: entry.passedEarningsLockout,
daysToNextEarnings: entry.daysToNextEarnings,
passedDividendGate: entry.passedDividendGate,
daysToNextExDividend: entry.daysToNextExDividend,
universeSourceLabel: entry.universeSource?.label,
universeEnteredAtUtc: entry.universeEnteredAtUtc,
passedSimulationVeto: entry.passedSimulationVeto,
reasoningLabel: entry.passedAiValidation ? 'KI-These' : 'Ablehnungsgrund',
reasoningText: entry.aiThesisSummary,
footer: entry.hasProposal
? Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.4)),
),
child: Row(
children: [
Icon(Icons.rocket_launch_outlined, size: 16, color: AppTheme.primaryEmerald),
const SizedBox(width: 8),
Expanded(
child: Text(
'Aus dieser Analyse entstand ein Trade-Vorschlag (Proposal-ID: ${entry.proposalId}).',
style: TextStyle(color: AppTheme.primaryEmerald, fontSize: 12, height: 1.4, fontWeight: FontWeight.w600),
),
),
],
),
)
: null,
);
}
String _formatFullTimestamp(DateTime utc) {
final local = utc.toLocal();
final d = local.day.toString().padLeft(2, '0');
final m = local.month.toString().padLeft(2, '0');
final h = local.hour.toString().padLeft(2, '0');
final min = local.minute.toString().padLeft(2, '0');
return '$d.$m.${local.year} $h:$min';
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.transparent,
body: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Evaluierungs-Historie',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white),
),
const SizedBox(height: 2),
Text(
'Jede Analyse, jeder Filter, jedes Ergebnis nachvollziehbar ohne DB-Zugriff.',
style: TextStyle(color: AppTheme.textMuted, fontSize: 13),
),
],
),
),
IconButton(
onPressed: _fetch,
icon: const Icon(Icons.refresh_rounded, color: Colors.white70),
tooltip: 'Neu laden',
),
],
),
const SizedBox(height: 16),
Expanded(
child: SingleChildScrollView(
child: BlocBuilder<AdminEvaluationHistoryBloc, AdminEvaluationHistoryState>(
builder: (context, state) {
final summary = state is AdminEvaluationHistoryLoaded ? state.response.summary : EvaluationHistorySummaryModel.empty();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
EvaluationHistoryKpiHeader(summary: summary, apiClient: widget.apiClient),
const SizedBox(height: 16),
EvaluationHistoryFilterBar(
fromUtc: _fromUtc,
toUtc: _toUtc,
outcome: _outcome,
triggerSource: _triggerSource,
search: _search,
onChanged: _onFilterChanged,
),
const SizedBox(height: 16),
_buildBody(state),
],
);
},
),
),
),
],
),
),
);
}
Widget _buildBody(AdminEvaluationHistoryState state) {
if (state is AdminEvaluationHistoryLoading || state is AdminEvaluationHistoryInitial) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald)),
);
}
if (state is AdminEvaluationHistoryError) {
return GlassContainer(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline_rounded, color: AppTheme.accentRed, size: 40),
const SizedBox(height: 12),
Text(state.message, style: TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.bold), textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _fetch,
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald, foregroundColor: Colors.black),
child: const Text('Erneut versuchen'),
),
],
),
);
}
final loaded = state as AdminEvaluationHistoryLoaded;
final entries = loaded.response.entries;
if (entries.isEmpty) {
// Explicit empty state (Rules.md §4) — never a silent blank list, so an
// admin who set a narrow filter knows the filter matched nothing rather
// than wondering whether the tab itself is broken.
return GlassContainer(
padding: const EdgeInsets.all(24),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.inbox_outlined, size: 44, color: AppTheme.textMuted),
const SizedBox(height: 12),
Text(
'Keine Analysen im gewählten Zeitraum/Filter gefunden.',
style: TextStyle(color: AppTheme.textMuted, fontSize: 14),
textAlign: TextAlign.center,
),
],
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Server already returns each page sorted by EvaluatedAtUtc descending
// (EvaluationHistoryService.GetHistoryAsync: .OrderByDescending(s =>
// s.EvaluatedAtUtc)) — rendered in received order, no client re-sort needed.
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: entries.length,
itemBuilder: (context, index) {
final entry = entries[index];
return EvaluationHistoryListItem(entry: entry, onTap: () => _showDetail(entry));
},
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
loaded.response.totalCount == 0
? '0 Einträge'
: '${loaded.rangeStart}${loaded.rangeEnd} von ${loaded.response.totalCount}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
Row(
children: [
OutlinedButton(
onPressed: loaded.hasPreviousPage ? () => _goToPage(_page - 1) : null,
child: const Text('Zurück'),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: loaded.hasNextPage ? () => _goToPage(_page + 1) : null,
child: const Text('Weiter'),
),
],
),
],
),
],
);
}
}
@@ -0,0 +1,302 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/signalr_service.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../models/admin_update_user_request_dto.dart';
import '../models/admin_user_model.dart';
import '../bloc/admin_bloc.dart';
import '../repositories/admin_repository.dart';
import '../widgets/admin_kpi_header.dart';
import '../widgets/admin_user_card_item.dart';
import '../widgets/create_user_dialog.dart';
import '../widgets/edit_user_dialog.dart';
import '../widgets/system_diagnostics_widget.dart';
class AdminUsersScreen extends StatelessWidget {
final ApiClient apiClient;
final SignalRService? signalRService;
const AdminUsersScreen({
super.key,
required this.apiClient,
this.signalRService,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => AdminBloc(
repository: AdminRepository(apiClient: apiClient),
)..add(FetchAdminUsers()),
child: _AdminUsersScreenContent(
apiClient: apiClient,
signalRService: signalRService,
),
);
}
}
class _AdminUsersScreenContent extends StatefulWidget {
final ApiClient apiClient;
final SignalRService? signalRService;
const _AdminUsersScreenContent({
required this.apiClient,
this.signalRService,
});
@override
State<_AdminUsersScreenContent> createState() => _AdminUsersScreenContentState();
}
class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with SingleTickerProviderStateMixin {
late TabController _tabController;
String _searchQuery = '';
String _roleFilter = 'Alle';
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _openCreateUser(BuildContext context) async {
final res = await showDialog(context: context, builder: (_) => const CreateUserDialog());
if (res != null && context.mounted) {
context.read<AdminBloc>().add(CreateAdminUser(res));
}
}
void _openEditUser(BuildContext context, AdminUserModel user) async {
final res = await showDialog(context: context, builder: (_) => EditUserDialog(user: user));
if (res != null && context.mounted) {
context.read<AdminBloc>().add(UpdateAdminUser(user.id, res as AdminUpdateUserRequestDto));
}
}
void _toggleUserActiveStatus(BuildContext context, AdminUserModel user, bool newActive) {
final dto = AdminUpdateUserRequestDto(role: user.role, isActive: newActive);
context.read<AdminBloc>().add(UpdateAdminUser(user.id, dto));
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.transparent,
body: BlocBuilder<AdminBloc, AdminState>(
builder: (context, state) {
final users = state is AdminLoaded ? state.users : <AdminUserModel>[];
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Administration & System',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white),
),
const SizedBox(height: 2),
Text(
'Finlytic Admin-Dashboard • Microservices & Benutzer',
style: TextStyle(color: AppTheme.textMuted, fontSize: 13),
),
],
),
Row(
children: [
IconButton(
onPressed: () => context.read<AdminBloc>().add(FetchAdminUsers()),
icon: const Icon(Icons.refresh_rounded, color: Colors.white70),
tooltip: 'Neu laden',
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: () => _openCreateUser(context),
icon: const Icon(Icons.person_add_alt_1_rounded, size: 18),
label: const Text('Neuer Benutzer'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
],
),
],
),
const SizedBox(height: 16),
AdminKpiHeader(
users: users,
signalRService: widget.signalRService,
),
const SizedBox(height: 16),
Container(
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppTheme.glassBorder),
),
child: TabBar(
controller: _tabController,
indicatorColor: AppTheme.primaryEmerald,
indicatorSize: TabBarIndicatorSize.tab,
labelColor: AppTheme.primaryEmerald,
unselectedLabelColor: AppTheme.textMuted,
labelStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
indicator: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.4)),
),
tabs: const [
Tab(icon: Icon(Icons.people_alt_outlined, size: 18), text: 'Nutzerverwaltung'),
Tab(icon: Icon(Icons.monitor_heart_outlined, size: 18), text: 'System-Diagnose & MQTT'),
],
),
),
const SizedBox(height: 16),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildUserManagementTab(context, state, users),
SystemDiagnosticsWidget(signalRService: widget.signalRService),
],
),
),
],
),
);
},
),
);
}
Widget _buildUserManagementTab(BuildContext context, AdminState state, List<AdminUserModel> allUsers) {
if (state is AdminLoading) {
return Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald));
}
if (state is AdminError) {
return Center(
child: GlassContainer(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline_rounded, color: AppTheme.accentRed, size: 40),
const SizedBox(height: 12),
Text(state.message, style: TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.read<AdminBloc>().add(FetchAdminUsers()),
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald, foregroundColor: Colors.black),
child: const Text('Erneut versuchen'),
),
],
),
),
);
}
final filteredUsers = allUsers.where((u) {
final matchesSearch = u.fullName.toLowerCase().contains(_searchQuery.toLowerCase()) ||
u.email.toLowerCase().contains(_searchQuery.toLowerCase());
final matchesRole = _roleFilter == 'Alle' || u.role.toLowerCase() == _roleFilter.toLowerCase();
return matchesSearch && matchesRole;
}).toList();
return Column(
children: [
Row(
children: [
Expanded(
child: TextField(
onChanged: (val) => setState(() => _searchQuery = val),
decoration: InputDecoration(
hintText: 'Benutzer nach Name oder E-Mail suchen...',
prefixIcon: Icon(Icons.search_rounded, color: AppTheme.textMuted),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: () => setState(() => _searchQuery = ''),
)
: null,
),
),
),
const SizedBox(width: 12),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: ['Alle', 'Admin', 'Premium', 'User'].map((role) {
final isSelected = _roleFilter == role;
return Padding(
padding: const EdgeInsets.only(right: 6),
child: ChoiceChip(
label: Text(role),
selected: isSelected,
selectedColor: AppTheme.primaryEmerald,
backgroundColor: AppTheme.glassSurface,
labelStyle: TextStyle(
color: isSelected ? Colors.black : AppTheme.textSecondary,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
fontSize: 12,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
side: BorderSide(color: isSelected ? AppTheme.primaryEmerald : AppTheme.glassBorder),
onSelected: (val) {
if (val) setState(() => _roleFilter = role);
},
),
);
}).toList(),
),
),
],
),
const SizedBox(height: 14),
Expanded(
child: filteredUsers.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.person_search_outlined, size: 48, color: AppTheme.textMuted),
const SizedBox(height: 12),
Text('Keine passenden Benutzer gefunden.', style: TextStyle(color: AppTheme.textMuted, fontSize: 14)),
],
),
)
: ListView.builder(
itemCount: filteredUsers.length,
itemBuilder: (context, index) {
final u = filteredUsers[index];
return AdminUserCardItem(
user: u,
onToggleActive: (val) => _toggleUserActiveStatus(context, u, val),
onEdit: () => _openEditUser(context, u),
);
},
),
),
],
);
}
}
@@ -0,0 +1,403 @@
import 'package:flutter/material.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../core/widgets/status_badge.dart';
import '../models/service_setting_dto.dart';
import '../repositories/admin_repository.dart';
import '../widgets/live_log_console.dart';
class ServiceDetailScreen extends StatefulWidget {
final String serviceName;
final ApiClient apiClient;
final AdminRepository? repository;
const ServiceDetailScreen({
super.key,
required this.serviceName,
required this.apiClient,
this.repository,
});
@override
State<ServiceDetailScreen> createState() => _ServiceDetailScreenState();
}
class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
late final AdminRepository _repository;
bool _isLoading = true;
bool _isSaving = false;
String _error = '';
List<ServiceSettingDto> _settings = [];
final Map<String, TextEditingController> _controllers = {};
@override
void initState() {
super.initState();
_repository = widget.repository ?? AdminRepository(apiClient: widget.apiClient);
_fetchServiceDetails();
}
@override
void dispose() {
for (var ctrl in _controllers.values) {
ctrl.dispose();
}
super.dispose();
}
Future<void> _fetchServiceDetails() async {
try {
final groupedSettings = await _repository.fetchSettings();
final serviceSettings = groupedSettings[widget.serviceName] ?? [];
setState(() {
_settings = serviceSettings;
for (var s in _settings) {
final key = s.key;
final val = s.value;
if (!_controllers.containsKey(key)) {
_controllers[key] = TextEditingController(text: val);
} else {
_controllers[key]!.text = val;
}
}
_isLoading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Future<void> _saveSettings() async {
setState(() => _isSaving = true);
try {
final payload = <String, dynamic>{};
_controllers.forEach((k, v) {
final text = v.text.trim();
if (text.toLowerCase() == 'true') {
payload[k] = true;
} else if (text.toLowerCase() == 'false') {
payload[k] = false;
} else if (int.tryParse(text) != null) {
payload[k] = int.parse(text);
} else if (double.tryParse(text) != null) {
payload[k] = double.parse(text);
} else {
payload[k] = text;
}
});
await _repository.updateServiceSettings(widget.serviceName, payload);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle_outline, color: Colors.black),
const SizedBox(width: 8),
Expanded(
child: Text(
'Einstellungen für ${widget.serviceName} gespeichert & via MQTT synchronisiert.',
style: const TextStyle(color: Colors.black, fontWeight: FontWeight.w600),
),
),
],
),
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Fehler beim Speichern: $e'),
backgroundColor: AppTheme.accentRed,
behavior: SnackBarBehavior.floating,
),
);
}
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
String _formatLabel(String key) {
// Strip the "Logging.Channel." prefix for display - the section header already says "Logging-Kanäle",
// repeating it on every single chip label added visual noise without any extra information.
final withoutChannelPrefix = key.startsWith('Logging.Channel.') ? key.substring('Logging.Channel.'.length) : key;
return withoutChannelPrefix
.replaceAll(RegExp(r'(?<!^)(?=[A-Z])'), ' ')
.replaceAll('Minutes', '(Minuten)')
.replaceAll('Seconds', '(Sekunden)')
.replaceAll('Hours', '(Stunden)')
.replaceAll('Days', '(Tage)')
.replaceAll('Limit', 'Grenzwert')
.replaceAll('Period', 'Periode')
.replaceAll('Percentage', '(%)')
.replaceAll('Multiplier', 'Multiplikator');
}
/// Groups settings by kind so the settings card reads as organized sections instead of one long,
/// unstructured list mixing logging toggles, feature switches, numeric thresholds, and free text together.
/// Order is fixed (not alphabetical) so the most-scanned category (logging channels, usually the most
/// numerous) sits first.
static const List<String> _categoryOrder = ['Logging-Kanäle', 'Umschalter', 'Zahlenwerte', 'Text'];
String _categoryFor(ServiceSettingDto s) {
if (s.key.startsWith('Logging.Channel.')) return 'Logging-Kanäle';
final type = s.type.toLowerCase();
final looksBoolean = type == 'bool' || s.value.toLowerCase() == 'true' || s.value.toLowerCase() == 'false';
if (looksBoolean) return 'Umschalter';
final looksNumeric = type == 'int' || type == 'double' || type == 'number' || type == 'decimal';
if (looksNumeric) return 'Zahlenwerte';
return 'Text';
}
Map<String, List<ServiceSettingDto>> get _groupedSettings {
final groups = <String, List<ServiceSettingDto>>{};
for (final s in _settings) {
groups.putIfAbsent(_categoryFor(s), () => []).add(s);
}
return groups;
}
Widget _buildSectionHeader(String title, IconData icon) {
return Padding(
padding: const EdgeInsets.only(bottom: 10, top: 4),
child: Row(
children: [
Icon(icon, size: 15, color: AppTheme.textMuted),
const SizedBox(width: 6),
Text(
title,
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: AppTheme.textMuted, letterSpacing: 0.4),
),
],
),
);
}
/// Compact toggle "chip" for a single boolean setting - used for logging channels, which can easily number
/// a dozen+ per service, so a full-width `SwitchListTile` per entry (the previous, only, layout for every
/// setting regardless of category or count) made the card feel "gequetscht"/cramped and pushed the actually
/// important numeric settings far down the page.
Widget _buildToggleChip(ServiceSettingDto s, TextEditingController controller) {
final boolVal = controller.text.toLowerCase() == 'true';
return Tooltip(
message: s.description.isNotEmpty ? s.description : _formatLabel(s.key),
triggerMode: TooltipTriggerMode.tap,
textStyle: const TextStyle(color: Colors.white, fontSize: 12),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.glassBorder),
),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => setState(() => controller.text = (!boolVal).toString()),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: boolVal ? AppTheme.primaryEmerald.withValues(alpha: 0.15) : AppTheme.glassSurface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: boolVal ? AppTheme.primaryEmerald.withValues(alpha: 0.5) : AppTheme.glassBorder),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
boolVal ? Icons.check_circle : Icons.circle_outlined,
size: 14,
color: boolVal ? AppTheme.primaryEmerald : AppTheme.textMuted,
),
const SizedBox(width: 6),
Text(
_formatLabel(s.key),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: boolVal ? Colors.white : AppTheme.textMuted,
),
),
],
),
),
),
);
}
Widget _buildSwitchSetting(ServiceSettingDto s, TextEditingController controller) {
final boolVal = controller.text.toLowerCase() == 'true';
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: boolVal ? AppTheme.primaryEmerald.withValues(alpha: 0.4) : AppTheme.glassBorder),
),
child: SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(_formatLabel(s.key), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
subtitle: s.description.isNotEmpty ? Text(s.description, style: TextStyle(fontSize: 11, color: AppTheme.textMuted)) : null,
value: boolVal,
activeThumbColor: AppTheme.primaryEmerald,
activeTrackColor: AppTheme.primaryEmerald.withValues(alpha: 0.3),
onChanged: (val) => setState(() => controller.text = val.toString()),
),
);
}
Widget _buildTextFieldSetting(ServiceSettingDto s, TextEditingController controller, {required bool isNumeric}) {
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: TextField(
controller: controller,
keyboardType: isNumeric ? const TextInputType.numberWithOptions(decimal: true) : TextInputType.text,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: _formatLabel(s.key),
helperText: s.description.isNotEmpty ? s.description : null,
helperMaxLines: 2,
prefixIcon: Icon(
isNumeric ? Icons.numbers_outlined : Icons.tune_outlined,
size: 18,
color: AppTheme.primaryEmerald,
),
),
),
);
}
List<Widget> _buildGroupedSettingsSections() {
final grouped = _groupedSettings;
final widgets = <Widget>[];
for (final category in _categoryOrder) {
final items = grouped[category];
if (items == null || items.isEmpty) continue;
widgets.add(_buildSectionHeader(
'$category (${items.length})',
switch (category) {
'Logging-Kanäle' => Icons.terminal_rounded,
'Umschalter' => Icons.toggle_on_outlined,
'Zahlenwerte' => Icons.numbers_outlined,
_ => Icons.tune_outlined,
},
));
if (category == 'Logging-Kanäle') {
final chips = <Widget>[];
for (final s in items) {
final controller = _controllers[s.key];
if (controller != null) chips.add(_buildToggleChip(s, controller));
}
widgets.add(Wrap(spacing: 8, runSpacing: 8, children: chips));
} else if (category == 'Umschalter') {
for (final s in items) {
final controller = _controllers[s.key];
if (controller != null) widgets.add(_buildSwitchSetting(s, controller));
}
} else {
for (final s in items) {
final controller = _controllers[s.key];
if (controller != null) {
widgets.add(_buildTextFieldSetting(s, controller, isNumeric: category == 'Zahlenwerte'));
}
}
}
widgets.add(const SizedBox(height: 14));
}
return widgets;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.darkBackground,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: Text('${widget.serviceName} Details', style: const TextStyle(fontWeight: FontWeight.bold)),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _error.isNotEmpty
? Center(child: Text(_error, style: const TextStyle(color: Colors.red)))
: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GlassContainer(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Einstellungen & Konfiguration', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
StatusBadge(label: 'MQTT Sync', color: AppTheme.primaryEmerald),
],
),
const SizedBox(height: 16),
const Divider(color: Colors.white10),
const SizedBox(height: 16),
if (_settings.isEmpty)
const Text('Keine spezifischen Einstellungen gefunden.')
else
..._buildGroupedSettingsSections(),
const SizedBox(height: 12),
if (_settings.isNotEmpty)
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isSaving ? null : _saveSettings,
icon: _isSaving
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
: const Icon(Icons.save_outlined),
label: Text(
_isSaving ? 'Speichere & Sende via MQTT...' : 'Einstellungen Speichern',
style: const TextStyle(fontWeight: FontWeight.bold),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
],
),
),
const SizedBox(height: 24),
LiveLogConsole(
serviceName: widget.serviceName,
apiClient: widget.apiClient,
),
],
),
),
);
}
}
@@ -0,0 +1,224 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../core/network/signalr_service.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../models/admin_user_model.dart';
/// Modern KPI Header Card Row for Admin Panel Dashboard Overview.
/// Displays real-time live metrics for Users, Administrators, Microservices Health, and MQTT Bus.
/// Continuously updates live EXCLUSIVELY over SignalR WebSockets (`/hubs/health`).
class AdminKpiHeader extends StatefulWidget {
final List<AdminUserModel> users;
final SignalRService? signalRService;
const AdminKpiHeader({
super.key,
required this.users,
this.signalRService,
});
@override
State<AdminKpiHeader> createState() => _AdminKpiHeaderState();
}
class _AdminKpiHeaderState extends State<AdminKpiHeader> {
int? _totalServices;
int? _onlineServices;
bool _mqttConnected = false;
StreamSubscription<List<Map<String, dynamic>>>? _healthSub;
@override
void initState() {
super.initState();
if (widget.signalRService != null) {
_healthSub = widget.signalRService!.healthStream.listen((data) {
if (mounted && data.isNotEmpty) {
final int total = data.length;
final int online = data.where((item) => item['status']?.toString().toLowerCase() == 'online').length;
setState(() {
_totalServices = total;
_onlineServices = online;
_mqttConnected = online > 0;
});
}
});
}
}
@override
void dispose() {
_healthSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final totalUsers = widget.users.length;
final activeUsers = widget.users.where((u) => u.isActive).length;
final adminCount = widget.users.where((u) => u.role.toLowerCase() == 'admin').length;
final isMobile = MediaQuery.of(context).size.width < 700;
final String servicesValue = _totalServices != null ? '$_onlineServices / $_totalServices' : 'SignalR...';
final String servicesSubtitle = (_onlineServices == _totalServices && _totalServices != null && _totalServices! > 0
? 'Alle Dienste online (WebSocket)'
: (_onlineServices != null ? '$_onlineServices von $_totalServices erreichbar' : 'Verbinde WebSocket...'));
final Color servicesColor = (_onlineServices == _totalServices && _totalServices != null && _totalServices! > 0)
? AppTheme.primaryEmerald
: (_onlineServices != null && _onlineServices! > 0 ? AppTheme.accentCyan : AppTheme.accentRed);
final cards = [
_KpiCard(
title: 'Benutzer Gesamt',
value: totalUsers.toString(),
subtitle: '$activeUsers aktiv • ${totalUsers - activeUsers} gesperrt',
icon: Icons.people_alt_rounded,
accentColor: AppTheme.primaryEmerald,
),
_KpiCard(
title: 'Administratoren',
value: adminCount.toString(),
subtitle: 'Vollzugriff auf System',
icon: Icons.admin_panel_settings_rounded,
accentColor: const Color(0xFFA855F7), // Purple accent
),
_KpiCard(
title: 'Mikrodienste',
value: servicesValue,
subtitle: servicesSubtitle,
icon: Icons.dns_rounded,
accentColor: servicesColor,
showPulse: _onlineServices != null && _onlineServices! > 0,
),
_KpiCard(
title: 'MQTT Live-Bus',
value: _mqttConnected ? 'Aktiv' : 'Offline',
subtitle: _mqttConnected ? 'SignalR & RPC Bereit' : 'Warte auf WebSocket',
icon: Icons.sensors_rounded,
accentColor: _mqttConnected ? const Color(0xFF10B981) : AppTheme.accentRed,
),
];
if (isMobile) {
return GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 1.5,
children: cards,
);
}
return Row(
children: cards
.map((card) => Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: card,
),
))
.toList(),
);
}
}
class _KpiCard extends StatelessWidget {
final String title;
final String value;
final String subtitle;
final IconData icon;
final Color accentColor;
final bool showPulse;
const _KpiCard({
required this.title,
required this.value,
required this.subtitle,
required this.icon,
required this.accentColor,
this.showPulse = false,
});
@override
Widget build(BuildContext context) {
return GlassContainer(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppTheme.textMuted,
),
),
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: accentColor.withValues(alpha: 0.15),
shape: BoxShape.circle,
border: Border.all(color: accentColor.withValues(alpha: 0.3)),
),
child: Icon(icon, size: 16, color: accentColor),
),
],
),
const SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
value,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
letterSpacing: -0.5,
),
),
if (showPulse) ...[
const SizedBox(width: 8),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: AppTheme.primaryEmerald,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: AppTheme.primaryEmerald.withValues(alpha: 0.6),
blurRadius: 6,
spreadRadius: 2,
),
],
),
),
],
],
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
),
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/status_badge.dart';
import '../models/admin_user_model.dart';
class AdminUserCardItem extends StatelessWidget {
final AdminUserModel user;
final ValueChanged<bool> onToggleActive;
final VoidCallback onEdit;
const AdminUserCardItem({
super.key,
required this.user,
required this.onToggleActive,
required this.onEdit,
});
String _getInitials(String name) {
if (name.isEmpty) return 'U';
final parts = name.trim().split(' ');
if (parts.length >= 2) {
return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
}
return name.substring(0, name.length >= 2 ? 2 : 1).toUpperCase();
}
@override
Widget build(BuildContext context) {
final String role = user.role;
final bool isActive = user.isActive;
final Color roleColor = role == 'Admin'
? const Color(0xFFA855F7)
: role == 'Premium'
? AppTheme.primaryEmerald
: AppTheme.accentCyan;
final String initials = _getInitials(user.fullName.isNotEmpty ? user.fullName : user.email);
return GlassContainer(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
child: Row(
children: [
Container(
width: 42,
height: 42,
alignment: Alignment.center,
decoration: BoxDecoration(
color: roleColor.withValues(alpha: 0.2),
shape: BoxShape.circle,
border: Border.all(color: roleColor, width: 1.5),
),
child: Text(
initials,
style: TextStyle(fontWeight: FontWeight.bold, color: roleColor, fontSize: 14),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
user.fullName.isNotEmpty ? user.fullName : user.email,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
const SizedBox(width: 8),
StatusBadge(label: role, color: roleColor),
],
),
const SizedBox(height: 2),
Text(
user.email,
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
],
),
),
Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
isActive ? 'Aktiv' : 'Gesperrt',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isActive ? AppTheme.primaryEmerald : AppTheme.accentRed,
),
),
],
),
const SizedBox(width: 8),
Switch(
value: isActive,
activeThumbColor: AppTheme.primaryEmerald,
onChanged: onToggleActive,
),
const SizedBox(width: 8),
IconButton.filledTonal(
icon: const Icon(Icons.edit_outlined, size: 18),
tooltip: 'Benutzer Bearbeiten',
onPressed: onEdit,
),
],
),
],
),
);
}
}
@@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../models/admin_create_user_request_dto.dart';
/// Modal dialog for creating new user accounts by Admin.
class CreateUserDialog extends StatefulWidget {
const CreateUserDialog({super.key});
@override
State<CreateUserDialog> createState() => _CreateUserDialogState();
}
class _CreateUserDialogState extends State<CreateUserDialog> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _nameController = TextEditingController();
String _selectedRole = 'User';
bool _obscurePassword = true;
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: AppTheme.cardSurface,
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
child: GlassContainer(
borderRadius: 20,
padding: const EdgeInsets.all(24),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.15),
shape: BoxShape.circle,
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.3)),
),
child: Icon(Icons.person_add_outlined, color: AppTheme.primaryEmerald, size: 20),
),
const SizedBox(width: 12),
const Text(
'Neuen Benutzer Anlegen',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 16),
const Divider(color: Colors.white10),
const SizedBox(height: 16),
TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Vollständiger Name',
prefixIcon: Icon(Icons.badge_outlined, size: 20),
),
),
const SizedBox(height: 12),
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'E-Mail Adresse',
prefixIcon: Icon(Icons.email_outlined, size: 20),
),
),
const SizedBox(height: 12),
TextField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Passwort',
prefixIcon: const Icon(Icons.lock_outline, size: 20),
suffixIcon: IconButton(
icon: Icon(_obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 20),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
),
),
),
const SizedBox(height: 16),
Text(
'Benutzerrolle Zuweisen',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppTheme.textSecondary),
),
const SizedBox(height: 8),
Row(
children: [
_RoleChip(
role: 'User',
label: 'User',
isSelected: _selectedRole == 'User',
onTap: () => setState(() => _selectedRole = 'User'),
),
const SizedBox(width: 8),
_RoleChip(
role: 'Premium',
label: 'Premium',
isSelected: _selectedRole == 'Premium',
onTap: () => setState(() => _selectedRole = 'Premium'),
),
const SizedBox(width: 8),
_RoleChip(
role: 'Admin',
label: 'Admin',
isSelected: _selectedRole == 'Admin',
onTap: () => setState(() => _selectedRole = 'Admin'),
),
],
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () {
if (_emailController.text.trim().isEmpty) return;
Navigator.pop(context, AdminCreateUserRequestDto(
email: _emailController.text.trim(),
password: _passwordController.text.trim(),
fullName: _nameController.text.trim(),
role: _selectedRole,
));
},
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: const Text('Nutzer Anlegen', style: TextStyle(fontWeight: FontWeight.bold)),
),
],
),
],
),
),
),
);
}
}
class _RoleChip extends StatelessWidget {
final String role;
final String label;
final bool isSelected;
final VoidCallback onTap;
const _RoleChip({
required this.role,
required this.label,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final Color roleColor = role == 'Admin'
? const Color(0xFFA855F7)
: role == 'Premium'
? AppTheme.primaryEmerald
: AppTheme.accentCyan;
return Expanded(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
alignment: Alignment.center,
decoration: BoxDecoration(
color: isSelected ? roleColor.withValues(alpha: 0.2) : AppTheme.glassSurface,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isSelected ? roleColor : AppTheme.glassBorder,
width: isSelected ? 1.5 : 1,
),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? AppTheme.textPrimary : AppTheme.textSecondary,
),
),
),
),
);
}
}
@@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../models/admin_user_model.dart';
import '../models/admin_update_user_request_dto.dart';
/// Modal dialog for editing user role or active status by Admin.
class EditUserDialog extends StatefulWidget {
final AdminUserModel user;
const EditUserDialog({super.key, required this.user});
@override
State<EditUserDialog> createState() => _EditUserDialogState();
}
class _EditUserDialogState extends State<EditUserDialog> {
late String _role;
late bool _isActive;
@override
void initState() {
super.initState();
_role = widget.user.role.isNotEmpty ? widget.user.role : 'User';
_isActive = widget.user.isActive;
}
@override
Widget build(BuildContext context) {
final String email = widget.user.email;
final String name = widget.user.fullName;
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
child: GlassContainer(
borderRadius: 20,
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.accentCyan.withValues(alpha: 0.15),
shape: BoxShape.circle,
border: Border.all(color: AppTheme.accentCyan.withValues(alpha: 0.3)),
),
child: Icon(Icons.manage_accounts_outlined, color: AppTheme.accentCyan, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name.isNotEmpty ? name : 'Benutzer Bearbeiten',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
Text(
email,
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
const SizedBox(height: 16),
const Divider(color: Colors.white10),
const SizedBox(height: 16),
Text(
'Rolle Ändern',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppTheme.textSecondary),
),
const SizedBox(height: 8),
Row(
children: [
_RoleChip(
role: 'User',
label: 'User',
isSelected: _role == 'User',
onTap: () => setState(() => _role = 'User'),
),
const SizedBox(width: 8),
_RoleChip(
role: 'Premium',
label: 'Premium',
isSelected: _role == 'Premium',
onTap: () => setState(() => _role = 'Premium'),
),
const SizedBox(width: 8),
_RoleChip(
role: 'Admin',
label: 'Admin',
isSelected: _role == 'Admin',
onTap: () => setState(() => _role = 'Admin'),
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.glassBorder),
),
child: SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Konto Status', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
subtitle: Text(_isActive ? 'Aktiv (Zugriff gewährt)' : 'Gesperrt (Zugriff verweigert)',
style: TextStyle(fontSize: 12, color: _isActive ? AppTheme.primaryEmerald : AppTheme.accentRed)),
value: _isActive,
activeThumbColor: AppTheme.primaryEmerald,
onChanged: (val) => setState(() => _isActive = val),
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () => Navigator.pop(context, AdminUpdateUserRequestDto(role: _role, isActive: _isActive)),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: const Text('Speichern', style: TextStyle(fontWeight: FontWeight.bold)),
),
],
),
],
),
),
);
}
}
class _RoleChip extends StatelessWidget {
final String role;
final String label;
final bool isSelected;
final VoidCallback onTap;
const _RoleChip({
required this.role,
required this.label,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final Color roleColor = role == 'Admin'
? const Color(0xFFA855F7)
: role == 'Premium'
? AppTheme.primaryEmerald
: AppTheme.accentCyan;
return Expanded(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
alignment: Alignment.center,
decoration: BoxDecoration(
color: isSelected ? roleColor.withValues(alpha: 0.2) : AppTheme.glassSurface,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isSelected ? roleColor : AppTheme.glassBorder,
width: isSelected ? 1.5 : 1,
),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? AppTheme.textPrimary : AppTheme.textSecondary,
),
),
),
),
);
}
}
@@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../models/evaluation_history_enums.dart';
/// Full filter snapshot emitted by [EvaluationHistoryFilterBar.onChanged] on
/// every single control change — deliberately not a partial/sparse update, so
/// there is no ambiguity between "caller didn't touch this field" and "caller
/// explicitly cleared this field" on the receiving end.
typedef EvaluationHistoryFilterChanged = void Function({
required DateTime? fromUtc,
required DateTime? toUtc,
required OutcomeReason? outcome,
required TriggerSource? triggerSource,
required String search,
});
/// Filter bar for the admin evaluation-history tab: a from/to date range (plain
/// `showDatePicker` — a full calendar-range widget is overkill for "roughly which
/// days"), an [OutcomeReason] dropdown, a [TriggerSource] dropdown, and an
/// ISIN/symbol search field. All four map 1:1 onto the server's optional query
/// filters (`fromUtc`/`toUtc`/`outcome`/`triggerSource`/`search`).
class EvaluationHistoryFilterBar extends StatefulWidget {
final DateTime? fromUtc;
final DateTime? toUtc;
final OutcomeReason? outcome;
final TriggerSource? triggerSource;
final String search;
final EvaluationHistoryFilterChanged onChanged;
const EvaluationHistoryFilterBar({
super.key,
required this.fromUtc,
required this.toUtc,
required this.outcome,
required this.triggerSource,
required this.search,
required this.onChanged,
});
@override
State<EvaluationHistoryFilterBar> createState() => _EvaluationHistoryFilterBarState();
}
class _EvaluationHistoryFilterBarState extends State<EvaluationHistoryFilterBar> {
late final TextEditingController _searchController;
@override
void initState() {
super.initState();
_searchController = TextEditingController(text: widget.search);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _pickDate({required bool isFrom}) async {
final initial = (isFrom ? widget.fromUtc : widget.toUtc)?.toLocal() ?? DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: DateTime(2020, 1, 1),
lastDate: DateTime.now().add(const Duration(days: 1)),
);
if (picked == null) return;
if (isFrom) {
_emit(fromUtc: DateTime.utc(picked.year, picked.month, picked.day));
} else {
// Inclusive upper bound on the whole selected day.
_emit(toUtc: DateTime.utc(picked.year, picked.month, picked.day, 23, 59, 59));
}
}
/// Emits the full filter snapshot, overriding only the field(s) that
/// actually changed and carrying every other field through unchanged from
/// `widget.*` — see [EvaluationHistoryFilterChanged].
void _emit({
Object? fromUtc = _unset,
Object? toUtc = _unset,
Object? outcome = _unset,
Object? triggerSource = _unset,
String? search,
}) {
widget.onChanged(
fromUtc: fromUtc == _unset ? widget.fromUtc : fromUtc as DateTime?,
toUtc: toUtc == _unset ? widget.toUtc : toUtc as DateTime?,
outcome: outcome == _unset ? widget.outcome : outcome as OutcomeReason?,
triggerSource: triggerSource == _unset ? widget.triggerSource : triggerSource as TriggerSource?,
search: search ?? widget.search,
);
}
String _formatDate(DateTime? dt) {
if (dt == null) return 'Egal';
final local = dt.toLocal();
return '${local.day.toString().padLeft(2, '0')}.${local.month.toString().padLeft(2, '0')}.${local.year}';
}
@override
Widget build(BuildContext context) {
return GlassContainer(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
onSubmitted: (val) => _emit(search: val),
decoration: InputDecoration(
hintText: 'ISIN oder Symbol suchen...',
prefixIcon: Icon(Icons.search_rounded, color: AppTheme.textMuted),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: () {
_searchController.clear();
_emit(search: '');
},
)
: IconButton(
icon: const Icon(Icons.arrow_forward, size: 18),
onPressed: () => _emit(search: _searchController.text),
),
),
),
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 10,
runSpacing: 10,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_dateChip(label: 'Von: ${_formatDate(widget.fromUtc)}', onTap: () => _pickDate(isFrom: true)),
_dateChip(label: 'Bis: ${_formatDate(widget.toUtc)}', onTap: () => _pickDate(isFrom: false)),
if (widget.fromUtc != null || widget.toUtc != null)
TextButton(
onPressed: () => _emit(fromUtc: null, toUtc: null),
child: const Text('Zeitraum zurücksetzen', style: TextStyle(fontSize: 12)),
),
SizedBox(
width: 190,
child: DropdownButtonFormField<OutcomeReason?>(
initialValue: widget.outcome,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Ergebnis', isDense: true),
items: [
const DropdownMenuItem<OutcomeReason?>(value: null, child: Text('Alle Ergebnisse')),
...OutcomeReason.values.map(
(r) => DropdownMenuItem<OutcomeReason?>(value: r, child: Text(r.label)),
),
],
onChanged: (val) => _emit(outcome: val),
),
),
SizedBox(
width: 170,
child: DropdownButtonFormField<TriggerSource?>(
initialValue: widget.triggerSource,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Ausgelöst durch', isDense: true),
items: [
const DropdownMenuItem<TriggerSource?>(value: null, child: Text('Alle Quellen')),
...TriggerSource.values.map(
(t) => DropdownMenuItem<TriggerSource?>(value: t, child: Text(t.label)),
),
],
onChanged: (val) => _emit(triggerSource: val),
),
),
],
),
],
),
);
}
Widget _dateChip({required String label, required VoidCallback onTap}) {
return OutlinedButton.icon(
onPressed: onTap,
icon: Icon(Icons.calendar_today_outlined, size: 14, color: AppTheme.textSecondary),
label: Text(label, style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
style: OutlinedButton.styleFrom(
side: BorderSide(color: AppTheme.glassBorder),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
),
);
}
}
/// Sentinel distinguishing "caller of [_EvaluationHistoryFilterBarState._emit]
/// didn't touch this field" (default) from "caller explicitly passed `null`"
/// (clear this field) — needed because `Object?`'s own null is one of the two
/// values this default has to be distinguishable from.
const Object _unset = Object();
@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/time_utils.dart';
import '../../../core/widgets/glass_container.dart';
import '../models/evaluation_history_enums.dart';
import '../models/evaluation_history_summary_model.dart';
import 'watchlist_card.dart';
/// Headline KPI row for the admin evaluation-history tab: how many analyses ran
/// in the current filter window, the outcome breakdown (this is what makes a
/// "why are there no new proposals" question answerable at a glance — e.g. most
/// assets sitting in [OutcomeReason.belowScoreThreshold]), the average composite
/// score, and how long ago the last trade proposal was actually created.
///
/// Every number here comes straight from `EvaluationHistorySummaryModel`
/// (server-aggregated over the same filtered set as the entry list) — nothing is
/// computed client-side from the current page alone (Rules.md §4).
class EvaluationHistoryKpiHeader extends StatelessWidget {
final EvaluationHistorySummaryModel summary;
final ApiClient apiClient;
const EvaluationHistoryKpiHeader({super.key, required this.summary, required this.apiClient});
@override
Widget build(BuildContext context) {
final isMobile = MediaQuery.of(context).size.width < 700;
final lastProposalText = summary.lastProposalCreatedAtUtc == null
? 'Noch nie'
: TimeUtils.formatRelativeTime(summary.lastProposalCreatedAtUtc!.toIso8601String());
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
isMobile
? Column(
children: [
_kpiCard('Analysen (Filter)', '${summary.totalEvaluations}', Icons.query_stats_rounded, AppTheme.accentCyan),
const SizedBox(height: 10),
_kpiCard('Ø Composite-Score', summary.averageCompositeScore.toStringAsFixed(1), Icons.speed_rounded, AppTheme.primaryEmerald),
const SizedBox(height: 10),
_kpiCard(
'Letzter Vorschlag',
lastProposalText,
Icons.rocket_launch_outlined,
summary.lastProposalCreatedAtUtc == null ? AppTheme.textMuted : Colors.amber,
),
],
)
: Row(
children: [
Expanded(child: _kpiCard('Analysen (Filter)', '${summary.totalEvaluations}', Icons.query_stats_rounded, AppTheme.accentCyan)),
const SizedBox(width: 10),
Expanded(
child: _kpiCard(
'Ø Composite-Score', summary.averageCompositeScore.toStringAsFixed(1), Icons.speed_rounded, AppTheme.primaryEmerald),
),
const SizedBox(width: 10),
Expanded(
child: _kpiCard(
'Letzter Vorschlag',
lastProposalText,
Icons.rocket_launch_outlined,
summary.lastProposalCreatedAtUtc == null ? AppTheme.textMuted : Colors.amber,
),
),
],
),
const SizedBox(height: 10),
isMobile
? Column(
children: [
_buildBreakdownCard(),
const SizedBox(height: 10),
WatchlistCard(apiClient: apiClient),
],
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 2, child: _buildBreakdownCard()),
const SizedBox(width: 10),
Expanded(child: WatchlistCard(apiClient: apiClient)),
],
),
],
);
}
Widget _buildBreakdownCard() {
return GlassContainer(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('AUFSCHLÜSSELUNG NACH GRUND', style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
const SizedBox(height: 10),
summary.totalEvaluations == 0
? Text('Keine Analysen im gewählten Filter.', style: TextStyle(color: AppTheme.textMuted, fontSize: 12))
: Wrap(
spacing: 8,
runSpacing: 8,
children: OutcomeReason.values
.map((reason) => _outcomeChip(reason, summary.countFor(reason)))
.where((w) => w != null)
.cast<Widget>()
.toList(),
),
],
),
);
}
Widget? _outcomeChip(OutcomeReason reason, int count) {
if (count == 0) return null;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: reason.color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: reason.color.withValues(alpha: 0.4)),
),
child: Text(
'${reason.label}: $count',
style: TextStyle(color: reason.color, fontWeight: FontWeight.bold, fontSize: 12),
),
);
}
Widget _kpiCard(String title, String value, IconData icon, Color color) {
return GlassContainer(
padding: const EdgeInsets.all(14),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
shape: BoxShape.circle,
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Icon(icon, size: 18, color: color),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: TextStyle(fontSize: 11, color: AppTheme.textMuted, fontWeight: FontWeight.w600)),
const SizedBox(height: 2),
Text(
value,
style: TextStyle(fontSize: 15, color: AppTheme.textPrimary, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../core/widgets/status_badge.dart';
import '../models/evaluation_history_entry_model.dart';
/// One row of the paginated evaluation-history list: symbol/ISIN, timestamp,
/// composite score, and color-coded [OutcomeReason]/[TriggerSource] badges.
/// Tapping opens the full score/reasoning breakdown via the caller-supplied
/// [onTap] (wired to the shared `EvaluationScoreBreakdownSheet` by the screen).
class EvaluationHistoryListItem extends StatelessWidget {
final EvaluationHistoryEntryModel entry;
final VoidCallback onTap;
const EvaluationHistoryListItem({super.key, required this.entry, required this.onTap});
String _formatTimestamp(DateTime utc) {
final local = utc.toLocal();
final d = local.day.toString().padLeft(2, '0');
final m = local.month.toString().padLeft(2, '0');
final h = local.hour.toString().padLeft(2, '0');
final min = local.minute.toString().padLeft(2, '0');
return '$d.$m.${local.year} $h:$min';
}
@override
Widget build(BuildContext context) {
final scoreColor = entry.compositeOpportunityScore >= 70
? AppTheme.primaryEmerald
: (entry.compositeOpportunityScore >= 40 ? Colors.amber : AppTheme.accentRed);
return GlassContainer(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(12),
onTap: onTap,
child: Row(
children: [
SizedBox(
width: 48,
height: 48,
child: Center(
child: Text(
entry.compositeOpportunityScore.toStringAsFixed(0),
style: TextStyle(color: scoreColor, fontWeight: FontWeight.bold, fontSize: 18),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
entry.symbol.isNotEmpty ? entry.symbol : entry.isin,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14),
),
if (entry.symbol.isNotEmpty && entry.isin.isNotEmpty) ...[
const SizedBox(width: 6),
Text(entry.isin, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
],
if (entry.hasProposal) ...[
const SizedBox(width: 6),
Icon(Icons.link_rounded, size: 13, color: AppTheme.primaryEmerald),
],
],
),
const SizedBox(height: 4),
Text(_formatTimestamp(entry.evaluatedAtUtc), style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
],
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
StatusBadge(label: entry.outcomeReason.label, color: entry.outcomeReason.color),
const SizedBox(height: 6),
StatusBadge(label: entry.triggerSource.label, color: entry.triggerSource.color),
],
),
const SizedBox(width: 4),
Icon(Icons.chevron_right_rounded, color: AppTheme.textMuted, size: 20),
],
),
);
}
}
@@ -0,0 +1,407 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:signalr_core/signalr_core.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/signalr_service.dart';
import '../../../core/services/secure_storage_service.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../models/log_message_dto.dart';
class LiveLogConsole extends StatefulWidget {
final String serviceName;
final ApiClient apiClient;
const LiveLogConsole({
super.key,
required this.serviceName,
required this.apiClient,
});
@override
State<LiveLogConsole> createState() => _LiveLogConsoleState();
}
class _LiveLogConsoleState extends State<LiveLogConsole> {
HubConnection? _hubConnection;
final List<LogMessageDto> _logs = [];
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
bool _isConnected = false;
bool _isPaused = false;
bool _autoScroll = true;
String _selectedLevel = 'ALL';
String _searchQuery = '';
@override
void initState() {
super.initState();
_fetchInitialLogs();
_connectSignalR();
}
@override
void dispose() {
_disconnectSignalR();
_scrollController.dispose();
_searchController.dispose();
super.dispose();
}
Future<void> _fetchInitialLogs() async {
try {
final res = await widget.apiClient.get('/api/v1/admin/settings/logs/${widget.serviceName}');
if (res.data is List) {
final list = (res.data as List).map((item) => LogMessageDto.fromJson(Map<String, dynamic>.from(item as Map))).toList();
if (mounted) {
setState(() {
_logs.addAll(list);
});
_scrollToBottomIfNeeded();
}
}
} catch (e) {
if (kDebugMode) debugPrint('[LiveLogConsole] Error fetching initial logs: $e');
}
}
Future<void> _connectSignalR() async {
try {
final storage = SecureStorageService();
final token = await storage.getToken();
_hubConnection = HubConnectionBuilder()
.withUrl(
'${SignalRService.baseUrl}/hubs/logs',
HttpConnectionOptions(
accessTokenFactory: () async => token,
transport: HttpTransportType.webSockets,
logging: (level, message) {
if (kDebugMode) debugPrint('[SignalR Logs WS] $message');
},
),
)
.withAutomaticReconnect()
.build();
_hubConnection!.on('ReceiveLogMessage', (arguments) {
if (arguments != null && arguments.isNotEmpty) {
try {
final map = Map<String, dynamic>.from(arguments.first as Map);
final log = LogMessageDto.fromJson(map);
if (log.serviceName.isEmpty || log.serviceName.toLowerCase() == widget.serviceName.toLowerCase()) {
if (mounted && !_isPaused) {
setState(() {
_logs.add(log);
if (_logs.length > 500) {
_logs.removeAt(0);
}
});
_scrollToBottomIfNeeded();
}
}
} catch (e) {
if (kDebugMode) debugPrint('[SignalR Log Parse Error] $e');
}
}
});
_hubConnection!.onclose((error) {
if (mounted) setState(() => _isConnected = false);
});
_hubConnection!.onreconnected((connectionId) {
if (mounted) {
setState(() => _isConnected = true);
_hubConnection?.invoke('JoinServiceLogs', args: [widget.serviceName]);
}
});
await _hubConnection!.start();
await _hubConnection!.invoke('JoinServiceLogs', args: [widget.serviceName]);
if (mounted) {
setState(() => _isConnected = true);
}
} catch (e) {
if (kDebugMode) debugPrint('[SignalR Log Connection Error] $e');
if (mounted) setState(() => _isConnected = false);
}
}
Future<void> _disconnectSignalR() async {
try {
if (_hubConnection != null) {
await _hubConnection!.invoke('LeaveServiceLogs', args: [widget.serviceName]);
await _hubConnection!.stop();
}
} catch (_) {}
_hubConnection = null;
}
void _scrollToBottomIfNeeded() {
if (_autoScroll && _scrollController.hasClients) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
}
/// Collapses the backend's full `Microsoft.Extensions.Logging.LogLevel` names ("Information", "Warning",
/// "Trace", "Critical", ...) down to the 4 short codes the filter chips use ("INFO", "WARN", "DEBUG",
/// "ERROR"). The filter previously compared `log.level.toUpperCase()` ("INFORMATION") directly against the
/// chip value ("INFO") - which never matched anything but "ALL", so selecting any specific level silently
/// hid every log line instead of actually filtering.
String _normalizeLevel(String level) {
switch (level.toUpperCase()) {
case 'INFORMATION':
case 'INFO':
return 'INFO';
case 'WARNING':
case 'WARN':
return 'WARN';
case 'ERROR':
case 'CRITICAL':
return 'ERROR';
case 'DEBUG':
case 'TRACE':
return 'DEBUG';
default:
return level.toUpperCase();
}
}
List<LogMessageDto> get _filteredLogs {
return _logs.where((log) {
if (_selectedLevel != 'ALL' && _normalizeLevel(log.level) != _selectedLevel) {
return false;
}
if (_searchQuery.isNotEmpty) {
final query = _searchQuery.toLowerCase();
final matchMsg = log.message.toLowerCase().contains(query);
final matchChannel = log.channel.toLowerCase().contains(query);
return matchMsg || matchChannel;
}
return true;
}).toList();
}
Color _getLevelColor(String level) {
switch (level.toUpperCase()) {
case 'ERROR':
case 'CRITICAL':
return Colors.redAccent;
case 'WARNING':
case 'WARN':
return Colors.amberAccent;
case 'DEBUG':
case 'TRACE':
return Colors.blueGrey.shade300;
case 'INFORMATION':
case 'INFO':
default:
return AppTheme.primaryEmerald;
}
}
String _formatTime(DateTime time) {
final h = time.hour.toString().padLeft(2, '0');
final m = time.minute.toString().padLeft(2, '0');
final s = time.second.toString().padLeft(2, '0');
final ms = time.millisecond.toString().padLeft(3, '0');
return '$h:$m:$s.$ms';
}
@override
Widget build(BuildContext context) {
final filtered = _filteredLogs;
return GlassContainer(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Bar
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.terminal_rounded, color: AppTheme.primaryEmerald, size: 22),
const SizedBox(width: 10),
Text(
'Live Service-Logs (${widget.serviceName})',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
],
),
Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _isConnected ? AppTheme.primaryEmerald : Colors.redAccent,
),
),
const SizedBox(width: 6),
Text(
_isConnected ? 'Live WebSocket' : 'Offline',
style: TextStyle(
fontSize: 12,
color: _isConnected ? AppTheme.primaryEmerald : Colors.redAccent,
fontWeight: FontWeight.w600,
),
),
],
),
],
),
const SizedBox(height: 14),
// Control Bar: Search + Level Filters + Actions
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
// Search Field
SizedBox(
width: 220,
height: 36,
child: TextField(
controller: _searchController,
onChanged: (val) => setState(() => _searchQuery = val.trim()),
style: const TextStyle(fontSize: 13, color: Colors.white),
decoration: InputDecoration(
hintText: 'Logs durchsuchen...',
hintStyle: const TextStyle(fontSize: 12, color: Colors.white38),
prefixIcon: const Icon(Icons.search, size: 16, color: Colors.white54),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 14, color: Colors.white54),
onPressed: () {
_searchController.clear();
setState(() => _searchQuery = '');
},
)
: null,
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 0),
filled: true,
fillColor: Colors.black26,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none),
),
),
),
// Filter Chips
for (final lvl in ['ALL', 'INFO', 'WARN', 'ERROR', 'DEBUG'])
ChoiceChip(
label: Text(lvl, style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: _selectedLevel == lvl ? Colors.black : Colors.white70)),
selected: _selectedLevel == lvl,
selectedColor: AppTheme.primaryEmerald,
backgroundColor: Colors.white10,
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 4),
onSelected: (selected) {
if (selected) setState(() => _selectedLevel = lvl);
},
),
const SizedBox(width: 8),
// Action buttons
IconButton(
tooltip: _isPaused ? 'Stream Fortsetzen' : 'Stream Pausieren',
icon: Icon(_isPaused ? Icons.play_arrow_rounded : Icons.pause_rounded, size: 20, color: _isPaused ? Colors.amberAccent : Colors.white70),
onPressed: () => setState(() => _isPaused = !_isPaused),
),
IconButton(
tooltip: _autoScroll ? 'Auto-Scroll an' : 'Auto-Scroll aus',
icon: Icon(Icons.vertical_align_bottom_rounded, size: 20, color: _autoScroll ? AppTheme.primaryEmerald : Colors.white38),
onPressed: () => setState(() => _autoScroll = !_autoScroll),
),
IconButton(
tooltip: 'Konsole leeren',
icon: const Icon(Icons.delete_outline_rounded, size: 20, color: Colors.white54),
onPressed: () => setState(() => _logs.clear()),
),
],
),
const SizedBox(height: 12),
// Terminal Box
Container(
height: 380,
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF0D1117), // Deep dark console
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white12),
),
child: filtered.isEmpty
? Center(
child: Text(
_logs.isEmpty ? 'Warte auf Log-Nachrichten von ${widget.serviceName}...' : 'Keine Logs passend zum Filter.',
style: const TextStyle(fontSize: 12, color: Colors.white38, fontStyle: FontStyle.italic),
),
)
: ListView.builder(
controller: _scrollController,
itemCount: filtered.length,
itemBuilder: (context, index) {
final item = filtered[index];
final color = _getLevelColor(item.level);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.5),
child: SelectableText.rich(
TextSpan(
style: const TextStyle(fontFamily: 'monospace', fontSize: 11.5, height: 1.4),
children: [
TextSpan(
text: '${_formatTime(item.timestamp)} ',
style: const TextStyle(color: Colors.white38),
),
TextSpan(
text: '[${item.level.toUpperCase().padRight(5)}] ',
style: TextStyle(color: color, fontWeight: FontWeight.bold),
),
if (item.channel.isNotEmpty)
TextSpan(
text: '{${item.channel}} ',
style: TextStyle(color: Colors.cyanAccent.withValues(alpha: 0.8)),
),
TextSpan(
text: item.message,
style: const TextStyle(color: Colors.white),
),
if (item.exception != null && item.exception!.isNotEmpty)
TextSpan(
text: '\n ${item.exception}',
style: const TextStyle(color: Colors.redAccent, fontSize: 10.5),
),
],
),
),
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,313 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../repositories/admin_repository.dart';
import 'service_settings_form.dart';
class ServiceConfigMeta {
final String key;
final String displayName;
final String description;
final IconData icon;
final Color accentColor;
const ServiceConfigMeta({
required this.key,
required this.displayName,
required this.description,
required this.icon,
required this.accentColor,
});
}
class PipelineSettingsWidget extends StatefulWidget {
final ApiClient? apiClient;
final AdminRepository? repository;
const PipelineSettingsWidget({super.key, this.apiClient, this.repository});
@override
State<PipelineSettingsWidget> createState() => _PipelineSettingsWidgetState();
}
class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
late final AdminRepository _repository;
String _selectedServiceKey = 'FinlyticAssets';
bool _isLoading = false;
bool _isSaving = false;
static const List<ServiceConfigMeta> _services = [
ServiceConfigMeta(
key: 'FinlyticAssets',
displayName: 'Asset Katalog & Logos',
description: 'Verwaltet ISIN Asset-Stammdaten & Trade Republic Logo Fetcher',
icon: Icons.inventory_2_outlined,
accentColor: Color(0xFF00E5FF),
),
ServiceConfigMeta(
key: 'FinlyticNews',
displayName: 'News Scraper & AI',
description: 'RSS Web Scraper Intervall & Entwurf-Retention',
icon: Icons.newspaper_outlined,
accentColor: Color(0xFF3B82F6),
),
ServiceConfigMeta(
key: 'FinlyticTechnicalAnalysis',
displayName: 'Technische Analyse',
description: 'EMA/SMA Perioden, RSI Grenzwerte & Supertrend Multiplikator',
icon: Icons.show_chart_outlined,
accentColor: Color(0xFF8B5CF6),
),
ServiceConfigMeta(
key: 'FinlyticSentiment',
displayName: 'Sentiment NLP',
description: 'NLP Vertrauens-Schwellenwerte & Text-Batching',
icon: Icons.psychology_outlined,
accentColor: Color(0xFFEC4899),
),
ServiceConfigMeta(
key: 'FinlyticEngine',
displayName: 'Trading Engine',
description: 'Strategy Screener, Trade Lifecycle & Risikomanagement',
icon: Icons.candlestick_chart_outlined,
accentColor: Color(0xFF10B981),
),
ServiceConfigMeta(
key: 'FinlyticFundamentals',
displayName: 'Fundamentaldaten',
description: 'Cache TTL Dauer & Yahoo Finance Fallback',
icon: Icons.corporate_fare_outlined,
accentColor: Color(0xFF06B6D4),
),
ServiceConfigMeta(
key: 'FinlyticBot',
displayName: 'FinlyticBot (Paper)',
description: 'Alpaca Paper Trading, Risikomanagement & Sizing Engine',
icon: Icons.smart_toy_outlined,
accentColor: Color(0xFF10B981),
),
];
final Map<String, Map<String, TextEditingController>> _controllers = {
'FinlyticAssets': {
'TradeRepublicMaxRequestPageSize': TextEditingController(text: '100'),
'AssetUpdateTypeDelay': TextEditingController(text: '0'),
'BatchAssetUpdateDelay': TextEditingController(text: '5'),
},
'FinlyticNews': {
'ScrapingIntervalMinutes': TextEditingController(text: '15'),
'PollingFrequencyMinutes': TextEditingController(text: '15'),
'ArticleRetentionDays': TextEditingController(text: '90'),
'DefaultPageSize': TextEditingController(text: '20'),
},
'FinlyticTechnicalAnalysis': {
'EmaShortPeriod': TextEditingController(text: '20'),
'SmaMediumPeriod': TextEditingController(text: '50'),
'SmaLongPeriod': TextEditingController(text: '200'),
'RsiOverboughtLimit': TextEditingController(text: '70'),
'RsiOversoldLimit': TextEditingController(text: '30'),
'SupertrendMultiplier': TextEditingController(text: '3.0'),
},
'FinlyticSentiment': {
'MinConfidenceScore': TextEditingController(text: '0.70'),
'MaxBatchSize': TextEditingController(text: '50'),
},
'FinlyticEngine': {
'Engine.MinCompositeScore': TextEditingController(text: '75.0'),
'Engine.WeightTechnical': TextEditingController(text: '0.45'),
'Engine.WeightSentiment': TextEditingController(text: '0.35'),
'Engine.WeightFundamental': TextEditingController(text: '0.20'),
'Engine.EarningsLockoutDays': TextEditingController(text: '2'),
'Engine.MinDerivativeLeverage': TextEditingController(text: '5.0'),
'Engine.TargetDefaultLeverage': TextEditingController(text: '7.0'),
'Engine.KnockOutSafetyBufferPercent': TextEditingController(text: '2.0'),
'Engine.EnableAiValidation': TextEditingController(text: 'true'),
'Engine.EnablePaperTradingBot': TextEditingController(text: 'false'),
'Engine.PollingIntervalSeconds': TextEditingController(text: '120'),
'Engine.MonitoringIntervalSeconds': TextEditingController(text: '60'),
},
'FinlyticFundamentals': {
'CacheTtlHours': TextEditingController(text: '24'),
'EnableYahooFallback': TextEditingController(text: 'true'),
},
'FinlyticBot': {
'Alpaca.KeyId': TextEditingController(text: ''),
'Alpaca.SecretKey': TextEditingController(text: ''),
'Alpaca.IsPaper': TextEditingController(text: 'true'),
'Bot.EnableAutoExecution': TextEditingController(text: 'true'),
'Bot.RiskPerTradePercent': TextEditingController(text: '1.0'),
'Bot.MaxPositionAllocationPercent': TextEditingController(text: '20.0'),
'Bot.MaxConcurrentPositions': TextEditingController(text: '5'),
'Bot.DailyLossLimitPercent': TextEditingController(text: '3.0'),
'Bot.MonitoringIntervalSeconds': TextEditingController(text: '15'),
},
};
bool _initialized = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_initialized) {
_initialized = true;
final client = widget.apiClient ?? context.read<ApiClient>();
_repository = widget.repository ?? AdminRepository(apiClient: client);
_fetchSettings();
}
}
Future<void> _fetchSettings() async {
setState(() => _isLoading = true);
try {
final settings = await _repository.fetchSettings();
settings.forEach((svc, items) {
_controllers.putIfAbsent(svc, () => {});
for (final s in items) {
if (_controllers[svc]!.containsKey(s.key)) {
_controllers[svc]![s.key]!.text = s.value;
} else {
_controllers[svc]![s.key] = TextEditingController(text: s.value);
}
}
});
} catch (_) {
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _saveSettings() async {
setState(() => _isSaving = true);
try {
final currentSvcControllers = _controllers[_selectedServiceKey] ?? {};
final payload = <String, String>{};
currentSvcControllers.forEach((k, v) {
payload[k] = v.text;
});
await _repository.updateServiceSettings(_selectedServiceKey, payload);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle_outline, color: Colors.black),
const SizedBox(width: 8),
Expanded(
child: Text(
'Einstellungen für $_selectedServiceKey gespeichert & synchronisiert.',
style: const TextStyle(color: Colors.black, fontWeight: FontWeight.w600),
),
),
],
),
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
);
}
} catch (ex) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Fehler beim Speichern: $ex'),
backgroundColor: AppTheme.accentRed,
behavior: SnackBarBehavior.floating,
),
);
}
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
@override
Widget build(BuildContext context) {
final activeService = _services.firstWhere((s) => s.key == _selectedServiceKey, orElse: () => _services.first);
final activeControllers = _controllers[_selectedServiceKey] ?? {};
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _services.map((svc) {
final isSelected = svc.key == _selectedServiceKey;
return Padding(
padding: const EdgeInsets.only(right: 8, bottom: 12),
child: InkWell(
onTap: () => setState(() => _selectedServiceKey = svc.key),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: isSelected ? svc.accentColor.withValues(alpha: 0.15) : AppTheme.glassSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? svc.accentColor : AppTheme.glassBorder,
width: isSelected ? 1.5 : 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(svc.icon, size: 16, color: isSelected ? svc.accentColor : AppTheme.textMuted),
const SizedBox(width: 8),
Text(
svc.displayName,
style: TextStyle(
color: isSelected ? Colors.white : AppTheme.textMuted,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
fontSize: 12,
),
),
],
),
),
),
);
}).toList(),
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(activeService.displayName, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: 2),
Text(activeService.description, style: TextStyle(fontSize: 12, color: AppTheme.textMuted)),
],
),
ElevatedButton.icon(
onPressed: _isSaving ? null : _saveSettings,
icon: _isSaving
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
: const Icon(Icons.save_outlined, size: 16),
label: Text(_isSaving ? 'Speichere...' : 'Speichern'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
),
),
],
),
const SizedBox(height: 14),
if (_isLoading)
Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald))
else
ServiceSettingsForm(
controllers: activeControllers,
accentColor: activeService.accentColor,
),
],
);
}
}
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
class ServiceSettingsForm extends StatelessWidget {
final Map<String, TextEditingController> controllers;
final Color accentColor;
const ServiceSettingsForm({
super.key,
required this.controllers,
required this.accentColor,
});
Widget _buildField(String key, TextEditingController ctrl) {
final isBool = ctrl.text == 'true' || ctrl.text == 'false';
if (isBool) {
return StatefulBuilder(
builder: (ctx, setLocal) {
return SwitchListTile(
title: Text(key, style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600)),
subtitle: Text('Boolesche Konfigurationsflagge', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
value: ctrl.text == 'true',
activeThumbColor: accentColor,
onChanged: (newVal) {
setLocal(() {
ctrl.text = newVal ? 'true' : 'false';
});
},
);
},
);
}
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(key, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
const SizedBox(height: 6),
TextField(
controller: ctrl,
style: const TextStyle(color: Colors.white, fontSize: 13),
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
if (controllers.isEmpty) {
return GlassContainer(
padding: const EdgeInsets.all(20),
child: Center(
child: Text('Keine konfigurierbaren Parameter für diesen Dienst vorhanden.', style: TextStyle(color: AppTheme.textMuted)),
),
);
}
return GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: controllers.entries.map((e) => _buildField(e.key, e.value)).toList(),
),
);
}
}
@@ -0,0 +1,283 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/signalr_service.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../core/widgets/status_badge.dart';
import '../views/service_detail_screen.dart';
/// Real-Time System Diagnostics Widget driven EXCLUSIVELY over SignalR WebSockets (`/hubs/health`).
/// ZERO REST HTTP API calls are performed.
class SystemDiagnosticsWidget extends StatefulWidget {
final SignalRService? signalRService;
const SystemDiagnosticsWidget({
super.key,
this.signalRService,
});
@override
State<SystemDiagnosticsWidget> createState() => _SystemDiagnosticsWidgetState();
}
class _SystemDiagnosticsWidgetState extends State<SystemDiagnosticsWidget> {
List<Map<String, dynamic>> _serviceStatuses = [];
StreamSubscription<List<Map<String, dynamic>>>? _healthSub;
@override
void initState() {
super.initState();
if (widget.signalRService != null) {
_healthSub = widget.signalRService!.healthStream.listen((data) {
if (mounted) {
setState(() {
_serviceStatuses = data;
});
}
});
}
}
@override
void dispose() {
_healthSub?.cancel();
super.dispose();
}
IconData _getServiceIcon(String name) {
switch (name) {
case 'FinlyticBackend':
return Icons.hub_outlined;
case 'FinlyticAssets':
return Icons.inventory_2_outlined;
case 'FinlyticNews':
return Icons.newspaper_outlined;
case 'FinlyticTechnicals':
case 'FinlyticTechnicalAnalysis':
return Icons.show_chart_outlined;
case 'FinlyticSentiment':
return Icons.psychology_outlined;
case 'FinlyticEngine':
case 'FinlyticAnalyzer':
case 'FinlyticTrades':
return Icons.candlestick_chart_outlined;
case 'FinlyticFundamentals':
return Icons.corporate_fare_outlined;
case 'FinlyticBot':
return Icons.smart_toy_outlined;
default:
return Icons.dns_outlined;
}
}
@override
Widget build(BuildContext context) {
final int totalCount = _serviceStatuses.length;
final int onlineCount = _serviceStatuses.where((s) => s['status']?.toString().toLowerCase() == 'online').length;
final bool isWsConnected = widget.signalRService?.isConnected ?? false;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// WebSocket Status Banner
GlassContainer(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.15),
shape: BoxShape.circle,
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.3)),
),
child: Icon(Icons.sensors_rounded, color: AppTheme.primaryEmerald, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'SignalR WebSocket Live-Diagnose',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
const SizedBox(width: 8),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: isWsConnected ? AppTheme.primaryEmerald : AppTheme.accentRed,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: (isWsConnected ? AppTheme.primaryEmerald : AppTheme.accentRed).withValues(alpha: 0.8),
blurRadius: 6,
spreadRadius: 2,
),
],
),
),
],
),
const SizedBox(height: 2),
Text(
'100% über SignalR WebSockets (/hubs/health). Keine HTTP API Anfragen.',
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
],
),
),
const SizedBox(width: 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: (onlineCount == totalCount && totalCount > 0 ? AppTheme.primaryEmerald : AppTheme.accentCyan).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: (onlineCount == totalCount && totalCount > 0 ? AppTheme.primaryEmerald : AppTheme.accentCyan).withValues(alpha: 0.4)),
),
child: Text(
totalCount > 0 ? '$onlineCount / $totalCount Online' : 'Verbinde WebSocket...',
style: TextStyle(
color: onlineCount == totalCount && totalCount > 0 ? AppTheme.primaryEmerald : AppTheme.accentCyan,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
),
],
),
),
const SizedBox(height: 16),
const Text(
'Echtzeit Dienststatus (SignalR Push)',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 12),
if (_serviceStatuses.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Center(
child: Column(
children: [
CircularProgressIndicator(color: AppTheme.primaryEmerald),
const SizedBox(height: 16),
Text('Warte auf SignalR WebSocket Daten von /hubs/health...', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)),
],
),
),
)
else
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _serviceStatuses.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: MediaQuery.of(context).size.width > 900 ? 2 : 1,
childAspectRatio: 2.7,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemBuilder: (context, index) {
final svc = _serviceStatuses[index];
final String name = svc['name']?.toString() ?? 'Unbekannt';
final String type = svc['type']?.toString() ?? '';
final String status = svc['status']?.toString() ?? 'Offline';
final bool isOnline = status.toLowerCase() == 'online';
final String portInfo = svc['port']?.toString() ?? 'MQTT Only';
final String db = svc['db']?.toString() ?? 'PostgreSQL';
return InkWell(
onTap: () {
final apiClient = context.read<ApiClient>();
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ServiceDetailScreen(serviceName: name, apiClient: apiClient),
),
);
},
borderRadius: BorderRadius.circular(20),
child: GlassContainer(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(
_getServiceIcon(name),
size: 18,
color: isOnline ? AppTheme.primaryEmerald : AppTheme.accentRed,
),
const SizedBox(width: 8),
Text(
name,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
],
),
StatusBadge(
label: status,
color: isOnline ? AppTheme.primaryEmerald : AppTheme.accentRed,
),
],
),
Text(
type,
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
overflow: TextOverflow.ellipsis,
),
const Divider(height: 10, color: Colors.white10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.storage_outlined, size: 12, color: AppTheme.textMuted),
const SizedBox(width: 4),
Text(
db,
style: TextStyle(fontSize: 11, color: AppTheme.textMuted),
),
],
),
Row(
children: [
Icon(name == 'FinlyticBackend' ? Icons.language_outlined : Icons.cable_outlined,
size: 12, color: AppTheme.accentCyan),
const SizedBox(width: 4),
Text(
portInfo,
style: TextStyle(
fontSize: 11,
color: name == 'FinlyticBackend' ? AppTheme.primaryEmerald : AppTheme.accentCyan,
fontWeight: FontWeight.w600,
),
),
],
),
],
),
],
),
),
);
},
),
],
),
);
}
}
@@ -0,0 +1,346 @@
import 'package:flutter/material.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../models/recent_setup_model.dart';
import '../models/watchlist_entry_model.dart';
import '../repositories/admin_repository.dart';
/// Card sitting next to "AUFSCHLÜSSELUNG NACH GRUND" showing how many assets
/// FinlyticTechnicals' background scanner is currently watching. Tapping opens
/// a dialog listing every entry — this directly answers "is anything even
/// being checked in the background right now", independent of whether any of
/// those checks have (yet) produced a proposal-worthy evaluation the engine
/// history tab above would show.
class WatchlistCard extends StatefulWidget {
final ApiClient apiClient;
const WatchlistCard({super.key, required this.apiClient});
@override
State<WatchlistCard> createState() => _WatchlistCardState();
}
class _WatchlistCardState extends State<WatchlistCard> {
late final AdminRepository _repository = AdminRepository(apiClient: widget.apiClient);
List<WatchlistEntryModel>? _entries;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final entries = await _repository.fetchWatchlist();
if (!mounted) return;
setState(() {
_entries = entries;
_error = null;
});
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
void _showDialog() {
showDialog(
context: context,
builder: (_) => _WatchlistDialog(repository: _repository, initialEntries: _entries ?? const []),
);
}
@override
Widget build(BuildContext context) {
final count = _entries?.length;
final value = _error != null ? '' : (count?.toString() ?? '');
return GlassContainer(
padding: const EdgeInsets.all(14),
onTap: _showDialog,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('WATCHLIST', style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
const Spacer(),
Icon(Icons.list_alt_rounded, size: 16, color: AppTheme.accentCyan),
],
),
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(value, style: TextStyle(fontSize: 22, color: AppTheme.textPrimary, fontWeight: FontWeight.bold)),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text('überwachte Assets', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
),
],
),
if (_error != null) ...[
const SizedBox(height: 6),
Text(_error!, style: TextStyle(color: AppTheme.accentRed, fontSize: 11)),
],
],
),
);
}
}
class _WatchlistDialog extends StatefulWidget {
final AdminRepository repository;
final List<WatchlistEntryModel> initialEntries;
const _WatchlistDialog({required this.repository, required this.initialEntries});
@override
State<_WatchlistDialog> createState() => _WatchlistDialogState();
}
class _WatchlistDialogState extends State<_WatchlistDialog> {
late List<WatchlistEntryModel> _entries = widget.initialEntries;
bool _refreshing = false;
Future<void> _refresh() async {
setState(() => _refreshing = true);
try {
final fresh = await widget.repository.fetchWatchlist();
if (!mounted) return;
setState(() {
_entries = fresh;
_refreshing = false;
});
} catch (_) {
if (!mounted) return;
setState(() => _refreshing = false);
}
}
String _formatTimestamp(DateTime utc) {
final local = utc.toLocal();
final d = local.day.toString().padLeft(2, '0');
final m = local.month.toString().padLeft(2, '0');
final h = local.hour.toString().padLeft(2, '0');
final min = local.minute.toString().padLeft(2, '0');
return '$d.$m.${local.year} $h:$min';
}
@override
Widget build(BuildContext context) {
final activeTheme = AppTheme.activePreset;
return Dialog(
backgroundColor: activeTheme.cardSurface,
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 640),
child: GlassContainer(
borderRadius: 20,
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(Icons.list_alt_rounded, color: AppTheme.accentCyan, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text('Watchlist (${_entries.length})', style: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold)),
),
IconButton(
onPressed: _refreshing ? null : _refresh,
icon: _refreshing
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.refresh_rounded, color: Colors.white70),
tooltip: 'Neu laden',
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close_rounded, color: Colors.white70),
),
],
),
Text(
'Assets, die FinlyticTechnicals derzeit im Hintergrund fortlaufend überprüft. Eintrag antippen für die letzten Bewertungen.',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
const SizedBox(height: 12),
if (_entries.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text('Die Watchlist ist derzeit leer.', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)),
),
)
else
Flexible(
child: ListView.separated(
shrinkWrap: true,
itemCount: _entries.length,
separatorBuilder: (_, __) => const Divider(height: 1, color: Colors.white12),
itemBuilder: (context, index) => _WatchlistEntryTile(
entry: _entries[index],
repository: widget.repository,
formatTimestamp: _formatTimestamp,
),
),
),
],
),
),
),
);
}
}
class _WatchlistEntryTile extends StatefulWidget {
final WatchlistEntryModel entry;
final AdminRepository repository;
final String Function(DateTime) formatTimestamp;
const _WatchlistEntryTile({required this.entry, required this.repository, required this.formatTimestamp});
@override
State<_WatchlistEntryTile> createState() => _WatchlistEntryTileState();
}
class _WatchlistEntryTileState extends State<_WatchlistEntryTile> {
List<RecentSetupModel>? _history;
bool _loading = false;
String? _error;
Future<void> _loadHistory() async {
if (_history != null || _loading) return;
setState(() => _loading = true);
try {
final history = await widget.repository.fetchWatchlistEntryHistory(widget.entry.isin);
if (!mounted) return;
setState(() {
_history = history;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
final entry = widget.entry;
return Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
onExpansionChanged: (expanded) {
if (expanded) _loadHistory();
},
tilePadding: EdgeInsets.zero,
title: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.symbol?.isNotEmpty == true ? entry.symbol! : entry.isin,
style: TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.bold, fontSize: 14),
),
Text(entry.isin, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
],
),
),
if (entry.source != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: AppTheme.accentCyan.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: AppTheme.accentCyan.withValues(alpha: 0.4)),
),
child: Text(entry.source!.label, style: TextStyle(color: AppTheme.accentCyan, fontSize: 10, fontWeight: FontWeight.bold)),
),
],
),
subtitle: Text(
'Seit ${widget.formatTimestamp(entry.addedAtUtc)}'
'${entry.expiresAtUtc != null ? ' · Läuft ab ${widget.formatTimestamp(entry.expiresAtUtc!)}' : ''}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
),
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildHistoryBody(),
),
],
),
);
}
Widget _buildHistoryBody() {
if (_loading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Center(child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))),
);
}
if (_error != null) {
return Text(_error!, style: TextStyle(color: AppTheme.accentRed, fontSize: 12));
}
final history = _history ?? const [];
if (history.isEmpty) {
return Text(
'Noch keine technische Bewertung für dieses Asset erfasst.',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('LETZTE BEWERTUNGEN', style: TextStyle(color: AppTheme.textSecondary, fontSize: 10, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
const SizedBox(height: 6),
...history.map((setup) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
SizedBox(
width: 90,
child: Text(widget.formatTimestamp(setup.createdAt), style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
),
Expanded(
child: Text(setup.strategyName, style: TextStyle(color: AppTheme.textPrimary, fontSize: 11), overflow: TextOverflow.ellipsis),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: (setup.isTopPick ? AppTheme.primaryEmerald : Colors.amber).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(
setup.qualityScore.toStringAsFixed(1),
style: TextStyle(
color: setup.isTopPick ? AppTheme.primaryEmerald : Colors.amber,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
],
),
)),
],
);
}
}
@@ -0,0 +1,19 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'asset_fundamentals_event.dart';
import 'asset_fundamentals_state.dart';
import '../../repositories/asset_repository.dart';
class AssetFundamentalsBloc extends Bloc<AssetFundamentalsEvent, AssetFundamentalsState> {
final AssetRepository repository;
AssetFundamentalsBloc({required this.repository}) : super(AssetFundamentalsInitial()) {
on<LoadAssetFundamentals>((event, emit) async {
emit(AssetFundamentalsLoading());
try {
final data = await repository.getAssetFundamentals(event.isin, event.forceRefresh, ticker: event.ticker);
emit(AssetFundamentalsLoaded(data));
} catch (e) {
emit(AssetFundamentalsError(e.toString()));
}
});
}
}
@@ -0,0 +1,7 @@
abstract class AssetFundamentalsEvent {}
class LoadAssetFundamentals extends AssetFundamentalsEvent {
final String isin;
final bool forceRefresh;
final String? ticker;
LoadAssetFundamentals(this.isin, {this.forceRefresh = false, this.ticker});
}
@@ -0,0 +1,13 @@
import '../../models/fundamental_data_model.dart';
abstract class AssetFundamentalsState {}
class AssetFundamentalsInitial extends AssetFundamentalsState {}
class AssetFundamentalsLoading extends AssetFundamentalsState {}
class AssetFundamentalsLoaded extends AssetFundamentalsState {
final FundamentalDataModel? data;
AssetFundamentalsLoaded(this.data);
}
class AssetFundamentalsError extends AssetFundamentalsState {
final String message;
AssetFundamentalsError(this.message);
}
@@ -0,0 +1,63 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../models/asset_model.dart';
import '../../models/fundamental_data_model.dart';
import '../../models/technical_analysis_model.dart';
import '../../repositories/asset_repository.dart';
import 'asset_header_event.dart';
import 'asset_header_state.dart';
class AssetHeaderBloc extends Bloc<AssetHeaderEvent, AssetHeaderState> {
final AssetRepository repository;
AssetHeaderBloc({required this.repository}) : super(AssetHeaderInitial()) {
on<LoadAssetHeader>((event, emit) async {
final prevData = state is AssetHeaderLoaded ? (state as AssetHeaderLoaded).data : (state is AssetHeaderLoading ? (state as AssetHeaderLoading).previousData : null);
emit(AssetHeaderLoading(previousData: prevData));
try {
final results = await Future.wait([
repository.getAssetFundamentals(event.isin, event.forceRefresh, ticker: event.ticker),
repository.getAssetTechnical(event.isin, event.forceRefresh, ticker: event.ticker),
]);
final fundamentals = results[0] as FundamentalDataModel?;
final technical = results[1] as TechnicalAnalysisModel?;
if (fundamentals != null) {
double initialPrice = fundamentals.currentPrice;
String initialCurrency = fundamentals.tradingCurrency ?? 'EUR';
if (technical != null && technical.candles.isNotEmpty) {
final lastClose = technical.candles.last.close;
if (lastClose > 0) {
initialPrice = lastClose;
}
if (technical.currency.isNotEmpty) {
initialCurrency = technical.currency;
}
}
final assetModel = AssetModel(
isin: fundamentals.isin,
symbol: fundamentals.primaryTicker.isNotEmpty ? fundamentals.primaryTicker : fundamentals.isin,
name: fundamentals.companyName,
currentPrice: initialPrice,
currency: initialCurrency,
exchange: fundamentals.exchange ?? 'XETRA',
exchanges: [],
tickers: fundamentals.availableTickers.map((t) => AssetTickerOption(
ticker: t.ticker,
exchange: t.exchange ?? 'Unknown',
tradingCurrency: t.tradingCurrency ?? initialCurrency,
currentPrice: t.currentPrice ?? initialPrice,
)).toList(),
image: '/api/v1/logo/${fundamentals.isin}',
);
emit(AssetHeaderLoaded(assetModel));
} else {
emit(AssetHeaderError('Failed to load asset header data'));
}
} catch (e) {
emit(AssetHeaderError(e.toString()));
}
});
}
}
@@ -0,0 +1,8 @@
abstract class AssetHeaderEvent {}
class LoadAssetHeader extends AssetHeaderEvent {
final String isin;
final bool forceRefresh;
final String? exchange;
final String? ticker;
LoadAssetHeader(this.isin, {this.forceRefresh = false, this.exchange, this.ticker});
}
@@ -0,0 +1,16 @@
import '../../models/asset_model.dart';
abstract class AssetHeaderState {}
class AssetHeaderInitial extends AssetHeaderState {}
class AssetHeaderLoading extends AssetHeaderState {
final AssetModel? previousData;
AssetHeaderLoading({this.previousData});
}
class AssetHeaderLoaded extends AssetHeaderState {
final AssetModel? data;
AssetHeaderLoaded(this.data);
}
class AssetHeaderError extends AssetHeaderState {
final String message;
AssetHeaderError(this.message);
}
@@ -0,0 +1,46 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'asset_technical_event.dart';
import 'asset_technical_state.dart';
import '../../repositories/asset_repository.dart';
class AssetTechnicalBloc extends Bloc<AssetTechnicalEvent, AssetTechnicalState> {
final AssetRepository repository;
AssetTechnicalBloc({required this.repository}) : super(AssetTechnicalInitial()) {
on<LoadAssetTechnical>((event, emit) async {
emit(AssetTechnicalLoading());
try {
final data = await repository.getAssetTechnical(event.isin, event.forceRefresh, ticker: event.ticker);
emit(AssetTechnicalLoaded(data));
} catch (e) {
emit(AssetTechnicalError(e.toString()));
}
});
on<TogglePatternFilter>((event, emit) {
if (state is AssetTechnicalLoaded) {
final current = state as AssetTechnicalLoaded;
final updated = Set<int>.from(current.disabledPatternIndices);
if (event.enabled) {
updated.remove(event.patternIndex);
} else {
updated.add(event.patternIndex);
}
emit(current.copyWith(disabledPatternIndices: updated));
}
});
on<ToggleIndicatorFilter>((event, emit) {
if (state is AssetTechnicalLoaded) {
final current = state as AssetTechnicalLoaded;
emit(current.copyWith(
showSma50: event.showSma50,
showSma200: event.showSma200,
showEma: event.showEma,
showSupertrend: event.showSupertrend,
showPatterns: event.showPatterns,
showSignals: event.showSignals,
));
}
});
}
}
@@ -0,0 +1,32 @@
abstract class AssetTechnicalEvent {}
class LoadAssetTechnical extends AssetTechnicalEvent {
final String isin;
final bool forceRefresh;
final String? ticker;
LoadAssetTechnical(this.isin, {this.forceRefresh = false, this.ticker});
}
class TogglePatternFilter extends AssetTechnicalEvent {
final int patternIndex;
final bool enabled;
TogglePatternFilter({required this.patternIndex, required this.enabled});
}
class ToggleIndicatorFilter extends AssetTechnicalEvent {
final bool? showSma50;
final bool? showSma200;
final bool? showEma;
final bool? showSupertrend;
final bool? showPatterns;
final bool? showSignals;
ToggleIndicatorFilter({
this.showSma50,
this.showSma200,
this.showEma,
this.showSupertrend,
this.showPatterns,
this.showSignals,
});
}
@@ -0,0 +1,56 @@
import '../../models/technical_analysis_model.dart';
abstract class AssetTechnicalState {}
class AssetTechnicalInitial extends AssetTechnicalState {}
class AssetTechnicalLoading extends AssetTechnicalState {}
class AssetTechnicalLoaded extends AssetTechnicalState {
final TechnicalAnalysisModel? data;
final Set<int> disabledPatternIndices;
final bool showSma50;
final bool showSma200;
final bool showEma;
final bool showSupertrend;
final bool showPatterns;
final bool showSignals;
AssetTechnicalLoaded(
this.data, {
this.disabledPatternIndices = const {},
this.showSma50 = true,
this.showSma200 = true,
this.showEma = true,
this.showSupertrend = true,
this.showPatterns = true,
this.showSignals = true,
});
AssetTechnicalLoaded copyWith({
TechnicalAnalysisModel? data,
Set<int>? disabledPatternIndices,
bool? showSma50,
bool? showSma200,
bool? showEma,
bool? showSupertrend,
bool? showPatterns,
bool? showSignals,
}) {
return AssetTechnicalLoaded(
data ?? this.data,
disabledPatternIndices: disabledPatternIndices ?? this.disabledPatternIndices,
showSma50: showSma50 ?? this.showSma50,
showSma200: showSma200 ?? this.showSma200,
showEma: showEma ?? this.showEma,
showSupertrend: showSupertrend ?? this.showSupertrend,
showPatterns: showPatterns ?? this.showPatterns,
showSignals: showSignals ?? this.showSignals,
);
}
}
class AssetTechnicalError extends AssetTechnicalState {
final String message;
AssetTechnicalError(this.message);
}
@@ -0,0 +1,66 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'asset_trades_event.dart';
import 'asset_trades_state.dart';
import '../../repositories/asset_repository.dart';
class AssetTradesBloc extends Bloc<AssetTradesEvent, AssetTradesState> {
final AssetRepository repository;
AssetTradesBloc({required this.repository}) : super(AssetTradesInitial()) {
on<LoadAssetTrades>((event, emit) async {
emit(AssetTradesLoading());
try {
final data = await repository.getAssetTrades(event.isin, event.status);
emit(AssetTradesLoaded(data));
} catch (e) {
emit(AssetTradesError(e.toString()));
}
});
on<TriggerManualAnalysis>((event, emit) async {
emit(AssetTradesLoading());
try {
// Server contract: always 200 -> AssetEvaluationResultDto, whether the
// pipeline produced a proposal or rejected the opportunity. The trade
// list itself is unaffected until the user actually accepts a
// proposal, so it is simply reloaded as-is; the analysis result is
// surfaced separately for the UI to react to exactly once.
final result = await repository.triggerManualAnalysis(event.isin, payload: event.payload);
final existingTrades = await repository.getAssetTrades(event.isin, null);
emit(AssetTradesLoaded(existingTrades, manualAnalysisResult: result));
} catch (e) {
emit(AssetTradesError("Failed to trigger manual analysis: $e"));
}
});
on<DismissTradeEvent>((event, emit) {
// Purely local: no server call, see DismissTradeEvent doc comment.
final current = state;
if (current is AssetTradesLoaded) {
emit(AssetTradesLoaded(current.data.where((t) => t.id != event.tradeId).toList()));
}
});
on<AcceptTradeEvent>((event, emit) async {
try {
await repository.acceptTrade(event.tradeAcceptanceDto);
add(LoadAssetTrades(event.isin));
} catch (e) {
emit(AssetTradesError("Failed to accept trade: $e"));
}
});
on<AddTradeFillEvent>((event, emit) async {
try {
await repository.addTradeFill(event.tradeId, executedPrice: event.executedPrice, quantity: event.quantity);
add(LoadAssetTrades(event.isin));
} catch (e) {
emit(AssetTradesError("Failed to update trade execution: $e"));
}
});
on<CloseTradeEvent>((event, emit) async {
try {
await repository.closeTrade(event.tradeId, event.exitPrice);
add(LoadAssetTrades(event.isin));
} catch (e) {
emit(AssetTradesError("Failed to close trade: $e"));
}
});
}
}
@@ -0,0 +1,49 @@
import 'package:finlytic_app/features/trades/models/trade_acceptance_dto.dart';
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_request_dto.dart';
abstract class AssetTradesEvent {}
class LoadAssetTrades extends AssetTradesEvent {
final String isin;
final String? status;
LoadAssetTrades(this.isin, {this.status});
}
class TriggerManualAnalysis extends AssetTradesEvent {
final String isin;
final ManualAnalysisRequestDto? payload;
TriggerManualAnalysis(this.isin, {this.payload});
}
/// Dismisses a trade proposal from the locally displayed list only.
///
/// There is no server-side "reject" anymore: a proposal is a system-wide
/// opportunity that any user may accept independently, so rejecting it has
/// no server-side meaning. This purely removes the card from the current
/// in-memory list; the proposal keeps existing server-side until its 24h
/// TTL expires, so it can reappear after the next reload (Rules.md §4
/// no fabricated "permanently rejected" state is invented).
class DismissTradeEvent extends AssetTradesEvent {
final String tradeId;
DismissTradeEvent(this.tradeId);
}
class AcceptTradeEvent extends AssetTradesEvent {
final TradeAcceptanceDto tradeAcceptanceDto;
final String isin;
AcceptTradeEvent(this.tradeAcceptanceDto, this.isin);
}
/// Records a corrective/additional fill against an already-active trade
/// (review-execution path). Distinct from [AcceptTradeEvent], which targets
/// a proposal, not an existing trade see `AssetRepository.addTradeFill`.
class AddTradeFillEvent extends AssetTradesEvent {
final String tradeId;
final String isin;
final double executedPrice;
final double quantity;
AddTradeFillEvent(this.tradeId, this.isin, this.executedPrice, this.quantity);
}
class CloseTradeEvent extends AssetTradesEvent {
final String tradeId;
final String isin;
final double exitPrice;
CloseTradeEvent(this.tradeId, this.isin, this.exitPrice);
}
@@ -0,0 +1,29 @@
import '../../../trades/models/trade_model.dart';
abstract class AssetTradesState {}
class AssetTradesInitial extends AssetTradesState {}
class AssetTradesLoading extends AssetTradesState {}
class AssetTradesLoaded extends AssetTradesState {
final List<TradeModel> data;
/// Transient result of a just-triggered manual analysis. Only set on the
/// state instance emitted directly by `TriggerManualAnalysis` a plain
/// reload/dismiss/accept emits a fresh `AssetTradesLoaded` without it, so a
/// `BlocConsumer` listener naturally reacts to it exactly once instead of
/// on every rebuild.
///
/// Always fully populated when set: the server contract no longer has a
/// silent "204, no proposal" outcome, so unlike the old
/// `manualAnalysisProposal`/`manualAnalysisEmpty` pair, a single non-null
/// value here already tells the caller everything check
/// `manualAnalysisResult!.hasProposal` to distinguish an accepted
/// opportunity from a rejected one with real scores/AI reasoning attached
/// (Rules.md §4).
final AssetEvaluationResultModel? manualAnalysisResult;
AssetTradesLoaded(this.data, {this.manualAnalysisResult});
}
class AssetTradesError extends AssetTradesState {
final String message;
AssetTradesError(this.message);
}
@@ -0,0 +1,106 @@
import 'package:equatable/equatable.dart';
class AssetModel extends Equatable {
final String isin;
final String symbol;
final String name;
final double currentPrice;
final String currency;
final String exchange;
final List<String> exchanges;
final List<AssetTickerOption> tickers;
final String image;
const AssetModel({
required this.isin,
required this.symbol,
required this.name,
this.currentPrice = 0.0,
required this.currency,
required this.exchange,
required this.exchanges,
required this.tickers,
required this.image,
});
factory AssetModel.fromJson(Map<String, dynamic> json) {
double parseDouble(dynamic val) {
if (val == null) return 0.0;
if (val is num) return val.toDouble();
return double.tryParse(val.toString()) ?? 0.0;
}
return AssetModel(
isin: json['isin']?.toString() ?? '',
symbol: json['symbol']?.toString() ?? '',
name: json['name']?.toString() ?? '',
currentPrice: parseDouble(json['price'] ?? json['currentPrice']),
currency: json['currency']?.toString() ?? 'EUR',
exchange: json['exchange']?.toString() ?? 'XETRA',
exchanges: (json['exchanges'] as List?)?.map((e) => e.toString()).toList() ?? [],
tickers: (json['tickers'] as List?)
?.map((t) => AssetTickerOption.fromJson(t))
.toList() ??
[],
image: json['image']?.toString() ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'isin': isin,
'symbol': symbol,
'name': name,
'currentPrice': currentPrice,
'currency': currency,
'exchange': exchange,
'exchanges': exchanges,
'tickers': tickers.map((t) => t.toJson()).toList(),
'image': image,
};
}
@override
List<Object?> get props => [isin, symbol, name, currentPrice, currency, exchange, exchanges, tickers, image];
}
class AssetTickerOption extends Equatable {
final String ticker;
final String exchange;
final String tradingCurrency;
final double currentPrice;
const AssetTickerOption({
required this.ticker,
required this.exchange,
required this.tradingCurrency,
required this.currentPrice,
});
factory AssetTickerOption.fromJson(Map<String, dynamic> json) {
double parseDouble(dynamic val) {
if (val == null) return 0.0;
if (val is num) return val.toDouble();
return double.tryParse(val.toString()) ?? 0.0;
}
return AssetTickerOption(
ticker: json['ticker']?.toString() ?? '',
exchange: json['exchange']?.toString() ?? 'XETRA',
tradingCurrency: json['tradingCurrency']?.toString() ?? json['currency']?.toString() ?? 'EUR',
currentPrice: parseDouble(json['currentPrice'] ?? json['price']),
);
}
Map<String, dynamic> toJson() {
return {
'ticker': ticker,
'exchange': exchange,
'tradingCurrency': tradingCurrency,
'currentPrice': currentPrice,
};
}
@override
List<Object?> get props => [ticker, exchange, tradingCurrency, currentPrice];
}
@@ -0,0 +1,50 @@
import 'package:equatable/equatable.dart';
class CompanyExecutiveModel extends Equatable {
final String name;
final String title;
final int? age;
final double? compensation;
final String? payment;
const CompanyExecutiveModel({
required this.name,
required this.title,
this.age,
this.compensation,
this.payment,
});
factory CompanyExecutiveModel.fromJson(Map<String, dynamic> json) {
double? compVal;
if (json['compensation'] != null) {
compVal = (json['compensation'] as num?)?.toDouble() ?? double.tryParse(json['compensation'].toString());
}
final rawPayment = json['payment']?.toString();
if (compVal == null && rawPayment != null && rawPayment.isNotEmpty) {
compVal = double.tryParse(rawPayment);
}
return CompanyExecutiveModel(
name: json['name']?.toString() ?? '',
title: json['title']?.toString() ?? '',
age: json['age'] != null ? int.tryParse(json['age'].toString()) : null,
compensation: compVal,
payment: rawPayment,
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'title': title,
if (age != null) 'age': age,
if (compensation != null) 'compensation': compensation,
if (payment != null) 'payment': payment,
};
}
@override
List<Object?> get props => [name, title, age, compensation, payment];
}
@@ -0,0 +1,158 @@
import 'package:equatable/equatable.dart';
class FinancialStatementModel extends Equatable {
final String periodType;
final String endDate;
// Income Statement
final double? totalRevenue;
final double? costOfRevenue;
final double? grossProfit;
final double? operatingExpenses;
final double? operatingIncome;
final double? ebitda;
final double? netIncome;
final double? epsBasic;
final double? epsDiluted;
// Balance Sheet
final double? cashAndCashEquivalents;
final double? accountsReceivable;
final double? inventory;
final double? totalCurrentAssets;
final double? totalNonCurrentAssets;
final double? currentLiabilities;
final double? longTermDebt;
final double? totalLiabilities;
final double? totalStockholdersEquity;
// Cash Flow
final double? operatingCashFlow;
final double? investingCashFlow;
final double? capitalExpenditures;
final double? financingCashFlow;
final double? freeCashFlow;
const FinancialStatementModel({
required this.periodType,
required this.endDate,
this.totalRevenue,
this.costOfRevenue,
this.grossProfit,
this.operatingExpenses,
this.operatingIncome,
this.ebitda,
this.netIncome,
this.epsBasic,
this.epsDiluted,
this.cashAndCashEquivalents,
this.accountsReceivable,
this.inventory,
this.totalCurrentAssets,
this.totalNonCurrentAssets,
this.currentLiabilities,
this.longTermDebt,
this.totalLiabilities,
this.totalStockholdersEquity,
this.operatingCashFlow,
this.investingCashFlow,
this.capitalExpenditures,
this.financingCashFlow,
this.freeCashFlow,
});
factory FinancialStatementModel.fromJson(Map<String, dynamic> json) {
double? parseD(dynamic val) {
if (val == null) return null;
if (val is num) return val.toDouble();
return double.tryParse(val.toString());
}
return FinancialStatementModel(
periodType: json['periodType']?.toString() ?? '',
endDate: json['endDate']?.toString() ?? '',
totalRevenue: parseD(json['totalRevenue']),
costOfRevenue: parseD(json['costOfRevenue']),
grossProfit: parseD(json['grossProfit']),
operatingExpenses: parseD(json['operatingExpenses']),
operatingIncome: parseD(json['operatingIncome']),
ebitda: parseD(json['ebitda']),
netIncome: parseD(json['netIncome']),
epsBasic: parseD(json['epsBasic']),
epsDiluted: parseD(json['epsDiluted']),
cashAndCashEquivalents: parseD(json['cashAndCashEquivalents']),
accountsReceivable: parseD(json['accountsReceivable']),
inventory: parseD(json['inventory']),
totalCurrentAssets: parseD(json['totalCurrentAssets']),
totalNonCurrentAssets: parseD(json['totalNonCurrentAssets']),
currentLiabilities: parseD(json['currentLiabilities']),
longTermDebt: parseD(json['longTermDebt']),
totalLiabilities: parseD(json['totalLiabilities']),
totalStockholdersEquity: parseD(json['totalStockholdersEquity']),
operatingCashFlow: parseD(json['operatingCashFlow']),
investingCashFlow: parseD(json['investingCashFlow']),
capitalExpenditures: parseD(json['capitalExpenditures']),
financingCashFlow: parseD(json['financingCashFlow']),
freeCashFlow: parseD(json['freeCashFlow']),
);
}
Map<String, dynamic> toJson() {
return {
'periodType': periodType,
'endDate': endDate,
'totalRevenue': totalRevenue,
'costOfRevenue': costOfRevenue,
'grossProfit': grossProfit,
'operatingExpenses': operatingExpenses,
'operatingIncome': operatingIncome,
'ebitda': ebitda,
'netIncome': netIncome,
'epsBasic': epsBasic,
'epsDiluted': epsDiluted,
'cashAndCashEquivalents': cashAndCashEquivalents,
'accountsReceivable': accountsReceivable,
'inventory': inventory,
'totalCurrentAssets': totalCurrentAssets,
'totalNonCurrentAssets': totalNonCurrentAssets,
'currentLiabilities': currentLiabilities,
'longTermDebt': longTermDebt,
'totalLiabilities': totalLiabilities,
'totalStockholdersEquity': totalStockholdersEquity,
'operatingCashFlow': operatingCashFlow,
'investingCashFlow': investingCashFlow,
'capitalExpenditures': capitalExpenditures,
'financingCashFlow': financingCashFlow,
'freeCashFlow': freeCashFlow,
};
}
@override
List<Object?> get props => [
periodType,
endDate,
totalRevenue,
costOfRevenue,
grossProfit,
operatingExpenses,
operatingIncome,
ebitda,
netIncome,
epsBasic,
epsDiluted,
cashAndCashEquivalents,
accountsReceivable,
inventory,
totalCurrentAssets,
totalNonCurrentAssets,
currentLiabilities,
longTermDebt,
totalLiabilities,
totalStockholdersEquity,
operatingCashFlow,
investingCashFlow,
capitalExpenditures,
financingCashFlow,
freeCashFlow,
];
}
@@ -0,0 +1,36 @@
import 'package:equatable/equatable.dart';
class ForwardEstimateModel extends Equatable {
final String period;
final double? expectedRevenue;
final double? expectedEps;
final double? expectedGrowthRate;
const ForwardEstimateModel({
required this.period,
this.expectedRevenue,
this.expectedEps,
this.expectedGrowthRate,
});
factory ForwardEstimateModel.fromJson(Map<String, dynamic> json) {
return ForwardEstimateModel(
period: json['period']?.toString() ?? '',
expectedRevenue: (json['expectedRevenue'] as num?)?.toDouble() ?? (json['expectedRevenue'] != null ? double.tryParse(json['expectedRevenue'].toString()) : null),
expectedEps: (json['expectedEps'] as num?)?.toDouble() ?? (json['expectedEps'] != null ? double.tryParse(json['expectedEps'].toString()) : null),
expectedGrowthRate: (json['expectedGrowthRate'] as num?)?.toDouble() ?? (json['expectedGrowthRate'] != null ? double.tryParse(json['expectedGrowthRate'].toString()) : null),
);
}
Map<String, dynamic> toJson() {
return {
'period': period,
if (expectedRevenue != null) 'expectedRevenue': expectedRevenue,
if (expectedEps != null) 'expectedEps': expectedEps,
if (expectedGrowthRate != null) 'expectedGrowthRate': expectedGrowthRate,
};
}
@override
List<Object?> get props => [period, expectedRevenue, expectedEps, expectedGrowthRate];
}
@@ -0,0 +1,352 @@
import 'package:equatable/equatable.dart';
import 'ticker_model.dart';
import 'company_officer_model.dart';
import 'financial_statement_model.dart';
import 'forward_estimate_model.dart';
export 'ticker_model.dart';
export 'company_officer_model.dart';
export 'financial_statement_model.dart';
export 'forward_estimate_model.dart';
class FundamentalDataModel extends Equatable {
final String isin;
final String primaryTicker;
final String ticker;
final String companyName;
final String? exchange;
final String? tradingCurrency;
final String? businessSummary;
final String? sector;
final String? industry;
final String? country;
final int? employees;
final double currentPrice;
final double dayChangeAbsolute;
final double dayChangePercent;
final double? fiftyTwoWeekHigh;
final double? fiftyTwoWeekLow;
final double? marketCapitalization;
final double? enterpriseValue;
final double? peRatioTrailing;
final double? peRatioForward;
final double? pegRatio;
final double? pbRatio;
final double? psRatio;
final double? evToEbitda;
final double? evToRevenue;
final double? totalRevenue;
final double? revenueGrowthYoY;
final double? grossProfit;
final double? ebitda;
final double? dilutedEps;
final double? totalCash;
final double? totalDebt;
final double? operatingCashFlow;
final double? freeCashFlow;
final double? grossMargin;
final double? operatingMargin;
final double? netProfitMargin;
final double? returnOnEquity;
final double? returnOnAssets;
final double? returnOnInvestedCapital;
final double? debtToEquity;
final double? currentRatio;
final double? quickRatio;
final double? interestCoverage;
final double? dividendYield;
final double? payoutRatio;
final String? exDividendDate;
final String? nextEarningsDate;
final double? percentHeldByInstitutions;
final double? percentHeldByInsiders;
final double? shortRatio;
final double? shortPercentOfFloat;
final String? consensusRating;
final double? priceTargetLow;
final double? priceTargetHigh;
final double? priceTargetMedian;
final double? priceTargetMean;
final List<CompanyExecutiveModel> executives;
final List<FinancialStatementModel> financialStatements;
final List<ForwardEstimateModel> estimates;
final List<TickerModel> availableTickers;
const FundamentalDataModel({
required this.isin,
required this.primaryTicker,
required this.ticker,
required this.companyName,
this.exchange,
this.tradingCurrency,
this.businessSummary,
this.sector,
this.industry,
this.country,
this.employees,
required this.currentPrice,
required this.dayChangeAbsolute,
required this.dayChangePercent,
this.fiftyTwoWeekHigh,
this.fiftyTwoWeekLow,
this.marketCapitalization,
this.enterpriseValue,
this.peRatioTrailing,
this.peRatioForward,
this.pegRatio,
this.pbRatio,
this.psRatio,
this.evToEbitda,
this.evToRevenue,
this.totalRevenue,
this.revenueGrowthYoY,
this.grossProfit,
this.ebitda,
this.dilutedEps,
this.totalCash,
this.totalDebt,
this.operatingCashFlow,
this.freeCashFlow,
this.grossMargin,
this.operatingMargin,
this.netProfitMargin,
this.returnOnEquity,
this.returnOnAssets,
this.returnOnInvestedCapital,
this.debtToEquity,
this.currentRatio,
this.quickRatio,
this.interestCoverage,
this.dividendYield,
this.payoutRatio,
this.exDividendDate,
this.nextEarningsDate,
this.percentHeldByInstitutions,
this.percentHeldByInsiders,
this.shortRatio,
this.shortPercentOfFloat,
this.consensusRating,
this.priceTargetLow,
this.priceTargetHigh,
this.priceTargetMedian,
this.priceTargetMean,
required this.executives,
required this.financialStatements,
required this.estimates,
this.availableTickers = const [],
});
factory FundamentalDataModel.fromJson(Map<String, dynamic> json) {
double? parseNullableDouble(dynamic val) {
if (val == null) return null;
if (val is num) return val.toDouble();
return double.tryParse(val.toString());
}
final assetMap = json['asset'] is Map<String, dynamic> ? json['asset'] as Map<String, dynamic> : null;
final fundMap = json['fundamentals'] is Map<String, dynamic> ? json['fundamentals'] as Map<String, dynamic> : null;
String extractTickerStr(dynamic val) {
if (val == null) return '';
if (val is Map<String, dynamic>) {
return val['ticker']?.toString() ?? '';
}
return val.toString();
}
String? extractExchangeStr(dynamic val) {
if (val == null) return null;
if (val is Map<String, dynamic>) {
return val['exchange']?.toString();
}
return null;
}
final isinVal = assetMap?['isin']?.toString() ?? json['isin']?.toString() ?? '';
final primaryTickerVal = extractTickerStr(assetMap?['primaryTicker'] ?? json['primaryTicker']);
final tickerVal = extractTickerStr(fundMap?['ticker'] ?? json['ticker']).isNotEmpty
? extractTickerStr(fundMap?['ticker'] ?? json['ticker'])
: primaryTickerVal;
final companyNameVal = assetMap?['name']?.toString() ?? json['companyName']?.toString() ?? tickerVal;
final businessSummaryVal = assetMap?['description']?.toString() ?? json['businessSummary']?.toString();
final exchangeVal = extractExchangeStr(fundMap?['ticker']) ??
extractExchangeStr(assetMap?['primaryTicker']) ??
json['exchange']?.toString();
final rawTickers = assetMap?['availableTickers'] ?? json['availableTickers'];
List<TickerModel> availableTickersList = [];
if (rawTickers is List) {
availableTickersList = rawTickers
.whereType<Map<String, dynamic>>()
.map((t) => TickerModel.fromJson(t))
.toList();
}
final totalRev = parseNullableDouble(fundMap?['totalRevenue'] ?? json['totalRevenue']);
final rawGrossProfit = parseNullableDouble(fundMap?['grossProfit'] ?? json['grossProfit']);
double? grossMarginVal = parseNullableDouble(fundMap?['grossMargins'] ?? fundMap?['grossMargin'] ?? json['grossMargin']);
double? grossProfVal = rawGrossProfit;
if (rawGrossProfit != null) {
if (rawGrossProfit <= 1.0 && rawGrossProfit >= 0.0) {
grossMarginVal ??= rawGrossProfit;
if (totalRev != null && totalRev > 0) {
grossProfVal = rawGrossProfit * totalRev;
}
} else if (totalRev != null && totalRev > 0) {
grossMarginVal ??= rawGrossProfit / totalRev;
}
}
final evVal = parseNullableDouble(fundMap?['enterpriseValue'] ?? json['enterpriseValue']);
double? evToRevVal = parseNullableDouble(fundMap?['evToRevenue'] ?? fundMap?['enterpriseValueToRevenue'] ?? json['evToRevenue']);
if (evToRevVal == null && evVal != null && totalRev != null && totalRev > 0) {
evToRevVal = evVal / totalRev;
}
String? exDivDateStr = fundMap?['exDividendDate']?.toString() ?? json['exDividendDate']?.toString();
String? nextEarningsDateStr = fundMap?['nextEarningsDate']?.toString() ?? json['nextEarningsDate']?.toString();
final rawEvents = json['events'];
if (rawEvents is List) {
final now = DateTime.now();
final divEvents = rawEvents.whereType<Map<String, dynamic>>().where((e) {
final t = e['type']?.toString().toUpperCase() ?? '';
return t == 'DIVIDEND' || t == 'EX_DIVIDEND';
}).toList();
if (exDivDateStr == null && divEvents.isNotEmpty) {
divEvents.sort((a, b) {
final da = DateTime.tryParse(a['date']?.toString() ?? '') ?? DateTime(1970);
final db = DateTime.tryParse(b['date']?.toString() ?? '') ?? DateTime(1970);
return da.compareTo(db);
});
final upcoming = divEvents.firstWhere((e) {
final d = DateTime.tryParse(e['date']?.toString() ?? '');
return d != null && d.isAfter(now.subtract(const Duration(days: 7)));
}, orElse: () => divEvents.last);
exDivDateStr = upcoming['date']?.toString();
}
final earningsEvents = rawEvents.whereType<Map<String, dynamic>>().where((e) {
final t = e['type']?.toString().toUpperCase() ?? '';
return t.contains('EARNINGS');
}).toList();
if (nextEarningsDateStr == null && earningsEvents.isNotEmpty) {
earningsEvents.sort((a, b) {
final da = DateTime.tryParse(a['date']?.toString() ?? '') ?? DateTime(1970);
final db = DateTime.tryParse(b['date']?.toString() ?? '') ?? DateTime(1970);
return da.compareTo(db);
});
final upcoming = earningsEvents.firstWhere((e) {
final d = DateTime.tryParse(e['date']?.toString() ?? '');
return d != null && d.isAfter(now.subtract(const Duration(days: 1)));
}, orElse: () => earningsEvents.last);
nextEarningsDateStr = upcoming['date']?.toString();
}
}
return FundamentalDataModel(
isin: isinVal,
primaryTicker: primaryTickerVal,
ticker: tickerVal,
companyName: companyNameVal,
exchange: exchangeVal,
tradingCurrency: fundMap?['currency']?.toString() ?? json['tradingCurrency']?.toString(),
businessSummary: businessSummaryVal,
sector: assetMap?['sector']?.toString() ?? json['sector']?.toString(),
industry: assetMap?['industry']?.toString() ?? json['industry']?.toString(),
country: assetMap?['country']?.toString() ?? json['country']?.toString(),
employees: (assetMap?['employees'] ?? json['employees']) is int
? (assetMap?['employees'] ?? json['employees']) as int
: int.tryParse((assetMap?['employees'] ?? json['employees'])?.toString() ?? ''),
currentPrice: parseNullableDouble(fundMap?['currentPrice'] ?? json['currentPrice']) ?? 0.0,
dayChangeAbsolute: parseNullableDouble(fundMap?['dayChangeAbsolute'] ?? json['dayChangeAbsolute']) ?? 0.0,
dayChangePercent: parseNullableDouble(fundMap?['dayChangePercent'] ?? json['dayChangePercent']) ?? 0.0,
fiftyTwoWeekHigh: parseNullableDouble(fundMap?['fiftyTwoWeekHigh'] ?? json['fiftyTwoWeekHigh']),
fiftyTwoWeekLow: parseNullableDouble(fundMap?['fiftyTwoWeekLow'] ?? json['fiftyTwoWeekLow']),
marketCapitalization: parseNullableDouble(fundMap?['marketCap'] ?? fundMap?['marketCapitalization'] ?? json['marketCapitalization']),
enterpriseValue: evVal,
peRatioTrailing: parseNullableDouble(fundMap?['trailingPe'] ?? fundMap?['trailingPE'] ?? fundMap?['peRatioTrailing'] ?? json['peRatioTrailing'] ?? json['trailingPe']),
peRatioForward: parseNullableDouble(fundMap?['forwardPe'] ?? fundMap?['forwardPE'] ?? fundMap?['peRatioForward'] ?? json['peRatioForward'] ?? json['forwardPe']),
pegRatio: parseNullableDouble(fundMap?['pegRatio'] ?? json['pegRatio']),
pbRatio: parseNullableDouble(fundMap?['priceToBook'] ?? fundMap?['pbRatio'] ?? json['pbRatio']),
psRatio: parseNullableDouble(fundMap?['priceToSales'] ?? fundMap?['priceToSalesTrailing12Months'] ?? fundMap?['psRatio'] ?? json['psRatio']),
evToEbitda: parseNullableDouble(fundMap?['evToEbitda'] ?? fundMap?['enterpriseToEbitda'] ?? json['evToEbitda']),
evToRevenue: evToRevVal,
totalRevenue: totalRev,
revenueGrowthYoY: parseNullableDouble(fundMap?['revenueGrowthYoY'] ?? fundMap?['revenueGrowth'] ?? json['revenueGrowthYoY']),
grossProfit: grossProfVal,
ebitda: parseNullableDouble(fundMap?['ebitda'] ?? json['ebitda']),
dilutedEps: parseNullableDouble(fundMap?['dilutedEps'] ?? fundMap?['trailingEps'] ?? json['dilutedEps']),
totalCash: parseNullableDouble(fundMap?['totalCash'] ?? json['totalCash']),
totalDebt: parseNullableDouble(fundMap?['totalDebt'] ?? json['totalDebt']),
operatingCashFlow: parseNullableDouble(fundMap?['operatingCashFlow'] ?? fundMap?['operatingCashflow'] ?? json['operatingCashFlow']),
freeCashFlow: parseNullableDouble(fundMap?['freeCashFlow'] ?? fundMap?['freeCashflow'] ?? json['freeCashFlow']),
grossMargin: grossMarginVal,
operatingMargin: parseNullableDouble(fundMap?['operatingIncome'] ?? fundMap?['operatingMargins'] ?? fundMap?['operatingMargin'] ?? json['operatingMargin']),
netProfitMargin: parseNullableDouble(fundMap?['netIncome'] ?? fundMap?['profitMargins'] ?? fundMap?['netProfitMargin'] ?? json['netProfitMargin']),
returnOnEquity: parseNullableDouble(fundMap?['returnOnEquity'] ?? json['returnOnEquity']),
returnOnAssets: parseNullableDouble(fundMap?['returnOnAssets'] ?? json['returnOnAssets']),
returnOnInvestedCapital: parseNullableDouble(fundMap?['returnOnInvestedCapital'] ?? json['returnOnInvestedCapital']),
debtToEquity: parseNullableDouble(fundMap?['debtToEquity'] ?? json['debtToEquity']),
currentRatio: parseNullableDouble(fundMap?['currentRatio'] ?? json['currentRatio']),
quickRatio: parseNullableDouble(fundMap?['quickRatio'] ?? json['quickRatio']),
interestCoverage: parseNullableDouble(fundMap?['interestCoverage'] ?? json['interestCoverage']),
dividendYield: parseNullableDouble(fundMap?['forwardDividendYield'] ?? fundMap?['dividendYield'] ?? json['dividendYield']),
payoutRatio: parseNullableDouble(fundMap?['payoutRatio'] ?? json['payoutRatio']),
exDividendDate: exDivDateStr,
nextEarningsDate: nextEarningsDateStr,
percentHeldByInstitutions: parseNullableDouble(fundMap?['percentHeldByInstitutions'] ?? json['percentHeldByInstitutions']),
percentHeldByInsiders: parseNullableDouble(fundMap?['percentHeldByInsiders'] ?? json['percentHeldByInsiders']),
shortRatio: parseNullableDouble(fundMap?['shortRatio'] ?? json['shortRatio']),
shortPercentOfFloat: parseNullableDouble(fundMap?['shortPercentOfFloat'] ?? json['shortPercentOfFloat']),
consensusRating: fundMap?['consensusRating']?.toString() ?? json['consensusRating']?.toString(),
priceTargetLow: parseNullableDouble(fundMap?['priceTargetLow'] ?? json['priceTargetLow']),
priceTargetHigh: parseNullableDouble(fundMap?['priceTargetHigh'] ?? json['priceTargetHigh']),
priceTargetMedian: parseNullableDouble(fundMap?['priceTargetMedian'] ?? json['priceTargetMedian']),
priceTargetMean: parseNullableDouble(fundMap?['priceTargetMean'] ?? json['priceTargetMean']),
executives: (json['executives'] as List?)
?.whereType<Map<String, dynamic>>()
.map((e) => CompanyExecutiveModel.fromJson(e))
.toList() ??
[],
financialStatements: (json['financialStatements'] as List?)
?.whereType<Map<String, dynamic>>()
.map((e) => FinancialStatementModel.fromJson(e))
.toList() ??
[],
estimates: (json['estimates'] as List?)
?.whereType<Map<String, dynamic>>()
.map((e) => ForwardEstimateModel.fromJson(e))
.toList() ??
[],
availableTickers: availableTickersList,
);
}
@override
List<Object?> get props => [
isin, primaryTicker, ticker, companyName, exchange, tradingCurrency,
businessSummary, sector, industry, country, employees, currentPrice,
dayChangeAbsolute, dayChangePercent, fiftyTwoWeekHigh, fiftyTwoWeekLow,
marketCapitalization, enterpriseValue, peRatioTrailing, peRatioForward,
pegRatio, pbRatio, psRatio, evToEbitda, evToRevenue, grossMargin,
operatingMargin, netProfitMargin, returnOnEquity, returnOnAssets,
returnOnInvestedCapital, debtToEquity, currentRatio, quickRatio,
dividendYield, payoutRatio, exDividendDate, nextEarningsDate,
percentHeldByInstitutions, percentHeldByInsiders, shortRatio,
shortPercentOfFloat, consensusRating, priceTargetLow, priceTargetHigh,
priceTargetMedian, priceTargetMean, executives, financialStatements,
estimates, availableTickers,
];
}
@@ -0,0 +1,51 @@
class ManualAnalysisRequestDto {
final String isin;
final String symbol;
final int riskScore;
final int minTimeframeValue;
final int maxTimeframeValue;
final String timeframeUnit;
final String instrumentType;
final String userNotes;
final String headline;
ManualAnalysisRequestDto({
required this.isin,
required this.symbol,
required this.riskScore,
required this.minTimeframeValue,
required this.maxTimeframeValue,
required this.timeframeUnit,
required this.instrumentType,
required this.userNotes,
required this.headline,
});
Map<String, dynamic> toJson() {
return {
'isin': isin,
'symbol': symbol,
'riskScore': riskScore,
'minTimeframeValue': minTimeframeValue,
'maxTimeframeValue': maxTimeframeValue,
'timeframeUnit': timeframeUnit,
'instrumentType': instrumentType,
'userNotes': userNotes,
'headline': headline,
};
}
factory ManualAnalysisRequestDto.fromJson(Map<String, dynamic> json) {
return ManualAnalysisRequestDto(
isin: json['isin'] as String,
symbol: json['symbol'] as String,
riskScore: json['riskScore'] as int,
minTimeframeValue: json['minTimeframeValue'] as int,
maxTimeframeValue: json['maxTimeframeValue'] as int,
timeframeUnit: json['timeframeUnit'] as String,
instrumentType: json['instrumentType'] as String,
userNotes: json['userNotes'] as String,
headline: json['headline'] as String,
);
}
}
@@ -0,0 +1,305 @@
import 'package:equatable/equatable.dart';
class CandleModel extends Equatable {
final DateTime timestamp;
final double open;
final double high;
final double low;
final double close;
final double volume;
const CandleModel({
required this.timestamp,
required this.open,
required this.high,
required this.low,
required this.close,
required this.volume,
});
factory CandleModel.fromJson(Map<String, dynamic> json) {
return CandleModel(
timestamp: DateTime.tryParse(json['timestamp']?.toString() ?? '') ?? DateTime.now(),
open: (json['open'] as num?)?.toDouble() ?? 0.0,
high: (json['high'] as num?)?.toDouble() ?? 0.0,
low: (json['low'] as num?)?.toDouble() ?? 0.0,
close: (json['close'] as num?)?.toDouble() ?? 0.0,
volume: (json['volume'] as num?)?.toDouble() ?? 0.0,
);
}
@override
List<Object?> get props => [timestamp, open, high, low, close, volume];
}
class IndicatorModel extends Equatable {
final DateTime timestamp;
final double? ema20;
final double? sma50;
final double? sma200;
final double? rsi14;
final double? macdLine;
final double? macdSignal;
final double? macdHistogram;
final double? atr14;
final double? vwap;
final double? supertrendUpper;
final double? supertrendLower;
final String? supertrendDirection;
final double? recommendedStopLoss;
const IndicatorModel({
required this.timestamp,
this.ema20,
this.sma50,
this.sma200,
this.rsi14,
this.macdLine,
this.macdSignal,
this.macdHistogram,
this.atr14,
this.vwap,
this.supertrendUpper,
this.supertrendLower,
this.supertrendDirection,
this.recommendedStopLoss,
});
factory IndicatorModel.fromJson(Map<String, dynamic> json) {
return IndicatorModel(
timestamp: DateTime.tryParse(json['timestamp']?.toString() ?? '') ?? DateTime.now(),
ema20: (json['ema20'] as num?)?.toDouble(),
sma50: (json['sma50'] as num?)?.toDouble(),
sma200: (json['sma200'] as num?)?.toDouble(),
rsi14: (json['rsi14'] as num?)?.toDouble(),
macdLine: (json['macdLine'] as num?)?.toDouble(),
macdSignal: (json['macdSignal'] as num?)?.toDouble(),
macdHistogram: (json['macdHistogram'] as num?)?.toDouble(),
atr14: (json['atr14'] as num?)?.toDouble(),
vwap: (json['vwap'] as num?)?.toDouble(),
supertrendUpper: (json['supertrendUpper'] as num?)?.toDouble(),
supertrendLower: (json['supertrendLower'] as num?)?.toDouble(),
supertrendDirection: json['supertrendDirection']?.toString(),
recommendedStopLoss: (json['recommendedStopLoss'] as num?)?.toDouble(),
);
}
@override
List<Object?> get props => [
timestamp, ema20, sma50, sma200, rsi14, macdLine, macdSignal,
macdHistogram, atr14, vwap, supertrendUpper, supertrendLower,
supertrendDirection, recommendedStopLoss
];
}
class StrategySignalModel extends Equatable {
final String title;
final DateTime date;
final double price;
final String type; // BUY or SELL
const StrategySignalModel({
required this.title,
required this.date,
required this.price,
required this.type,
});
factory StrategySignalModel.fromJson(Map<String, dynamic> json) {
final rawDir = (json['direction'] ?? json['signalType'] ?? json['type'])?.toString().toUpperCase() ?? 'BUY';
final sigDir = (rawDir == 'BUY' || rawDir == 'SELL') ? rawDir : 'BUY';
final sigTitle = (json['title'] ?? json['type'] ?? json['description'])?.toString() ?? 'Signal';
final dateStr = (json['timestamp'] ?? json['date'] ?? json['time'])?.toString();
return StrategySignalModel(
title: sigTitle,
date: dateStr != null ? (DateTime.tryParse(dateStr) ?? DateTime.now()) : DateTime.now(),
price: (json['price'] as num?)?.toDouble() ?? 0.0,
type: sigDir,
);
}
@override
List<Object?> get props => [title, date, price, type];
}
class PatternPoint extends Equatable {
final DateTime time;
final double price;
const PatternPoint(this.time, this.price);
factory PatternPoint.fromJson(Map<String, dynamic> json) => PatternPoint(DateTime.tryParse(json['time']?.toString() ?? '') ?? DateTime.now(), (json['price'] as num?)?.toDouble() ?? 0.0);
@override
List<Object?> get props => [time, price];
}
class BreakoutSignalModel extends Equatable {
final String direction; // "UP", "DOWN"
final double targetPrice;
final double potentialPercent;
const BreakoutSignalModel({
required this.direction,
required this.targetPrice,
required this.potentialPercent,
});
factory BreakoutSignalModel.fromJson(Map<String, dynamic> json) {
return BreakoutSignalModel(
direction: (json['direction'] ?? json['Direction'])?.toString() ?? 'UP',
targetPrice: (json['targetPrice'] ?? json['TargetPrice'] as num?)?.toDouble() ?? 0.0,
potentialPercent: (json['potentialPercent'] ?? json['PotentialPercent'] as num?)?.toDouble() ?? 0.0,
);
}
@override
List<Object?> get props => [direction, targetPrice, potentialPercent];
}
class ChartPatternModel extends Equatable {
final String type;
final String description;
final double confidencePercent;
final BreakoutSignalModel? breakoutSignal;
final List<PatternPoint> upperLine;
final List<PatternPoint> lowerLine;
const ChartPatternModel({
required this.type,
this.description = '',
this.confidencePercent = 0.0,
this.breakoutSignal,
required this.upperLine,
required this.lowerLine,
});
factory ChartPatternModel.fromJson(Map<String, dynamic> json) {
BreakoutSignalModel? breakout;
final bJson = json['breakoutSignal'] ?? json['BreakoutSignal'];
if (bJson != null && bJson is Map<String, dynamic>) {
breakout = BreakoutSignalModel.fromJson(bJson);
}
return ChartPatternModel(
type: json['type']?.toString() ?? json['Type']?.toString() ?? 'Pattern',
description: json['description']?.toString() ?? json['Description']?.toString() ?? '',
confidencePercent: (json['confidencePercent'] ?? json['ConfidencePercent'] as num?)?.toDouble() ?? 0.0,
breakoutSignal: breakout,
upperLine: (json['upperLine'] as List<dynamic>? ?? []).map((e) => PatternPoint.fromJson(e as Map<String, dynamic>)).toList(),
lowerLine: (json['lowerLine'] as List<dynamic>? ?? []).map((e) => PatternPoint.fromJson(e as Map<String, dynamic>)).toList(),
);
}
@override
List<Object?> get props => [type, description, confidencePercent, breakoutSignal, upperLine, lowerLine];
}
class TechnicalAnalysisModel extends Equatable {
final String symbol;
final String currency;
final double? currentPrice;
final String trend;
final String rsi;
final String macd;
final String overallSignal;
final String sma50;
final String sma200;
final double? vix;
final String? sp500Trend;
final double? dxy;
final double? stopLossAtr;
final List<CandleModel> candles;
final List<IndicatorModel> indicators;
final List<ChartPatternModel> patterns;
final List<StrategySignalModel> signals;
const TechnicalAnalysisModel({
required this.symbol,
this.currency = 'EUR',
this.currentPrice,
required this.trend,
required this.rsi,
required this.macd,
required this.overallSignal,
required this.sma50,
required this.sma200,
this.vix,
this.sp500Trend,
this.dxy,
this.stopLossAtr,
this.candles = const [],
this.indicators = const [],
this.patterns = const [],
this.signals = const [],
});
factory TechnicalAnalysisModel.fromJson(Map<String, dynamic> json) {
var rawCandles = json['candles'] as List<dynamic>? ?? [];
var candlesList = rawCandles.map((c) => CandleModel.fromJson(c as Map<String, dynamic>)).toList();
var rawIndicators = json['indicators'] as List<dynamic>? ?? [];
var indicatorsList = rawIndicators.map((i) => IndicatorModel.fromJson(i as Map<String, dynamic>)).toList();
var rawSignals = json['signals'] as List<dynamic>? ?? [];
var signalsList = rawSignals.map((s) => StrategySignalModel.fromJson(s as Map<String, dynamic>)).toList();
var rawPatterns = json['patterns'] as List<dynamic>? ?? [];
var patternsList = rawPatterns.map((p) => ChartPatternModel.fromJson(p as Map<String, dynamic>)).toList();
final lastInd = indicatorsList.isNotEmpty ? indicatorsList.last : null;
final regime = json['marketRegime'] as Map<String, dynamic>?;
String parsedTrend = lastInd?.supertrendDirection ?? 'Neutral';
if (parsedTrend.toUpperCase() == 'BUY') parsedTrend = 'Bullisch ▲';
if (parsedTrend.toUpperCase() == 'SELL') parsedTrend = 'Bearisch ▼';
String parsedSignal = 'HOLD';
if (signalsList.isNotEmpty) {
parsedSignal = signalsList.last.type.toUpperCase();
}
return TechnicalAnalysisModel(
symbol: json['symbol']?.toString() ?? '',
currency: json['currency']?.toString() ?? 'EUR',
currentPrice: (json['currentPrice'] as num?)?.toDouble(),
trend: parsedTrend,
rsi: lastInd?.rsi14?.toStringAsFixed(1) ?? 'N/A',
macd: lastInd?.macdHistogram?.toStringAsFixed(2) ?? lastInd?.macdLine?.toStringAsFixed(2) ?? 'N/A',
overallSignal: parsedSignal,
sma50: lastInd?.sma50?.toStringAsFixed(2) ?? 'N/A',
sma200: lastInd?.sma200?.toStringAsFixed(2) ?? 'N/A',
vix: (regime?['vixValue'] as num?)?.toDouble(),
sp500Trend: regime?['marketTrend']?.toString(),
dxy: (regime?['dxyValue'] as num?)?.toDouble(),
stopLossAtr: lastInd?.recommendedStopLoss,
candles: candlesList,
indicators: indicatorsList,
patterns: patternsList,
signals: signalsList,
);
}
Map<String, dynamic> toJson() {
return {
'symbol': symbol,
'currency': currency,
'trend': trend,
'rsi': rsi,
'macd': macd,
'overallSignal': overallSignal,
'sma50': sma50,
'sma200': sma200,
'vix': vix,
'sp500Trend': sp500Trend,
'dxy': dxy,
'stopLossAtr': stopLossAtr,
};
}
@override
List<Object?> get props => [
symbol, currency, trend, rsi, macd, overallSignal, sma50, sma200, vix,
sp500Trend, dxy, stopLossAtr, candles, indicators, patterns, signals
];
}
@@ -0,0 +1,36 @@
import 'package:equatable/equatable.dart';
class TickerModel extends Equatable {
final String ticker;
final String? exchange;
final String? tradingCurrency;
final double? currentPrice;
const TickerModel({
required this.ticker,
this.exchange,
this.tradingCurrency,
this.currentPrice,
});
factory TickerModel.fromJson(Map<String, dynamic> json) {
return TickerModel(
ticker: json['ticker']?.toString() ?? '',
exchange: json['exchange']?.toString(),
tradingCurrency: json['tradingCurrency']?.toString(),
currentPrice: (json['currentPrice'] as num?)?.toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'ticker': ticker,
if (exchange != null) 'exchange': exchange,
if (tradingCurrency != null) 'tradingCurrency': tradingCurrency,
if (currentPrice != null) 'currentPrice': currentPrice,
};
}
@override
List<Object?> get props => [ticker, exchange, tradingCurrency, currentPrice];
}
@@ -0,0 +1,147 @@
import 'package:finlytic_app/core/network/api_client.dart';
import 'package:finlytic_app/features/asset_detail/models/fundamental_data_model.dart';
import 'package:finlytic_app/features/asset_detail/models/technical_analysis_model.dart';
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_request_dto.dart';
import 'package:finlytic_app/features/trades/models/trade_model.dart';
import 'package:finlytic_app/features/trades/models/trade_acceptance_dto.dart';
import 'package:finlytic_app/features/trades/models/close_trade_request_dto.dart';
import 'package:finlytic_app/features/trades/repositories/trade_repository.dart';
class AssetRepository {
final ApiClient apiClient;
final TradeRepository _tradeRepository;
// In-memory request deduplication & cache
final Map<String, Future<FundamentalDataModel?>> _pendingFundamentals = {};
final Map<String, FundamentalDataModel> _fundamentalsCache = {};
final Map<String, Future<TechnicalAnalysisModel?>> _pendingTechnicals = {};
final Map<String, TechnicalAnalysisModel> _technicalsCache = {};
AssetRepository({required this.apiClient, TradeRepository? tradeRepository})
: _tradeRepository = tradeRepository ?? TradeRepository(apiClient: apiClient);
String _buildCacheKey(String isin, String? ticker) => '${isin.toUpperCase()}_${(ticker ?? '').toUpperCase()}';
Future<FundamentalDataModel?> getAssetFundamentals(String isin, bool forceRefresh, {String? ticker}) async {
final key = _buildCacheKey(isin, ticker);
if (!forceRefresh && _fundamentalsCache.containsKey(key)) {
return _fundamentalsCache[key];
}
if (_pendingFundamentals.containsKey(key)) {
return await _pendingFundamentals[key];
}
final future = _fetchFundamentals(isin, forceRefresh, ticker: ticker);
_pendingFundamentals[key] = future;
try {
final result = await future;
if (result != null) {
_fundamentalsCache[key] = result;
}
return result;
} finally {
_pendingFundamentals.remove(key);
}
}
Future<FundamentalDataModel?> _fetchFundamentals(String isin, bool forceRefresh, {String? ticker}) async {
try {
String url = '/api/v1/assets/$isin/fundamentals?forceRefresh=$forceRefresh';
if (ticker != null && ticker.isNotEmpty) {
url += '&ticker=$ticker';
}
final res = await apiClient.get(url);
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
return FundamentalDataModel.fromJson(res.data);
}
} catch (_) {}
return null;
}
Future<TechnicalAnalysisModel?> getAssetTechnical(String isin, bool forceRefresh, {String? ticker}) async {
final key = _buildCacheKey(isin, ticker);
if (!forceRefresh && _technicalsCache.containsKey(key)) {
return _technicalsCache[key];
}
if (_pendingTechnicals.containsKey(key)) {
return await _pendingTechnicals[key];
}
final future = _fetchTechnicals(isin, forceRefresh, ticker: ticker);
_pendingTechnicals[key] = future;
try {
final result = await future;
if (result != null) {
_technicalsCache[key] = result;
}
return result;
} finally {
_pendingTechnicals.remove(key);
}
}
Future<TechnicalAnalysisModel?> _fetchTechnicals(String isin, bool forceRefresh, {String? ticker}) async {
try {
String url = '/api/v1/assets/$isin/technicals?forceRefresh=$forceRefresh';
if (ticker != null && ticker.isNotEmpty) {
url += '&ticker=$ticker';
}
final res = await apiClient.get(url);
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
return TechnicalAnalysisModel.fromJson(res.data);
}
} catch (_) {}
return null;
}
Future<double?> getLivePrice(String isin) async {
try {
final res = await apiClient.get('/api/v1/assets/$isin/live');
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
final val = res.data['currentPrice'] ?? res.data['CurrentPrice'];
if (val is num) return val.toDouble();
if (val != null) return double.tryParse(val.toString());
}
} catch (_) {}
return null;
}
Future<List<TradeModel>> getAssetTrades(String isin, String? status) async {
return _tradeRepository.fetchTrades(isin: isin, status: status);
}
/// Triggers an on-demand manual analysis for [isin] via `POST /api/v1/analyze/manual`.
///
/// Server contract: always `200 OK` with a full `AssetEvaluationResultDto`
/// body even when the analysis ran but did not clear the bar for a trade
/// proposal (`AssetEvaluationResultModel.proposal == null`), the response
/// still carries the real, already-computed scores and AI reasoning, so
/// there is no more silent `204 No Content` outcome to handle here
/// (Rules.md §4). A non-2xx status (missing ISIN, engine unreachable, no
/// RPC response, unexpected error) surfaces as a `DioException` that
/// propagates to the caller instead of being swallowed into `null`.
Future<AssetEvaluationResultModel> triggerManualAnalysis(String isin, {ManualAnalysisRequestDto? payload}) async {
final body = payload != null ? payload.toJson() : {'isin': isin};
final res = await apiClient.post('/api/v1/analyze/manual', data: body);
if (res.data != null && res.data is Map<String, dynamic>) {
return AssetEvaluationResultModel.fromJson(res.data);
}
throw StateError('Manual analysis endpoint returned an unexpected empty/non-object body.');
}
Future<void> acceptTrade(TradeAcceptanceDto tradeAcceptanceDto) async => _tradeRepository.acceptTrade(tradeAcceptanceDto);
Future<void> closeTrade(String tradeId, double exitPrice) async =>
_tradeRepository.closeTrade(tradeId, dto: CloseTradeRequestDto(userExitPrice: exitPrice));
Future<TradeModel> addTradeFill(String tradeId, {required double executedPrice, required double quantity}) async =>
_tradeRepository.addTradeFill(tradeId, executedPrice: executedPrice, quantity: quantity);
}
@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import '../widgets/metric_explanation_modal.dart';
class MetricExplanations {
static const Map<String, Map<String, String>> data = {
'EMA (20)': {
'title': 'Exponential Moving Average (20 Perioden)',
'formula': 'EMA_t = (Preis_t * (2 / (20 + 1))) + EMA_{t-1} * (1 - (2 / (20 + 1)))',
'description': 'Exponentiell gewichteter gleitender Durchschnitt der letzten 20 Kerzen. Gewichtete aktuelle Kurse stärker als ältere.',
'tradingSignificance': 'Dient als dynamische Unterstützung/Widerstand für kurzfristige Trends. Ein Schnittpunkt über die Kerzen zeigt Kaufsignale.',
},
'SMA (50)': {
'title': 'Simple Moving Average (50 Perioden)',
'formula': 'SMA = (Summe der Schlusskurse der letzten 50 Kerzen) / 50',
'description': 'Einfacher gleitender Durchschnitt der letzten 50 Perioden.',
'tradingSignificance': 'Standard-Indikator für mittelfristige Trends. Preis über SMA50 deutet auf einen intakten Aufwärtstrend hin.',
},
'SMA (200)': {
'title': 'Simple Moving Average (200 Perioden)',
'formula': 'SMA = (Summe der Schlusskurse der letzten 200 Kerzen) / 200',
'description': 'Einfacher gleitender Durchschnitt der letzten 200 Perioden.',
'tradingSignificance': 'Wichtigster Indikator für den langfristigen Trend. "Golden Cross" (SMA50 schneidet SMA200 nach oben) ist ein starkes Bullen-Signal.',
},
'Supertrend': {
'title': 'Supertrend Indikator',
'formula': 'Upper/Lower Band = (High + Low)/2 ± (Multiplier * ATR(10))',
'description': 'Kombiniert ATR (Average True Range) und Durchschnittskurs zur Trendfolge.',
'tradingSignificance': 'Grün zeigt einen etablierten Aufwärtstrend mit dynamischem Stop-Loss Level; Rot signalisiert Abwärtstrend.',
},
'RSI (14)': {
'title': 'Relative Strength Index (14 Perioden)',
'formula': 'RSI = 100 - (100 / (1 + (Durchschnittl. Gewinn / Durchschnittl. Verlust)))',
'description': 'Oszillator zur Messung der Geschwindigkeit und Veränderung von Kursbewegungen.',
'tradingSignificance': 'Werte > 70 gelten als überkauft (Verkaufsrisiko), Werte < 30 gelten als überverkauft (Kaufchance).',
},
'KGV (Trailing P/E)': {
'title': 'Kurs-Gewinn-Verhältnis (Trailing P/E)',
'formula': 'KGV = Aktienkurs / Gewinn pro Aktie (EPS der letzten 12 Monate)',
'description': 'Gibt an, das Wievielfache des Jahresgewinns für eine Aktie gezahlt wird.',
'tradingSignificance': 'Ein niedriges KGV kann auf eine Unterbewertung hindeuten; ein hohes KGV verlangt hohes zukünftiges Gewinnwachstum.',
},
'KGV (Forward P/E)': {
'title': 'Zukünftiges KGV (Forward P/E)',
'formula': 'Forward KGV = Aktueller Kurs / Erwarteter Gewinn pro Aktie (nächste 12 Monate)',
'description': 'Basiert auf den Konsens-Gewinnerwartungen von Analysten für das kommende Jahr.',
'tradingSignificance': 'Ermöglicht den Vergleich mit dem historischen KGV, um festzustellen, ob das Gewinnwachstum die Bewertung verbilligt.',
},
'PEG Ratio': {
'title': 'Price/Earnings-to-Growth Ratio',
'formula': 'PEG = KGV / Zukünftiges Gewinnwachstum in %',
'description': 'Setzt das KGV ins Verhältnis zum erwarteten Gewinnwachstum des Unternehmens.',
'tradingSignificance': 'PEG < 1.0 gilt als fair oder unterbewertet im Verhältnis zum Wachstum. PEG > 2.0 gilt als teuer.',
},
'KBV (P/B Ratio)': {
'title': 'Kurs-Buchwert-Verhältnis (P/B Ratio)',
'formula': 'KBV = Aktienkurs / Buchwert pro Aktie',
'description': 'Vergleicht den Börsenwert des Unternehmens mit seinem bilanziellen Eigenkapital.',
'tradingSignificance': 'Besonders wichtig für Finanzwerte und Substanzwerte. KBV < 1 bedeutet, dass die Aktie unter ihrem Buchwert handelt.',
},
'KUV (P/S Ratio)': {
'title': 'Kurs-Umsatz-Verhältnis (P/S Ratio)',
'formula': 'KUV = Marktkapitalisierung / Gesamter Jahresumsatz',
'description': 'Vergleicht den Marktwert des Unternehmens mit seinem Jahresumsatz.',
'tradingSignificance': 'Nützlich bei noch unprofitablen Wachstumsunternehmen, bei denen noch kein positives KGV berechnet werden kann.',
},
'EV / EBITDA': {
'title': 'Enterprise Value zu EBITDA',
'formula': 'EV/EBITDA = Enterprise Value / (Gewinn vor Zinsen, Steuern & Abschreibungen)',
'description': 'Misst den Unternehmenswert inklusive Schulden im Verhältnis zur operativen Cash-Generierung.',
'tradingSignificance': 'Kapitalstruktur-neutraler Bewertungs-Multiple. Erlaubt fairen Vergleich zwischen Unternehmen mit unterschiedlicher Verschuldung.',
},
'EV / Sales': {
'title': 'Enterprise Value zu Umsatz',
'formula': 'EV/Sales = Enterprise Value / Jahresumsatz',
'description': 'Vergleicht den gesamten Unternehmenswert (Eigen- + Fremdkapital) mit den Erlösen.',
'tradingSignificance': 'Robustere Kennzahl als KUV, da sie auch die Schuldenlast des Unternehmens berücksichtigt.',
},
'Enterprise Value': {
'title': 'Enterprise Value (Unternehmenswert)',
'formula': 'EV = Marktkapitalisierung + Gesamtschulden - Liquide Mittel (Cash)',
'description': 'Der theoretische Übernahmepreis für das gesamte Unternehmen inklusive Tilgung aller Verbindlichkeiten.',
'tradingSignificance': 'Der tatsächliche wirtschaftliche Wert des Geschäftsbetriebs.',
},
'Marktkapitalisierung': {
'title': 'Marktkapitalisierung (Market Cap)',
'formula': 'Market Cap = Gesamtzahl ausstehender Aktien * Aktueller Aktienkurs',
'description': 'Der Gesamtwert aller frei gehandelten Aktien des Unternehmens an der Börse.',
'tradingSignificance': 'Teilt Unternehmen in Large Cap (>10 Mrd. €), Mid Cap (2-10 Mrd. €) und Small Cap (<2 Mrd. €) ein.',
},
'Short Ratio': {
'title': 'Days to Cover (Short Ratio)',
'formula': 'Short Ratio = Anzahl leerverkaufter Aktien / Durchschnittliches Tagesvolumen',
'description': 'Gibt an, wie viele Handelstage Leerverkäufer bräuchten, um alle Positionen einzudecken.',
'tradingSignificance': 'Hohe Werte (> 5-7 Tage) erhöhen die Wahrscheinlichkeit eines heftigen "Short Squeezes" bei positiven News.',
},
'Bruttomarge (Gross)': {
'title': 'Bruttogewinnmarge (Gross Margin)',
'formula': 'Gross Margin = ((Umsatz - Herstellkosten) / Umsatz) * 100',
'description': 'Prozentualer Anteil des Umsatzes, der nach Abzug der direkten Produktionskosten verbleibt.',
'tradingSignificance': 'Hohe Bruttomargen (> 50-70%) zeigen eine starke Preissetzungsmacht und Wettbewerbsvorteile (Moat).',
},
'Operative Marge': {
'title': 'Operative Gewinnmarge (EBIT Margin)',
'formula': 'Operating Margin = (Operatives Ergebnis (EBIT) / Umsatz) * 100',
'description': 'Prozentualer Anteil des Umsatzes, der nach allen operativen Kosten (F&E, Vertrieb, Admin) übrig bleibt.',
'tradingSignificance': 'Kerngröße für die operative Effizienz des Managements.',
},
'Nettogewinnmarge': {
'title': 'Nettogewinnmarge (Net Profit Margin)',
'formula': 'Net Profit Margin = (Nettogewinn nach Steuern / Umsatz) * 100',
'description': 'Prozentualer Reingewinn, der von jedem Euro Umsatz im Unternehmen verbleibt.',
'tradingSignificance': 'Zeigt die finale Rentabilität nach allen Zinsen und Steuern.',
},
'Eigenkapitalrendite (ROE)': {
'title': 'Eigenkapitalrendite (Return on Equity)',
'formula': 'ROE = (Nettogewinn / Eigenkapital) * 100',
'description': 'Misst, wie effizient das Management das eingesetzte Eigenkapital verzinst.',
'tradingSignificance': 'Werte > 15-20% stehen für hochprofitable Qualitätsunternehmen.',
},
'Verschuldungsgrad (D/E)': {
'title': 'Debt-to-Equity Ratio (D/E)',
'formula': 'D/E = Gesamtschulden / Eigenkapital',
'description': 'Setzt das Fremdkapital ins Verhältnis zum Eigenkapital.',
'tradingSignificance': 'Werte > 1.5 - 2.0 deuten auf ein erhöhtes mehraufwand- und Insolvenzrisiko bei steigenden Zinsen hin.',
},
};
static bool hasExplanation(String key) => data.containsKey(key);
static void showModal(BuildContext context, String key) => show(context, key);
static void show(BuildContext context, String key) {
final info = data[key];
if (info == null) return;
showDialog(
context: context,
builder: (context) => MetricExplanationModal(
title: info['title']!,
formula: info['formula']!,
description: info['description']!,
tradingSignificance: info['tradingSignificance']!,
),
);
}
}
@@ -0,0 +1,254 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
class PatternExplanations {
static const Map<String, Map<String, String>> dictionary = {
'ASCENDING_TRIANGLE': {
'title': 'Steigendes Dreieck (Ascending Triangle)',
'bias': 'BULLISH',
'description': 'Ein bullisches Fortsetzungsmuster, das durch eine horizontale Widerstandslinie oben und eine steigende Unterstützungslinie unten gekennzeichnet ist.',
'significance': 'Käufer werden bei jedem Rücksetzer aggressiver (höhere Tiefs). Ein Ausbruch über die obere Widerstandslinie signalisiert eine starke Fortsetzung des Aufwärtstrends.',
'action': 'Kauf-Order / Breakout-Trade beim Ausbruch über den horizontalen Widerstand.',
'reliability': 'Hoch',
'target': 'Höhe des Dreiecks an der Basis, addiert zum Ausbruchsniveau.',
'stop_loss': 'Knapp unter der unteren (steigenden) Trendlinie.',
},
'DESCENDING_TRIANGLE': {
'title': 'Fallendes Dreieck (Descending Triangle)',
'bias': 'BEARISH',
'description': 'Ein bärisches Fortsetzungsmuster mit einer horizontalen Unterstützungslinie unten und fallenden Hochs oben.',
'significance': 'Verkäufer drücken den Kurs bei jeder Erholung schneller nach unten. Ein Bruch der unteren Unterstützung führt meist zu dynamischen Abverkäufen.',
'action': 'Short-Trade oder Verkauf bei Durchbruch der unteren Unterstützungslinie.',
'reliability': 'Hoch',
'target': 'Höhe des Dreiecks an der Basis, subtrahiert vom Ausbruchsniveau.',
'stop_loss': 'Knapp über der oberen (fallenden) Trendlinie.',
},
'HEAD_AND_SHOULDERS': {
'title': 'Kopf-Schulter-Formation (Head & Shoulders)',
'bias': 'BEARISH',
'description': 'Klassisches Umkehrmuster bestehend aus drei Höchstständen: der mittleren höchsten Spitze (Kopf) und zwei kleineren Höchstständen links und rechts (Schultern).',
'significance': 'Ein nachhaltiger Bruch der Nackenlinie (Neckline) markiert das Ende eines Aufwärtstrends und den Beginn einer Bärenphase.',
'action': 'Verkauf/Short-Position beim Bruch der Nackenlinie.',
'reliability': 'Sehr Hoch',
'target': 'Distanz zwischen Kopf und Nackenlinie, vom Ausbruchspunkt der Nackenlinie nach unten projiziert.',
'stop_loss': 'Knapp über der rechten Schulter.',
},
'INVERSE_HEAD_AND_SHOULDERS': {
'title': 'Umgekehrte Kopf-Schulter-Formation',
'bias': 'BULLISH',
'description': 'Bullisches Bodenbildungsmuster nach einem Abwärtstrend mit drei Tiefspunkten.',
'significance': 'Signalisiert das Ende des Abwärtstrends und den Beginn eines neuen Bullenmarktes.',
'action': 'Kauf bei Ausbruch über die obere Nackenlinie.',
'reliability': 'Sehr Hoch',
'target': 'Distanz zwischen Kopf (tiefster Punkt) und Nackenlinie, vom Ausbruchspunkt nach oben projiziert.',
'stop_loss': 'Knapp unter der rechten Schulter.',
},
'BULL_FLAG': {
'title': 'Bullische Flagge (Bull Flag)',
'bias': 'BULLISH',
'description': 'Kurze Konsolidierung gegen den übergeordneten starken Aufwärtstrend (Fahnenstange).',
'significance': 'Zeigt eine temporäre Gewinnmitnahme vor der nächsten Welle nach oben.',
'action': 'Kauf beim Ausbruch aus der oberen Begrenzung des Flaggenkanals.',
'reliability': 'Hoch',
'target': 'Länge des vorherigen Aufwärtstrends (Fahnenstange), angesetzt am Ausbruchspunkt der Flagge.',
'stop_loss': 'Unterhalb des unteren Randes der Flagge.',
},
'BEAR_FLAG': {
'title': 'Bärische Flagge (Bear Flag)',
'bias': 'BEARISH',
'description': 'Kurze Aufwärtskonsolidierung in einem steilen Abwärtstrend.',
'significance': 'Signalisiert eine Fortsetzung des steilen Abverkaufs.',
'action': 'Short-Position bei Durchbrechen der unteren Flaggenkante.',
'reliability': 'Hoch',
'target': 'Länge des vorherigen Abwärtstrends (Fahnenstange), angesetzt am Ausbruchspunkt der Flagge.',
'stop_loss': 'Oberhalb des oberen Randes der Flagge.',
},
'DOUBLE_BOTTOM': {
'title': 'Doppelboden (W-Formation)',
'bias': 'BULLISH',
'description': 'Zwei aufeinanderfolgende Tiefpunkte auf etwa gleichem Kursniveau.',
'significance': 'Starke Unterstützung auf dem Tiefststand wurde zweimal erfolgreich verteidigt. Ausbruch über das Zwischenhoch bestätigt W-Boden.',
'action': 'Kauf bei Überschreiten des W-Zwischenhochs.',
'reliability': 'Mittel bis Hoch',
'target': 'Distanz zwischen dem Tief und dem Zwischenhoch, auf das Zwischenhoch addiert.',
'stop_loss': 'Knapp unter den beiden Tiefpunkten.',
},
'DOUBLE_TOP': {
'title': 'Doppeltopp (M-Formation)',
'bias': 'BEARISH',
'description': 'Zwei markante Höchststände auf ähnlicher Höhe, die nicht durchbrochen werden konnten.',
'significance': 'Widerstandszone ist zu stark für die Bullen. Bruch des Zwischen-Tiefs leitet Trendwende ein.',
'action': 'Verkauf/Short bei Bruch des Zwischentiefs.',
'reliability': 'Mittel bis Hoch',
'target': 'Distanz zwischen dem Hoch und dem Zwischentief, vom Zwischentief subtrahiert.',
'stop_loss': 'Knapp über den beiden Höchstständen.',
},
'CHANNEL': {
'title': 'Trendkanal (Trading Channel)',
'bias': 'NEUTRAL',
'description': 'Parallele obere und untere Trendlinien, zwischen denen der Kurs Oszilliert.',
'significance': 'Erlaubt Swing-Trading zwischen den Kanallinien oder Breakout-Trading beim Ausbruch.',
'action': 'Kauf an der Unterkante, Verkauf an der Oberkante oder Breakout-Trading.',
'reliability': 'Mittel',
'target': 'Die gegenüberliegende Kanallinie (beim Swing-Trading) oder die Kanalbreite (beim Ausbruch).',
'stop_loss': 'Außerhalb des Kanals auf der entgegengesetzten Seite des Einstiegs.',
},
'SUPPORT_RESISTANCE': {
'title': 'Unterstützungs- & Widerstandslinien',
'bias': 'NEUTRAL',
'description': 'Preisniveaus, an denen historisch gehäuft Kauf- oder Verkaufsinteresse auftrat.',
'significance': 'Wichtige Marken für Stop-Loss Platzierungen und Kursziele.',
'action': 'Trading an Key-Levels mit engem Risikomanagement.',
'reliability': 'Variabel',
'target': 'Das nächste große Unterstützungs- oder Widerstandslevel.',
'stop_loss': 'Knapp jenseits der gebrochenen Linie (im Falle eines Fehlausbruchs).',
},
};
static Color getColorForPattern(String patternType) {
const colors = [
Colors.amberAccent,
Colors.cyanAccent,
Colors.purpleAccent,
Colors.pinkAccent,
Colors.lightGreenAccent,
Colors.orangeAccent,
];
return colors[patternType.hashCode.abs() % colors.length];
}
static String getGermanName(String rawPatternType) {
final key = dictionary.keys.firstWhere(
(k) => rawPatternType.toUpperCase().contains(k) || k.contains(rawPatternType.toUpperCase()),
orElse: () => '',
);
if (key.isNotEmpty && dictionary.containsKey(key)) {
return dictionary[key]!['title'] ?? rawPatternType;
}
return rawPatternType;
}
static void showPatternDetails(BuildContext context, String rawPatternType) {
final key = dictionary.keys.firstWhere(
(k) => rawPatternType.toUpperCase().contains(k) || k.contains(rawPatternType.toUpperCase()),
orElse: () => '',
);
final info = key.isNotEmpty ? dictionary[key]! : {
'title': rawPatternType,
'bias': 'NEUTRAL',
'description': 'Ein vom FinlyticAnalyzer erkanntes technisches Chart-Muster ($rawPatternType).',
'significance': 'Trendlinien und Schlüssel-Zonen zur Bestimmung von Ein- und Ausstiegssignalen.',
'action': 'Nutzen Sie Stopp-Orders und beachten Sie den übergeordneten Markt-Trend.',
'reliability': 'Unbekannt',
'target': 'Abhängig vom spezifischen Muster und der Volatilität.',
'stop_loss': 'Immer an lokalen Unterstützungs- oder Widerstandszonen platzieren.',
};
final isBullish = info['bias'] == 'BULLISH';
final isBearish = info['bias'] == 'BEARISH';
final biasColor = isBullish ? AppTheme.primaryEmerald : (isBearish ? AppTheme.accentRed : AppTheme.accentCyan);
showDialog(
context: context,
builder: (modalContext) => AlertDialog(
backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppTheme.glassBorder),
),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
info['title']!,
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: biasColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: biasColor),
),
child: Text(
info['bias']!,
style: TextStyle(color: biasColor, fontWeight: FontWeight.bold, fontSize: 11),
),
),
],
),
],
),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Formationsbeschreibung:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
const SizedBox(height: 4),
Text(info['description']!, style: TextStyle(color: AppTheme.textSecondary, fontSize: 13, height: 1.4)),
const SizedBox(height: 14),
const Text('Markt-Bedeutung & Psychologie:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
const SizedBox(height: 4),
Text(info['significance']!, style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.4)),
const SizedBox(height: 14),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Zuverlässigkeit:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
const SizedBox(height: 2),
Text(info['reliability']!, style: TextStyle(color: AppTheme.textSecondary, fontSize: 13)),
],
),
),
],
),
const SizedBox(height: 14),
const Text('Kursziel (Take Profit):', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
const SizedBox(height: 4),
Text(info['target']!, style: TextStyle(color: AppTheme.primaryEmerald, fontSize: 13, height: 1.4)),
const SizedBox(height: 14),
const Text('Stop-Loss Platzierung:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
const SizedBox(height: 4),
Text(info['stop_loss']!, style: TextStyle(color: AppTheme.accentRed, fontSize: 13, height: 1.4)),
const SizedBox(height: 18),
const Text('Empfohlene Trading-Handlung:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
const SizedBox(height: 4),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.glassBorder),
),
child: Text(info['action']!, style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, fontWeight: FontWeight.w600, height: 1.4)),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(modalContext),
child: const Text('Schließen', style: TextStyle(color: Colors.white)),
),
],
),
);
}
}
@@ -0,0 +1,64 @@
import '../../favorites/models/favorite_asset_model.dart';
import '../models/fundamental_data_model.dart';
class TickerResolver {
/// Resolves the optimal ticker according to user prioritization:
/// 1. Candidate / active user-selected symbol (if valid and not equal to ISIN)
/// 2. Favorite selected ticker (if asset is in favorites and has a valid ticker)
/// 3. Primary Ticker (from Fundamentals header)
/// 4. First available ticker from AvailableTickers list
/// 5. ISIN fallback
static String? resolve({
required String isin,
String? candidateSymbol,
List<FavoriteAssetModel>? favoriteDetails,
FundamentalDataModel? fundamentals,
}) {
final cleanIsin = isin.trim().toUpperCase();
// 1. Check Candidate / User-selected symbol
if (candidateSymbol != null && candidateSymbol.trim().isNotEmpty) {
final cClean = candidateSymbol.trim();
if (cClean.toUpperCase() != cleanIsin) {
return cClean;
}
}
// 2. Check Favorite selected ticker
if (favoriteDetails != null && favoriteDetails.isNotEmpty) {
for (final f in favoriteDetails) {
final fIsin = f.isin.trim().toUpperCase();
final fSym = f.symbol.trim().toUpperCase();
if (fIsin == cleanIsin || fSym == cleanIsin) {
if (f.symbol.trim().isNotEmpty && f.symbol.trim().toUpperCase() != cleanIsin) {
return f.symbol.trim();
}
}
}
}
// 3. Check Fundamentals Primary Ticker
if (fundamentals != null) {
final primary = fundamentals.primaryTicker.trim();
if (primary.isNotEmpty && primary.toUpperCase() != cleanIsin) {
return primary;
}
final fTicker = fundamentals.ticker.trim();
if (fTicker.isNotEmpty && fTicker.toUpperCase() != cleanIsin) {
return fTicker;
}
// 4. First available ticker in availableTickers
for (final t in fundamentals.availableTickers) {
final tTick = t.ticker.trim();
if (tTick.isNotEmpty && tTick.toUpperCase() != cleanIsin) {
return tTick;
}
}
}
// 5. Fallback: ISIN
return isin.trim().isNotEmpty ? isin.trim() : null;
}
}
@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/api_client.dart';
import '../../favorites/cubit/favorites_cubit.dart';
import '../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../bloc/fundamentals/asset_fundamentals_event.dart';
import '../bloc/technical/asset_technical_bloc.dart';
import '../bloc/technical/asset_technical_event.dart';
import '../bloc/trades/asset_trades_bloc.dart';
import '../bloc/trades/asset_trades_event.dart';
import '../repositories/asset_repository.dart';
import '../utils/ticker_resolver.dart';
import 'layouts/asset_page_desktop_layout.dart';
import 'layouts/asset_page_mobile_layout.dart';
class AssetDetailScreen extends StatelessWidget {
final String isin;
final String? name;
final String? symbol;
final ApiClient apiClient;
const AssetDetailScreen({
super.key,
required this.isin,
this.symbol,
required this.apiClient,
required this.name,
});
@override
Widget build(BuildContext context) {
final repository = AssetRepository(apiClient: apiClient);
return BlocBuilder<FavoritesCubit, FavoritesState>(
builder: (context, favState) {
final initialTicker = TickerResolver.resolve(
isin: isin,
candidateSymbol: symbol,
favoriteDetails: favState.favoriteDetails,
);
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => AssetFundamentalsBloc(repository: repository)
..add(LoadAssetFundamentals(isin, ticker: initialTicker)),
),
BlocProvider(
create: (context) => AssetTechnicalBloc(repository: repository)
..add(LoadAssetTechnical(isin, ticker: initialTicker)),
),
BlocProvider(
create: (context) => AssetTradesBloc(repository: repository)
..add(LoadAssetTrades(isin)),
),
],
child: Scaffold(
body: LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 900) {
return AssetPageDesktopLayout(
isin: isin,
name: name,
selectedTicker: initialTicker ?? symbol,
);
}
return AssetPageMobileLayout(
isin: isin,
name: name,
selectedTicker: initialTicker ?? symbol,
);
},
),
),
);
},
);
}
}
@@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/theme/app_theme.dart';
import '../bloc/technical/asset_technical_bloc.dart';
import '../bloc/technical/asset_technical_event.dart';
import '../bloc/technical/asset_technical_state.dart';
import '../widgets/chart/candlestick_chart.dart';
import '../widgets/technical/indicator_ribbon_bar.dart';
class FullscreenChartScreen extends StatefulWidget {
final String isin;
final String? symbol;
final AssetTechnicalBloc technicalBloc;
const FullscreenChartScreen({
super.key,
required this.isin,
this.symbol,
required this.technicalBloc,
});
static Future<void> open(BuildContext context, {required String isin, String? symbol}) {
final bloc = context.read<AssetTechnicalBloc>();
return Navigator.of(context).push(
PageRouteBuilder(
opaque: true,
pageBuilder: (ctx, anim, secAnim) => FullscreenChartScreen(
isin: isin,
symbol: symbol,
technicalBloc: bloc,
),
transitionsBuilder: (ctx, anim, secAnim, child) {
return FadeTransition(opacity: anim, child: child);
},
),
);
}
@override
State<FullscreenChartScreen> createState() => _FullscreenChartScreenState();
}
class _FullscreenChartScreenState extends State<FullscreenChartScreen> {
@override
void initState() {
super.initState();
// Rotate to landscape on mobile devices
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
}
@override
void dispose() {
// Restore orientation back to default portrait/auto
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppTheme.activePreset;
return BlocProvider.value(
value: widget.technicalBloc,
child: Scaffold(
backgroundColor: theme.darkBackground,
body: SafeArea(
child: BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
builder: (context, state) {
if (state is AssetTechnicalLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is AssetTechnicalLoaded && state.data != null) {
final data = state.data!;
final activePatterns = <ChartPatternModel>[];
for (int i = 0; i < data.patterns.length; i++) {
if (!state.disabledPatternIndices.contains(i)) {
activePatterns.add(data.patterns[i]);
}
}
return Column(
children: [
_buildHeader(context, theme, state),
Expanded(
child: LayoutBuilder(
builder: (ctx, constraints) {
return CandlestickChart(
candles: data.candles,
indicators: data.indicators,
patterns: activePatterns,
signals: data.signals,
showSma50: state.showSma50,
showSma200: state.showSma200,
showEma: state.showEma,
showPatterns: state.showPatterns,
showSignals: state.showSignals,
showSupertrend: state.showSupertrend,
height: constraints.maxHeight,
isFullscreen: true,
onToggleFullscreen: () => Navigator.of(context).pop(),
);
},
),
),
],
);
}
return Center(
child: Text('Keine Chartdaten verfügbar', style: TextStyle(color: theme.textMuted)),
);
},
),
),
),
);
}
Widget _buildHeader(BuildContext context, ThemePreset theme, AssetTechnicalLoaded state) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: theme.cardSurface,
border: Border(bottom: BorderSide(color: theme.glassBorder)),
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, size: 20, color: Colors.white70),
tooltip: 'Zurück',
onPressed: () => Navigator.of(context).pop(),
),
const SizedBox(width: 4),
Text(
widget.symbol ?? widget.isin,
style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 14),
),
const SizedBox(width: 12),
Expanded(
child: IndicatorRibbonBar(
showSma50: state.showSma50,
showSma200: state.showSma200,
showEma: state.showEma,
showSupertrend: state.showSupertrend,
showPatterns: state.showPatterns,
showSignals: state.showSignals,
onToggleSma50: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSma50: v)),
onToggleSma200: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSma200: v)),
onToggleEma: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showEma: v)),
onToggleSupertrend: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSupertrend: v)),
onTogglePatterns: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showPatterns: v)),
onToggleSignals: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSignals: v)),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.fullscreen_exit, size: 22, color: Colors.white70),
tooltip: 'Vollbild beenden',
onPressed: () => Navigator.of(context).pop(),
),
],
),
);
}
}
@@ -0,0 +1,205 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../favorites/cubit/favorites_cubit.dart';
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
import '../../bloc/technical/asset_technical_bloc.dart';
import '../../bloc/technical/asset_technical_event.dart';
import '../../bloc/trades/asset_trades_bloc.dart';
import '../../bloc/trades/asset_trades_event.dart';
import '../../utils/ticker_resolver.dart';
import '../../widgets/header/asset_hero_header.dart';
import '../tabs/fundamentals_tab.dart';
import '../tabs/technical_tab.dart';
import '../tabs/trades_tab.dart';
class AssetPageDesktopLayout extends StatefulWidget {
final String isin;
final String? name;
final String? selectedTicker;
const AssetPageDesktopLayout({
super.key,
required this.isin,
this.selectedTicker,
this.name,
});
@override
State<AssetPageDesktopLayout> createState() => _AssetPageDesktopLayoutState();
}
class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
with SingleTickerProviderStateMixin {
late TabController _tabController;
String? _selectedTicker;
@override
void initState() {
super.initState();
_selectedTicker = widget.selectedTicker;
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _handleExchangeChanged(String newExchange, String newTicker) {
setState(() {
_selectedTicker = newTicker;
});
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
ticker: newTicker, forceRefresh: false));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
ticker: newTicker, forceRefresh: false));
final favCubit = context.read<FavoritesCubit>();
if (favCubit.state.isFavorite(widget.isin)) {
favCubit.updateFavoriteTicker(widget.isin, newTicker);
}
}
void _handleForceRefresh() {
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
ticker: _selectedTicker, forceRefresh: true));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
ticker: _selectedTicker, forceRefresh: true));
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.isin));
}
@override
Widget build(BuildContext context) {
final theme = AppTheme.activePreset;
return BlocListener<AssetFundamentalsBloc, AssetFundamentalsState>(
listener: (context, state) {
if (state is AssetFundamentalsLoaded && state.data != null) {
if (_selectedTicker == null ||
_selectedTicker!.trim().isEmpty ||
_selectedTicker!.trim().toUpperCase() == widget.isin.trim().toUpperCase()) {
final favList = context.read<FavoritesCubit>().state.favoriteDetails;
final bestTicker = TickerResolver.resolve(
isin: widget.isin,
candidateSymbol: widget.selectedTicker,
favoriteDetails: favList,
fundamentals: state.data,
);
if (bestTicker != null &&
bestTicker.isNotEmpty &&
bestTicker.toUpperCase() != widget.isin.toUpperCase()) {
setState(() {
_selectedTicker = bestTicker;
});
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(
widget.isin,
ticker: bestTicker,
forceRefresh: false,
));
}
}
}
},
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Hero Header
AssetHeroHeader(
isin: widget.isin,
name: widget.name ?? widget.isin,
symbol: _selectedTicker ?? widget.selectedTicker,
onExchangeChanged: _handleExchangeChanged,
onForceRefresh: _handleForceRefresh,
),
// 2. Full-Width Interactive Chart Section
Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: theme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.glassBorder),
),
child: TechnicalTab(
isin: widget.isin,
symbol: _selectedTicker,
showChartOnly: true,
chartHeight: 460,
),
),
const SizedBox(height: 8),
// 3. Detailed Sections & Fundamentals under the Chart
Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: theme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.glassBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TabBar(
controller: _tabController,
labelColor: theme.primaryColor,
unselectedLabelColor: theme.textMuted,
indicatorColor: theme.primaryColor,
dividerColor: theme.glassBorder,
labelStyle: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 13),
tabs: const [
Tab(
icon: Icon(Icons.analytics_outlined, size: 18),
text: 'FUNDAMENTALS & ÜBERSICHT'),
Tab(
icon: Icon(Icons.architecture_outlined, size: 18),
text: 'MUSTER & SIGNALE'),
Tab(
icon: Icon(Icons.candlestick_chart_outlined, size: 18),
text: 'TRADES'),
],
),
AnimatedBuilder(
animation: _tabController,
builder: (context, _) {
switch (_tabController.index) {
case 0:
return FundamentalsTab(
isin: widget.isin,
symbol: _selectedTicker,
isEmbedded: true,
);
case 1:
return TechnicalTab(
isin: widget.isin,
symbol: _selectedTicker,
showDetailsOnly: true,
);
case 2:
return SizedBox(
height: 600,
child: TradesTab(symbol: widget.isin),
);
default:
return const SizedBox.shrink();
}
},
),
],
),
),
const SizedBox(height: 24),
],
),
));
}
}
@@ -0,0 +1,208 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../favorites/cubit/favorites_cubit.dart';
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
import '../../bloc/technical/asset_technical_bloc.dart';
import '../../bloc/technical/asset_technical_event.dart';
import '../../bloc/trades/asset_trades_bloc.dart';
import '../../bloc/trades/asset_trades_event.dart';
import '../../utils/ticker_resolver.dart';
import '../../widgets/header/asset_hero_header.dart';
import '../tabs/fundamentals_tab.dart';
import '../tabs/technical_tab.dart';
import '../tabs/trades_tab.dart';
class AssetPageMobileLayout extends StatefulWidget {
final String isin;
final String? name;
final String? selectedTicker;
const AssetPageMobileLayout({
super.key,
required this.isin,
this.selectedTicker,
this.name,
});
@override
State<AssetPageMobileLayout> createState() => _AssetPageMobileLayoutState();
}
class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
with SingleTickerProviderStateMixin {
late TabController _tabController;
String? _selectedTicker;
@override
void initState() {
super.initState();
_selectedTicker = widget.selectedTicker;
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _handleExchangeChanged(String newExchange, String newTicker) {
setState(() {
_selectedTicker = newTicker;
});
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
ticker: newTicker, forceRefresh: false));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
ticker: newTicker, forceRefresh: false));
final favCubit = context.read<FavoritesCubit>();
if (favCubit.state.isFavorite(widget.isin)) {
favCubit.updateFavoriteTicker(widget.isin, newTicker);
}
}
void _handleForceRefresh() {
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
ticker: _selectedTicker, forceRefresh: true));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
ticker: _selectedTicker, forceRefresh: true));
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.isin));
}
@override
Widget build(BuildContext context) {
final theme = AppTheme.activePreset;
return BlocListener<AssetFundamentalsBloc, AssetFundamentalsState>(
listener: (context, state) {
if (state is AssetFundamentalsLoaded && state.data != null) {
if (_selectedTicker == null ||
_selectedTicker!.trim().isEmpty ||
_selectedTicker!.trim().toUpperCase() == widget.isin.trim().toUpperCase()) {
final favList = context.read<FavoritesCubit>().state.favoriteDetails;
final bestTicker = TickerResolver.resolve(
isin: widget.isin,
candidateSymbol: widget.selectedTicker,
favoriteDetails: favList,
fundamentals: state.data,
);
if (bestTicker != null &&
bestTicker.isNotEmpty &&
bestTicker.toUpperCase() != widget.isin.toUpperCase()) {
setState(() {
_selectedTicker = bestTicker;
});
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(
widget.isin,
ticker: bestTicker,
forceRefresh: false,
));
}
}
}
},
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Hero Header
AssetHeroHeader(
isin: widget.isin,
name: widget.name ?? widget.isin,
symbol: _selectedTicker ?? widget.selectedTicker,
onExchangeChanged: _handleExchangeChanged,
onForceRefresh: _handleForceRefresh,
),
// 2. Interactive Chart
Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: theme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.glassBorder),
),
child: TechnicalTab(
isin: widget.isin,
symbol: _selectedTicker,
showChartOnly: true,
chartHeight: 320,
),
),
const SizedBox(height: 8),
// 3. Tabbed Detailed Analysis
Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: theme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.glassBorder),
),
child: Column(
children: [
TabBar(
controller: _tabController,
isScrollable: true,
tabAlignment: TabAlignment.start,
labelColor: theme.primaryColor,
unselectedLabelColor: theme.textSecondary,
indicatorColor: theme.primaryColor,
dividerColor: theme.glassBorder,
labelStyle: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 13),
tabs: const [
Tab(
icon: Icon(Icons.analytics_outlined, size: 18),
text: 'ÜBERSICHT'),
Tab(
icon: Icon(Icons.architecture_outlined, size: 18),
text: 'MUSTER'),
Tab(
icon:
Icon(Icons.candlestick_chart_outlined, size: 18),
text: 'TRADES'),
],
),
AnimatedBuilder(
animation: _tabController,
builder: (context, _) {
switch (_tabController.index) {
case 0:
return FundamentalsTab(
isin: widget.isin,
symbol: _selectedTicker,
isEmbedded: true,
);
case 1:
return TechnicalTab(
isin: widget.isin,
symbol: _selectedTicker,
showDetailsOnly: true,
);
case 2:
return SizedBox(
height: 500,
child: TradesTab(symbol: widget.isin),
);
default:
return const SizedBox.shrink();
}
},
),
],
),
),
const SizedBox(height: 24),
],
),
),
);
}
}
@@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/shimmer_loading.dart';
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
import '../../widgets/fundamentals/analyst_price_target_card.dart';
import '../../widgets/fundamentals/fundamental_category_panels.dart';
import '../../widgets/fundamentals/company_profile_section.dart';
class FundamentalsTab extends StatelessWidget {
final String isin;
final String? symbol;
final bool isEmbedded;
const FundamentalsTab({
super.key,
this.symbol,
this.isEmbedded = false,
required this.isin,
});
String _getCurrencySymbol(String? ticker) {
if (ticker == null || ticker.isEmpty) return '';
final t = ticker.toUpperCase();
if (t.endsWith('.DE') || t.endsWith('.F') || t.endsWith('.VI') || t.endsWith('.PA') || t.endsWith('.AS') || t.endsWith('.MC') || t.endsWith('.MI')) {
return '';
}
if (t.endsWith('.L')) return '£';
if (t.endsWith('.TO') || t.endsWith('.V')) return 'CA\$';
return '\$';
}
String _getCurrencyCode(String? ticker) {
if (ticker == null || ticker.isEmpty) return 'EUR';
final t = ticker.toUpperCase();
if (t.endsWith('.DE') || t.endsWith('.F') || t.endsWith('.VI') || t.endsWith('.PA') || t.endsWith('.AS') || t.endsWith('.MC') || t.endsWith('.MI')) {
return 'EUR';
}
if (t.endsWith('.L')) return 'GBP';
if (t.endsWith('.TO') || t.endsWith('.V')) return 'CAD';
return 'USD';
}
@override
Widget build(BuildContext context) {
return BlocBuilder<AssetFundamentalsBloc, AssetFundamentalsState>(
builder: (context, state) {
if (state is AssetFundamentalsLoading) {
return _buildFundamentalsShimmer(context);
}
if (state is AssetFundamentalsError) {
return Center(
child: GlassContainer(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, color: AppTheme.accentRed, size: 48),
const SizedBox(height: 12),
Text('Fehler beim Laden der Fundamentaldaten: ${state.message}', style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(isin, ticker: symbol, forceRefresh: true)),
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
),
],
),
),
);
}
if (state is AssetFundamentalsLoaded && state.data != null) {
final data = state.data!;
final sym = _getCurrencySymbol(data.ticker);
final curCode = _getCurrencyCode(data.ticker);
return SingleChildScrollView(
physics: isEmbedded ? const NeverScrollableScrollPhysics() : null,
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Analyst Forecasts & Price Targets Header Card
AnalystPriceTargetCard(data: data, currencySymbol: sym),
const SizedBox(height: 20),
// 2. Responsive Side-by-Side Category List Panels (Valuation, Profitability, Dividends)
FundamentalCategoryPanels(data: data, currencySymbol: sym, currencyCode: curCode),
const SizedBox(height: 20),
// 3. Company Description & Detailed Executive Board
_buildSectionHeader('Unternehmensprofil & Führungskräfte', Icons.business_outlined),
const SizedBox(height: 12),
CompanyProfileSection(data: data, currencySymbol: sym),
],
),
);
}
return _buildEmptyState(context);
},
);
}
Widget _buildSectionHeader(String title, IconData icon) {
return Row(
children: [
Icon(icon, color: AppTheme.primaryEmerald, size: 20),
const SizedBox(width: 8),
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
],
);
}
Widget _buildEmptyState(BuildContext context) {
return Center(
child: GlassContainer(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.analytics_outlined, color: AppTheme.textMuted, size: 48),
const SizedBox(height: 12),
const Text('Keine Fundamentaldaten verfügbar.', style: TextStyle(color: Colors.white70)),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(isin, ticker: symbol, forceRefresh: true)),
icon: const Icon(Icons.refresh),
label: const Text('Aktualisieren'),
),
],
),
),
);
}
Widget _buildFundamentalsShimmer(BuildContext context) {
final isDesktop = MediaQuery.of(context).size.width >= 1050;
final isTablet = MediaQuery.of(context).size.width >= 680 && MediaQuery.of(context).size.width < 1050;
Widget panelShimmer() {
return GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ShimmerLoading(width: 180, height: 18, borderRadius: 6),
const SizedBox(height: 12),
const Divider(color: Colors.white10, height: 1),
const SizedBox(height: 8),
for (int i = 0; i < 9; i++) ...[
Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: const [
ShimmerLoading(width: 100, height: 14, borderRadius: 4),
ShimmerLoading(width: 60, height: 14, borderRadius: 4),
],
),
),
],
],
),
);
}
return SingleChildScrollView(
physics: isEmbedded ? const NeverScrollableScrollPhysics() : null,
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ShimmerLoading(width: double.infinity, height: 86, borderRadius: 16),
const SizedBox(height: 20),
if (isDesktop)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: panelShimmer()),
const SizedBox(width: 14),
Expanded(child: panelShimmer()),
const SizedBox(width: 14),
Expanded(child: panelShimmer()),
],
)
else if (isTablet)
Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: panelShimmer()),
const SizedBox(width: 12),
Expanded(child: panelShimmer()),
],
),
const SizedBox(height: 12),
panelShimmer(),
],
)
else
Column(
children: [
panelShimmer(),
const SizedBox(height: 12),
panelShimmer(),
const SizedBox(height: 12),
panelShimmer(),
],
),
const SizedBox(height: 20),
const ShimmerLoading(width: 220, height: 20, borderRadius: 6),
const SizedBox(height: 12),
const ShimmerLoading(width: double.infinity, height: 140, borderRadius: 16),
],
),
);
}
}
@@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
class NewsTab extends StatelessWidget {
final String symbol;
const NewsTab({super.key, required this.symbol});
@override
Widget build(BuildContext context) {
return const Center(child: Text('News Data', style: TextStyle(color: Colors.white)));
}
}
@@ -0,0 +1,231 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/shimmer_loading.dart';
import '../../bloc/technical/asset_technical_bloc.dart';
import '../../bloc/technical/asset_technical_event.dart';
import '../../bloc/technical/asset_technical_state.dart';
import '../../widgets/chart/candlestick_chart.dart';
import '../../widgets/technical/pattern_card_item.dart';
import '../../widgets/technical/signal_card_item.dart';
import '../../widgets/technical/indicator_ribbon_bar.dart';
import '../fullscreen_chart_screen.dart';
class TechnicalTab extends StatelessWidget {
final String isin;
final String? symbol;
final bool isDesktopLeftPanel;
final bool showChartOnly;
final bool showDetailsOnly;
final double chartHeight;
const TechnicalTab({
super.key,
this.symbol,
this.isDesktopLeftPanel = false,
this.showChartOnly = false,
this.showDetailsOnly = false,
this.chartHeight = 420,
required this.isin,
});
@override
Widget build(BuildContext context) {
return BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
builder: (context, state) {
if (state is AssetTechnicalLoading) {
return _buildTechnicalShimmer(context);
}
if (state is AssetTechnicalError) {
return Center(
child: GlassContainer(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.show_chart, color: AppTheme.accentRed, size: 48),
const SizedBox(height: 12),
Text(
'Fehler beim Laden der Technischen Analyse: ${state.message}',
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => context.read<AssetTechnicalBloc>().add(
LoadAssetTechnical(isin, ticker: symbol, forceRefresh: true),
),
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
),
],
),
),
);
}
if (state is AssetTechnicalLoaded && state.data != null) {
final data = state.data!;
final candles = data.candles;
final patterns = data.patterns;
final signals = data.signals;
final indicators = data.indicators;
final activePatterns = <ChartPatternModel>[];
for (int i = 0; i < patterns.length; i++) {
if (!state.disabledPatternIndices.contains(i)) {
activePatterns.add(patterns[i]);
}
}
final chartWidget = CandlestickChart(
candles: candles,
indicators: indicators,
patterns: activePatterns,
signals: signals,
showSma50: state.showSma50,
showSma200: state.showSma200,
showEma: state.showEma,
showPatterns: state.showPatterns,
showSignals: state.showSignals,
showSupertrend: state.showSupertrend,
height: chartHeight,
onToggleFullscreen: () => FullscreenChartScreen.open(context, isin: isin, symbol: symbol),
);
final chartRibbon = IndicatorRibbonBar(
showSma50: state.showSma50,
showSma200: state.showSma200,
showEma: state.showEma,
showSupertrend: state.showSupertrend,
showPatterns: state.showPatterns,
showSignals: state.showSignals,
onToggleSma50: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSma50: v)),
onToggleSma200: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSma200: v)),
onToggleEma: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showEma: v)),
onToggleSupertrend: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSupertrend: v)),
onTogglePatterns: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showPatterns: v)),
onToggleSignals: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSignals: v)),
onToggleFullscreen: () => FullscreenChartScreen.open(context, isin: isin, symbol: symbol),
);
if (showChartOnly) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
chartRibbon,
const SizedBox(height: 10),
chartWidget,
],
);
}
final detailsSection = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (patterns.isNotEmpty) ...[
const Text('Erkannte Chartformationen & Muster', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: 8),
for (int i = 0; i < patterns.length; i++)
PatternCardItem(
pattern: patterns[i],
index: i,
isEnabled: !state.disabledPatternIndices.contains(i),
onToggle: (enabled) {
context.read<AssetTechnicalBloc>().add(
TogglePatternFilter(patternIndex: i, enabled: enabled),
);
},
),
const SizedBox(height: 16),
],
if (signals.isNotEmpty) ...[
const Text('Strategische Kauf- & Verkaufssignale', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: 8),
for (final sig in signals) SignalCardItem(signal: sig),
],
],
);
if (showDetailsOnly) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 16),
child: detailsSection,
);
}
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
chartRibbon,
const SizedBox(height: 8),
chartWidget,
const SizedBox(height: 16),
detailsSection,
const SizedBox(height: 16),
],
),
);
}
return Center(
child: Text('Keine technisches Indikatoren verfügbar', style: TextStyle(color: AppTheme.textMuted)),
);
},
);
}
Widget _buildTechnicalShimmer(BuildContext context) {
if (showChartOnly) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12),
const SizedBox(height: 8),
ShimmerLoading(width: double.infinity, height: chartHeight, borderRadius: 16),
],
);
}
if (showDetailsOnly) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ShimmerLoading(width: 240, height: 20, borderRadius: 6),
const SizedBox(height: 14),
for (int i = 0; i < 4; i++) ...[
const ShimmerLoading(width: double.infinity, height: 68, borderRadius: 12),
const SizedBox(height: 10),
],
],
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12),
const SizedBox(height: 8),
ShimmerLoading(width: double.infinity, height: chartHeight, borderRadius: 16),
const SizedBox(height: 16),
const ShimmerLoading(width: 240, height: 20, borderRadius: 6),
const SizedBox(height: 14),
for (int i = 0; i < 3; i++) ...[
const ShimmerLoading(width: double.infinity, height: 68, borderRadius: 12),
const SizedBox(height: 10),
],
],
),
);
}
}
@@ -0,0 +1,344 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/shimmer_loading.dart';
import '../../../../core/widgets/status_badge.dart';
import '../../../../shared/widgets/evaluation_score_breakdown_sheet.dart';
import '../../../bot/repositories/bot_repository.dart';
import '../../../proposals/views/proposal_decision_screen.dart';
import '../../../trades/models/trade_model.dart';
import '../../../trades/widgets/trade_execution_cockpit.dart';
import '../../../trades/widgets/trade_closing_cockpit.dart';
import '../../bloc/trades/asset_trades_bloc.dart';
import '../../bloc/trades/asset_trades_event.dart';
import '../../bloc/trades/asset_trades_state.dart';
import '../../widgets/trades/manual_analysis_dialog.dart';
import '../../widgets/trades/asset_trade_item_card.dart';
class TradesTab extends StatefulWidget {
final String symbol;
const TradesTab({super.key, required this.symbol});
@override
State<TradesTab> createState() => _TradesTabState();
}
class _TradesTabState extends State<TradesTab> {
late final BotRepository _botRepository;
@override
void initState() {
super.initState();
_botRepository = BotRepository(apiClient: context.read<ApiClient>());
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.symbol));
}
void _showEditTradeExecutionDialog(BuildContext context, TradeModel trade, {bool isActive = false}) {
final tradesBloc = context.read<AssetTradesBloc>();
TradeExecutionCockpit.show(
context,
trade: trade,
defaultSymbol: widget.symbol,
isActive: isActive,
onAccept: (dto) {
final tId = trade.id;
// `TradeExecutionCockpit._buildDto()` already picks the right identifier
// (trade.id for isActive, trade.proposalId otherwise) and always fills
// actualEntryPrice/quantity from the two fields the dialog actually
// collects but the two identifiers target different server-side
// operations: accepting a *proposal* vs. recording a fill against an
// already-*existing* trade (`UserTradesController.AcceptTrade` looks
// `dto.tradeId` up as a proposal id, which fails for an active trade's
// own id). Route accordingly instead of always calling AcceptTradeEvent.
if (isActive) {
final price = dto.actualEntryPrice ?? dto.entryPrice;
final qty = dto.quantity ?? dto.positionSize;
if (price == null || qty == null) return;
tradesBloc.add(AddTradeFillEvent(tId, widget.symbol, price, qty));
} else {
tradesBloc.add(AcceptTradeEvent(dto, widget.symbol));
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isActive ? 'Einstellungen für Trade $tId gespeichert!' : 'Trade $tId angenommen & Position eröffnet!'),
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
),
);
},
onReject: (tId) {
// Purely local dismissal there is no server-side rejection (a
// proposal is a system-wide opportunity anyone may still accept).
// Wording must not claim a permanence the backend doesn't provide.
tradesBloc.add(DismissTradeEvent(tId));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text(
'Vorschlag ausgeblendet er kann beim nächsten Neuladen erneut erscheinen, bis er serverseitig abläuft.',
),
backgroundColor: AppTheme.textSecondary,
behavior: SnackBarBehavior.floating,
),
);
},
);
}
Future<void> _executeProposalViaBot(BuildContext context, TradeProposalModel proposal) async {
Navigator.of(context).pop();
try {
await _botRepository.executeProposal(proposal.proposalId);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Vorschlag für ${proposal.symbol} an den Bot übergeben.'),
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Fehler bei der Bot-Übergabe: $e'),
backgroundColor: AppTheme.accentRed,
behavior: SnackBarBehavior.floating,
),
);
}
}
void _showProposalDecision(BuildContext context, TradeProposalModel proposal) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ProposalDecisionScreen(
proposal: proposal,
onExecuteBot: () => _executeProposalViaBot(context, proposal),
onManualTrade: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Manuelle Eröffnung: Bitte über die Order-Maske deines Brokers ausführen.'),
behavior: SnackBarBehavior.floating,
),
);
},
),
),
);
}
/// Shows the real, already-computed score breakdown and AI reasoning for a
/// manual analysis that ran but did not produce a trade proposal
/// ([AssetEvaluationResultModel.proposal] is `null`). Replaces the old bare
/// "kein Vorschlag" snackbar: the user gets to see *why* the opportunity
/// was rejected, not just *that* it was (Rules.md §4). Every value shown
/// here comes straight from the server response nothing is invented, and
/// [AssetEvaluationResultModel.daysToNextEarnings] is only rendered when
/// the server actually sent a value.
void _showEvaluationRejectedSheet(BuildContext context, AssetEvaluationResultModel result) {
EvaluationScoreBreakdownSheet.show(
context,
title: 'Analyse abgeschlossen kein Vorschlag',
subtitle:
'Für ${widget.symbol} wurde keine aktive Trade-Empfehlung erzeugt. Die berechneten Werte und die KI-Begründung stehen unten.',
headerIcon: result.aiApproved ? Icons.psychology_outlined : Icons.block_outlined,
headerColor: result.aiApproved ? AppTheme.primaryEmerald : AppTheme.accentRed,
compositeScore: result.compositeScore,
technicalScore: result.technicalScore,
sentimentScore: result.sentimentScore,
fundamentalScore: result.fundamentalScore,
passedEarningsLockout: result.passedEarningsLockout,
daysToNextEarnings: result.daysToNextEarnings,
passedDividendGate: result.passedDividendGate,
daysToNextExDividend: result.daysToNextExDividend,
reasoningLabel: result.aiApproved ? 'KI-These' : 'Ablehnungsgrund',
reasoningText: result.aiThesisSummary,
identifiedRisks: result.aiIdentifiedRisks,
);
}
@override
Widget build(BuildContext context) {
return BlocConsumer<AssetTradesBloc, AssetTradesState>(
listener: (context, state) {
if (state is! AssetTradesLoaded) return;
final result = state.manualAnalysisResult;
if (result == null) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (result.hasProposal) {
_showProposalDecision(context, result.proposal!);
} else {
// Rejected (or no technical setup at all) - show the real, already
// computed scores and AI reasoning instead of a bare "no proposal"
// snackbar, so the user understands *why*, not just *that*
// (Rules.md §4).
_showEvaluationRejectedSheet(context, result);
}
});
},
builder: (context, state) {
final List<TradeModel> tradesList = (state is AssetTradesLoaded) ? state.data : [];
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Trade & Signal Management', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15, color: Colors.white)),
const SizedBox(height: 4),
Text('KI-gestützte technische & fundamentale Trade-Analyse anfordern', style: TextStyle(color: AppTheme.textMuted, fontSize: 12), maxLines: 1, overflow: TextOverflow.ellipsis),
],
),
),
const SizedBox(width: 8),
StatusBadge(label: widget.symbol, color: AppTheme.accentCyan),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
ManualAnalysisDialog.show(
context,
symbol: widget.symbol,
initialRiskScore: 50.0,
onTrigger: (payload) {
context.read<AssetTradesBloc>().add(TriggerManualAnalysis(widget.symbol, payload: payload));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('KI-Analyse für ${widget.symbol} wird ausgeführt...'),
backgroundColor: AppTheme.accentCyan,
behavior: SnackBarBehavior.floating,
),
);
},
);
},
icon: const Icon(Icons.auto_awesome, size: 18),
label: const Text('KI-Analyse Starten', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentCyan,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
],
),
),
const SizedBox(height: 20),
if (state is AssetTradesLoading)
_buildTradesShimmer(context)
else if (state is AssetTradesError)
GlassContainer(
padding: const EdgeInsets.all(16),
child: Text('Fehler: ${state.message}', style: TextStyle(color: AppTheme.accentRed)),
)
else if (state is AssetTradesLoaded) ...[
_buildTradeList(
'Aktive Trade-Signale & Positionen',
tradesList.where((t) => t.isActive || t.isProposed).toList(),
),
const SizedBox(height: 20),
_buildTradeList(
'Historische Trades & KI-Bewertungen',
tradesList.where((t) => t.isClosed || t.isRejected).toList(),
),
],
],
),
);
},
);
}
Widget _buildTradesShimmer(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ShimmerLoading(width: 260, height: 20, borderRadius: 6),
const SizedBox(height: 12),
for (int i = 0; i < 3; i++) ...[
const ShimmerLoading(width: double.infinity, height: 105, borderRadius: 14),
const SizedBox(height: 12),
],
],
);
}
Widget _buildTradeList(String title, List<TradeModel> trades) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: 10),
if (trades.isEmpty)
GlassContainer(
padding: const EdgeInsets.all(16),
child: Center(
child: Text('Keine Trades in dieser Kategorie vorhanden.', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
),
)
else
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: trades.length,
itemBuilder: (context, index) {
final trade = trades[index];
final isActive = trade.isActive;
return AssetTradeItemCard(
trade: trade,
defaultSymbol: widget.symbol,
onAccept: () => _showEditTradeExecutionDialog(context, trade),
onSettings: () => _showEditTradeExecutionDialog(context, trade, isActive: true),
onClose: isActive
? () {
TradeClosingCockpit.show(
context,
trade: trade,
defaultSymbol: widget.symbol,
onClose: (dto) {
final isinVal = trade.underlyingIsin.isNotEmpty ? trade.underlyingIsin : widget.symbol;
context.read<AssetTradesBloc>().add(CloseTradeEvent(trade.id, isinVal, dto.userExitPrice));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Trade ${trade.id} geschlossen! Realisierter Ausstiegskurs: €${dto.userExitPrice.toStringAsFixed(2)}'),
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
),
);
},
);
}
: null,
);
},
),
],
);
}
}
@@ -0,0 +1,306 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../models/technical_analysis_model.dart';
import 'candlestick_painter.dart';
export '../../models/technical_analysis_model.dart' show CandleModel, IndicatorModel, ChartPatternModel, PatternPoint, StrategySignalModel;
class CandlestickChart extends StatefulWidget {
final List<CandleModel> candles;
final List<ChartPatternModel> patterns;
final List<StrategySignalModel> signals;
final List<IndicatorModel> indicators;
final bool showSma50;
final bool showSma200;
final bool showEma;
final bool showPatterns;
final bool showSignals;
final bool showSupertrend;
final double height;
final bool isFullscreen;
final VoidCallback? onToggleFullscreen;
const CandlestickChart({
super.key,
required this.candles,
this.patterns = const [],
this.signals = const [],
this.indicators = const [],
this.showSma50 = true,
this.showSma200 = true,
this.showEma = true,
this.showPatterns = true,
this.showSignals = true,
this.showSupertrend = true,
this.height = 420,
this.isFullscreen = false,
this.onToggleFullscreen,
});
@override
State<CandlestickChart> createState() => _CandlestickChartState();
}
class _CandlestickChartState extends State<CandlestickChart> {
double _scale = 1.0;
double _panOffset = 0.0;
double _baseScale = 1.0;
double _basePanOffset = 0.0;
Offset _startFocalPoint = Offset.zero;
bool _isDragging = false;
Offset? _tapPosition;
CandleModel? _selectedCandle;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_fitLatestCandles();
});
}
@override
void didUpdateWidget(covariant CandlestickChart oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.candles.length != widget.candles.length) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_fitLatestCandles();
});
}
}
void _fitLatestCandles() {
if (widget.candles.isEmpty || !mounted) return;
final renderBox = context.findRenderObject() as RenderBox?;
final width = (renderBox?.size.width ?? 600) - 60;
final double candleWidth = 10.0 * _scale;
final double totalCandleSpace = candleWidth + (5.0 * _scale);
final double futureSpace = totalCandleSpace * 10;
final double totalWidth = (widget.candles.length * totalCandleSpace) + futureSpace;
setState(() {
if (totalWidth > width) {
_panOffset = width - totalWidth;
} else {
_panOffset = 0.0;
}
});
}
void _applyZoom(double factor, [double? focalX]) {
if (widget.candles.isEmpty || !mounted) return;
final renderBox = context.findRenderObject() as RenderBox?;
final chartWidth = (renderBox?.size.width ?? 600) - 60;
final fx = focalX ?? (chartWidth / 2);
setState(() {
final oldScale = _scale;
_scale = (_scale * factor).clamp(0.1, 6.0);
_panOffset = fx - ((fx - _panOffset) * (_scale / oldScale));
_clampPanOffset(chartWidth);
});
}
void _clampPanOffset(double chartWidth) {
if (widget.candles.isEmpty) return;
final double totalCandleSpace = (10.0 + 5.0) * _scale;
final double totalWidth = (widget.candles.length * totalCandleSpace) + (totalCandleSpace * 10);
if (totalWidth <= chartWidth) {
_panOffset = 0.0;
} else {
final double minPan = chartWidth - totalWidth - 30;
const double maxPan = 30.0;
_panOffset = _panOffset.clamp(minPan, maxPan);
}
}
@override
Widget build(BuildContext context) {
final theme = AppTheme.activePreset;
return Container(
height: widget.height,
decoration: BoxDecoration(
color: theme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.glassBorder),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Listener(
onPointerSignal: (pointerSignal) {
if (pointerSignal is PointerScrollEvent) {
if (pointerSignal.scrollDelta.dx != 0) {
final renderBox = context.findRenderObject() as RenderBox?;
final chartWidth = (renderBox?.size.width ?? 600) - 60;
setState(() {
_panOffset -= pointerSignal.scrollDelta.dx;
_clampPanOffset(chartWidth);
});
} else if (pointerSignal.scrollDelta.dy != 0) {
final zoomFactor = pointerSignal.scrollDelta.dy < 0 ? 1.15 : 0.85;
_applyZoom(zoomFactor, pointerSignal.localPosition.dx);
}
}
},
child: MouseRegion(
cursor: _isDragging ? SystemMouseCursors.grabbing : SystemMouseCursors.grab,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onScaleStart: (details) {
_baseScale = _scale;
_basePanOffset = _panOffset;
_startFocalPoint = details.focalPoint;
setState(() => _isDragging = true);
},
onScaleUpdate: (details) {
final renderBox = context.findRenderObject() as RenderBox?;
final chartWidth = (renderBox?.size.width ?? 600) - 60;
setState(() {
if (details.scale != 1.0) {
final oldScale = _scale;
_scale = (_baseScale * details.scale).clamp(0.1, 6.0);
final fx = details.localFocalPoint.dx;
_panOffset = fx - ((fx - _basePanOffset) * (_scale / oldScale));
} else {
_panOffset = _basePanOffset + (details.focalPoint.dx - _startFocalPoint.dx);
}
_clampPanOffset(chartWidth);
});
},
onScaleEnd: (details) {
setState(() => _isDragging = false);
},
onTapDown: (details) {
_handleTap(details.localPosition);
},
child: Stack(
children: [
CustomPaint(
size: Size.infinite,
painter: CandlestickPainter(
candles: widget.candles,
patterns: widget.patterns,
signals: widget.signals,
indicators: widget.indicators,
scale: _scale,
panOffset: _panOffset,
theme: theme,
showPatterns: widget.showPatterns,
showSma50: widget.showSma50,
showSma200: widget.showSma200,
showEma: widget.showEma,
showSignals: widget.showSignals,
showSupertrend: widget.showSupertrend,
tapPosition: _tapPosition,
),
),
if (_selectedCandle != null) _buildTooltip(theme),
_buildZoomControls(theme),
],
),
),
),
),
),
);
}
Widget _buildZoomControls(ThemePreset theme) {
return Positioned(
right: 10,
bottom: 28,
child: Container(
decoration: BoxDecoration(
color: theme.cardSurface.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.glassBorder),
),
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildZoomButton(icon: Icons.chevron_left, tooltip: 'Nach links bewegen', onTap: () {
final renderBox = context.findRenderObject() as RenderBox?;
setState(() { _panOffset += 150; _clampPanOffset((renderBox?.size.width ?? 600) - 60); });
}),
_buildZoomButton(icon: Icons.chevron_right, tooltip: 'Nach rechts bewegen', onTap: () {
final renderBox = context.findRenderObject() as RenderBox?;
setState(() { _panOffset -= 150; _clampPanOffset((renderBox?.size.width ?? 600) - 60); });
}),
Container(width: 1, height: 16, color: theme.glassBorder),
_buildZoomButton(icon: Icons.add, tooltip: 'Vergrößern', onTap: () => _applyZoom(1.25)),
_buildZoomButton(icon: Icons.remove, tooltip: 'Verkleinern', onTap: () => _applyZoom(0.8)),
_buildZoomButton(icon: Icons.fit_screen_outlined, tooltip: 'Aktuelle Kerzen einpassen', onTap: _fitLatestCandles),
_buildZoomButton(icon: Icons.refresh, tooltip: 'Zoom 1:1 zurücksetzen', onTap: () { setState(() => _scale = 1.0); _fitLatestCandles(); }),
if (widget.onToggleFullscreen != null) ...[
Container(width: 1, height: 16, color: theme.glassBorder),
_buildZoomButton(
icon: widget.isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
tooltip: widget.isFullscreen ? 'Vollbild beenden' : 'Vollbildmodus (Querformat)',
onTap: widget.onToggleFullscreen!,
),
],
],
),
),
);
}
Widget _buildZoomButton({required IconData icon, required String tooltip, required VoidCallback onTap}) {
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(icon, size: 16, color: Colors.white70),
),
),
);
}
void _handleTap(Offset pos) {
if (widget.candles.isEmpty) return;
final double candleWidth = 10.0 * _scale;
final double totalCandleSpace = candleWidth + (5.0 * _scale);
final int index = ((pos.dx - _panOffset) / totalCandleSpace).round();
if (index >= 0 && index < widget.candles.length) {
setState(() {
_tapPosition = pos;
_selectedCandle = widget.candles[index];
});
}
}
Widget _buildTooltip(ThemePreset theme) {
final c = _selectedCandle!;
final dStr = "${c.timestamp.year}-${c.timestamp.month.toString().padLeft(2, '0')}-${c.timestamp.day.toString().padLeft(2, '0')}";
return Positioned(
left: 10,
top: 10,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.cardSurface.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.glassBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(dStr, style: TextStyle(color: theme.textMuted, fontSize: 12)),
Text('O: ${c.open.toStringAsFixed(2)} | H: ${c.high.toStringAsFixed(2)} | L: ${c.low.toStringAsFixed(2)} | C: ${c.close.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
Text('Vol: ${c.volume.toStringAsFixed(0)}', style: TextStyle(color: theme.textSecondary, fontSize: 11)),
],
),
),
);
}
}
@@ -0,0 +1,292 @@
import 'dart:math';
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../models/technical_analysis_model.dart';
import 'chart_overlay_renderer.dart';
class CandlestickPainter extends CustomPainter {
final List<CandleModel> candles;
final List<ChartPatternModel> patterns;
final List<StrategySignalModel> signals;
final List<IndicatorModel> indicators;
final double scale;
final double panOffset;
final ThemePreset theme;
final bool showPatterns;
final bool showSma50;
final bool showSma200;
final bool showEma;
final bool showSignals;
final bool showSupertrend;
final Offset? tapPosition;
final double rightPadding = 60.0;
final double bottomPadding = 20.0;
CandlestickPainter({
required this.candles,
required this.patterns,
required this.signals,
required this.indicators,
required this.scale,
required this.panOffset,
required this.theme,
required this.showPatterns,
required this.showSma50,
required this.showSma200,
required this.showEma,
required this.showSignals,
required this.showSupertrend,
this.tapPosition,
});
@override
void paint(Canvas canvas, Size size) {
if (candles.isEmpty) return;
final double chartWidth = size.width - rightPadding;
final double baseWidth = 10.0;
final double spacing = 5.0;
final double candleWidth = baseWidth * scale;
final double totalCandleSpace = candleWidth + (spacing * scale);
double maxPrice = 0;
double minPrice = double.infinity;
int visibleCount = 0;
for (int i = 0; i < candles.length; i++) {
final dx = (i * totalCandleSpace) + panOffset;
if (dx + candleWidth > -100 && dx < chartWidth + 100) {
visibleCount++;
final c = candles[i];
if (c.high > maxPrice) maxPrice = c.high;
if (c.low < minPrice) minPrice = c.low;
}
}
if (visibleCount == 0 || minPrice == double.infinity || maxPrice <= 0) {
for (var c in candles) {
if (c.high > maxPrice) maxPrice = c.high;
if (c.low < minPrice) minPrice = c.low;
}
}
if (minPrice == double.infinity || maxPrice <= 0) return;
final range = maxPrice - minPrice;
maxPrice += max(range * 0.1, 1.0);
minPrice -= max(range * 0.1, 1.0);
final paddedRange = maxPrice - minPrice;
if (paddedRange <= 0) return;
final double chartHeight = size.height - bottomPadding;
final double volumeHeight = chartHeight * 0.15;
final double candleAreaHeight = chartHeight - volumeHeight;
double maxVolume = 0;
for (int i = 0; i < candles.length; i++) {
if (candles[i].volume > maxVolume) maxVolume = candles[i].volume;
}
if (maxVolume == 0) maxVolume = 1;
ChartOverlayRenderer.drawGridAndAxis(
canvas: canvas,
size: size,
chartWidth: chartWidth,
candleAreaHeight: candleAreaHeight,
minPrice: minPrice,
maxPrice: maxPrice,
range: paddedRange,
candles: candles,
scale: scale,
panOffset: panOffset,
bottomPadding: bottomPadding,
theme: theme,
);
final paintBullish = Paint()..color = theme.primaryColor..style = PaintingStyle.fill;
final paintBearish = Paint()..color = theme.accentRed..style = PaintingStyle.fill;
final paintWickBullish = Paint()..color = theme.primaryColor..strokeWidth = 1.5;
final paintWickBearish = Paint()..color = theme.accentRed..strokeWidth = 1.5;
final ema20Path = Path();
final sma50Path = Path();
final sma200Path = Path();
final supertrendPath = Path();
bool firstEma20 = true;
bool firstSma50 = true;
bool firstSma200 = true;
bool firstSupertrend = true;
double getXForTime(DateTime t) {
if (candles.isEmpty) return 0.0;
final lastCandle = candles.last;
if (t.isAfter(lastCandle.timestamp) && candles.length > 1) {
final totalSpan = lastCandle.timestamp.difference(candles.first.timestamp).inSeconds;
final secPerCandle = totalSpan / (candles.length - 1);
if (secPerCandle > 0) {
final futureSecs = t.difference(lastCandle.timestamp).inSeconds;
final futureCandles = futureSecs / secPerCandle;
final lastDx = ((candles.length - 1) * totalCandleSpace) + panOffset + candleWidth / 2;
return lastDx + (futureCandles * totalCandleSpace);
}
}
int bestIndex = 0;
int minDiff = 999999999;
for (int i = 0; i < candles.length; i++) {
final diff = candles[i].timestamp.difference(t).inSeconds.abs();
if (diff < minDiff) {
minDiff = diff;
bestIndex = i;
}
}
return (bestIndex * totalCandleSpace) + panOffset + candleWidth / 2;
}
double getYForPrice(double price) {
return candleAreaHeight - ((price - minPrice) / paddedRange) * candleAreaHeight;
}
canvas.save();
canvas.clipRect(Rect.fromLTWH(0, 0, chartWidth, chartHeight));
for (int i = 0; i < candles.length; i++) {
final candle = candles[i];
final isBullish = candle.close >= candle.open;
final dx = (i * totalCandleSpace) + panOffset;
if (dx < -candleWidth || dx > chartWidth) continue;
final yHigh = getYForPrice(candle.high);
final yLow = getYForPrice(candle.low);
final yOpen = getYForPrice(candle.open);
final yClose = getYForPrice(candle.close);
canvas.drawLine(
Offset(dx + candleWidth / 2, yHigh),
Offset(dx + candleWidth / 2, yLow),
isBullish ? paintWickBullish : paintWickBearish,
);
final top = min(yOpen, yClose);
final bottom = max(yOpen, yClose);
final bodyHeight = max(bottom - top, 1.0);
canvas.drawRect(
Rect.fromLTWH(dx, top, candleWidth, bodyHeight),
isBullish ? paintBullish : paintBearish,
);
final vHeight = (candle.volume / maxVolume) * volumeHeight;
final vTop = chartHeight - vHeight;
canvas.drawRect(
Rect.fromLTWH(dx, vTop, candleWidth, vHeight),
Paint()..color = (isBullish ? theme.primaryColor : theme.accentRed).withValues(alpha: 0.3)..style = PaintingStyle.fill,
);
if (indicators.isNotEmpty) {
final cx = dx + candleWidth / 2;
IndicatorModel? match;
for (var ind in indicators) {
if (ind.timestamp.isAtSameMomentAs(candle.timestamp) || ind.timestamp.difference(candle.timestamp).inHours.abs() < 12) {
match = ind;
break;
}
}
if (match != null) {
if (showEma && match.ema20 != null) {
final y = getYForPrice(match.ema20!);
if (firstEma20) { ema20Path.moveTo(cx, y); firstEma20 = false; }
else { ema20Path.lineTo(cx, y); }
}
if (showSma50 && match.sma50 != null) {
final y = getYForPrice(match.sma50!);
if (firstSma50) { sma50Path.moveTo(cx, y); firstSma50 = false; }
else { sma50Path.lineTo(cx, y); }
}
if (showSma200 && match.sma200 != null) {
final y = getYForPrice(match.sma200!);
if (firstSma200) { sma200Path.moveTo(cx, y); firstSma200 = false; }
else { sma200Path.lineTo(cx, y); }
}
if (showSupertrend) {
final stVal = match.supertrendDirection == 'BULLISH' ? match.supertrendLower : match.supertrendUpper;
if (stVal != null) {
final y = getYForPrice(stVal);
if (firstSupertrend) { supertrendPath.moveTo(cx, y); firstSupertrend = false; }
else { supertrendPath.lineTo(cx, y); }
}
}
}
}
}
if (showEma && !firstEma20) {
canvas.drawPath(ema20Path, Paint()..color = Colors.blueAccent..style = PaintingStyle.stroke..strokeWidth = 1.5);
}
if (showSma50 && !firstSma50) {
canvas.drawPath(sma50Path, Paint()..color = Colors.orangeAccent..style = PaintingStyle.stroke..strokeWidth = 1.5);
}
if (showSma200 && !firstSma200) {
canvas.drawPath(sma200Path, Paint()..color = Colors.redAccent..style = PaintingStyle.stroke..strokeWidth = 2.0);
}
if (showSupertrend && !firstSupertrend) {
canvas.drawPath(supertrendPath, Paint()..color = AppTheme.primaryEmerald..style = PaintingStyle.stroke..strokeWidth = 2.0);
}
if (showPatterns) {
ChartOverlayRenderer.drawPatterns(
canvas: canvas,
patterns: patterns,
getX: getXForTime,
getY: getYForPrice,
);
ChartOverlayRenderer.drawFutureProjectionZone(
canvas: canvas,
candles: candles,
patterns: patterns,
chartWidth: chartWidth,
candleAreaHeight: candleAreaHeight,
getX: getXForTime,
getY: getYForPrice,
theme: theme,
);
}
if (showSignals) {
ChartOverlayRenderer.drawSignals(
canvas: canvas,
signals: signals,
getX: getXForTime,
getY: getYForPrice,
theme: theme,
);
}
if (tapPosition != null && tapPosition!.dx < chartWidth) {
ChartOverlayRenderer.drawCrosshair(
canvas: canvas,
tapPosition: tapPosition!,
chartWidth: chartWidth,
chartHeight: chartHeight,
theme: theme,
);
}
canvas.restore();
}
@override
bool shouldRepaint(covariant CandlestickPainter oldDelegate) {
return oldDelegate.scale != scale ||
oldDelegate.panOffset != panOffset ||
oldDelegate.candles != candles ||
oldDelegate.patterns != patterns ||
oldDelegate.signals != signals ||
oldDelegate.indicators != indicators ||
oldDelegate.tapPosition != tapPosition;
}
}
@@ -0,0 +1,331 @@
import 'dart:math';
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../models/technical_analysis_model.dart';
import '../../utils/pattern_explanations.dart';
/// Helper for rendering chart overlays: grid, axes, patterns, future projections, and signals.
class ChartOverlayRenderer {
static void drawGridAndAxis({
required Canvas canvas,
required Size size,
required double chartWidth,
required double candleAreaHeight,
required double minPrice,
required double maxPrice,
required double range,
required List<CandleModel> candles,
required double scale,
required double panOffset,
required double bottomPadding,
required ThemePreset theme,
}) {
final gridPaint = Paint()..color = theme.glassBorder..strokeWidth = 1;
final textPainter = TextPainter(textDirection: TextDirection.ltr);
const int gridLines = 5;
for (int i = 0; i <= gridLines; i++) {
final y = candleAreaHeight - (i / gridLines) * candleAreaHeight;
final price = minPrice + (i / gridLines) * range;
canvas.drawLine(Offset(0, y), Offset(chartWidth, y), gridPaint);
textPainter.text = TextSpan(
text: price.toStringAsFixed(2),
style: TextStyle(color: theme.textMuted, fontSize: 11),
);
textPainter.layout();
textPainter.paint(canvas, Offset(chartWidth + 5, y - 6));
}
if (candles.isEmpty) return;
const double baseWidth = 10.0;
const double spacing = 5.0;
final double candleWidth = baseWidth * scale;
final double totalCandleSpace = candleWidth + (spacing * scale);
final int xSteps = (chartWidth / 80).floor();
if (xSteps <= 0) return;
for (int i = 1; i < xSteps; i++) {
double x = i * (chartWidth / xSteps);
int candleIndex = ((x - panOffset) / totalCandleSpace).round();
if (candleIndex >= 0 && candleIndex < candles.length) {
final t = candles[candleIndex].timestamp;
textPainter.text = TextSpan(
text: "${t.month.toString().padLeft(2, '0')}-${t.day.toString().padLeft(2, '0')}",
style: TextStyle(color: theme.textMuted, fontSize: 10),
);
textPainter.layout();
textPainter.paint(canvas, Offset(x - textPainter.width / 2, size.height - bottomPadding + 4));
canvas.drawLine(Offset(x, 0), Offset(x, size.height - bottomPadding), gridPaint);
}
}
}
static void drawFutureProjectionZone({
required Canvas canvas,
required List<CandleModel> candles,
required List<ChartPatternModel> patterns,
required double chartWidth,
required double candleAreaHeight,
required double Function(DateTime) getX,
required double Function(double) getY,
required ThemePreset theme,
}) {
if (candles.isEmpty) return;
final lastCandle = candles.last;
final double lastX = getX(lastCandle.timestamp);
if (lastX < chartWidth - 10) {
final futureRect = Rect.fromLTRB(lastX, 0, chartWidth, candleAreaHeight);
final futureBgPaint = Paint()
..color = theme.primaryColor.withValues(alpha: 0.05)
..style = PaintingStyle.fill;
canvas.drawRect(futureRect, futureBgPaint);
final sepPaint = Paint()
..color = theme.primaryColor.withValues(alpha: 0.3)
..style = PaintingStyle.stroke
..strokeWidth = 1.0;
canvas.drawLine(Offset(lastX, 0), Offset(lastX, candleAreaHeight), sepPaint);
final textPainter = TextPainter(textDirection: TextDirection.ltr);
textPainter.text = TextSpan(
text: 'PROGNOSE (KI & MUSTER)',
style: TextStyle(color: theme.primaryColor, fontSize: 9, fontWeight: FontWeight.bold, letterSpacing: 0.8),
);
textPainter.layout();
textPainter.paint(canvas, Offset(lastX + 8, 8));
for (var pattern in patterns) {
double targetPrice = 0.0;
if (pattern.breakoutSignal != null && pattern.breakoutSignal!.targetPrice > 0) {
targetPrice = pattern.breakoutSignal!.targetPrice;
} else if (pattern.lowerLine.isNotEmpty && pattern.upperLine.isNotEmpty) {
final diff = (pattern.upperLine.last.price - pattern.lowerLine.last.price).abs();
targetPrice = lastCandle.close >= pattern.lowerLine.last.price
? lastCandle.close + (diff > 0 ? diff : lastCandle.close * 0.05)
: lastCandle.close - (diff > 0 ? diff : lastCandle.close * 0.05);
} else if (pattern.upperLine.isNotEmpty) {
targetPrice = pattern.upperLine.last.price;
} else if (pattern.lowerLine.isNotEmpty) {
targetPrice = pattern.lowerLine.last.price;
}
if (targetPrice > 0) {
final targetY = getY(targetPrice);
const int numSteps = 8;
final double availableWidth = max(chartWidth - lastX - 40, 60.0);
final double stepWidth = availableWidth / numSteps;
final isBullish = targetPrice >= lastCandle.close;
final projColor = isBullish ? AppTheme.primaryEmerald : AppTheme.accentRed;
double currX = lastX;
double currPrice = lastCandle.close;
final double priceDeltaPerStep = (targetPrice - lastCandle.close) / numSteps;
for (int k = 1; k <= numSteps; k++) {
final nextX = lastX + (k * stepWidth);
final waveNoise = sin(k * 0.9) * (priceDeltaPerStep.abs() * 0.25);
final nextPrice = lastCandle.close + (priceDeltaPerStep * k) + waveNoise;
final highPrice = max(currPrice, nextPrice) + priceDeltaPerStep.abs() * 0.15;
final lowPrice = min(currPrice, nextPrice) - priceDeltaPerStep.abs() * 0.15;
final yOpen = getY(currPrice);
final yClose = getY(nextPrice);
final yHigh = getY(highPrice);
final yLow = getY(lowPrice);
final cWidth = max(stepWidth * 0.55, 3.0);
final cLeft = nextX - cWidth / 2;
final isStepBullish = nextPrice >= currPrice;
final stepColor = isStepBullish ? AppTheme.primaryEmerald : AppTheme.accentRed;
canvas.drawLine(
Offset(nextX, yHigh),
Offset(nextX, yLow),
Paint()..color = stepColor.withValues(alpha: 0.45)..strokeWidth = 1.0,
);
final top = min(yOpen, yClose);
final bottom = max(yOpen, yClose);
canvas.drawRect(
Rect.fromLTWH(cLeft, top, cWidth, max(bottom - top, 1.5)),
Paint()..color = stepColor.withValues(alpha: 0.35)..style = PaintingStyle.fill,
);
currX = nextX;
currPrice = nextPrice;
}
final targetX = currX;
final pct = ((targetPrice - lastCandle.close) / lastCandle.close) * 100;
final pctSign = pct >= 0 ? '+' : '';
final targetBadgePainter = TextPainter(
text: TextSpan(
text: ' ZIEL: ${targetPrice.toStringAsFixed(2)} € ($pctSign${pct.toStringAsFixed(1)}%) ',
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
),
textDirection: TextDirection.ltr,
);
targetBadgePainter.layout();
final badgeRect = RRect.fromLTRBR(
targetX - targetBadgePainter.width / 2,
targetY - targetBadgePainter.height / 2 - 3,
targetX + targetBadgePainter.width / 2,
targetY + targetBadgePainter.height / 2 + 3,
const Radius.circular(6),
);
canvas.drawRRect(badgeRect, Paint()..color = projColor.withValues(alpha: 0.92));
targetBadgePainter.paint(canvas, Offset(targetX - targetBadgePainter.width / 2, targetY - targetBadgePainter.height / 2));
}
}
}
}
static void drawPatterns({
required Canvas canvas,
required List<ChartPatternModel> patterns,
required double Function(DateTime) getX,
required double Function(double) getY,
}) {
for (var pattern in patterns) {
final color = PatternExplanations.getColorForPattern(pattern.type);
final paint = Paint()..color = color..style = PaintingStyle.stroke..strokeWidth = 2.5;
final fillPaint = Paint()..color = color.withValues(alpha: 0.12)..style = PaintingStyle.fill;
Offset? firstPoint;
void drawLine(List<PatternPoint> points) {
if (points.length < 2) return;
final path = Path();
final startX = getX(points[0].time);
final startY = getY(points[0].price);
path.moveTo(startX, startY);
firstPoint ??= Offset(startX, startY);
for (int i = 1; i < points.length; i++) {
final px = getX(points[i].time);
final py = getY(points[i].price);
path.lineTo(px, py);
}
canvas.drawPath(path, paint);
for (var p in points) {
final px = getX(p.time);
final py = getY(p.price);
canvas.drawCircle(Offset(px, py), 4, Paint()..color = color);
canvas.drawCircle(Offset(px, py), 2, Paint()..color = Colors.white);
}
}
if (pattern.upperLine.length >= 2 && pattern.lowerLine.length >= 2) {
final polyPath = Path();
polyPath.moveTo(getX(pattern.upperLine[0].time), getY(pattern.upperLine[0].price));
for (int i = 1; i < pattern.upperLine.length; i++) {
polyPath.lineTo(getX(pattern.upperLine[i].time), getY(pattern.upperLine[i].price));
}
for (int i = pattern.lowerLine.length - 1; i >= 0; i--) {
polyPath.lineTo(getX(pattern.lowerLine[i].time), getY(pattern.lowerLine[i].price));
}
polyPath.close();
canvas.drawPath(polyPath, fillPaint);
}
drawLine(pattern.upperLine);
drawLine(pattern.lowerLine);
if (firstPoint != null) {
final label = PatternExplanations.getGermanName(pattern.type);
final textPainter = TextPainter(
text: TextSpan(
text: ' $label ',
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
),
textDirection: TextDirection.ltr,
)..layout();
final badgeX = firstPoint!.dx;
final badgeY = firstPoint!.dy - 18;
final badgeRect = RRect.fromLTRBR(
badgeX,
badgeY,
badgeX + textPainter.width + 4,
badgeY + textPainter.height + 4,
const Radius.circular(4),
);
canvas.drawRRect(badgeRect, Paint()..color = color.withValues(alpha: 0.85));
textPainter.paint(canvas, Offset(badgeX + 2, badgeY + 2));
}
}
}
static void drawSignals({
required Canvas canvas,
required List<StrategySignalModel> signals,
required double Function(DateTime) getX,
required double Function(double) getY,
required ThemePreset theme,
}) {
for (var signal in signals) {
final x = getX(signal.date);
final y = getY(signal.price);
final isBuy = signal.type.toUpperCase() == 'BUY';
final isSell = signal.type.toUpperCase() == 'SELL';
if (!isBuy && !isSell) continue;
final color = isBuy ? theme.primaryColor : theme.accentRed;
final label = isBuy ? '▲ BUY' : '▼ SELL';
final textPainter = TextPainter(
text: TextSpan(text: label, style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)),
textDirection: TextDirection.ltr,
);
textPainter.layout();
final badgeWidth = textPainter.width + 12;
final badgeHeight = textPainter.height + 6;
final badgeY = isBuy ? y + 12 : y - badgeHeight - 12;
final badgeRect = RRect.fromLTRBR(
x - badgeWidth / 2,
badgeY,
x + badgeWidth / 2,
badgeY + badgeHeight,
const Radius.circular(10),
);
canvas.drawRRect(badgeRect, Paint()..color = color.withValues(alpha: 0.95));
canvas.drawLine(
Offset(x, y),
Offset(x, isBuy ? badgeY : badgeY + badgeHeight),
Paint()..color = color..strokeWidth = 1.5,
);
textPainter.paint(canvas, Offset(x - textPainter.width / 2, badgeY + 3));
}
}
static void drawCrosshair({
required Canvas canvas,
required Offset tapPosition,
required double chartWidth,
required double chartHeight,
required ThemePreset theme,
}) {
final paint = Paint()
..color = theme.textMuted.withValues(alpha: 0.5)
..strokeWidth = 1
..style = PaintingStyle.stroke;
canvas.drawLine(Offset(tapPosition.dx, 0), Offset(tapPosition.dx, chartHeight), paint);
if (tapPosition.dy <= chartHeight) {
canvas.drawLine(Offset(0, tapPosition.dy), Offset(chartWidth, tapPosition.dy), paint);
}
}
}
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/status_badge.dart';
import '../../models/fundamental_data_model.dart';
class AnalystPriceTargetCard extends StatelessWidget {
final FundamentalDataModel data;
final String currencySymbol;
const AnalystPriceTargetCard({
super.key,
required this.data,
this.currencySymbol = '\$',
});
String _fmtCurrency(double? val) {
if (val == null) return 'N/A';
return '$currencySymbol${val.toStringAsFixed(2)}';
}
@override
Widget build(BuildContext context) {
final rating = data.consensusRating ?? 'N/A';
final targetMean = data.priceTargetMean;
final targetLow = data.priceTargetLow;
final targetHigh = data.priceTargetHigh;
return GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.trending_up, color: AppTheme.primaryEmerald, size: 22),
const SizedBox(width: 8),
const Text(
'Analysten-Konsens & Kursziele',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white),
),
],
),
StatusBadge(label: rating.toUpperCase(), color: AppTheme.primaryEmerald),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildTargetStat('Mindestkursziel', _fmtCurrency(targetLow), AppTheme.accentRed),
_buildTargetStat('Konsens-Ziel (Durchschnitt)', _fmtCurrency(targetMean), AppTheme.primaryEmerald),
_buildTargetStat('Höchstkursziel', _fmtCurrency(targetHigh), AppTheme.accentCyan),
],
),
],
),
);
}
Widget _buildTargetStat(String title, String val, Color col) {
return Column(
children: [
Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
const SizedBox(height: 4),
Text(val, style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 16)),
],
);
}
}
@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../models/fundamental_data_model.dart';
class CompanyProfileSection extends StatelessWidget {
final FundamentalDataModel data;
final String currencySymbol;
const CompanyProfileSection({
super.key,
required this.data,
this.currencySymbol = '\$',
});
String _fmtCompensation(double? val) {
if (val == null || val <= 0) return '---';
if (val >= 1e6) return '$currencySymbol${(val / 1e6).toStringAsFixed(2)}M';
if (val >= 1e3) return '$currencySymbol${(val / 1e3).toStringAsFixed(0)}K';
return '$currencySymbol${val.toStringAsFixed(0)}';
}
String _formatExecutivePayment(CompanyExecutiveModel exec) {
if (exec.compensation != null && exec.compensation! > 0) {
return _fmtCompensation(exec.compensation);
}
if (exec.payment != null && exec.payment!.isNotEmpty) {
final p = exec.payment!.trim();
if (p.startsWith(currencySymbol) || p.startsWith('') || p.startsWith(r'$')) {
return p;
}
final numeric = double.tryParse(p);
if (numeric != null && numeric > 0) {
return _fmtCompensation(numeric);
}
return '$currencySymbol$p';
}
return '---';
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (data.sector != null || data.industry != null || data.country != null) ...[
Row(
children: [
if (data.sector != null) ...[
_buildProfileBadge(data.sector!, Icons.category_outlined),
const SizedBox(width: 8),
],
if (data.country != null)
_buildProfileBadge(data.country!, Icons.place_outlined),
],
),
const SizedBox(height: 12),
],
Text(
data.businessSummary != null && data.businessSummary!.isNotEmpty
? data.businessSummary!
: 'Keine Beschreibung für dieses Asset verfügbar.',
style: const TextStyle(color: Colors.white70, height: 1.5, fontSize: 13),
),
if (data.employees != null) ...[
const SizedBox(height: 12),
Row(
children: [
Icon(Icons.people_outline, size: 16, color: AppTheme.textMuted),
const SizedBox(width: 6),
Text(
'Vollzeitbeschäftigte: ${data.employees}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
],
),
],
],
),
),
if (data.executives.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'Führungskräfte & Vorstand',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.white),
),
const SizedBox(height: 8),
GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: Column(
children: [
for (int i = 0; i < data.executives.length; i++) ...[
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
data.executives[i].name,
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white, fontSize: 13),
),
const SizedBox(height: 2),
Text(
data.executives[i].title,
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
overflow: TextOverflow.ellipsis,
),
],
),
),
Builder(
builder: (context) {
final payStr = _formatExecutivePayment(data.executives[i]);
if (payStr == '---') return const SizedBox.shrink();
return Text(
payStr,
style: TextStyle(color: AppTheme.primaryEmerald, fontWeight: FontWeight.bold, fontSize: 12),
);
},
),
],
),
),
if (i < data.executives.length - 1) const Divider(color: Colors.white10, height: 1),
],
],
),
),
],
],
);
}
Widget _buildProfileBadge(String text, IconData icon) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.glassBorder),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: AppTheme.primaryEmerald),
const SizedBox(width: 6),
Text(text, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w500)),
],
),
);
}
}
@@ -0,0 +1,263 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../models/fundamental_data_model.dart';
import '../../utils/metric_explanations.dart';
class _MetricRowItem {
final String label;
final String value;
const _MetricRowItem(this.label, this.value);
}
class FundamentalCategoryPanels extends StatelessWidget {
final FundamentalDataModel data;
final String currencySymbol;
final String currencyCode;
const FundamentalCategoryPanels({
super.key,
required this.data,
this.currencySymbol = '\$',
this.currencyCode = 'USD',
});
String _formatNumber(double? number) {
if (number == null) return 'N/A';
final abs = number.abs();
final sign = number < 0 ? '-' : '';
if (abs >= 1e12) return '$sign$currencySymbol${(abs / 1e12).toStringAsFixed(2)} Tsd. Mrd. $currencyCode';
if (abs >= 1e9) return '$sign$currencySymbol${(abs / 1e9).toStringAsFixed(2)} Mrd. $currencyCode';
if (abs >= 1e6) return '$sign$currencySymbol${(abs / 1e6).toStringAsFixed(2)} Mio. $currencyCode';
return '$sign$currencySymbol${NumberFormat("#,##0.00", "de_DE").format(abs)} $currencyCode';
}
String _fmtCurrency(double? val) {
if (val == null) return 'N/A';
return '$currencySymbol${val.toStringAsFixed(2)}';
}
String _fmtMultiple(double? val) {
if (val == null) return 'N/A';
return '${val.toStringAsFixed(2)}x';
}
String _fmtPercent(double? val) {
if (val == null) return 'N/A';
final p = (val.abs() <= 5.0 && val != 0.0) ? val * 100.0 : val;
return '${p.toStringAsFixed(2)}%';
}
String _fmtDebtToEquity(double? val) {
if (val == null) return 'N/A';
final p = val > 10.0 ? val : val * 100.0;
return '${p.toStringAsFixed(1)}%';
}
String _fmtDate(String? raw) {
if (raw == null || raw.isEmpty) return 'N/A';
final dt = DateTime.tryParse(raw);
if (dt == null) return raw;
return DateFormat('dd.MM.yyyy').format(dt);
}
@override
Widget build(BuildContext context) {
final valuationItems = [
_MetricRowItem('KGV (Trailing P/E)', _fmtMultiple(data.peRatioTrailing)),
_MetricRowItem('KGV (Forward P/E)', _fmtMultiple(data.peRatioForward)),
_MetricRowItem('PEG Ratio', _fmtMultiple(data.pegRatio)),
_MetricRowItem('KBV (P/B Ratio)', _fmtMultiple(data.pbRatio)),
_MetricRowItem('KUV (P/S Ratio)', _fmtMultiple(data.psRatio)),
_MetricRowItem('EV / EBITDA', _fmtMultiple(data.evToEbitda)),
_MetricRowItem('EV / Sales', _fmtMultiple(data.evToRevenue)),
_MetricRowItem('Enterprise Value', _formatNumber(data.enterpriseValue)),
_MetricRowItem('Marktkapitalisierung', _formatNumber(data.marketCapitalization)),
_MetricRowItem('Gewinn je Aktie (EPS)', _fmtCurrency(data.dilutedEps)),
_MetricRowItem('52W Höchststand', _fmtCurrency(data.fiftyTwoWeekHigh)),
_MetricRowItem('52W Tiefststand', _fmtCurrency(data.fiftyTwoWeekLow)),
];
final profitabilityItems = [
_MetricRowItem('Umsatzerlöse (Revenue)', _formatNumber(data.totalRevenue)),
_MetricRowItem('Umsatzwachstum (YoY)', _fmtPercent(data.revenueGrowthYoY)),
_MetricRowItem('Bruttogewinn', _formatNumber(data.grossProfit)),
_MetricRowItem('Bruttomarge (Gross)', _fmtPercent(data.grossMargin)),
_MetricRowItem('EBITDA', _formatNumber(data.ebitda)),
_MetricRowItem('Operative Marge', _fmtPercent(data.operatingMargin)),
_MetricRowItem('Nettogewinnmarge', _fmtPercent(data.netProfitMargin)),
_MetricRowItem('Eigenkapitalrendite (ROE)', _fmtPercent(data.returnOnEquity)),
_MetricRowItem('Gesamtkapitalrendite (ROA)', _fmtPercent(data.returnOnAssets)),
_MetricRowItem('Verschuldungsgrad (D/E)', _fmtDebtToEquity(data.debtToEquity)),
_MetricRowItem('Current Ratio', _fmtMultiple(data.currentRatio)),
_MetricRowItem('Liquide Mittel (Cash)', _formatNumber(data.totalCash)),
_MetricRowItem('Gesamtverschuldung (Debt)', _formatNumber(data.totalDebt)),
_MetricRowItem('Operativer Cashflow', _formatNumber(data.operatingCashFlow)),
_MetricRowItem('Free Cashflow', _formatNumber(data.freeCashFlow)),
];
final dividendItems = [
_MetricRowItem('Dividendenrendite', _fmtPercent(data.dividendYield)),
_MetricRowItem('Ausschüttungsquote (Payout)', _fmtPercent(data.payoutRatio)),
_MetricRowItem('Ex-Dividendentag', _fmtDate(data.exDividendDate)),
_MetricRowItem('Nächste Quartalszahlen', _fmtDate(data.nextEarningsDate)),
_MetricRowItem('Konsens-Rating', data.consensusRating != null ? data.consensusRating!.toUpperCase() : 'N/A'),
_MetricRowItem('Institutioneller Anteil', _fmtPercent(data.percentHeldByInstitutions)),
_MetricRowItem('Insider Anteil', _fmtPercent(data.percentHeldByInsiders)),
_MetricRowItem('Short % of Float', _fmtPercent(data.shortPercentOfFloat)),
];
final panel1 = _buildCategoryPanel(
context: context,
title: 'Bewertungskennzahlen & Multiples',
icon: Icons.analytics_outlined,
items: valuationItems,
);
final panel2 = _buildCategoryPanel(
context: context,
title: 'Rentabilität & Finanzen',
icon: Icons.account_balance_outlined,
items: profitabilityItems,
);
final panel3 = _buildCategoryPanel(
context: context,
title: 'Dividenden & Termine',
icon: Icons.pie_chart_outline,
items: dividendItems,
);
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 1050) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: panel1),
const SizedBox(width: 14),
Expanded(child: panel2),
const SizedBox(width: 14),
Expanded(child: panel3),
],
);
} else if (constraints.maxWidth >= 680) {
return Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: panel1),
const SizedBox(width: 12),
Expanded(child: panel2),
],
),
const SizedBox(height: 12),
panel3,
],
);
} else {
return Column(
children: [
panel1,
const SizedBox(height: 12),
panel2,
const SizedBox(height: 12),
panel3,
],
);
}
},
);
}
Widget _buildCategoryPanel({
required BuildContext context,
required String title,
required IconData icon,
required List<_MetricRowItem> items,
}) {
return GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: Icon(icon, color: AppTheme.primaryEmerald, size: 16),
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.white),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 10),
const Divider(color: Colors.white10, height: 1),
const SizedBox(height: 4),
for (int i = 0; i < items.length; i++) ...[
_buildMetricTile(context, items[i].label, items[i].value, isEven: i.isEven),
],
],
),
);
}
Widget _buildMetricTile(BuildContext context, String label, String value, {bool isEven = false}) {
final hasExplanation = MetricExplanations.hasExplanation(label);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
decoration: BoxDecoration(
color: isEven ? Colors.white.withValues(alpha: 0.02) : Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
label,
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
overflow: TextOverflow.ellipsis,
),
),
if (hasExplanation) ...[
const SizedBox(width: 4),
InkWell(
onTap: () => MetricExplanations.showModal(context, label),
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(Icons.info_outline, size: 12, color: AppTheme.textMuted),
),
),
],
],
),
),
const SizedBox(width: 8),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white, fontSize: 12),
),
],
),
);
}
}
@@ -0,0 +1,342 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/asset_logo_widget.dart';
import '../../../../shared/widgets/favorite_star_button.dart';
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
import '../../bloc/technical/asset_technical_bloc.dart';
import '../../bloc/technical/asset_technical_state.dart';
import '../../models/fundamental_data_model.dart';
import 'package:url_launcher/url_launcher.dart';
class AssetHeroHeader extends StatelessWidget {
final String isin;
final String name;
final String? symbol;
final void Function(String exchange, String ticker)? onExchangeChanged;
final VoidCallback? onForceRefresh;
const AssetHeroHeader({
super.key,
this.onExchangeChanged,
this.onForceRefresh,
required this.isin,
required this.name,
this.symbol,
});
@override
Widget build(BuildContext context) {
final theme = AppTheme.activePreset;
return BlocBuilder<AssetFundamentalsBloc, AssetFundamentalsState>(
builder: (context, fundState) {
String displayName = name;
String primaryTicker = '';
final String? logoUrl = isin.isNotEmpty ? '/api/v1/logo/$isin' : null;
List<TickerModel> tickerOptions = [
TickerModel(ticker: symbol ?? 'Loading', exchange: 'Loading', tradingCurrency: '', currentPrice: null)
];
if (fundState is AssetFundamentalsLoaded && fundState.data != null) {
final data = fundState.data!;
if (data.companyName.isNotEmpty) {
displayName = data.companyName;
}
if (data.primaryTicker.isNotEmpty) {
primaryTicker = data.primaryTicker;
}
if (data.availableTickers.isNotEmpty) {
tickerOptions = data.availableTickers;
}
}
final selectedOption = tickerOptions.firstWhere(
(t) => (symbol != null && symbol!.isNotEmpty) &&
(t.ticker.toLowerCase() == symbol!.toLowerCase() || (t.exchange != null && t.exchange!.toLowerCase() == symbol!.toLowerCase())),
orElse: () => tickerOptions.firstWhere(
(t) => primaryTicker.isNotEmpty && t.ticker.toLowerCase() == primaryTicker.toLowerCase(),
orElse: () => tickerOptions.first,
),
);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
decoration: BoxDecoration(
color: theme.cardSurface,
border: Border(bottom: BorderSide(color: theme.glassBorder)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Row(
children: [
if (Navigator.canPop(context)) ...[
IconButton(
tooltip: 'Zurück',
icon: Icon(Icons.arrow_back, color: theme.textPrimary),
onPressed: () => Navigator.maybePop(context),
),
const SizedBox(width: 8),
],
AssetLogoWidget(symbolOrName: isin, imageUrl: logoUrl, size: 48),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(
displayName,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w900,
color: theme.textPrimary,
letterSpacing: 0.5,
),
),
const SizedBox(height: 2),
SelectableText(
isin,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: theme.primaryColor,
letterSpacing: 1.0,
),
),
],
),
),
],
),
),
Row(
children: [
IconButton(
tooltip: 'Open in Yahoo Finance',
icon: Icon(Icons.open_in_new, color: theme.textSecondary),
onPressed: () async {
final url = Uri.parse('https://finance.yahoo.com/quote/${selectedOption.ticker}');
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
},
),
const SizedBox(width: 8),
IconButton(
tooltip: 'Force Refresh Data',
icon: Icon(Icons.refresh, color: theme.primaryColor),
onPressed: onForceRefresh,
),
const SizedBox(width: 8),
FavoriteStarButton(symbol: symbol, identifier: isin, name: displayName),
],
),
],
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
builder: (context, taState) {
double? livePrice = selectedOption.currentPrice;
String liveCurrency = selectedOption.tradingCurrency ?? 'EUR';
if (taState is AssetTechnicalLoaded && taState.data != null) {
if (taState.data!.candles.isNotEmpty) {
final lastClose = taState.data!.candles.last.close;
if (lastClose > 0) {
livePrice = lastClose;
}
}
if (taState.data!.currency.isNotEmpty) {
liveCurrency = taState.data!.currency;
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'AKTUELLER PREIS',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: theme.primaryColor,
letterSpacing: 1.5,
),
),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
SelectableText(
livePrice != null && livePrice > 0 ? livePrice.toStringAsFixed(2) : '---',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: theme.textPrimary,
),
),
const SizedBox(width: 6),
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
liveCurrency,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: theme.primaryColor,
),
),
),
],
),
],
);
},
),
PopupMenuButton<String>(
initialValue: selectedOption.ticker,
tooltip: 'Börsenplatz & Ticker auswählen',
color: theme.cardSurface,
onSelected: (newTicker) {
if (onExchangeChanged != null) {
final opt = tickerOptions.firstWhere(
(t) => t.ticker == newTicker,
orElse: () => tickerOptions.first,
);
onExchangeChanged!(opt.exchange ?? 'Unknown', opt.ticker);
}
},
itemBuilder: (context) {
return tickerOptions.map((opt) {
final ex = opt.exchange ?? 'Unknown';
final tick = opt.ticker;
final isPrimary = primaryTicker.isNotEmpty &&
(tick.toLowerCase() == primaryTicker.toLowerCase());
final isSelected = tick == symbol || ex == symbol;
return PopupMenuItem<String>(
value: tick,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 2),
child: Row(
children: [
Icon(
isPrimary ? Icons.star_rounded : Icons.business,
size: 18,
color: isPrimary
? AppTheme.accentCyan
: (isSelected ? theme.primaryColor : theme.textMuted),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'$tick ($ex)',
style: TextStyle(
fontWeight: (isSelected || isPrimary) ? FontWeight.bold : FontWeight.normal,
color: isSelected
? theme.primaryColor
: (isPrimary ? Colors.white : theme.textPrimary),
),
),
),
if (isPrimary) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.accentCyan.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: AppTheme.accentCyan.withValues(alpha: 0.5)),
),
child: Text(
'PRIMARY',
style: TextStyle(
color: AppTheme.accentCyan,
fontSize: 9,
fontWeight: FontWeight.w900,
letterSpacing: 0.6,
),
),
),
],
],
),
),
);
}).toList();
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: (primaryTicker.isNotEmpty && selectedOption.ticker.toLowerCase() == primaryTicker.toLowerCase())
? AppTheme.accentCyan.withValues(alpha: 0.15)
: theme.accentColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: (primaryTicker.isNotEmpty && selectedOption.ticker.toLowerCase() == primaryTicker.toLowerCase())
? AppTheme.accentCyan.withValues(alpha: 0.5)
: theme.accentColor.withValues(alpha: 0.4),
),
),
child: Row(
children: [
Icon(
(primaryTicker.isNotEmpty && selectedOption.ticker.toLowerCase() == primaryTicker.toLowerCase())
? Icons.star_rounded
: Icons.business,
size: 15,
color: (primaryTicker.isNotEmpty && selectedOption.ticker.toLowerCase() == primaryTicker.toLowerCase())
? AppTheme.accentCyan
: theme.accentColor,
),
const SizedBox(width: 6),
Text(
'${selectedOption.ticker} (${selectedOption.exchange ?? 'Unknown'})',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: (primaryTicker.isNotEmpty && selectedOption.ticker.toLowerCase() == primaryTicker.toLowerCase())
? AppTheme.accentCyan
: theme.accentColor,
),
),
const SizedBox(width: 4),
Icon(
Icons.arrow_drop_down,
size: 16,
color: (primaryTicker.isNotEmpty && selectedOption.ticker.toLowerCase() == primaryTicker.toLowerCase())
? AppTheme.accentCyan
: theme.accentColor,
),
],
),
),
),
],
),
],
),
);
},
);
}
}
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
/// Modal dialog explaining key financial metric formulas and trading significance.
class MetricExplanationModal extends StatelessWidget {
final String title;
final String formula;
final String description;
final String tradingSignificance;
const MetricExplanationModal({
super.key,
required this.title,
required this.formula,
required this.description,
required this.tradingSignificance,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Kennzahl: $title'),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Formel:', style: TextStyle(fontWeight: FontWeight.bold)),
Container(
margin: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(8),
),
child: Text(formula, style: const TextStyle(fontFamily: 'monospace', color: Colors.cyanAccent)),
),
const SizedBox(height: 12),
const Text('Erklärung:', style: TextStyle(fontWeight: FontWeight.bold)),
Text(description, style: const TextStyle(fontSize: 13)),
const SizedBox(height: 12),
const Text('Bedeutung für Trading & Bewertung:', style: TextStyle(fontWeight: FontWeight.bold)),
Text(tradingSignificance, style: const TextStyle(fontSize: 13, color: Colors.white70)),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Schließen')),
],
);
}
}
@@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
class CandleData {
final DateTime time;
final double open;
final double high;
final double low;
final double close;
CandleData({
required this.time,
required this.open,
required this.high,
required this.low,
required this.close,
});
factory CandleData.fromJson(Map<String, dynamic> json) {
return CandleData(
time: json['timestamp'] != null ? DateTime.parse(json['timestamp'].toString()) : DateTime.now(),
open: (json['open'] as num? ?? 0.0).toDouble(),
high: (json['high'] as num? ?? 0.0).toDouble(),
low: (json['low'] as num? ?? 0.0).toDouble(),
close: (json['close'] as num? ?? 0.0).toDouble(),
);
}
}
class CandleChartWidget extends StatelessWidget {
final List<CandleData> candles;
final double? supportLevel;
final double? resistanceLevel;
const CandleChartWidget({
super.key,
this.candles = const [],
this.supportLevel,
this.resistanceLevel,
});
@override
Widget build(BuildContext context) {
if (candles.isEmpty) {
return Container(
color: AppTheme.cardSurface,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.show_chart, color: AppTheme.textMuted, size: 48),
const SizedBox(height: 12),
Text(
'Keine Candlestick-Daten verfgbar',
style: TextStyle(color: AppTheme.textMuted, fontSize: 13),
),
],
),
),
);
}
return Container(
color: Colors.black.withValues(alpha: 0.6),
padding: const EdgeInsets.all(12),
child: CustomPaint(
painter: _CandlePainter(
candles: candles,
supportLevel: supportLevel,
resistanceLevel: resistanceLevel,
),
child: Container(),
),
);
}
}
class _CandlePainter extends CustomPainter {
final List<CandleData> candles;
final double? supportLevel;
final double? resistanceLevel;
_CandlePainter({
required this.candles,
this.supportLevel,
this.resistanceLevel,
});
@override
void paint(Canvas canvas, Size size) {
if (candles.isEmpty) return;
double minPrice = candles.first.low;
double maxPrice = candles.first.high;
for (var c in candles) {
if (c.low < minPrice) minPrice = c.low;
if (c.high > maxPrice) maxPrice = c.high;
}
if (supportLevel != null && supportLevel! < minPrice) minPrice = supportLevel!;
if (resistanceLevel != null && resistanceLevel! > maxPrice) maxPrice = resistanceLevel!;
final priceRange = (maxPrice - minPrice) == 0 ? 1.0 : (maxPrice - minPrice);
final padding = size.height * 0.05;
final usableHeight = size.height - (padding * 2);
double getY(double price) {
final normalized = (price - minPrice) / priceRange;
return size.height - padding - (normalized * usableHeight);
}
// Gridlines
final gridPaint = Paint()
..color = Colors.white10
..strokeWidth = 1;
for (int i = 1; i <= 4; i++) {
final y = size.height * (i / 5);
canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint);
}
// Support Line
if (supportLevel != null) {
final supPaint = Paint()
..color = AppTheme.primaryEmerald.withValues(alpha: 0.6)
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
final y = getY(supportLevel!);
canvas.drawLine(Offset(0, y), Offset(size.width, y), supPaint);
}
// Resistance Line
if (resistanceLevel != null) {
final resPaint = Paint()
..color = AppTheme.accentRed.withValues(alpha: 0.6)
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
final y = getY(resistanceLevel!);
canvas.drawLine(Offset(0, y), Offset(size.width, y), resPaint);
}
// Candlesticks
final candleWidth = (size.width / candles.length) * 0.7;
final candleSpacing = size.width / candles.length;
for (int i = 0; i < candles.length; i++) {
final candle = candles[i];
final x = (i * candleSpacing) + (candleSpacing / 2);
final isBullish = candle.close >= candle.open;
final candleColor = isBullish ? AppTheme.primaryEmerald : AppTheme.accentRed;
final wickPaint = Paint()
..color = candleColor
..strokeWidth = 1.5;
final highY = getY(candle.high);
final lowY = getY(candle.low);
canvas.drawLine(Offset(x, highY), Offset(x, lowY), wickPaint);
final openY = getY(candle.open);
final closeY = getY(candle.close);
final topY = openY < closeY ? openY : closeY;
final bodyHeight = (openY - closeY).abs();
final bodyPaint = Paint()
..color = candleColor
..style = PaintingStyle.fill;
canvas.drawRect(
Rect.fromLTWH(
x - (candleWidth / 2),
topY,
candleWidth,
bodyHeight < 1 ? 1 : bodyHeight,
),
bodyPaint,
);
}
}
@override
bool shouldRepaint(covariant _CandlePainter oldDelegate) => true;
}
@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../utils/metric_explanations.dart';
class IndicatorRibbonBar extends StatelessWidget {
final bool showSma50;
final bool showSma200;
final bool showEma;
final bool showSupertrend;
final bool showPatterns;
final bool showSignals;
final ValueChanged<bool> onToggleSma50;
final ValueChanged<bool> onToggleSma200;
final ValueChanged<bool> onToggleEma;
final ValueChanged<bool> onToggleSupertrend;
final ValueChanged<bool> onTogglePatterns;
final ValueChanged<bool> onToggleSignals;
final VoidCallback? onToggleFullscreen;
final bool isFullscreen;
const IndicatorRibbonBar({
super.key,
required this.showSma50,
required this.showSma200,
required this.showEma,
required this.showSupertrend,
required this.showPatterns,
required this.showSignals,
required this.onToggleSma50,
required this.onToggleSma200,
required this.onToggleEma,
required this.onToggleSupertrend,
required this.onTogglePatterns,
required this.onToggleSignals,
this.onToggleFullscreen,
this.isFullscreen = false,
});
Widget _buildChip(BuildContext context, String label, bool isSelected, ValueChanged<bool> onChanged, Color color) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
FilterChip(
selected: isSelected,
label: Text(
label,
style: TextStyle(
color: isSelected ? Colors.black : color,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
selectedColor: color,
backgroundColor: color.withValues(alpha: 0.15),
side: BorderSide(color: color.withValues(alpha: 0.4)),
showCheckmark: false,
onSelected: onChanged,
),
InkWell(
onTap: () => MetricExplanations.show(context, label),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Icon(Icons.info_outline, size: 14, color: AppTheme.textMuted),
),
),
],
);
}
@override
Widget build(BuildContext context) {
return GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildChip(context, 'SMA 50', showSma50, onToggleSma50, AppTheme.accentCyan),
const SizedBox(width: 8),
_buildChip(context, 'SMA 200', showSma200, onToggleSma200, Colors.amber),
const SizedBox(width: 8),
_buildChip(context, 'EMA 20', showEma, onToggleEma, Colors.purpleAccent),
const SizedBox(width: 8),
_buildChip(context, 'Supertrend', showSupertrend, onToggleSupertrend, AppTheme.primaryEmerald),
const SizedBox(width: 8),
_buildChip(context, 'Muster', showPatterns, onTogglePatterns, Colors.orangeAccent),
const SizedBox(width: 8),
_buildChip(context, 'Signale', showSignals, onToggleSignals, Colors.greenAccent),
if (onToggleFullscreen != null) ...[
const SizedBox(width: 12),
Container(width: 1, height: 20, color: AppTheme.activePreset.glassBorder),
const SizedBox(width: 12),
InkWell(
onTap: onToggleFullscreen,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: AppTheme.activePreset.cardSurface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.activePreset.glassBorder),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
size: 16,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
isFullscreen ? 'Normal' : 'Vollbild',
style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold),
),
],
),
),
),
],
],
),
),
);
}
}
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/status_badge.dart';
import '../../models/technical_analysis_model.dart';
import '../../utils/pattern_explanations.dart';
class PatternCardItem extends StatelessWidget {
final ChartPatternModel pattern;
final int index;
final bool isEnabled;
final ValueChanged<bool> onToggle;
const PatternCardItem({
super.key,
required this.pattern,
required this.index,
required this.isEnabled,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
final patternColor = PatternExplanations.getColorForPattern(pattern.type);
final allPoints = [...pattern.upperLine, ...pattern.lowerLine];
DateTime? startDate;
DateTime? endDate;
if (allPoints.isNotEmpty) {
allPoints.sort((a, b) => a.time.compareTo(b.time));
startDate = allPoints.first.time;
endDate = allPoints.last.time;
}
final dateFormat = DateFormat('dd.MM.yy');
final dateStr = startDate != null && endDate != null
? '${dateFormat.format(startDate)} - ${dateFormat.format(endDate)}'
: 'Unbekannt';
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
Checkbox(
value: isEnabled,
activeColor: patternColor,
checkColor: Colors.black,
side: BorderSide(color: patternColor.withValues(alpha: 0.6)),
onChanged: (bool? val) => onToggle(val ?? false),
),
Expanded(
child: InkWell(
onTap: () => PatternExplanations.showPatternDetails(context, pattern.type),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: isEnabled ? patternColor.withValues(alpha: 0.15) : AppTheme.glassSurface,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.polyline_outlined,
color: isEnabled ? patternColor : AppTheme.textMuted,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
pattern.type,
style: TextStyle(
fontWeight: FontWeight.bold,
color: isEnabled ? Colors.white : AppTheme.textMuted,
fontSize: 14,
decoration: isEnabled ? null : TextDecoration.lineThrough,
),
),
const SizedBox(width: 6),
Icon(Icons.info_outline, size: 14, color: AppTheme.textMuted),
],
),
Text(
'Zeitraum: $dateStr\n'
'Linien: Oben (${pattern.upperLine.length} Pkt.) / Unten (${pattern.lowerLine.length} Pkt.)',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11, height: 1.3),
),
],
),
),
StatusBadge(
label: isEnabled ? 'AKTIV' : 'AUS',
color: isEnabled ? patternColor : AppTheme.textMuted,
),
],
),
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/status_badge.dart';
import '../../models/technical_analysis_model.dart';
class SignalCardItem extends StatelessWidget {
final StrategySignalModel signal;
const SignalCardItem({super.key, required this.signal});
@override
Widget build(BuildContext context) {
final isBuy = signal.type.toUpperCase() == 'BUY';
final color = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: GlassContainer(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Icon(isBuy ? Icons.north_east : Icons.south_east, color: color, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
signal.type.toUpperCase(),
style: TextStyle(fontWeight: FontWeight.bold, color: color, fontSize: 14),
),
const SizedBox(width: 8),
Text(
'@ €${signal.price.toStringAsFixed(2)}',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13),
),
],
),
const SizedBox(height: 4),
Text(
'Strategisches Kaufsignal ausgelöst durch technische Indikatoren.',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
),
],
),
),
StatusBadge(label: 'SIGNAL', color: color),
],
),
),
);
}
}
@@ -0,0 +1,371 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/status_badge.dart';
import '../../../trades/models/trade_model.dart';
/// Trade summary card for the asset-detail "Trades" tab.
///
/// Migrated onto `ActiveTradeDto` (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`).
/// A number of fields this card used to show no longer exist server-side at
/// all (reasoning/technicalRationale/fundamentalRationale/riskWarning,
/// hasPendingExitAlert/pendingExitReason, entryZoneMin/Max, maxLeverage,
/// timeframe/riskTolerance/companyName, closeReason) those sections were
/// removed rather than kept alive showing an empty/zero placeholder
/// (Rules.md §4).
class AssetTradeItemCard extends StatelessWidget {
final TradeModel trade;
final String defaultSymbol;
final VoidCallback? onAccept;
final VoidCallback? onSettings;
final VoidCallback? onClose;
const AssetTradeItemCard({
super.key,
required this.trade,
required this.defaultSymbol,
this.onAccept,
this.onSettings,
this.onClose,
});
String _fmt(double val) => val.toStringAsFixed(2);
@override
Widget build(BuildContext context) {
final isin = trade.underlyingIsin.isNotEmpty ? trade.underlyingIsin : defaultSymbol;
final isBuy = trade.direction.isLong;
final isActive = trade.isActive;
final sideColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
// Entry price: the real fill-weighted average the engine already
// computed, not a planned/target zone (that concept no longer exists
// server-side).
final entryPrice = trade.averageBuyIn;
// Live protective stop: `currentStopLoss` (not `initialStopLoss`) is
// used here because this card shows the trade's live state — the
// current stop already reflects any break-even/trailing adjustment the
// engine has made. `initialStopLoss` (the original plan value) is only
// relevant historically and is shown in the trade detail view instead.
final stopLoss = trade.currentStopLoss;
final tpStages = trade.exitPlan.takeProfitStages;
// Server-computed reward:risk multiple for the first take-profit stage
// used instead of a client-side recomputation from raw prices.
final primaryRMultiple = tpStages.isNotEmpty ? tpStages.first.rMultiple : null;
final investedCapital = entryPrice > 0 && trade.totalQuantity > 0 ? entryPrice * trade.totalQuantity : null;
// Never recomputed from raw prices client-side always the server's
// own figure (realized once resolved, otherwise its live unrealized
// value; see `TradeModel.pnlEur`).
final pnlEur = trade.pnlEur;
final pnlPercent = trade.unrealizedPnlPercent;
final isPnlWin = pnlEur >= 0;
final pnlColor = isPnlWin ? AppTheme.primaryEmerald : AppTheme.accentRed;
final showPnl = isActive || trade.isClosed;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
StatusBadge(label: trade.direction.label, color: sideColor),
const SizedBox(width: 8),
StatusBadge(
label: trade.status.label,
color: isActive ? AppTheme.primaryEmerald : (trade.isProposed ? AppTheme.accentCyan : AppTheme.textMuted),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(6),
),
child: Text(
trade.derivativeIsin != null && trade.derivativeIsin!.isNotEmpty
? '${trade.instrumentType.label} (${trade.derivativeIsin})'
: trade.instrumentType.label,
style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.bold),
),
),
],
),
Row(
children: [
if (isActive) ...[
if (onClose != null)
ElevatedButton.icon(
onPressed: onClose,
icon: const Icon(Icons.flag_outlined, size: 14),
label: const Text('Schließen'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentRed,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
const SizedBox(width: 8),
if (onSettings != null)
IconButton(
onPressed: onSettings,
icon: const Icon(Icons.settings, size: 16, color: Colors.white),
style: IconButton.styleFrom(
backgroundColor: AppTheme.glassSurface,
padding: const EdgeInsets.all(8),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
] else if (trade.isProposed) ...[
if (onAccept != null)
ElevatedButton.icon(
onPressed: onAccept,
icon: const Icon(Icons.check_circle, size: 14),
label: const Text('Trade Annehmen'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
],
),
],
),
// Active Drift Radar Bar
if (isActive) ...[
const SizedBox(height: 10),
_buildDriftRadarBar(trade),
],
const SizedBox(height: 12),
Text(
'${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol} ($isin)',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
const SizedBox(height: 12),
// Price Metrics Grid
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.glassBorder),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTradeStat('Einstiegskurs', '${_fmt(entryPrice)}', Colors.white),
_buildTradeStat(
trade.driftStatus == DriftStatus.trailingActive ? 'Stop (Trailing)' : 'Stop-Loss',
'${_fmt(stopLoss)}',
AppTheme.accentRed,
),
_buildTradeStat(
'Take-Profit',
tpStages.isNotEmpty ? tpStages.map((s) => '${_fmt(s.targetPrice)}').join(' / ') : 'Kein Fixziel (Trailing-Exit)',
AppTheme.primaryEmerald,
),
],
),
if (primaryRMultiple != null) ...[
const Divider(color: Colors.white10, height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTradeStat('Chance-Risiko (TP1, R-Multiple)', '${_fmt(primaryRMultiple)}R', AppTheme.accentCyan),
if (investedCapital != null) _buildTradeStat('Eingesetztes Kapital', '${_fmt(investedCapital)}', Colors.white70),
],
),
],
],
),
),
// Position size / quantity
if (trade.totalQuantity > 0) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.3)),
),
child: Row(
children: [
Icon(Icons.person_pin_outlined, size: 14, color: AppTheme.primaryEmerald),
const SizedBox(width: 6),
Expanded(
child: Text(
'Stückzahl: ${_fmt(trade.totalQuantity)}${trade.isDerivative ? ' (Derivat)' : ''}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Colors.white),
),
),
],
),
),
],
// PnL (server-computed, never recalculated client-side)
if (showPnl) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: pnlColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: pnlColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(isPnlWin ? Icons.trending_up : Icons.trending_down, size: 16, color: pnlColor),
const SizedBox(width: 6),
Text(
trade.isClosed ? 'Realisierter PnL:' : 'Aktueller PnL (unrealisiert):',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white),
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTradeStat('Aktueller Kurs', '${_fmt(trade.currentPrice)}', Colors.white),
_buildTradeStat('PnL (€)', '${isPnlWin ? "+€" : "-€"}${_fmt(pnlEur.abs())}', pnlColor),
_buildTradeStat('PnL (%)', '${pnlPercent >= 0 ? "+" : ""}${_fmt(pnlPercent)}%', pnlColor),
],
),
],
),
),
],
// Execution history (replaces the removed AI-Guardian hourly
// check-in timeline, which no backend DTO produces anymore
// this is the trade's real fill history instead).
if (trade.fills.isNotEmpty) ...[
const SizedBox(height: 8),
ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(bottom: 6),
dense: true,
leading: Icon(Icons.history_toggle_off, color: AppTheme.accentCyan, size: 18),
title: Text(
'Ausführungshistorie (${trade.fills.length} Fills)',
style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, fontWeight: FontWeight.bold),
),
children: trade.fills.reversed.map((f) {
return Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.03),
borderRadius: BorderRadius.circular(6),
),
child: Row(
children: [
Text(
'${f.executedAtUtc.day.toString().padLeft(2, '0')}.${f.executedAtUtc.month.toString().padLeft(2, '0')} '
'${f.executedAtUtc.hour.toString().padLeft(2, '0')}:${f.executedAtUtc.minute.toString().padLeft(2, '0')}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'${_fmt(f.quantity)} Stk. @ €${_fmt(f.price)}${f.fee > 0 ? ' (Gebühr €${_fmt(f.fee)})' : ''}',
style: const TextStyle(color: Colors.white70, fontSize: 11),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (f.note != null && f.note!.isNotEmpty)
Text(f.note!, style: TextStyle(color: AppTheme.textMuted, fontSize: 10, fontStyle: FontStyle.italic)),
],
),
);
}).toList(),
),
],
],
),
),
);
}
Widget _buildDriftRadarBar(TradeModel t) {
Color col;
String label;
IconData icon;
switch (t.driftStatus) {
case DriftStatus.trailingActive:
col = AppTheme.accentCyan;
label = 'Drift-Radar: Trailing-Stop aktiv nachgezogen';
icon = Icons.security;
break;
case DriftStatus.driftWarning:
col = Colors.orangeAccent;
label = 'Drift-Radar: Leichte Abweichung von Prognose';
icon = Icons.tune;
break;
case DriftStatus.onTrack:
col = AppTheme.primaryEmerald;
label = 'Drift-Radar: Prognose intakt';
icon = Icons.radar;
break;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: col.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: col.withValues(alpha: 0.25)),
),
child: Row(
children: [
Icon(icon, color: col, size: 14),
const SizedBox(width: 6),
Expanded(
child: Text(label, style: TextStyle(color: col, fontSize: 11, fontWeight: FontWeight.bold)),
),
],
),
);
}
Widget _buildTradeStat(String title, String val, Color col) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)),
const SizedBox(height: 2),
Text(val, style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 13)),
],
);
}
}
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../trades/models/trade_model.dart';
import '../../../trades/models/close_trade_request_dto.dart';
class CloseTradeDialog {
static void show(
BuildContext context, {
required TradeModel trade,
required String defaultSymbol,
required void Function(CloseTradeRequestDto) onClose,
}) {
final entry = trade.averageBuyIn;
final exitController = TextEditingController(text: entry.toStringAsFixed(2));
showDialog(
context: context,
builder: (dialogContext) {
return AlertDialog(
backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppTheme.glassBorder),
),
title: Row(
children: [
Icon(Icons.flag_outlined, color: AppTheme.accentRed, size: 22),
const SizedBox(width: 8),
const Expanded(
child: Text('Trade Position Schließen', style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
),
],
),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
(trade.derivativeIsin?.isNotEmpty ?? false)
? 'Trade-ID: ${trade.id} | Derivat: ${trade.derivativeIsin} (${trade.instrumentType.label}) | Asset: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}'
: 'Trade-ID: ${trade.id} | Asset: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
),
const SizedBox(height: 16),
TextField(
controller: exitController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: (trade.derivativeIsin?.isNotEmpty ?? false) ? 'Derivat-Verkaufskurs (€)' : 'Tatsächlicher Ausstiegskurs (€)',
hintText: 'Gekauft zu €${entry.toStringAsFixed(2)}',
helperText: (trade.derivativeIsin?.isNotEmpty ?? false) ? 'Gib den Verkaufskurs des Derivats/Zertifikats ein' : null,
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
),
ElevatedButton.icon(
onPressed: () {
final exitPrice = double.tryParse(exitController.text) ?? entry;
Navigator.pop(dialogContext);
onClose(CloseTradeRequestDto(userExitPrice: exitPrice));
},
icon: const Icon(Icons.check),
label: const Text('Position Schließen & Buchen'),
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.accentRed, foregroundColor: Colors.white),
),
],
);
},
);
}
}

Some files were not shown because too many files have changed in this diff Show More