오대리ㅣㅣㅣㅣ

This commit is contained in:
송원형
2026-04-07 16:17:03 +09:00
commit 5bb54fdefe
63 changed files with 7897 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'api_config.dart';
import 'token_storage.dart';
final tokenStorageProvider = Provider<TokenStorage>((ref) => TokenStorage());
final dioProvider = Provider<Dio>((ref) {
final storage = ref.watch(tokenStorageProvider);
final dio = Dio(
BaseOptions(
baseUrl: ApiConfig.baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 30),
headers: {'Content-Type': 'application/json'},
),
);
dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
final t = await storage.readToken();
if (t != null && t.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $t';
}
handler.next(options);
},
),
);
return dio;
});

View File

@@ -0,0 +1,8 @@
/// Backend base URL. Override at build time:
/// `flutter run --dart-define=API_BASE=http://10.0.2.2:8787`
class ApiConfig {
static const String baseUrl = String.fromEnvironment(
'API_BASE',
defaultValue: 'http://127.0.0.1:8787',
);
}

View File

@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _kThemeMode = 'app_theme_mode';
const _kNotificationsEnabled = 'app_notifications_enabled';
String _themeToStr(ThemeMode m) {
return switch (m) {
ThemeMode.light => 'light',
_ => 'system',
};
}
ThemeMode _themeFromStr(String? s) {
return switch (s) {
'light' => ThemeMode.light,
// Legacy: app no longer offers dark; map old saves to light.
'dark' => ThemeMode.light,
_ => ThemeMode.system,
};
}
final themeModeProvider = NotifierProvider<ThemeModeNotifier, ThemeMode>(ThemeModeNotifier.new);
class ThemeModeNotifier extends Notifier<ThemeMode> {
@override
ThemeMode build() {
Future.microtask(_load);
return ThemeMode.system;
}
Future<void> _load() async {
final p = await SharedPreferences.getInstance();
final s = p.getString(_kThemeMode);
if (s != null) {
state = _themeFromStr(s);
}
}
Future<void> setThemeMode(ThemeMode mode) async {
state = mode;
final p = await SharedPreferences.getInstance();
await p.setString(_kThemeMode, _themeToStr(mode));
}
}
final notificationsEnabledProvider =
NotifierProvider<NotificationsEnabledNotifier, bool>(NotificationsEnabledNotifier.new);
class NotificationsEnabledNotifier extends Notifier<bool> {
@override
bool build() {
Future.microtask(_load);
return true;
}
Future<void> _load() async {
final p = await SharedPreferences.getInstance();
if (p.containsKey(_kNotificationsEnabled)) {
state = p.getBool(_kNotificationsEnabled) ?? true;
}
}
Future<void> setEnabled(bool value) async {
state = value;
final p = await SharedPreferences.getInstance();
await p.setBool(_kNotificationsEnabled, value);
}
}
/// Reads persisted value (default true if never set). Use before first notifier tick if needed.
Future<bool> readNotificationsEnabledFromPrefs() async {
final p = await SharedPreferences.getInstance();
if (!p.containsKey(_kNotificationsEnabled)) return true;
return p.getBool(_kNotificationsEnabled) ?? true;
}

View File

