Compare commits
66 Commits
6337e63a77
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 567ddea46a | |||
| 2fe8cc0dca | |||
| d161efb370 | |||
| cb8a169043 | |||
| c5d7d359ba | |||
| 6a0f9af3f8 | |||
| 2b7d59f40d | |||
| 0894c40f07 | |||
| 676496b77d | |||
| 6974b2075b | |||
| 7060f0f7b1 | |||
| 5c95dd182c | |||
| a4959658a2 | |||
| f43ce2b7e9 | |||
| 12e7b57b16 | |||
| 600ccf299e | |||
| 8112598602 | |||
| 44b161d509 | |||
| 6ab84fe1de | |||
| 5497cc5de7 | |||
| 3972507cb0 | |||
| 75a2510b39 | |||
| aaef272f4e | |||
| d8ba28a810 | |||
| b5cc70c08c | |||
| 2ba54e8057 | |||
| b0f8d4b78b | |||
| f18f75c1ab | |||
| 1f9d66405a | |||
| 57554a9582 | |||
| 0d370d09e7 | |||
| 62e030e2cf | |||
| 1522c3480f | |||
| a94c36a878 | |||
| a1f2b888f6 | |||
| 3dbee36ca0 | |||
| 34fa774cbf | |||
| 882d24a316 | |||
| 7e18257e3e | |||
| 960a4bdbd6 | |||
| 067f1bfdd5 | |||
| 5a6a50a609 | |||
| 1ccb6b613f | |||
| 15f8f7896e | |||
| f08fecde23 | |||
| a3f9e55a7e | |||
| 1d244b338a | |||
| f94e3b8164 | |||
| 5c4b3165ba | |||
| c496651dd1 | |||
| 4d5ab09bbd | |||
| 2151fd89f0 | |||
| 1447f0aa4c | |||
| 4f733bf8c3 | |||
| 3d8af3940b | |||
| a9553e9fbf | |||
| fdf4b6efcb | |||
| a708d2977c | |||
| e7427b7464 | |||
| c74a4456af | |||
| 05d55a324c | |||
| aff831cf58 | |||
| 605e41cfeb | |||
| e0778b88ea | |||
| b57cc9894c | |||
| 5475c3ac51 |
+14
-1
@@ -22,4 +22,17 @@
|
||||
**/secrets.dev.yaml
|
||||
**/values.dev.yaml
|
||||
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
@@ -1,6 +1,8 @@
|
||||
## Build outputs
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/publish/
|
||||
TestResults/
|
||||
|
||||
## Rider / JetBrains / VS Code / Visual Studio
|
||||
.idea/
|
||||
@@ -22,11 +24,21 @@ assets/
|
||||
|
||||
## Secrets & local environment files
|
||||
.env
|
||||
.env.*
|
||||
.env.bak*
|
||||
*.env.local
|
||||
appsettings.Development.json
|
||||
|
||||
## User-specific files
|
||||
*.user
|
||||
*.suo
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
## Flutter & Dart
|
||||
**/.dart_tool/
|
||||
**/build/
|
||||
*.log
|
||||
|
||||
## Temporary data & scratch
|
||||
Yahoo finance data/
|
||||
*.tmp
|
||||
*.tar
|
||||
FinlyticApp/lib/tickers_grep.json
|
||||
|
||||
@@ -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`).
|
||||
@@ -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
@@ -11,23 +11,200 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticCore", "FinlyticCor
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticNews", "FinlyticNews\FinlyticNews.csproj", "{03B4D920-6173-44E8-A1E2-8945D8393CEA}"
|
||||
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
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{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|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.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.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.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.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.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
|
||||
EndGlobal
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+161
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+263
@@ -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
Reference in New Issue
Block a user