feat(App): update Finlytic Flutter app UI and blocs
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user