@@ -0,0 +1,64 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'api_client.dart';
import 'token_storage.dart';
final authRepositoryProvider = Provider<AuthRepository>((ref) {
return AuthRepository(ref.watch(dioProvider), ref.watch(tokenStorageProvider));
});
class AuthRepository {
AuthRepository(this._dio, this._storage);
final Dio _dio;
final TokenStorage _storage;
Future<({String token, String userId, String? defaultContextId})> register({
required String email,
required String password,
String? displayName,
}) async {
final res = await _dio.post<Map<String, dynamic>>(
'/api/auth/register',
data: {
'email': email,
'password': password,
if (displayName != null) 'displayName': displayName,
},
);
final d = res.data!;
final token = d['token'] as String;
await _storage.writeToken(token);
return (
token: token,
userId: (d['user'] as Map)['id'] as String,
defaultContextId: d['defaultContextId'] as String?,
);
}
Future<({String token, String userId, String? defaultContextId})> login({
required String email,
required String password,
}) async {
final res = await _dio.post<Map<String, dynamic>>(
'/api/auth/login',
data: {'email': email, 'password': password},
);
final d = res.data!;
final token = d['token'] as String;
await _storage.writeToken(token);
return (
token: token,
userId: (d['user'] as Map)['id'] as String,
defaultContextId: d['defaultContextId'] as String?,
);
}
Future<void> logout() => _storage.clear();
Future<bool> hasSession() async {
final t = await _storage.readToken();
return t != null && t.isNotEmpty;
}
}

View File

@@ -0,0 +1,187 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../models/message_model.dart';
import '../models/ws_chat_side_event.dart';
import 'api_client.dart';
import 'api_config.dart';
import 'token_storage.dart';
final chatSocketProvider = Provider<ChatSocketService>((ref) {
final svc = ChatSocketService(ref.watch(tokenStorageProvider));
ref.onDispose(svc.dispose);
return svc;
});
class _PendingSend {
_PendingSend(this.roomId, this.body);
final String roomId;
final String body;
}
class ChatSocketService {
ChatSocketService(this._storage);
final TokenStorage _storage;
WebSocketChannel? _channel;
final _incoming = StreamController<MessageModel>.broadcast();
final _side = StreamController<Object>.broadcast();
final Set<String> _subscribedRooms = {};
final List<_PendingSend> _pendingSends = [];
Timer? _reconnectTimer;
int _reconnectAttempt = 0;
bool _disposed = false;
int get pendingSendCount => _pendingSends.length;
Stream<MessageModel> get messages => _incoming.stream;
/// [WsTypingEvent] and [WsReadEvent] from server.
Stream<Object> get sideEvents => _side.stream;
Uri _wsUri(String token) {
final base = ApiConfig.baseUrl.replaceFirst(RegExp(r'^http'), 'ws');
return Uri.parse('$base/ws').replace(queryParameters: {'token': token});
}
Future<void> _closeChannelOnly() async {
await _channel?.sink.close();
_channel = null;
}
Future<void> connect() async {
if (_disposed) return;
_reconnectTimer?.cancel();
_reconnectTimer = null;
await _closeChannelOnly();
final token = await _storage.readToken();
if (token == null || token.isEmpty) return;
try {
_channel = WebSocketChannel.connect(_wsUri(token));
} catch (_) {
_scheduleReconnect();
return;
}
_reconnectAttempt = 0;
_channel!.stream.listen(
(raw) {
try {
final map = jsonDecode(raw as String) as Map<String, dynamic>;
final t = map['type'] as String?;
if (t == 'message' && map['message'] != null) {
final m = map['message'] as Map<String, dynamic>;
_incoming.add(MessageModel.fromWs(m));
} else if (t == 'typing') {
_side.add(
WsTypingEvent(
roomId: map['roomId'] as String,
userId: map['userId'] as String,
active: map['active'] as bool? ?? false,
),
);
} else if (t == 'read') {
_side.add(
WsReadEvent(
roomId: map['roomId'] as String,
userId: map['userId'] as String,
upToMessageId: map['upToMessageId'] as String,
),
);
}
} catch (_) {}
},
onError: (_) => _onConnectionLost(),
onDone: _onConnectionLost,
cancelOnError: false,
);
for (final id in _subscribedRooms) {
_channel?.sink.add(jsonEncode({'type': 'subscribe', 'roomId': id}));
}
_flushPendingSends();
}
void _flushPendingSends() {
if (_channel == null || _pendingSends.isEmpty) return;
final batch = List<_PendingSend>.from(_pendingSends);
_pendingSends.clear();
for (final p in batch) {
try {
_channel?.sink.add(
jsonEncode({'type': 'send', 'roomId': p.roomId, 'body': p.body}),
);
} catch (_) {
_pendingSends.add(p);
}
}
}
void _onConnectionLost() {
if (_disposed) return;
_scheduleReconnect();
}
void _scheduleReconnect() {
if (_disposed) return;
_reconnectTimer?.cancel();
final ms = min(30000, 500 * (1 << min(_reconnectAttempt, 6)));
_reconnectAttempt++;
_reconnectTimer = Timer(Duration(milliseconds: ms), () {
if (_disposed) return;
unawaited(connect());
});
}
/// Logout: cancel reconnect, clear room subscriptions, close socket.
Future<void> disconnect() async {
_reconnectTimer?.cancel();
_reconnectTimer = null;
_subscribedRooms.clear();
_pendingSends.clear();
_reconnectAttempt = 0;
await _closeChannelOnly();
}
void subscribeRoom(String roomId) {
_subscribedRooms.add(roomId);
_channel?.sink.add(jsonEncode({'type': 'subscribe', 'roomId': roomId}));
}
/// Returns false if the message was queued (no socket); true if written to socket.
void sendTyping(String roomId, bool active) {
if (_channel == null) return;
try {
_channel!.sink.add(
jsonEncode({'type': 'typing', 'roomId': roomId, 'active': active}),
);
} catch (_) {}
}
bool sendMessage(String roomId, String body) {
if (_channel == null) {
_pendingSends.add(_PendingSend(roomId, body));
return false;
}
try {
_channel!.sink.add(
jsonEncode({'type': 'send', 'roomId': roomId, 'body': body}),
);
return true;
} catch (_) {
_pendingSends.add(_PendingSend(roomId, body));
return false;
}
}
void dispose() {
_disposed = true;
_reconnectTimer?.cancel();
_reconnectTimer = null;
unawaited(_closeChannelOnly());
_incoming.close();
_side.close();
}
}

