feat(App): update Finlytic Flutter app UI and blocs

This commit is contained in:
2026-08-09 21:01:46 +02:00
parent e7427b7464
commit a708d2977c
591 changed files with 1095105 additions and 0 deletions
@@ -0,0 +1,57 @@
import 'package:dio/dio.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 const String baseUrl = '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) {
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,136 @@
import 'dart:async';
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` and `/hubs/favorites-prices` WebSockets.
class SignalRService extends ChangeNotifier {
final SecureStorageService storageService;
HubConnection? _healthConnection;
HubConnection? _favoritesConnection;
bool _isConnected = false;
final _statusController = StreamController<bool>.broadcast();
final _healthController = StreamController<List<Map<String, dynamic>>>.broadcast();
final _favoritePricesController = 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;
static const String baseUrl = 'http://localhost:5000';
SignalRService(this.storageService);
Future<void> initSignalR() async {
if (_isConnected) return;
try {
final token = await storageService.getToken();
// 1. Connect SystemHealthHub over WebSockets
_healthConnection = HubConnectionBuilder()
.withUrl(
'$baseUrl/hubs/health',
HttpConnectionOptions(
accessTokenFactory: () async => token,
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');
}
}
});
_healthConnection!.onclose((error) {
if (kDebugMode) debugPrint('[SignalR Health WS] Closed: $error');
});
await _healthConnection!.start();
if (kDebugMode) debugPrint('[SignalR Health WS] Connected via WebSocket to /hubs/health.');
// 2. Connect FavoritesPriceHub over WebSockets
_favoritesConnection = HubConnectionBuilder()
.withUrl(
'$baseUrl/hubs/favorites-prices',
HttpConnectionOptions(
accessTokenFactory: () async => token,
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');
}
}
});
await _favoritesConnection!.start();
if (kDebugMode) debugPrint('[SignalR Favorites WS] Connected via WebSocket to /hubs/favorites-prices.');
_isConnected = true;
_statusController.add(true);
notifyListeners();
} catch (e) {
if (kDebugMode) debugPrint('[SignalR WebSocket Connection Error] $e');
_isConnected = false;
_statusController.add(false);
notifyListeners();
}
}
void broadcastHealthUpdate(List<Map<String, dynamic>> healthData) {
_healthController.add(healthData);
notifyListeners();
}
void broadcastFavoritePrices(Map<String, dynamic> priceMap) {
_favoritePricesController.add(priceMap);
notifyListeners();
}
void disconnect() async {
try {
await _healthConnection?.stop();
await _favoritesConnection?.stop();
} catch (_) {}
_isConnected = false;
_statusController.add(false);
notifyListeners();
}
@override
void dispose() {
disconnect();
_statusController.close();
_healthController.close();
_favoritePricesController.close();
super.dispose();
}
}
@@ -0,0 +1,37 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Secure token and session storage service.
class SecureStorageService {
final _storage = const FlutterSecureStorage();
static const String _keyToken = 'jwt_token';
static const String _keyUserEmail = 'user_email';
Future<void> saveToken(String token) async {
await _storage.write(key: _keyToken, value: token);
}
Future<String?> getToken() async {
return await _storage.read(key: _keyToken);
}
Future<void> saveUserEmail(String email) async {
await _storage.write(key: _keyUserEmail, value: email);
}
Future<String?> getUserEmail() async {
return await _storage.read(key: _keyUserEmail);
}
Future<void> deleteToken() async {
await _storage.delete(key: _keyToken);
}
Future<void> deleteUserEmail() async {
await _storage.delete(key: _keyUserEmail);
}
Future<void> clearAll() async {
await _storage.deleteAll();
}
}
+232
View File
@@ -0,0 +1,232 @@
import 'package:flutter/material.dart';
class ThemePreset {
final String id;
final String name;
final Brightness brightness;
final Color darkBackground;
final Color cardSurface;
final Color glassSurface;
final Color glassBorder;
final Color primaryColor;
final Color accentColor;
final Color accentRed;
final Color textPrimary;
final Color textSecondary;
final Color textMuted;
final double borderRadius;
final List<BoxShadow> boxShadows;
const ThemePreset({
required this.id,
required this.name,
required this.brightness,
required this.darkBackground,
required this.cardSurface,
required this.glassSurface,
required this.glassBorder,
required this.primaryColor,
required this.accentColor,
required this.accentRed,
required this.textPrimary,
required this.textSecondary,
required this.textMuted,
required this.borderRadius,
required this.boxShadows,
});
ThemeData toThemeData() {
final isDark = brightness == Brightness.dark;
return (isDark ? ThemeData.dark() : ThemeData.light()).copyWith(
scaffoldBackgroundColor: darkBackground,
primaryColor: primaryColor,
colorScheme: isDark
? ColorScheme.dark(
primary: primaryColor,
secondary: accentColor,
surface: cardSurface,
error: accentRed,
)
: ColorScheme.light(
primary: primaryColor,
secondary: accentColor,
surface: cardSurface,
error: accentRed,
),
appBarTheme: AppBarTheme(
backgroundColor: cardSurface,
elevation: isDark ? 0 : 1,
centerTitle: false,
iconTheme: IconThemeData(color: textPrimary),
titleTextStyle: TextStyle(color: textPrimary, fontSize: 18, fontWeight: FontWeight.bold),
),
cardTheme: CardThemeData(
color: cardSurface,
elevation: isDark ? 0 : 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(borderRadius),
side: BorderSide(color: glassBorder, width: 1),
),
),
dialogTheme: DialogThemeData(
backgroundColor: cardSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(borderRadius + 2),
side: BorderSide(color: glassBorder, width: 1),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: glassSurface,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(borderRadius),
borderSide: BorderSide(color: glassBorder),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(borderRadius),
borderSide: BorderSide(color: glassBorder),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(borderRadius),
borderSide: BorderSide(color: primaryColor, width: 1.5),
),
),
);
}
}
/// Dynamic Finlytic Multi-Theme Presets & Tokens Engine.
class AppTheme {
static const ThemePreset darkClassic = ThemePreset(
id: 'dark_classic',
name: 'Dark',
brightness: Brightness.dark,
darkBackground: Color(0xFF0A0E17),
cardSurface: Color(0xFF12182B),
glassSurface: Color(0x2A1A233A),
glassBorder: Color(0x33425980),
primaryColor: Color(0xFF00E676),
accentColor: Color(0xFF00E5FF),
accentRed: Color(0xFFFF5252),
textPrimary: Colors.white,
textSecondary: Color(0xFFB0BEC5),
textMuted: Color(0xFF607D8B),
borderRadius: 14.0,
boxShadows: [
BoxShadow(color: Color(0x1F000000), blurRadius: 10, offset: Offset(0, 4)),
],
);
static const ThemePreset darkCyberNeon = ThemePreset(
id: 'dark_cyber_neon',
name: 'Alternative Dark (Cyber Neon)',
brightness: Brightness.dark,
darkBackground: Color(0xFF0D0B1E),
cardSurface: Color(0xFF161233),
glassSurface: Color(0x3A261D52),
glassBorder: Color(0x5500FFA3),
primaryColor: Color(0xFF00FFA3),
accentColor: Color(0xFFFF007A),
accentRed: Color(0xFFFF2E93),
textPrimary: Color(0xFFF5F3FF),
textSecondary: Color(0xFFC4B5FD),
textMuted: Color(0xFF8B5CF6),
borderRadius: 8.0,
boxShadows: [
BoxShadow(color: Color(0x3300FFA3), blurRadius: 12, spreadRadius: -2),
],
);
static const ThemePreset lightClassic = ThemePreset(
id: 'light_classic',
name: 'Light',
brightness: Brightness.light,
darkBackground: Color(0xFFF8FAFC),
cardSurface: Color(0xFFFFFFFF),
glassSurface: Color(0xFFF1F5F9),
glassBorder: Color(0xFFE2E8F0),
primaryColor: Color(0xFF059669),
accentColor: Color(0xFF0284C7),
accentRed: Color(0xFFDC2626),
textPrimary: Color(0xFF0F172A),
textSecondary: Color(0xFF475569),
textMuted: Color(0xFF94A3B8),
borderRadius: 16.0,
boxShadows: [
BoxShadow(color: Color(0x0F000000), blurRadius: 12, offset: Offset(0, 4)),
],
);
static const ThemePreset lightWarmSand = ThemePreset(
id: 'light_warm_sand',
name: 'Alternative Light (Warm Paper)',
brightness: Brightness.light,
darkBackground: Color(0xFFF5F2EB),
cardSurface: Color(0xFFFFFDF9),
glassSurface: Color(0xFFEFEADF),
glassBorder: Color(0xFFE2D9C8),
primaryColor: Color(0xFFD97706),
accentColor: Color(0xFF2563EB),
accentRed: Color(0xFFE11D48),
textPrimary: Color(0xFF272522),
textSecondary: Color(0xFF57534E),
textMuted: Color(0xFFA8A29E),
borderRadius: 12.0,
boxShadows: [
BoxShadow(color: Color(0x14443422), blurRadius: 8, offset: Offset(0, 3)),
],
);
static const ThemePreset nordicMint = ThemePreset(
id: 'nordic_mint',
name: 'Nordic Mint',
brightness: Brightness.light,
darkBackground: Color(0xFFEFF6F5),
cardSurface: Color(0xFFFFFFFF),
glassSurface: Color(0xFFE0F2FE),
glassBorder: Color(0xFFCCFBF1),
primaryColor: Color(0xFF0D9488),
accentColor: Color(0xFF0284C7),
accentRed: Color(0xFFF43F5E),
textPrimary: Color(0xFF111827),
textSecondary: Color(0xFF374151),
textMuted: Color(0xFF6B7280),
borderRadius: 20.0,
boxShadows: [
BoxShadow(color: Color(0x0D0F766E), blurRadius: 14, offset: Offset(0, 4)),
],
);
static const List<ThemePreset> allPresets = [
darkClassic,
darkCyberNeon,
lightClassic,
lightWarmSand,
nordicMint,
];
static ThemePreset getPresetById(String? id) {
return allPresets.firstWhere(
(p) => p.id.toLowerCase() == id?.toLowerCase(),
orElse: () => darkClassic,
);
}
// Legacy static color mappings for backward compatibility
static Color get darkBackground => activePreset.darkBackground;
static Color get cardSurface => activePreset.cardSurface;
static Color get surfaceDark => activePreset.cardSurface;
static Color get glassSurface => activePreset.glassSurface;
static Color get glassBorder => activePreset.glassBorder;
static Color get primaryEmerald => activePreset.primaryColor;
static Color get accentCyan => activePreset.accentColor;
static Color get accentRed => activePreset.accentRed;
static Color get textPrimary => activePreset.textPrimary;
static Color get textSecondary => activePreset.textSecondary;
static Color get textMuted => activePreset.textMuted;
static ThemePreset activePreset = darkClassic;
static ThemeData get darkTheme => activePreset.toThemeData();
}
@@ -0,0 +1,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,98 @@
import '../network/api_client.dart';
/// Asset utility helpers to map ISIN codes, symbols, and company names to logos and details.
class AssetUtils {
static final Map<String, String> _isinToNameMap = {
'US0378331005': 'Apple Inc.',
'US5949181045': 'Microsoft Corp.',
'US0231351067': 'Amazon.com Inc.',
'US67066G1040': 'NVIDIA Corp.',
'US88160R1014': 'Tesla Inc.',
'US02079K3059': 'Alphabet Inc.',
'US30303M1027': 'Meta Platforms',
'DE0007164600': 'SAP SE',
'DE0007236101': 'Siemens AG',
'DE0008469008': 'Allianz SE',
'FR0004125920': 'Amundi',
};
static final Map<String, String> _nameToIsinMap = {
'APPLE INC.': 'US0378331005',
'APPLE': 'US0378331005',
'MICROSOFT CORP.': 'US5949181045',
'MICROSOFT': 'US5949181045',
'AMAZON.COM INC.': 'US0231351067',
'AMAZON': 'US0231351067',
'NVIDIA CORP.': 'US67066G1040',
'NVIDIA': 'US67066G1040',
'TESLA INC.': 'US88160R1014',
'TESLA': 'US88160R1014',
'ALPHABET INC.': 'US02079K3059',
'ALPHABET': 'US02079K3059',
'META PLATFORMS': 'US30303M1027',
'META': 'US30303M1027',
'SAP SE': 'DE0007164600',
'SAP': 'DE0007164600',
'SIEMENS AG': 'DE0007236101',
'SIEMENS': 'DE0007236101',
'ALLIANZ SE': 'DE0008469008',
'ALLIANZ': 'DE0008469008',
'AMUNDI': 'FR0004125920',
};
static final Map<String, String> _imageMap = {};
/// Registers an ISIN, Name, and optional Logo Image URL.
static void registerAsset(String isin, String name, [String? imageUrl]) {
final cleanIsin = isin.trim().toUpperCase();
final cleanName = name.trim();
if (cleanIsin.isNotEmpty && cleanName.isNotEmpty) {
_isinToNameMap[cleanIsin] = cleanName;
_nameToIsinMap[cleanName.toUpperCase()] = cleanIsin;
}
if (imageUrl != null && imageUrl.isNotEmpty) {
final resolved = resolveUrl(imageUrl);
if (cleanIsin.isNotEmpty) _imageMap[cleanIsin] = resolved;
if (cleanName.isNotEmpty) _imageMap[cleanName.toUpperCase()] = resolved;
}
}
/// Resolves relative logo URLs (/api/logo/...) to complete backend endpoints.
static String resolveUrl(String url) {
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
if (url.startsWith('/')) {
return '${ApiClient.baseUrl}$url';
}
return '${ApiClient.baseUrl}/$url';
}
/// Resolves an ISIN or symbol to readable asset name.
static String getAssetName(String isinOrSymbol) {
final key = isinOrSymbol.trim().toUpperCase();
if (_isinToNameMap.containsKey(key)) {
return _isinToNameMap[key]!;
}
return isinOrSymbol;
}
/// Resolves an asset name or symbol to ISIN.
static String? getIsin(String nameOrSymbol) {
final key = nameOrSymbol.trim().toUpperCase();
if (_isinToNameMap.containsKey(key)) return key;
return _nameToIsinMap[key];
}
/// Returns official local backend logo URL for given symbol/name/ISIN.
static String? getLogoUrl(String symbolOrName) {
final key = symbolOrName.trim().toUpperCase();
if (_imageMap.containsKey(key)) return resolveUrl(_imageMap[key]!);
final isin = getIsin(key) ?? (key.length == 12 ? key : null);
if (isin != null && isin.length == 12) {
return '${ApiClient.baseUrl}/api/logo/$isin';
}
return null;
}
}
@@ -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,130 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../theme/app_theme.dart';
import '../utils/asset_utils.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,
this.imageUrl,
this.size = 32,
this.enableHero = true,
});
@override
Widget build(BuildContext context) {
final rawUrl = imageUrl ?? AssetUtils.getLogoUrl(symbolOrName);
final logoUrl = rawUrl != null && rawUrl.isNotEmpty ? AssetUtils.resolveUrl(rawUrl) : null;
final initial = symbolOrName.isNotEmpty ? symbolOrName[0].toUpperCase() : 'A';
final colors = _getGradientColors(initial);
Widget content;
if (logoUrl != null && logoUrl.isNotEmpty && !_failedUrls.contains(logoUrl)) {
final isSvg = logoUrl.toLowerCase().endsWith('.svg') ||
logoUrl.contains('traderepublic.com') ||
logoUrl.contains('/api/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(
logoUrl,
width: size,
height: size,
fit: BoxFit.contain,
placeholderBuilder: (context) => _buildFallback(initial, colors),
errorBuilder: (context, error, stackTrace) {
_failedUrls.add(logoUrl);
return _buildFallback(initial, colors);
},
)
: Image.network(
logoUrl,
width: size,
height: size,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
_failedUrls.add(logoUrl);
return _buildFallback(initial, colors);
},
),
),
);
} else {
content = _buildFallback(initial, colors);
}
if (enableHero && symbolOrName.isNotEmpty) {
return Hero(
tag: 'asset_logo_$symbolOrName',
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,57 @@
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}) {
Color bg = AppTheme.textMuted;
if (status.toUpperCase().contains('POS') || (score != null && score > 0.15)) {
bg = AppTheme.primaryEmerald;
} else if (status.toUpperCase().contains('NEG') || (score != null && score < -0.15)) {
bg = AppTheme.accentRed;
} else if (status.toUpperCase().contains('NEU')) {
bg = AppTheme.accentCyan;
}
return StatusBadge(label: status.toUpperCase(), 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,
),
),
],
),
);
}
}