Flutter App
This commit is contained in:
76
flutter_app/lib/services/api_service.dart
Normal file
76
flutter_app/lib/services/api_service.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import '../models/price_data.dart';
|
||||
|
||||
class ApiService {
|
||||
final String baseUrl;
|
||||
|
||||
ApiService() : baseUrl = dotenv.env['API_URL'] ?? 'http://localhost:3000' {
|
||||
// Debug: Print the actual URL being used
|
||||
if (kDebugMode) {
|
||||
print('ApiService initialized with baseUrl: $baseUrl');
|
||||
print('Available env vars: ${dotenv.env.keys.toList()}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<LatestPrice?> fetchLatestPrice() async {
|
||||
try {
|
||||
final url = '$baseUrl/prices/latest';
|
||||
if (kDebugMode) {
|
||||
print('Fetching latest price from: $url');
|
||||
}
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (kDebugMode) {
|
||||
print('Response status: ${response.statusCode}');
|
||||
print('Response body: ${response.body}');
|
||||
}
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return LatestPrice.fromJson(data);
|
||||
} else {
|
||||
if (kDebugMode) {
|
||||
print('HTTP Error ${response.statusCode}: ${response.body}');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error fetching latest price: $e');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<HistoryData?> fetchHistory(String range) async {
|
||||
try {
|
||||
final url = '$baseUrl/index/history?range=$range';
|
||||
if (kDebugMode) {
|
||||
print('Fetching history from: $url');
|
||||
}
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (kDebugMode) {
|
||||
print('History response status: ${response.statusCode}');
|
||||
}
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return HistoryData.fromJson(data);
|
||||
} else {
|
||||
if (kDebugMode) {
|
||||
print('HTTP Error ${response.statusCode}: ${response.body}');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error fetching history: $e');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
150
flutter_app/lib/services/notification_service.dart
Normal file
150
flutter_app/lib/services/notification_service.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class NotificationService {
|
||||
static final NotificationService _instance = NotificationService._internal();
|
||||
factory NotificationService() => _instance;
|
||||
NotificationService._internal();
|
||||
|
||||
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
|
||||
bool _initialized = false;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
|
||||
try {
|
||||
// Android initialization
|
||||
const AndroidInitializationSettings androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
// iOS initialization
|
||||
const DarwinInitializationSettings iosSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
);
|
||||
|
||||
// Linux/Windows initialization
|
||||
const LinuxInitializationSettings linuxSettings = LinuxInitializationSettings(
|
||||
defaultActionName: 'Open rmtPocketWatcher',
|
||||
);
|
||||
|
||||
const InitializationSettings settings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: iosSettings,
|
||||
linux: linuxSettings,
|
||||
);
|
||||
|
||||
await _notifications.initialize(
|
||||
settings,
|
||||
onDidReceiveNotificationResponse: _onNotificationTapped,
|
||||
);
|
||||
|
||||
_initialized = true;
|
||||
|
||||
if (kDebugMode) {
|
||||
print('NotificationService initialized successfully');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Failed to initialize notifications: $e');
|
||||
}
|
||||
// Don't throw - allow app to continue without notifications
|
||||
}
|
||||
}
|
||||
|
||||
void _onNotificationTapped(NotificationResponse response) {
|
||||
// Handle notification tap if needed
|
||||
if (kDebugMode) {
|
||||
print('Notification tapped: ${response.payload}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showPriceAlert({
|
||||
required String title,
|
||||
required String body,
|
||||
required double auecAmount,
|
||||
required double price,
|
||||
required String seller,
|
||||
}) async {
|
||||
if (!_initialized) await initialize();
|
||||
if (!_initialized) return; // Skip if initialization failed
|
||||
|
||||
try {
|
||||
// Android notification details
|
||||
const AndroidNotificationDetails androidDetails = AndroidNotificationDetails(
|
||||
'price_alerts',
|
||||
'Price Alerts',
|
||||
channelDescription: 'Notifications for AUEC price alerts from rmtPocketWatcher',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
sound: RawResourceAndroidNotificationSound('notifcation'),
|
||||
icon: '@mipmap/ic_launcher',
|
||||
largeIcon: DrawableResourceAndroidBitmap('@mipmap/ic_launcher'),
|
||||
enableVibration: true,
|
||||
enableLights: true,
|
||||
showWhen: true,
|
||||
);
|
||||
|
||||
// iOS notification details
|
||||
const DarwinNotificationDetails iosDetails = DarwinNotificationDetails(
|
||||
sound: 'notifcation.mp3', // iOS looks in main bundle, not assets
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
badgeNumber: 1,
|
||||
subtitle: 'Lambda Banking Conglomerate',
|
||||
threadIdentifier: 'price_alerts',
|
||||
);
|
||||
|
||||
// Linux/Windows notification details
|
||||
final LinuxNotificationDetails linuxDetails = LinuxNotificationDetails(
|
||||
icon: AssetsLinuxIcon('assets/logo.png'),
|
||||
sound: AssetsLinuxSound('assets/notifcation.mp3'),
|
||||
category: LinuxNotificationCategory.imReceived,
|
||||
urgency: LinuxNotificationUrgency.critical,
|
||||
);
|
||||
|
||||
final NotificationDetails details = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
linux: linuxDetails,
|
||||
);
|
||||
|
||||
final int notificationId = DateTime.now().millisecondsSinceEpoch.remainder(100000);
|
||||
|
||||
await _notifications.show(
|
||||
notificationId,
|
||||
title,
|
||||
body,
|
||||
details,
|
||||
payload: 'price_alert:$seller:$auecAmount:$price',
|
||||
);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('Price alert notification sent: $title - $body');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Failed to show notification: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> requestPermissions() async {
|
||||
if (!_initialized) await initialize();
|
||||
|
||||
// Request permissions for iOS
|
||||
await _notifications
|
||||
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>()
|
||||
?.requestPermissions(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
|
||||
// Request permissions for Android 13+
|
||||
await _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.requestNotificationsPermission();
|
||||
}
|
||||
}
|
||||
54
flutter_app/lib/services/storage_service.dart
Normal file
54
flutter_app/lib/services/storage_service.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/price_data.dart';
|
||||
|
||||
class StorageService {
|
||||
static const String _alertsKey = 'price_alerts';
|
||||
static const String _customAuecKey = 'custom_auec_amount';
|
||||
|
||||
Future<List<PriceAlert>> getAlerts() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final alertsJson = prefs.getString(_alertsKey);
|
||||
if (alertsJson == null) return [];
|
||||
|
||||
final List<dynamic> decoded = jsonDecode(alertsJson);
|
||||
return decoded.map((e) => PriceAlert.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<void> saveAlerts(List<PriceAlert> alerts) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final alertsJson = jsonEncode(alerts.map((e) => e.toJson()).toList());
|
||||
await prefs.setString(_alertsKey, alertsJson);
|
||||
}
|
||||
|
||||
Future<void> addAlert(PriceAlert alert) async {
|
||||
final alerts = await getAlerts();
|
||||
alerts.add(alert);
|
||||
await saveAlerts(alerts);
|
||||
}
|
||||
|
||||
Future<void> updateAlert(PriceAlert alert) async {
|
||||
final alerts = await getAlerts();
|
||||
final index = alerts.indexWhere((a) => a.id == alert.id);
|
||||
if (index != -1) {
|
||||
alerts[index] = alert;
|
||||
await saveAlerts(alerts);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteAlert(String id) async {
|
||||
final alerts = await getAlerts();
|
||||
alerts.removeWhere((a) => a.id == id);
|
||||
await saveAlerts(alerts);
|
||||
}
|
||||
|
||||
Future<double?> getCustomAuecAmount() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getDouble(_customAuecKey);
|
||||
}
|
||||
|
||||
Future<void> setCustomAuecAmount(double amount) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(_customAuecKey, amount);
|
||||
}
|
||||
}
|
||||
127
flutter_app/lib/services/websocket_service.dart
Normal file
127
flutter_app/lib/services/websocket_service.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import '../models/price_data.dart';
|
||||
|
||||
class WebSocketService {
|
||||
WebSocketChannel? _channel;
|
||||
final _latestPriceController = StreamController<LatestPrice>.broadcast();
|
||||
final _connectionStatusController = StreamController<String>.broadcast();
|
||||
Timer? _reconnectTimer;
|
||||
bool _isConnecting = false;
|
||||
|
||||
Stream<LatestPrice> get latestPriceStream => _latestPriceController.stream;
|
||||
Stream<String> get connectionStatusStream => _connectionStatusController.stream;
|
||||
|
||||
void connect() {
|
||||
if (_isConnecting) return;
|
||||
_isConnecting = true;
|
||||
|
||||
try {
|
||||
final wsUrl = dotenv.env['WS_URL'] ?? 'ws://localhost:3000/ws/index';
|
||||
if (kDebugMode) {
|
||||
print('WebSocket connecting to: $wsUrl');
|
||||
print('Available env vars: ${dotenv.env.keys.toList()}');
|
||||
}
|
||||
|
||||
_channel = WebSocketChannel.connect(Uri.parse(wsUrl));
|
||||
|
||||
// Send subscription message after connection
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': 'subscribe',
|
||||
'data': {'channel': 'price_updates'}
|
||||
}));
|
||||
|
||||
_connectionStatusController.add('Connected');
|
||||
_isConnecting = false;
|
||||
|
||||
_channel!.stream.listen(
|
||||
(message) {
|
||||
try {
|
||||
final data = jsonDecode(message as String) as Map<String, dynamic>;
|
||||
if (kDebugMode) {
|
||||
print('WebSocket message received: ${data['type']}');
|
||||
}
|
||||
|
||||
switch (data['type']) {
|
||||
case 'price_update':
|
||||
final latestPrice = LatestPrice.fromJson({'data': data['data']});
|
||||
_latestPriceController.add(latestPrice);
|
||||
break;
|
||||
case 'history_data':
|
||||
// Handle history data if needed
|
||||
break;
|
||||
case 'connection_status':
|
||||
if (kDebugMode) {
|
||||
print('Connection status: ${data['data']}');
|
||||
}
|
||||
break;
|
||||
case 'error':
|
||||
if (kDebugMode) {
|
||||
print('WebSocket error message: ${data['data']}');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (kDebugMode) {
|
||||
print('Unknown message type: ${data['type']}');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error parsing WebSocket message: $e');
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
if (kDebugMode) {
|
||||
print('WebSocket error: $error');
|
||||
}
|
||||
_connectionStatusController.add('Error');
|
||||
_scheduleReconnect();
|
||||
},
|
||||
onDone: () {
|
||||
if (kDebugMode) {
|
||||
print('WebSocket connection closed');
|
||||
}
|
||||
_connectionStatusController.add('Disconnected');
|
||||
_scheduleReconnect();
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Failed to connect: $e');
|
||||
}
|
||||
_connectionStatusController.add('Error');
|
||||
_isConnecting = false;
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = Timer(const Duration(seconds: 5), () {
|
||||
if (kDebugMode) {
|
||||
print('Attempting to reconnect...');
|
||||
}
|
||||
connect();
|
||||
});
|
||||
}
|
||||
|
||||
void requestHistory(String range) {
|
||||
if (_channel != null) {
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': 'request_history',
|
||||
'range': range,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_reconnectTimer?.cancel();
|
||||
_channel?.sink.close();
|
||||
_latestPriceController.close();
|
||||
_connectionStatusController.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user