View File

@@ -0,0 +1,5 @@
/// Pre-seeded demo users (see server [seed.ts]). Use only for login UX helpers.
abstract final class DemoAccounts {
static const aliceEmail = 'alice@demo.msn';
static const password = 'demo1234';
}

View File

@@ -0,0 +1,79 @@
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../firebase_options.dart';
import 'msn_api.dart';
/// Background handler must be a top-level function.
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
debugPrint('FCM background: ${message.messageId}');
}
final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin();
bool _fcmInitialized = false;
/// Call after Firebase.initializeApp. Registers token with [registerPushToken] if API available.
Future<void> initializeFcmAndLocalNotifications(MsnApi api) async {
if (_fcmInitialized) return;
_fcmInitialized = true;
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosInit = DarwinInitializationSettings();
await _localNotifications.initialize(
const InitializationSettings(android: androidInit, iOS: iosInit),
);
final messaging = FirebaseMessaging.instance;
await messaging.setForegroundNotificationPresentationOptions(
alert: true,
badge: true,
sound: true,
);
await messaging.requestPermission(alert: true, badge: true, sound: true);
FirebaseMessaging.onMessage.listen((RemoteMessage msg) async {
final n = msg.notification;
final title = n?.title ?? 'IYKYKA';
final body = n?.body ?? msg.data['body'] as String? ?? '';
const androidDetails = AndroidNotificationDetails(
'iykyka_chat',
'채팅',
channelDescription: '새 메시지',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
);
const details = NotificationDetails(android: androidDetails);
await _localNotifications.show(
msg.hashCode,
title,
body,
details,
);
});
try {
final token = await messaging.getToken();
if (token != null && token.isNotEmpty) {
await api.registerPushToken(token);
debugPrint('FCM token registered with server');
}
} catch (e) {
debugPrint('FCM getToken/register skipped: $e');
}
FirebaseMessaging.instance.onTokenRefresh.listen((token) async {
try {
await api.registerPushToken(token);
} catch (e) {
debugPrint('FCM token refresh register failed: $e');
}
});
}

