오대리ㅣㅣㅣㅣ
This commit is contained in:
31
mobile/lib/core/api_client.dart
Normal file
31
mobile/lib/core/api_client.dart
Normal 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;
|
||||
});
|
||||
8
mobile/lib/core/api_config.dart
Normal file
8
mobile/lib/core/api_config.dart
Normal 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',
|
||||
);
|
||||
}
|
||||
77
mobile/lib/core/app_settings.dart
Normal file
77
mobile/lib/core/app_settings.dart
Normal 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;
|
||||
}
|
||||
64
mobile/lib/core/auth_repository.dart
Normal file
64
mobile/lib/core/auth_repository.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
187
mobile/lib/core/chat_socket.dart
Normal file
187
mobile/lib/core/chat_socket.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
5
mobile/lib/core/demo_accounts.dart
Normal file
5
mobile/lib/core/demo_accounts.dart
Normal 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';
|
||||
}
|
||||
79
mobile/lib/core/fcm_service.dart
Normal file
79
mobile/lib/core/fcm_service.dart
Normal 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
137
mobile/lib/core/msn_api.dart
Normal file
137
mobile/lib/core/msn_api.dart
Normal 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?;
|
||||
}
|
||||
}
|
||||
23
mobile/lib/core/push_routing.dart
Normal file
23
mobile/lib/core/push_routing.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
104
mobile/lib/core/session_controller.dart
Normal file
104
mobile/lib/core/session_controller.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
16
mobile/lib/core/token_storage.dart
Normal file
16
mobile/lib/core/token_storage.dart
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user