Add TWP Softphone Flutter app and complete mobile backend API
All checks were successful
Create Release / build (push) Successful in 4s
All checks were successful
Create Release / build (push) Successful in 4s
Backend: Add /voice/token endpoint with AccessToken + VoiceGrant for mobile VoIP, implement unhold_call() with call leg detection, wire FCM push notifications into call queue and webhook missed call handlers, add data-only FCM message support for Android background wake, and add Twilio API Key / Push Credential settings fields. Flutter app: Full softphone with Twilio Voice SDK integration, JWT auth with auto-refresh, SSE real-time queue updates, FCM push notifications, Material 3 UI with dashboard, active call screen, dialpad, and call controls (mute/speaker/hold/transfer). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
95
mobile/lib/services/auth_service.dart
Normal file
95
mobile/lib/services/auth_service.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import '../models/user.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class AuthService {
|
||||
final ApiClient _api;
|
||||
final FlutterSecureStorage _storage = const FlutterSecureStorage();
|
||||
Timer? _refreshTimer;
|
||||
|
||||
AuthService(this._api);
|
||||
|
||||
Future<User> login(String serverUrl, String username, String password,
|
||||
{String? fcmToken}) async {
|
||||
await _api.setBaseUrl(serverUrl);
|
||||
|
||||
final response = await _api.dio.post('/auth/login', data: {
|
||||
'username': username,
|
||||
'password': password,
|
||||
if (fcmToken != null) 'fcm_token': fcmToken,
|
||||
});
|
||||
|
||||
final data = response.data;
|
||||
if (data['success'] != true) {
|
||||
throw Exception(data['message'] ?? 'Login failed');
|
||||
}
|
||||
|
||||
await _storage.write(key: 'access_token', value: data['access_token']);
|
||||
await _storage.write(key: 'refresh_token', value: data['refresh_token']);
|
||||
|
||||
_scheduleRefresh(data['expires_in'] as int? ?? 3600);
|
||||
|
||||
return User.fromJson(data['user']);
|
||||
}
|
||||
|
||||
Future<bool> tryRestoreSession() async {
|
||||
final token = await _storage.read(key: 'access_token');
|
||||
if (token == null) return false;
|
||||
|
||||
await _api.restoreBaseUrl();
|
||||
if (_api.dio.options.baseUrl.isEmpty) return false;
|
||||
|
||||
try {
|
||||
final response = await _api.dio.get('/agent/status');
|
||||
return response.statusCode == 200;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refreshToken() async {
|
||||
final refreshToken = await _storage.read(key: 'refresh_token');
|
||||
if (refreshToken == null) throw Exception('No refresh token');
|
||||
|
||||
final response = await _api.dio.post('/auth/refresh', data: {
|
||||
'refresh_token': refreshToken,
|
||||
});
|
||||
|
||||
final data = response.data;
|
||||
if (data['success'] != true) {
|
||||
throw Exception('Token refresh failed');
|
||||
}
|
||||
|
||||
await _storage.write(key: 'access_token', value: data['access_token']);
|
||||
if (data['refresh_token'] != null) {
|
||||
await _storage.write(key: 'refresh_token', value: data['refresh_token']);
|
||||
}
|
||||
|
||||
_scheduleRefresh(data['expires_in'] as int? ?? 3600);
|
||||
}
|
||||
|
||||
void _scheduleRefresh(int expiresInSeconds) {
|
||||
_refreshTimer?.cancel();
|
||||
// Refresh 2 minutes before expiry
|
||||
final refreshIn = Duration(seconds: expiresInSeconds - 120);
|
||||
if (refreshIn.isNegative) return;
|
||||
_refreshTimer = Timer(refreshIn, () async {
|
||||
try {
|
||||
await refreshToken();
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
_refreshTimer?.cancel();
|
||||
try {
|
||||
await _api.dio.post('/auth/logout');
|
||||
} catch (_) {}
|
||||
await _storage.deleteAll();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_refreshTimer?.cancel();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user