View File

@@ -0,0 +1,137 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/context_member_model.dart';
import '../models/context_model.dart';
import '../models/message_model.dart';
import '../models/profile_model.dart';
import '../models/room_model.dart';
import 'api_client.dart';
final msnApiProvider = Provider<MsnApi>((ref) => MsnApi(ref.watch(dioProvider)));
class MsnApi {
MsnApi(this._dio);
final Dio _dio;
Future<List<ContextModel>> listContexts() async {
final res = await _dio.get<Map<String, dynamic>>('/api/contexts');
final list = res.data!['contexts'] as List<dynamic>;
return list.map((e) => ContextModel.fromJson(e as Map<String, dynamic>)).toList();
}
Future<ContextModel> createContext({required String name, String kind = 'work'}) async {
final res = await _dio.post<Map<String, dynamic>>(
'/api/contexts',
data: {'name': name, 'kind': kind},
);
final d = res.data!;
return ContextModel(
id: d['id'] as String,
name: d['name'] as String,
kind: d['kind'] as String,
);
}
Future<void> inviteToContext(String contextId, String email) async {
await _dio.post('/api/contexts/$contextId/members', data: {'email': email});
}
Future<List<ContextMember>> listContextMembers(String contextId) async {
final res = await _dio.get<Map<String, dynamic>>('/api/contexts/$contextId/members');
final list = res.data!['members'] as List<dynamic>? ?? [];
return list.map((e) => ContextMember.fromJson(e as Map<String, dynamic>)).toList();
}
Future<ProfileModel> getMyProfile(String contextId) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/profiles/me',
queryParameters: {'contextId': contextId},
);
return ProfileModel.fromJson(res.data!);
}
Future<ProfileModel> getUserProfile(String contextId, String userId) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/profiles/user/$userId',
queryParameters: {'contextId': contextId},
);
return ProfileModel.fromJson(res.data!);
}
Future<void> updateMyProfile(
String contextId, {
String? displayName,
String? statusMessage,
}) async {
await _dio.patch(
'/api/profiles/me',
queryParameters: {'contextId': contextId},
data: {
if (displayName != null) 'displayName': displayName,
if (statusMessage != null) 'statusMessage': statusMessage,
},
);
}
Future<List<RoomModel>> listRooms(String contextId) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/rooms',
queryParameters: {'contextId': contextId},
);
final list = res.data!['rooms'] as List<dynamic>? ?? [];
return list.map((e) => RoomModel.fromJson(e as Map<String, dynamic>)).toList();
}
Future<String> openDirectRoom(String contextId, String otherUserId) async {
final res = await _dio.post<Map<String, dynamic>>(
'/api/rooms/direct',
data: {'contextId': contextId, 'otherUserId': otherUserId},
);
return res.data!['roomId'] as String;
}
Future<List<MessageModel>> fetchMessages(String roomId, {String? before}) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/messages',
queryParameters: {
'roomId': roomId,
'limit': 50,
if (before != null) 'before': before,
},
);
final list = res.data!['messages'] as List<dynamic>? ?? [];
return list.map((e) => MessageModel.fromJson(e as Map<String, dynamic>)).toList();
}
Future<void> registerPushToken(String token, {String platform = 'fcm'}) async {
await _dio.post('/api/push/register', data: {'token': token, 'platform': platform});
}
Future<String> createGroupRoom(String contextId, String name, List<String> memberIds) async {
final res = await _dio.post<Map<String, dynamic>>(
'/api/rooms/group',
data: {'contextId': contextId, 'name': name, 'memberIds': memberIds},
);
return res.data!['roomId'] as String;
}
Future<List<Map<String, dynamic>>> searchMessages(String contextId, String q) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/messages/search',
queryParameters: {'contextId': contextId, 'q': q},
);
final list = res.data!['results'] as List<dynamic>? ?? [];
return list.cast<Map<String, dynamic>>();
}
/// Latest message id read by the other participant in a 1:1 room (null if none / group).
Future<String?> getReadState(String roomId) async {
final res = await _dio.get<Map<String, dynamic>>(
'/api/messages/read-state',
queryParameters: {'roomId': roomId},
);
return res.data!['lastReadMessageId'] as String?;
}
}

