초기 커밋

This commit is contained in:
2026-03-01 07:55:59 +09:00
commit b0262d6bab
67 changed files with 4660 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../constants/app_constants.dart';
import '../storage/secure_storage.dart';
class AuthInterceptor extends Interceptor {
AuthInterceptor({required this.ref});
final Ref ref;
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final secureStorage = ref.read(secureStorageProvider);
final token = await secureStorage.read(key: AppConstants.accessTokenKey);
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:talker_dio_logger/talker_dio_logger.dart';
import '../constants/api_constants.dart';
import '../logging/app_logger.dart';
import 'auth_interceptor.dart';
import 'token_refresh_interceptor.dart';
part 'dio_client.g.dart';
@Riverpod(keepAlive: true)
Dio dio(Ref ref) {
final dio = Dio(
BaseOptions(
baseUrl: ApiConstants.baseUrl,
connectTimeout: ApiConstants.connectTimeout,
receiveTimeout: ApiConstants.receiveTimeout,
sendTimeout: ApiConstants.sendTimeout,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
);
final talker = ref.read(appLoggerProvider);
dio.interceptors.addAll([
AuthInterceptor(ref: ref),
TokenRefreshInterceptor(ref: ref, dio: dio),
TalkerDioLogger(
talker: talker,
settings: const TalkerDioLoggerSettings(
printRequestHeaders: true,
printResponseHeaders: false,
printResponseData: true,
),
),
]);
return dio;
}

View File

@@ -0,0 +1,109 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:talker/talker.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../constants/api_constants.dart';
import '../constants/app_constants.dart';
import '../logging/app_logger.dart';
import '../storage/secure_storage.dart';
part 'socket_manager.g.dart';
@Riverpod(keepAlive: true)
SocketManager socketManager(Ref ref) {
final talker = ref.read(appLoggerProvider);
final secureStorage = ref.read(secureStorageProvider);
final manager = SocketManager(talker: talker, secureStorage: secureStorage);
ref.onDispose(manager.dispose);
return manager;
}
class SocketManager {
SocketManager({
required this.talker,
required this.secureStorage,
});
final Talker talker;
final SecureStorage secureStorage;
WebSocketChannel? _channel;
Timer? _reconnectTimer;
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
bool _isConnected = false;
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 5;
Stream<Map<String, dynamic>> get messageStream => _messageController.stream;
bool get isConnected => _isConnected;
Future<void> connect({String? path}) async {
try {
final token = await secureStorage.read(key: AppConstants.accessTokenKey);
final wsUrl = '${ApiConstants.wsUrl}${path ?? ''}';
final uri = Uri.parse(
token != null ? '$wsUrl?token=$token' : wsUrl,
);
_channel = WebSocketChannel.connect(uri);
await _channel!.ready;
_isConnected = true;
_reconnectAttempts = 0;
talker.info('WebSocket connected: $wsUrl');
_channel!.stream.listen(
(data) {
try {
final decoded = jsonDecode(data as String) as Map<String, dynamic>;
_messageController.add(decoded);
} catch (e) {
talker.error('WebSocket message parse error', e);
}
},
onError: (Object error) {
talker.error('WebSocket error', error);
_handleDisconnect();
},
onDone: () {
talker.warning('WebSocket disconnected');
_handleDisconnect();
},
);
} catch (e) {
talker.error('WebSocket connection failed', e);
_handleDisconnect();
}
}
void _handleDisconnect() {
_isConnected = false;
if (_reconnectAttempts < _maxReconnectAttempts) {
final delay = Duration(seconds: _reconnectAttempts * 2 + 1);
_reconnectTimer = Timer(delay, () {
_reconnectAttempts++;
talker.info('WebSocket reconnect attempt $_reconnectAttempts');
connect();
});
}
}
void send(Map<String, dynamic> data) {
if (_isConnected && _channel != null) {
_channel!.sink.add(jsonEncode(data));
} else {
talker.warning('WebSocket not connected, message not sent');
}
}
void dispose() {
_reconnectTimer?.cancel();
_channel?.sink.close();
_messageController.close();
_isConnected = false;
}
}

View File

@@ -0,0 +1,77 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../constants/api_constants.dart';
import '../constants/app_constants.dart';
import '../storage/secure_storage.dart';
class TokenRefreshInterceptor extends Interceptor {
TokenRefreshInterceptor({
required this.ref,
required this.dio,
});
final Ref ref;
final Dio dio;
bool _isRefreshing = false;
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
if (err.response?.statusCode == 401 && !_isRefreshing) {
_isRefreshing = true;
try {
final secureStorage = ref.read(secureStorageProvider);
final refreshToken = await secureStorage.read(
key: AppConstants.refreshTokenKey,
);
if (refreshToken == null) {
_isRefreshing = false;
return handler.next(err);
}
// Request new tokens
final refreshDio = Dio(
BaseOptions(baseUrl: ApiConstants.baseUrl),
);
final response = await refreshDio.post(
ApiConstants.refreshToken,
data: {'refresh_token': refreshToken},
);
final newAccessToken = response.data['access_token'] as String;
final newRefreshToken = response.data['refresh_token'] as String;
// Save new tokens
await secureStorage.write(
key: AppConstants.accessTokenKey,
value: newAccessToken,
);
await secureStorage.write(
key: AppConstants.refreshTokenKey,
value: newRefreshToken,
);
// Retry original request
final options = err.requestOptions;
options.headers['Authorization'] = 'Bearer $newAccessToken';
final retryResponse = await dio.fetch(options);
_isRefreshing = false;
return handler.resolve(retryResponse);
} on DioException {
_isRefreshing = false;
// Token refresh failed, clear tokens
final secureStorage = ref.read(secureStorageProvider);
await secureStorage.delete(key: AppConstants.accessTokenKey);
await secureStorage.delete(key: AppConstants.refreshTokenKey);
return handler.next(err);
}
}
return handler.next(err);
}
}