View File

@@ -0,0 +1,23 @@
import 'package:go_router/go_router.dart';
/// Parses FCM / notification payloads so the app opens the correct chat.
/// Expected keys: `contextId`, `roomId` (and optional `type`).
class PushRouting {
const PushRouting();
/// Build location for go_router, e.g. `/chat?roomId=...&contextId=...`
String? locationFromPayload(Map<String, dynamic> data) {
final roomId = data['roomId'] as String? ?? data['room_id'] as String?;
final contextId = data['contextId'] as String? ?? data['context_id'] as String?;
if (roomId == null || contextId == null) return null;
return Uri(
path: '/chat',
queryParameters: {'roomId': roomId, 'contextId': contextId},
).toString();
}
void navigateFromPayload(GoRouter router, Map<String, dynamic> data) {
final loc = locationFromPayload(data);
if (loc != null) router.go(loc);
}
}

View File

@@ -0,0 +1,104 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'api_client.dart';
final sessionProvider = AsyncNotifierProvider<SessionNotifier, SessionData?>(
SessionNotifier.new,
);
class SessionData {
const SessionData({
required this.userId,
required this.email,
this.defaultContextId,
this.selectedContextId,
});
final String userId;
final String email;
final String? defaultContextId;
final String? selectedContextId;
String? get effectiveContextId => selectedContextId ?? defaultContextId;
}
class SessionNotifier extends AsyncNotifier<SessionData?> {
static const _kContext = 'msn_selected_context_id';
@override
Future<SessionData?> build() async {
final storage = ref.read(tokenStorageProvider);
final token = await storage.readToken();
if (token == null || token.isEmpty) return null;
final dio = ref.read(dioProvider);
try {
final res = await dio.get<Map<String, dynamic>>('/api/auth/me');
final id = res.data!['id'] as String;
final email = res.data!['email'] as String;
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString(_kContext);
final ctxRes = await dio.get<Map<String, dynamic>>('/api/contexts');
final list = ctxRes.data!['contexts'] as List<dynamic>? ?? [];
String? personalId;
for (final c in list) {
final m = c as Map<String, dynamic>;
if (m['kind'] == 'personal') {
personalId = m['id'] as String;
break;
}
}
final def = personalId ?? (list.isNotEmpty ? (list.first as Map)['id'] as String : null);
return SessionData(
userId: id,
email: email,
defaultContextId: def,
selectedContextId: saved ?? def,
);
} on DioException {
await storage.clear();
return null;
}
}
Future<void> applyLogin({
required String userId,
required String email,
String? defaultContextId,
}) async {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString(_kContext);
state = AsyncData(
SessionData(
userId: userId,
email: email,
defaultContextId: defaultContextId,
selectedContextId: saved ?? defaultContextId,
),
);
}
Future<void> setSelectedContext(String contextId) async {
final cur = state.value;
if (cur == null) return;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kContext, contextId);
state = AsyncData(
SessionData(
userId: cur.userId,
email: cur.email,
defaultContextId: cur.defaultContextId,
selectedContextId: contextId,
),
);
}
Future<void> logout() async {
await ref.read(tokenStorageProvider).clear();
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kContext);
state = const AsyncData(null);
}
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
const _kToken = 'msn_jwt';
class TokenStorage {
TokenStorage({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
final FlutterSecureStorage _storage;
Future<String?> readToken() => _storage.read(key: _kToken);
Future<void> writeToken(String token) => _storage.write(key: _kToken, value: token);
Future<void> clear() => _storage.delete(key: _kToken);
}