오대리ㅣㅣㅣㅣ
This commit is contained in:
45
mobile/.gitignore
vendored
Normal file
45
mobile/.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Miscellaneous
|
||||||
|
*.class
|
||||||
|
*.log
|
||||||
|
*.pyc
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
.atom/
|
||||||
|
.build/
|
||||||
|
.buildlog/
|
||||||
|
.history
|
||||||
|
.svn/
|
||||||
|
.swiftpm/
|
||||||
|
migrate_working_dir/
|
||||||
|
|
||||||
|
# IntelliJ related
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# The .vscode folder contains launch configuration and tasks you configure in
|
||||||
|
# VS Code which you may wish to be included in version control, so this line
|
||||||
|
# is commented out by default.
|
||||||
|
#.vscode/
|
||||||
|
|
||||||
|
# Flutter/Dart/Pub related
|
||||||
|
**/doc/api/
|
||||||
|
**/ios/Flutter/.last_build_id
|
||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.pub-cache/
|
||||||
|
.pub/
|
||||||
|
/build/
|
||||||
|
/coverage/
|
||||||
|
|
||||||
|
# Symbolication related
|
||||||
|
app.*.symbols
|
||||||
|
|
||||||
|
# Obfuscation related
|
||||||
|
app.*.map.json
|
||||||
|
|
||||||
|
# Android Studio will place build artifacts here
|
||||||
|
/android/app/debug
|
||||||
|
/android/app/profile
|
||||||
|
/android/app/release
|
||||||
30
mobile/.metadata
Normal file
30
mobile/.metadata
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# This file tracks properties of this Flutter project.
|
||||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||||
|
#
|
||||||
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
|
version:
|
||||||
|
revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a"
|
||||||
|
channel: "stable"
|
||||||
|
|
||||||
|
project_type: app
|
||||||
|
|
||||||
|
# Tracks metadata for the flutter migrate command
|
||||||
|
migration:
|
||||||
|
platforms:
|
||||||
|
- platform: root
|
||||||
|
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||||
|
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||||
|
- platform: web
|
||||||
|
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||||
|
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||||
|
|
||||||
|
# User provided section
|
||||||
|
|
||||||
|
# List of Local paths (relative to this file) that should be
|
||||||
|
# ignored by the migrate tool.
|
||||||
|
#
|
||||||
|
# Files that are not part of the templates will be ignored by default.
|
||||||
|
unmanaged_files:
|
||||||
|
- 'lib/main.dart'
|
||||||
|
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||||
87
mobile/README.md
Normal file
87
mobile/README.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# IYKYKA — Flutter 클라이언트
|
||||||
|
|
||||||
|
멀티 컨텍스트(일상·업무 등) 기반 메신저 **IYKYKA**의 모바일 앱입니다. 서버 REST API + WebSocket과 연동합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 실행 방법
|
||||||
|
|
||||||
|
프로젝트 루트의 [README.md](../README.md)에 서버 기동 방법이 있습니다. 서버가 떠 있는 상태에서:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mobile
|
||||||
|
flutter pub get
|
||||||
|
flutter run --dart-define=API_BASE=http://127.0.0.1:8787
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Android 에뮬레이터** → `API_BASE=http://10.0.2.2:8787`
|
||||||
|
- **웹** → `sqflite` 미지원으로 로컬 메시지 캐시 전체는 동작하지 않을 수 있음. **Android / iOS / Windows 데스크톱** 권장
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기술 스택 (요약)
|
||||||
|
|
||||||
|
| 구분 | 사용 |
|
||||||
|
|------|------|
|
||||||
|
| 상태·DI | `flutter_riverpod` |
|
||||||
|
| 라우팅 | `go_router` |
|
||||||
|
| HTTP | `dio` (JWT 인터셉터) |
|
||||||
|
| 소켓 | `web_socket_channel` |
|
||||||
|
| 로컬 DB | `sqflite` (방별 메시지 캐시) |
|
||||||
|
| 보안 저장소 | `flutter_secure_storage` (토큰) |
|
||||||
|
| 설정 | `shared_preferences` (테마·알림 스위치 등) |
|
||||||
|
| 푸시 | `firebase_core` / `firebase_messaging`, 포그라운드는 `flutter_local_notifications` |
|
||||||
|
| 앱 정보 | `package_info_plus` (설정 화면 버전 표시) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현된 기능
|
||||||
|
|
||||||
|
### 인증·세션
|
||||||
|
- 회원가입 / 로그인, JWT 저장 및 API 자동 부착
|
||||||
|
- 로그아웃 시 소켓 종료 후 로그인 화면으로 이동
|
||||||
|
|
||||||
|
### 컨텍스트·홈
|
||||||
|
- 컨텍스트 목록·선택(일상 / 업무 등), 해당 컨텍스트 기준 방·멤버·내 프로필
|
||||||
|
- 이메일 초대, 업무 컨텍스트 생성, 그룹 채팅방 만들기
|
||||||
|
- 하단 탭: 대화 / 멤버 / 내 프로필
|
||||||
|
|
||||||
|
### 채팅
|
||||||
|
- WebSocket 실시간 메시지, 방 구독
|
||||||
|
- **오프라인/끊김 시** 전송 실패 메시지를 메모리 큐에 쌓았다가 **연결 복구 후 순차 전송**
|
||||||
|
- 1:1 방: **상대 입력 중** 표시(타이핑 이벤트·디바운스), **읽음** 표시(서버 읽음 상태 + WS 갱신)
|
||||||
|
- 메시지 읽음 처리(마지막 메시지 기준) API 호출
|
||||||
|
- 로컬 `sqflite`에 방별 메시지 캐시(동기화·오프라인 열람 보조)
|
||||||
|
|
||||||
|
### 검색
|
||||||
|
- **메시지 / 방 / 친구** 탭으로 구분, **클라이언트 측 필터**(쿼리 문자열)
|
||||||
|
|
||||||
|
### 설정
|
||||||
|
- 앱 이름·**버전**(`package_info_plus`)
|
||||||
|
- **테마**: 시스템 / 라이트 (OS 다크여도 앱은 라이트 토스 스타일 유지)
|
||||||
|
- **푸시 알림** 스위치(SharedPreferences; 켜진 경우 FCM 초기화·토큰 서버 등록 시도)
|
||||||
|
- 로그아웃
|
||||||
|
|
||||||
|
### 푸시(FCM)
|
||||||
|
- Firebase 초기화 실패 시에도 앱 기동은 계속(미설정·플레이스홀더 `firebase_options` 대비)
|
||||||
|
- FCM 토큰을 서버 `registerPushToken`으로 등록, 토큰 갱신 시 재등록
|
||||||
|
- 포그라운드 수신 시 로컬 알림 표시(설정 가능 범위 내)
|
||||||
|
|
||||||
|
### UI
|
||||||
|
- 토스 느낌의 라이트 테마(`theme/toss_theme.dart`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 명령
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart analyze lib
|
||||||
|
flutter test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
|
||||||
|
- 서버에서 실제 원격 푸시 발송(Firebase Admin 등)은 별도 연동 단계일 수 있으며, 클라이언트는 토큰 등록까지 동작하도록 구성되어 있습니다.
|
||||||
|
- 실제 배포·스토어 빌드 전에는 `firebase_options.dart` 및 Android/iOS Firebase 설정을 프로젝트에 맞게 교체해야 합니다.
|
||||||
5
mobile/analysis_options.yaml
Normal file
5
mobile/analysis_options.yaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
linter:
|
||||||
|
rules:
|
||||||
|
prefer_const_constructors: true
|
||||||
|
unawaited_futures: true
|
||||||
86
mobile/lib/app/router.dart
Normal file
86
mobile/lib/app/router.dart
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../core/session_controller.dart';
|
||||||
|
import '../features/auth/login_page.dart';
|
||||||
|
import '../features/auth/register_page.dart';
|
||||||
|
import '../features/chat/chat_page.dart';
|
||||||
|
import '../features/home/home_page.dart';
|
||||||
|
import '../features/search/search_page.dart';
|
||||||
|
import '../features/settings/settings_page.dart';
|
||||||
|
import '../core/app_settings.dart';
|
||||||
|
import '../theme/toss_theme.dart';
|
||||||
|
|
||||||
|
final goRouterProvider = Provider<GoRouter>((ref) {
|
||||||
|
ref.watch(sessionProvider);
|
||||||
|
ref.watch(themeModeProvider);
|
||||||
|
|
||||||
|
return GoRouter(
|
||||||
|
initialLocation: '/splash',
|
||||||
|
redirect: (context, state) {
|
||||||
|
final async = ref.read(sessionProvider);
|
||||||
|
final loc = state.matchedLocation;
|
||||||
|
|
||||||
|
if (async.isLoading) {
|
||||||
|
return loc == '/splash' ? null : '/splash';
|
||||||
|
}
|
||||||
|
if (async.hasError) {
|
||||||
|
return loc == '/login' ? null : '/login';
|
||||||
|
}
|
||||||
|
final s = async.value;
|
||||||
|
if (s == null) {
|
||||||
|
if (loc == '/login' || loc == '/register') return null;
|
||||||
|
return '/login';
|
||||||
|
}
|
||||||
|
if (loc == '/splash' || loc == '/login' || loc == '/register') {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/splash',
|
||||||
|
builder: (context, state) => const Scaffold(
|
||||||
|
backgroundColor: TossColors.bg,
|
||||||
|
body: Center(child: CircularProgressIndicator(color: TossColors.blue)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/login',
|
||||||
|
builder: (context, state) => const LoginPage(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/register',
|
||||||
|
builder: (context, state) => const RegisterPage(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/',
|
||||||
|
builder: (context, state) => const HomePage(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/chat',
|
||||||
|
builder: (context, state) {
|
||||||
|
final q = state.uri.queryParameters;
|
||||||
|
final roomId = q['roomId'];
|
||||||
|
final contextId = q['contextId'];
|
||||||
|
if (roomId == null || contextId == null) {
|
||||||
|
return const Scaffold(body: Center(child: Text('Invalid link')));
|
||||||
|
}
|
||||||
|
return ChatPage(roomId: roomId, contextId: contextId);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/search',
|
||||||
|
builder: (context, state) {
|
||||||
|
final cid = state.uri.queryParameters['contextId'] ?? '';
|
||||||
|
return SearchPage(contextId: cid);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/settings',
|
||||||
|
builder: (context, state) => const SettingsPage(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
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);
|
||||||
|
}
|
||||||
119
mobile/lib/data/message_local_store.dart
Normal file
119
mobile/lib/data/message_local_store.dart
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
|
||||||
|
import '../models/message_model.dart';
|
||||||
|
|
||||||
|
/// Local cache of messages keyed by context + room (plan: 맥락 ID 반영).
|
||||||
|
/// On **web**, uses in-memory storage only (`sqflite` is not supported).
|
||||||
|
class MessageLocalStore {
|
||||||
|
MessageLocalStore();
|
||||||
|
|
||||||
|
Database? _db;
|
||||||
|
|
||||||
|
/// Web-only: key = "contextId::roomId"
|
||||||
|
final Map<String, List<MessageModel>> _memory = {};
|
||||||
|
|
||||||
|
static String _memKey(String contextId, String roomId) => '$contextId::$roomId';
|
||||||
|
|
||||||
|
Future<Database> get database async {
|
||||||
|
if (kIsWeb) {
|
||||||
|
throw UnsupportedError('database getter should not be used on web');
|
||||||
|
}
|
||||||
|
if (_db != null) return _db!;
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
final path = p.join(dir.path, 'msn_messages.db');
|
||||||
|
_db = await openDatabase(
|
||||||
|
path,
|
||||||
|
version: 1,
|
||||||
|
onCreate: (db, v) async {
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE messages (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
context_id TEXT NOT NULL,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
sender_id TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'text',
|
||||||
|
PRIMARY KEY (id, context_id)
|
||||||
|
);
|
||||||
|
''');
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX idx_room_ctx ON messages(room_id, context_id, created_at);',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return _db!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [roomId] required so empty API results can clear the web cache.
|
||||||
|
Future<void> upsertMessages(
|
||||||
|
String contextId,
|
||||||
|
String roomId,
|
||||||
|
List<MessageModel> list,
|
||||||
|
) async {
|
||||||
|
if (kIsWeb) {
|
||||||
|
final key = _memKey(contextId, roomId);
|
||||||
|
if (list.isEmpty) {
|
||||||
|
_memory[key] = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final byId = <String, MessageModel>{
|
||||||
|
for (final m in _memory[key] ?? []) m.id: m,
|
||||||
|
};
|
||||||
|
for (final m in list) {
|
||||||
|
byId[m.id] = m;
|
||||||
|
}
|
||||||
|
final merged = byId.values.toList()
|
||||||
|
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||||
|
_memory[key] = merged;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final db = await database;
|
||||||
|
final batch = db.batch();
|
||||||
|
for (final m in list) {
|
||||||
|
batch.insert(
|
||||||
|
'messages',
|
||||||
|
{
|
||||||
|
'id': m.id,
|
||||||
|
'context_id': contextId,
|
||||||
|
'room_id': m.roomId,
|
||||||
|
'sender_id': m.senderId,
|
||||||
|
'body': m.body,
|
||||||
|
'created_at': m.createdAt,
|
||||||
|
'kind': m.kind,
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await batch.commit(noResult: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<MessageModel>> listForRoom(String contextId, String roomId) async {
|
||||||
|
if (kIsWeb) {
|
||||||
|
return List<MessageModel>.from(_memory[_memKey(contextId, roomId)] ?? []);
|
||||||
|
}
|
||||||
|
final db = await database;
|
||||||
|
final rows = await db.query(
|
||||||
|
'messages',
|
||||||
|
where: 'context_id = ? AND room_id = ?',
|
||||||
|
whereArgs: [contextId, roomId],
|
||||||
|
orderBy: 'created_at ASC',
|
||||||
|
);
|
||||||
|
return rows
|
||||||
|
.map(
|
||||||
|
(r) => MessageModel(
|
||||||
|
id: r['id']! as String,
|
||||||
|
roomId: r['room_id']! as String,
|
||||||
|
senderId: r['sender_id']! as String,
|
||||||
|
body: r['body']! as String,
|
||||||
|
createdAt: r['created_at']! as String,
|
||||||
|
kind: r['kind'] as String? ?? 'text',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
204
mobile/lib/features/auth/login_page.dart
Normal file
204
mobile/lib/features/auth/login_page.dart
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/auth_repository.dart';
|
||||||
|
import '../../core/demo_accounts.dart';
|
||||||
|
import '../../core/session_controller.dart';
|
||||||
|
import '../../theme/toss_theme.dart';
|
||||||
|
|
||||||
|
class LoginPage extends ConsumerStatefulWidget {
|
||||||
|
const LoginPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||||
|
final _email = TextEditingController();
|
||||||
|
final _password = TextEditingController();
|
||||||
|
String? _error;
|
||||||
|
bool _busy = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_email.dispose();
|
||||||
|
_password.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _fillDemo() {
|
||||||
|
setState(() {
|
||||||
|
_email.text = DemoAccounts.aliceEmail;
|
||||||
|
_password.text = DemoAccounts.password;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
setState(() {
|
||||||
|
_busy = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final r = await ref.read(authRepositoryProvider).login(
|
||||||
|
email: _email.text.trim(),
|
||||||
|
password: _password.text,
|
||||||
|
);
|
||||||
|
await ref.read(sessionProvider.notifier).applyLogin(
|
||||||
|
userId: r.userId,
|
||||||
|
email: _email.text.trim(),
|
||||||
|
defaultContextId: r.defaultContextId,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
context.go('/');
|
||||||
|
} catch (e) {
|
||||||
|
setState(() => _error = e.toString());
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final tt = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: TossColors.surface,
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: TossColors.blue.withValues(alpha: 0.15),
|
||||||
|
blurRadius: 24,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.chat_bubble_rounded, size: 36, color: TossColors.blue),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
Text(
|
||||||
|
'IYKYKA',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: tt.headlineLarge?.copyWith(
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'If you know, you know.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: tt.bodySmall?.copyWith(color: TossColors.textSecondary),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'일상과 직장, 한 앱에서 나눠 쓰기',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: tt.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: TossColors.surface,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.04),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('데모로 시작하기', style: tt.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'서버 실행 후 아래를 누르면 필드가 채워집니다. '
|
||||||
|
'홈에서 「데모 회사」를 고르면 직장 샘플 대화를 볼 수 있어요.',
|
||||||
|
style: tt.bodySmall?.copyWith(height: 1.4),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: _busy ? null : _fillDemo,
|
||||||
|
child: const Text('데모 계정으로 채우기'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
TextField(
|
||||||
|
controller: _email,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '이메일',
|
||||||
|
prefixIcon: Icon(Icons.mail_outline_rounded, color: TossColors.textSecondary),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
autofillHints: const [AutofillHints.email],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _password,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '비밀번호',
|
||||||
|
prefixIcon: Icon(Icons.lock_outline_rounded, color: TossColors.textSecondary),
|
||||||
|
),
|
||||||
|
obscureText: true,
|
||||||
|
autofillHints: const [AutofillHints.password],
|
||||||
|
),
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_error!,
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 54,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _busy ? null : _submit,
|
||||||
|
child: _busy
|
||||||
|
? const SizedBox(
|
||||||
|
height: 22,
|
||||||
|
width: 22,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text('로그인'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _busy ? null : () => context.go('/register'),
|
||||||
|
child: const Text('계정이 없어요'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
mobile/lib/features/auth/register_page.dart
Normal file
133
mobile/lib/features/auth/register_page.dart
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/auth_repository.dart';
|
||||||
|
import '../../core/session_controller.dart';
|
||||||
|
import '../../theme/toss_theme.dart';
|
||||||
|
|
||||||
|
/// New accounts only. Pre-seeded demo users (`alice@demo.msn`) are for sign-in, not registration.
|
||||||
|
class RegisterPage extends ConsumerStatefulWidget {
|
||||||
|
const RegisterPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<RegisterPage> createState() => _RegisterPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RegisterPageState extends ConsumerState<RegisterPage> {
|
||||||
|
final _email = TextEditingController();
|
||||||
|
final _password = TextEditingController();
|
||||||
|
final _name = TextEditingController();
|
||||||
|
String? _error;
|
||||||
|
bool _busy = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_email.dispose();
|
||||||
|
_password.dispose();
|
||||||
|
_name.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
setState(() {
|
||||||
|
_busy = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final r = await ref.read(authRepositoryProvider).register(
|
||||||
|
email: _email.text.trim(),
|
||||||
|
password: _password.text,
|
||||||
|
displayName: _name.text.trim().isEmpty ? null : _name.text.trim(),
|
||||||
|
);
|
||||||
|
await ref.read(sessionProvider.notifier).applyLogin(
|
||||||
|
userId: r.userId,
|
||||||
|
email: _email.text.trim(),
|
||||||
|
defaultContextId: r.defaultContextId,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
context.go('/');
|
||||||
|
} catch (e) {
|
||||||
|
setState(() => _error = e.toString());
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final tt = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: TossColors.bg,
|
||||||
|
appBar: AppBar(title: const Text('회원가입')),
|
||||||
|
body: SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text('새 계정 만들기', style: tt.headlineMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'데모 계정은 로그인 화면에서 이용해 주세요.',
|
||||||
|
style: tt.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
TextField(
|
||||||
|
controller: _email,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '이메일',
|
||||||
|
prefixIcon: Icon(Icons.mail_outline_rounded, color: TossColors.textSecondary),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _password,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '비밀번호',
|
||||||
|
prefixIcon: Icon(Icons.lock_outline_rounded, color: TossColors.textSecondary),
|
||||||
|
),
|
||||||
|
obscureText: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _name,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '표시 이름 (선택)',
|
||||||
|
prefixIcon: Icon(Icons.badge_outlined, color: TossColors.textSecondary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_error!,
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
SizedBox(
|
||||||
|
height: 52,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _busy ? null : _submit,
|
||||||
|
child: _busy
|
||||||
|
? const SizedBox(
|
||||||
|
height: 22,
|
||||||
|
width: 22,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||||
|
)
|
||||||
|
: const Text('가입하기'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _busy ? null : () => context.go('/login'),
|
||||||
|
child: const Text('로그인으로 돌아가기'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
510
mobile/lib/features/chat/chat_page.dart
Normal file
510
mobile/lib/features/chat/chat_page.dart
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../core/api_client.dart';
|
||||||
|
import '../../core/chat_socket.dart';
|
||||||
|
import '../../core/msn_api.dart';
|
||||||
|
import '../../core/session_controller.dart';
|
||||||
|
import '../../data/message_local_store.dart';
|
||||||
|
import '../../models/message_model.dart';
|
||||||
|
import '../../models/room_model.dart';
|
||||||
|
import '../../models/ws_chat_side_event.dart';
|
||||||
|
import '../../theme/toss_theme.dart';
|
||||||
|
import '../profile/user_profile_sheet.dart';
|
||||||
|
|
||||||
|
final messageLocalStoreProvider = Provider<MessageLocalStore>((ref) => MessageLocalStore());
|
||||||
|
|
||||||
|
class ChatPage extends ConsumerStatefulWidget {
|
||||||
|
const ChatPage({super.key, required this.roomId, required this.contextId});
|
||||||
|
|
||||||
|
final String roomId;
|
||||||
|
final String contextId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ChatPage> createState() => _ChatPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatPageState extends ConsumerState<ChatPage> {
|
||||||
|
final _controller = TextEditingController();
|
||||||
|
final _scroll = ScrollController();
|
||||||
|
final List<MessageModel> _items = [];
|
||||||
|
StreamSubscription<MessageModel>? _sub;
|
||||||
|
bool _loading = true;
|
||||||
|
String? _contextName;
|
||||||
|
String? _bootstrapError;
|
||||||
|
/// From room list; null until loaded.
|
||||||
|
bool? _roomIsGroup;
|
||||||
|
String? _roomName;
|
||||||
|
String? _lastReadByOther;
|
||||||
|
StreamSubscription<Object>? _sideSub;
|
||||||
|
Timer? _typingDebounce;
|
||||||
|
Timer? _typingClear;
|
||||||
|
String? _typingUserId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_bootstrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _bootstrap() async {
|
||||||
|
setState(() {
|
||||||
|
_bootstrapError = null;
|
||||||
|
_loading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
final api = ref.read(msnApiProvider);
|
||||||
|
final store = ref.read(messageLocalStoreProvider);
|
||||||
|
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
final contexts = await api.listContexts();
|
||||||
|
final match = contexts.where((c) => c.id == widget.contextId).toList();
|
||||||
|
if (match.isNotEmpty && mounted) {
|
||||||
|
setState(() => _contextName = match.first.name);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
RoomModel? roomMeta;
|
||||||
|
try {
|
||||||
|
final rooms = await api.listRooms(widget.contextId);
|
||||||
|
for (final r in rooms) {
|
||||||
|
if (r.id == widget.roomId) {
|
||||||
|
roomMeta = r;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
await ref.read(chatSocketProvider).connect();
|
||||||
|
final list = await api.fetchMessages(widget.roomId);
|
||||||
|
await store.upsertMessages(widget.contextId, widget.roomId, list);
|
||||||
|
final local = await store.listForRoom(widget.contextId, widget.roomId);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_items
|
||||||
|
..clear()
|
||||||
|
..addAll(local.isNotEmpty ? local : list);
|
||||||
|
_loading = false;
|
||||||
|
_roomIsGroup = roomMeta?.isGroup;
|
||||||
|
_roomName = roomMeta?.name;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (roomMeta?.isGroup == false) {
|
||||||
|
unawaited(_refreshReadState());
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.read(chatSocketProvider).subscribeRoom(widget.roomId);
|
||||||
|
_sub = ref.read(chatSocketProvider).messages.listen((m) {
|
||||||
|
if (m.roomId != widget.roomId) return;
|
||||||
|
setState(() {
|
||||||
|
_items.add(m);
|
||||||
|
});
|
||||||
|
unawaited(store.upsertMessages(widget.contextId, widget.roomId, [m]));
|
||||||
|
unawaited(_markRead(m));
|
||||||
|
if (_roomIsGroup == false) {
|
||||||
|
unawaited(_refreshReadState());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_sideSub = ref.read(chatSocketProvider).sideEvents.listen((ev) {
|
||||||
|
if (ev is WsTypingEvent) {
|
||||||
|
if (ev.roomId != widget.roomId) return;
|
||||||
|
final myId = ref.read(sessionProvider).value?.userId;
|
||||||
|
if (myId == null || ev.userId == myId) return;
|
||||||
|
_typingClear?.cancel();
|
||||||
|
if (ev.active) {
|
||||||
|
setState(() => _typingUserId = ev.userId);
|
||||||
|
_typingClear = Timer(const Duration(seconds: 3), () {
|
||||||
|
if (mounted) setState(() => _typingUserId = null);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState(() => _typingUserId = null);
|
||||||
|
}
|
||||||
|
} else if (ev is WsReadEvent) {
|
||||||
|
if (ev.roomId != widget.roomId) return;
|
||||||
|
unawaited(_refreshReadState());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_items.isNotEmpty) {
|
||||||
|
unawaited(_markRead(_items.last));
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('ChatPage _bootstrap: $e\n$st');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
_bootstrapError = e.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshReadState() async {
|
||||||
|
if (_roomIsGroup == true) return;
|
||||||
|
try {
|
||||||
|
final id = await ref.read(msnApiProvider).getReadState(widget.roomId);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _lastReadByOther = id);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _markRead(MessageModel last) async {
|
||||||
|
try {
|
||||||
|
await ref.read(dioProvider).post(
|
||||||
|
'/api/messages/read',
|
||||||
|
data: {'roomId': widget.roomId, 'upToMessageId': last.id},
|
||||||
|
);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_sub?.cancel();
|
||||||
|
_sideSub?.cancel();
|
||||||
|
_typingDebounce?.cancel();
|
||||||
|
_typingClear?.cancel();
|
||||||
|
ref.read(chatSocketProvider).sendTyping(widget.roomId, false);
|
||||||
|
_controller.dispose();
|
||||||
|
_scroll.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onInputChanged(String text) {
|
||||||
|
if (_roomIsGroup == true) return;
|
||||||
|
_typingDebounce?.cancel();
|
||||||
|
final socket = ref.read(chatSocketProvider);
|
||||||
|
if (text.trim().isEmpty) {
|
||||||
|
socket.sendTyping(widget.roomId, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_typingDebounce = Timer(const Duration(milliseconds: 400), () {
|
||||||
|
socket.sendTyping(widget.roomId, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _lastMyMessageIndex(String me) {
|
||||||
|
for (var i = _items.length - 1; i >= 0; i--) {
|
||||||
|
if (_items[i].senderId == me) return i;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _showReadReceipt(String me) {
|
||||||
|
if (_roomIsGroup == true || _lastReadByOther == null) return false;
|
||||||
|
final myIdx = _lastMyMessageIndex(me);
|
||||||
|
if (myIdx == null) return false;
|
||||||
|
final readIdx = _items.indexWhere((m) => m.id == _lastReadByOther);
|
||||||
|
if (readIdx < 0) return false;
|
||||||
|
return readIdx >= myIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _send() {
|
||||||
|
final text = _controller.text.trim();
|
||||||
|
if (text.isEmpty) return;
|
||||||
|
ref.read(chatSocketProvider).sendTyping(widget.roomId, false);
|
||||||
|
final sent = ref.read(chatSocketProvider).sendMessage(widget.roomId, text);
|
||||||
|
_controller.clear();
|
||||||
|
if (!sent && mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('연결을 기다리는 중입니다. 복구되면 자동으로 전송됩니다.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final me = ref.watch(sessionProvider).value?.userId ?? '';
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: TossColors.bg,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_roomIsGroup == true ? (_roomName ?? '그룹 채팅') : '채팅',
|
||||||
|
style: theme.textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
if (_typingUserId != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
|
'상대가 입력 중…',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: TossColors.blue,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_contextName != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: TossColors.blue.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_contextName!,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: TossColors.blueDark,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (_roomIsGroup == true)
|
||||||
|
IconButton(
|
||||||
|
tooltip: '그룹 정보',
|
||||||
|
icon: const Icon(Icons.groups_outlined),
|
||||||
|
onPressed: _showGroupRoomSheet,
|
||||||
|
)
|
||||||
|
else if (_roomIsGroup == false)
|
||||||
|
IconButton(
|
||||||
|
tooltip: '프로필',
|
||||||
|
icon: const Icon(Icons.person_outline_rounded),
|
||||||
|
onPressed: () async {
|
||||||
|
final other = await _resolveOtherUserId();
|
||||||
|
if (!context.mounted || other == null) return;
|
||||||
|
await showUserProfileSheet(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
contextId: widget.contextId,
|
||||||
|
userId: other,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator(color: TossColors.blue))
|
||||||
|
: _bootstrapError != null && _items.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.wifi_off_rounded, size: 48, color: theme.colorScheme.error),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'불러오지 못했어요',
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_bootstrapError!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
_sub?.cancel();
|
||||||
|
_sub = null;
|
||||||
|
_bootstrap();
|
||||||
|
},
|
||||||
|
child: const Text('다시 시도'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
controller: _scroll,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
|
||||||
|
itemCount: _items.length,
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final m = _items[i];
|
||||||
|
final mine = m.senderId == me;
|
||||||
|
final lastMy = _lastMyMessageIndex(me);
|
||||||
|
final showRead = mine &&
|
||||||
|
lastMy == i &&
|
||||||
|
_showReadReceipt(me);
|
||||||
|
return Align(
|
||||||
|
alignment: mine ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
mine ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: MediaQuery.sizeOf(context).width * 0.78,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: mine ? TossColors.blue : TossColors.surface,
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: const Radius.circular(20),
|
||||||
|
topRight: const Radius.circular(20),
|
||||||
|
bottomLeft: Radius.circular(mine ? 20 : 6),
|
||||||
|
bottomRight: Radius.circular(mine ? 6 : 20),
|
||||||
|
),
|
||||||
|
border: mine ? null : Border.all(color: TossColors.line),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
m.body,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: mine ? Colors.white : TossColors.textPrimary,
|
||||||
|
height: 1.35,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showRead)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 12, bottom: 4),
|
||||||
|
child: Text(
|
||||||
|
'읽음',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: TossColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
color: TossColors.surface,
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
12,
|
||||||
|
8,
|
||||||
|
12,
|
||||||
|
8 + MediaQuery.paddingOf(context).bottom,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '메시지를 입력하세요',
|
||||||
|
hintStyle: theme.textTheme.bodyMedium,
|
||||||
|
filled: true,
|
||||||
|
fillColor: TossColors.bg,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
minLines: 1,
|
||||||
|
maxLines: 4,
|
||||||
|
onChanged: _onInputChanged,
|
||||||
|
onSubmitted: (_) => _send(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Material(
|
||||||
|
color: TossColors.blue,
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: _send,
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(12),
|
||||||
|
child: Icon(Icons.arrow_upward_rounded, color: Colors.white, size: 22),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showGroupRoomSheet() async {
|
||||||
|
try {
|
||||||
|
final res = await ref.read(dioProvider).get<Map<String, dynamic>>(
|
||||||
|
'/api/rooms/${widget.roomId}/participants',
|
||||||
|
);
|
||||||
|
final ids = (res.data!['userIds'] as List<dynamic>).cast<String>();
|
||||||
|
if (!mounted) return;
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
backgroundColor: TossColors.surface,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (ctx) {
|
||||||
|
final tt = Theme.of(ctx).textTheme;
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_roomName ?? '그룹 채팅',
|
||||||
|
style: tt.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'참여자 ${ids.length}명',
|
||||||
|
style: tt.bodyLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'이 맥락에서만 보이는 그룹 대화입니다.',
|
||||||
|
style: tt.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _resolveOtherUserId() async {
|
||||||
|
try {
|
||||||
|
final res = await ref.read(dioProvider).get<Map<String, dynamic>>(
|
||||||
|
'/api/rooms/${widget.roomId}/participants',
|
||||||
|
);
|
||||||
|
final ids = (res.data!['userIds'] as List<dynamic>).cast<String>();
|
||||||
|
final session = ref.read(sessionProvider).value;
|
||||||
|
final meId = session?.userId;
|
||||||
|
for (final id in ids) {
|
||||||
|
if (id != meId) return id;
|
||||||
|
}
|
||||||
|
return ids.isNotEmpty ? ids.first : null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
189
mobile/lib/features/home/context_members_tab.dart
Normal file
189
mobile/lib/features/home/context_members_tab.dart
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/msn_api.dart';
|
||||||
|
import '../../core/session_controller.dart';
|
||||||
|
import '../../theme/toss_theme.dart';
|
||||||
|
import '../profile/user_profile_sheet.dart';
|
||||||
|
import 'home_providers.dart';
|
||||||
|
|
||||||
|
/// Members of the selected messenger space: row tap opens DM; profile icon opens sheet.
|
||||||
|
class ContextMembersTab extends ConsumerWidget {
|
||||||
|
const ContextMembersTab({super.key, required this.contextId});
|
||||||
|
|
||||||
|
final String contextId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final session = ref.watch(sessionProvider).value;
|
||||||
|
final myId = session?.userId;
|
||||||
|
final async = ref.watch(membersForContextProvider(contextId));
|
||||||
|
|
||||||
|
return async.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator(color: TossColors.blue)),
|
||||||
|
error: (e, _) => Center(child: Text('$e')),
|
||||||
|
data: (members) {
|
||||||
|
if (members.isEmpty) {
|
||||||
|
return ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
children: const [
|
||||||
|
SizedBox(height: 120),
|
||||||
|
Center(child: Text('친구가 없습니다')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return RefreshIndicator(
|
||||||
|
color: TossColors.blue,
|
||||||
|
onRefresh: () async {
|
||||||
|
ref.invalidate(membersForContextProvider(contextId));
|
||||||
|
await ref.read(membersForContextProvider(contextId).future);
|
||||||
|
},
|
||||||
|
child: ListView.separated(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
|
||||||
|
itemCount: members.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final m = members[i];
|
||||||
|
final isSelf = myId != null && m.userId == myId;
|
||||||
|
|
||||||
|
Future<void> openChat() async {
|
||||||
|
final roomId =
|
||||||
|
await ref.read(msnApiProvider).openDirectRoom(contextId, m.userId);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await context.push('/chat?roomId=$roomId&contextId=$contextId');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> openProfile() async {
|
||||||
|
await showUserProfileSheet(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
contextId: contextId,
|
||||||
|
userId: m.userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: TossColors.surface,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Ink(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: TossColors.line),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: () async {
|
||||||
|
if (isSelf) {
|
||||||
|
await openProfile();
|
||||||
|
} else {
|
||||||
|
await openChat();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 22,
|
||||||
|
backgroundColor: TossColors.blue.withValues(alpha: 0.1),
|
||||||
|
backgroundImage:
|
||||||
|
m.avatarUrl != null && m.avatarUrl!.isNotEmpty
|
||||||
|
? NetworkImage(m.avatarUrl!)
|
||||||
|
: null,
|
||||||
|
child: m.avatarUrl == null || m.avatarUrl!.isEmpty
|
||||||
|
? Text(
|
||||||
|
m.displayName.isNotEmpty
|
||||||
|
? m.displayName[0].toUpperCase()
|
||||||
|
: '?',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: TossColors.blue,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
m.displayName.isNotEmpty
|
||||||
|
? m.displayName
|
||||||
|
: m.userId,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isSelf)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8),
|
||||||
|
child: Text(
|
||||||
|
'나',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelSmall
|
||||||
|
?.copyWith(
|
||||||
|
color: TossColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (m.statusMessage != null &&
|
||||||
|
m.statusMessage!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
m.statusMessage!,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!isSelf)
|
||||||
|
Icon(
|
||||||
|
Icons.chat_bubble_outline,
|
||||||
|
size: 20,
|
||||||
|
color: TossColors.blue.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: '프로필',
|
||||||
|
icon: Icon(
|
||||||
|
Icons.person_outline_rounded,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
await openProfile();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
512
mobile/lib/features/home/home_page.dart
Normal file
512
mobile/lib/features/home/home_page.dart
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/app_settings.dart';
|
||||||
|
import '../../core/chat_socket.dart';
|
||||||
|
import '../../core/fcm_service.dart';
|
||||||
|
import '../../core/msn_api.dart';
|
||||||
|
import '../../core/session_controller.dart';
|
||||||
|
import '../../theme/toss_theme.dart';
|
||||||
|
import 'context_members_tab.dart';
|
||||||
|
import 'home_providers.dart';
|
||||||
|
import 'my_profile_tab.dart';
|
||||||
|
|
||||||
|
class HomePage extends ConsumerStatefulWidget {
|
||||||
|
const HomePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<HomePage> createState() => _HomePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomePageState extends ConsumerState<HomePage> {
|
||||||
|
int _tabIndex = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
await ref.read(chatSocketProvider).connect();
|
||||||
|
if (await readNotificationsEnabledFromPrefs()) {
|
||||||
|
try {
|
||||||
|
await initializeFcmAndLocalNotifications(ref.read(msnApiProvider));
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('FCM init: $e\n$st');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onRefresh() async {
|
||||||
|
ref.invalidate(contextsListProvider);
|
||||||
|
final cid = ref.read(sessionProvider).value?.effectiveContextId;
|
||||||
|
if (cid != null) {
|
||||||
|
ref.invalidate(roomsForContextProvider(cid));
|
||||||
|
ref.invalidate(membersForContextProvider(cid));
|
||||||
|
ref.invalidate(myProfileForContextProvider(cid));
|
||||||
|
}
|
||||||
|
await ref.read(contextsListProvider.future);
|
||||||
|
if (cid != null) {
|
||||||
|
await ref.read(roomsForContextProvider(cid).future);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickContext(String? id) async {
|
||||||
|
if (id == null) return;
|
||||||
|
await ref.read(sessionProvider.notifier).setSelectedContext(id);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _iconForKind(String kind) {
|
||||||
|
switch (kind) {
|
||||||
|
case 'work':
|
||||||
|
return Icons.business_outlined;
|
||||||
|
case 'personal':
|
||||||
|
return Icons.home_outlined;
|
||||||
|
default:
|
||||||
|
return Icons.folder_outlined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addWorkContext() async {
|
||||||
|
final name = TextEditingController();
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('New work context'),
|
||||||
|
content: TextField(
|
||||||
|
controller: name,
|
||||||
|
decoration: const InputDecoration(labelText: 'Name'),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
|
||||||
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Create')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok == true && name.text.trim().isNotEmpty) {
|
||||||
|
await ref.read(msnApiProvider).createContext(name: name.text.trim(), kind: 'work');
|
||||||
|
ref.invalidate(contextsListProvider);
|
||||||
|
}
|
||||||
|
name.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _inviteEmail() async {
|
||||||
|
final session = ref.read(sessionProvider).value;
|
||||||
|
final cid = session?.effectiveContextId;
|
||||||
|
if (cid == null) return;
|
||||||
|
final email = TextEditingController();
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Invite by email'),
|
||||||
|
content: TextField(
|
||||||
|
controller: email,
|
||||||
|
decoration: const InputDecoration(labelText: 'Email'),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
|
||||||
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Invite')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok == true && email.text.trim().isNotEmpty) {
|
||||||
|
try {
|
||||||
|
await ref.read(msnApiProvider).inviteToContext(cid, email.text.trim());
|
||||||
|
ref.invalidate(membersForContextProvider(cid));
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Invited')));
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
email.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openDirect() async {
|
||||||
|
final session = ref.read(sessionProvider).value;
|
||||||
|
final cid = session?.effectiveContextId;
|
||||||
|
if (cid == null) return;
|
||||||
|
final uid = TextEditingController();
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Open direct chat'),
|
||||||
|
content: TextField(
|
||||||
|
controller: uid,
|
||||||
|
decoration: const InputDecoration(labelText: 'Other user ID (UUID)'),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
|
||||||
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Open')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok == true && uid.text.trim().isNotEmpty) {
|
||||||
|
final roomId = await ref.read(msnApiProvider).openDirectRoom(cid, uid.text.trim());
|
||||||
|
if (!mounted) return;
|
||||||
|
await context.push('/chat?roomId=$roomId&contextId=$cid');
|
||||||
|
}
|
||||||
|
uid.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createGroup() async {
|
||||||
|
final session = ref.read(sessionProvider).value;
|
||||||
|
final cid = session?.effectiveContextId;
|
||||||
|
if (cid == null) return;
|
||||||
|
final name = TextEditingController();
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('New group'),
|
||||||
|
content: TextField(
|
||||||
|
controller: name,
|
||||||
|
decoration: const InputDecoration(labelText: 'Group name'),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
|
||||||
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Create')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok == true && name.text.trim().isNotEmpty) {
|
||||||
|
await ref.read(msnApiProvider).createGroupRoom(cid, name.text.trim(), []);
|
||||||
|
ref.invalidate(roomsForContextProvider(cid));
|
||||||
|
}
|
||||||
|
name.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final session = ref.watch(sessionProvider).value;
|
||||||
|
final cid = session?.effectiveContextId;
|
||||||
|
final contextsAsync = ref.watch(contextsListProvider);
|
||||||
|
|
||||||
|
const tabTitles = ['채팅', '친구', '내 정보'];
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: TossColors.bg,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(tabTitles[_tabIndex.clamp(0, tabTitles.length - 1)]),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.search),
|
||||||
|
onPressed: cid == null ? null : () => context.push('/search?contextId=$cid'),
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
onSelected: (v) async {
|
||||||
|
if (v == 'settings') await context.push('/settings');
|
||||||
|
if (v == 'logout') {
|
||||||
|
await ref.read(chatSocketProvider).disconnect();
|
||||||
|
await ref.read(sessionProvider.notifier).logout();
|
||||||
|
}
|
||||||
|
if (v == 'work') await _addWorkContext();
|
||||||
|
if (v == 'invite') await _inviteEmail();
|
||||||
|
if (v == 'group') await _createGroup();
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => const [
|
||||||
|
PopupMenuItem(value: 'settings', child: Text('설정')),
|
||||||
|
PopupMenuItem(value: 'work', child: Text('직장 맥락 추가')),
|
||||||
|
PopupMenuItem(value: 'invite', child: Text('이 맥락에 초대')),
|
||||||
|
PopupMenuItem(value: 'group', child: Text('그룹 만들기')),
|
||||||
|
PopupMenuItem(value: 'logout', child: Text('로그아웃')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: contextsAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator(color: TossColors.blue)),
|
||||||
|
error: (e, _) => Center(child: Text('Contexts: $e')),
|
||||||
|
data: (list) {
|
||||||
|
final effective = cid != null && list.any((c) => c.id == cid)
|
||||||
|
? cid
|
||||||
|
: (list.isNotEmpty ? list.first.id : null);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'메신저 공간',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'선택한 공간의 대화·친구·프로필이에요. 탭해서 일상 · 직장을 전환하세요',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
SizedBox(
|
||||||
|
height: 46,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: list.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final c = list[i];
|
||||||
|
final selected = effective == c.id;
|
||||||
|
return _TossContextChip(
|
||||||
|
label: c.name,
|
||||||
|
icon: _iconForKind(c.kind),
|
||||||
|
selected: selected,
|
||||||
|
onTap: () => _pickContext(c.id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: effective == null
|
||||||
|
? const Center(child: Text('맥락이 없습니다'))
|
||||||
|
: IndexedStack(
|
||||||
|
index: _tabIndex,
|
||||||
|
children: [
|
||||||
|
RefreshIndicator(
|
||||||
|
onRefresh: _onRefresh,
|
||||||
|
child: CustomScrollView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
slivers: _roomSlivers(context, effective),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ContextMembersTab(contextId: effective),
|
||||||
|
const MyProfileTab(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
bottomNavigationBar: NavigationBar(
|
||||||
|
selectedIndex: _tabIndex,
|
||||||
|
onDestinationSelected: (i) => setState(() => _tabIndex = i),
|
||||||
|
destinations: const [
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.chat_bubble_outline),
|
||||||
|
selectedIcon: Icon(Icons.chat_bubble),
|
||||||
|
label: '채팅',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.people_outline),
|
||||||
|
selectedIcon: Icon(Icons.people),
|
||||||
|
label: '친구',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.person_outline),
|
||||||
|
selectedIcon: Icon(Icons.person),
|
||||||
|
label: '내 정보',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: _tabIndex == 0
|
||||||
|
? FloatingActionButton(
|
||||||
|
onPressed: _openDirect,
|
||||||
|
child: const Icon(Icons.chat_bubble_outline),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _roomSlivers(BuildContext context, String effectiveContextId) {
|
||||||
|
final asyncRooms = ref.watch(roomsForContextProvider(effectiveContextId));
|
||||||
|
|
||||||
|
return asyncRooms.when(
|
||||||
|
loading: () => [
|
||||||
|
const SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: Center(child: CircularProgressIndicator(color: TossColors.blue)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
error: (e, _) => [
|
||||||
|
SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: Center(child: Text('$e')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
data: (rooms) {
|
||||||
|
if (rooms.isEmpty) {
|
||||||
|
return [
|
||||||
|
SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.chat_outlined, size: 48, color: Theme.of(context).disabledColor),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'대화가 없습니다',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'+ 버튼으로 직접 대화를 열 수 있습니다',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, i) {
|
||||||
|
final r = rooms[i];
|
||||||
|
final subtitle = r.lastBody ?? '메시지 없음';
|
||||||
|
final time = r.lastAt;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
|
child: Material(
|
||||||
|
color: TossColors.surface,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
elevation: 0,
|
||||||
|
shadowColor: Colors.black.withValues(alpha: 0.06),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
onTap: () => context.push(
|
||||||
|
'/chat?roomId=${r.id}&contextId=$effectiveContextId',
|
||||||
|
),
|
||||||
|
child: Ink(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: TossColors.line),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 22,
|
||||||
|
backgroundColor: TossColors.blue.withValues(alpha: 0.1),
|
||||||
|
child: Icon(
|
||||||
|
r.isGroup ? Icons.groups_rounded : Icons.person_rounded,
|
||||||
|
color: TossColors.blue,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
r.name ?? (r.isGroup ? '그룹 채팅' : '1:1 채팅'),
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (time != null && time.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
_shortTime(time),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: rooms.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _shortTime(String iso) {
|
||||||
|
try {
|
||||||
|
final d = DateTime.tryParse(iso);
|
||||||
|
if (d == null) return '';
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (d.year == now.year && d.month == now.month && d.day == now.day) {
|
||||||
|
return '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
return '${d.month}/${d.day}';
|
||||||
|
} catch (_) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pill chip: Toss blue when selected, white + border otherwise.
|
||||||
|
class _TossContextChip extends StatelessWidget {
|
||||||
|
const _TossContextChip({
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.selected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final bool selected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
color: selected ? TossColors.blue : TossColors.surface,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(
|
||||||
|
color: selected ? TossColors.blue : TossColors.line,
|
||||||
|
width: selected ? 0 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 18,
|
||||||
|
color: selected ? Colors.white : TossColors.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
color: selected ? Colors.white : TossColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
mobile/lib/features/home/home_providers.dart
Normal file
20
mobile/lib/features/home/home_providers.dart
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../core/msn_api.dart';
|
||||||
|
import '../../models/context_member_model.dart';
|
||||||
|
import '../../models/context_model.dart';
|
||||||
|
import '../../models/room_model.dart';
|
||||||
|
|
||||||
|
final contextsListProvider = FutureProvider.autoDispose<List<ContextModel>>((ref) async {
|
||||||
|
return ref.watch(msnApiProvider).listContexts();
|
||||||
|
});
|
||||||
|
|
||||||
|
final roomsForContextProvider =
|
||||||
|
FutureProvider.autoDispose.family<List<RoomModel>, String>((ref, contextId) async {
|
||||||
|
return ref.watch(msnApiProvider).listRooms(contextId);
|
||||||
|
});
|
||||||
|
|
||||||
|
final membersForContextProvider =
|
||||||
|
FutureProvider.autoDispose.family<List<ContextMember>, String>((ref, contextId) async {
|
||||||
|
return ref.watch(msnApiProvider).listContextMembers(contextId);
|
||||||
|
});
|
||||||
190
mobile/lib/features/home/my_profile_tab.dart
Normal file
190
mobile/lib/features/home/my_profile_tab.dart
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../core/msn_api.dart';
|
||||||
|
import '../../core/session_controller.dart';
|
||||||
|
import '../../models/context_model.dart';
|
||||||
|
import '../../models/profile_model.dart';
|
||||||
|
import '../../theme/toss_theme.dart';
|
||||||
|
import 'home_providers.dart';
|
||||||
|
|
||||||
|
final myProfileForContextProvider =
|
||||||
|
FutureProvider.autoDispose.family<ProfileModel, String>((ref, contextId) async {
|
||||||
|
return ref.watch(msnApiProvider).getMyProfile(contextId);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Edit display name and status per messenger space, with 일상 / 직장 segment synced to top chips.
|
||||||
|
class MyProfileTab extends ConsumerStatefulWidget {
|
||||||
|
const MyProfileTab({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<MyProfileTab> createState() => _MyProfileTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MyProfileTabState extends ConsumerState<MyProfileTab> {
|
||||||
|
final _nameCtrl = TextEditingController();
|
||||||
|
final _statusCtrl = TextEditingController();
|
||||||
|
bool _dirty = false;
|
||||||
|
bool _saving = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameCtrl.dispose();
|
||||||
|
_statusCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save(String contextId) async {
|
||||||
|
setState(() => _saving = true);
|
||||||
|
try {
|
||||||
|
await ref.read(msnApiProvider).updateMyProfile(
|
||||||
|
contextId,
|
||||||
|
displayName: _nameCtrl.text.trim(),
|
||||||
|
statusMessage: _statusCtrl.text.trim(),
|
||||||
|
);
|
||||||
|
ref.invalidate(myProfileForContextProvider(contextId));
|
||||||
|
ref.invalidate(membersForContextProvider(contextId));
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_dirty = false;
|
||||||
|
_saving = false;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('저장했습니다')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _saving = false);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSegmentChanged(String contextId) async {
|
||||||
|
await ref.read(sessionProvider.notifier).setSelectedContext(contextId);
|
||||||
|
setState(() => _dirty = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final session = ref.watch(sessionProvider).value;
|
||||||
|
final cid = session?.effectiveContextId;
|
||||||
|
final contextsAsync = ref.watch(contextsListProvider);
|
||||||
|
|
||||||
|
return contextsAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator(color: TossColors.blue)),
|
||||||
|
error: (e, _) => Center(child: Text('$e')),
|
||||||
|
data: (contexts) {
|
||||||
|
final personal = _firstOfKind(contexts, 'personal');
|
||||||
|
final work = _firstOfKind(contexts, 'work');
|
||||||
|
|
||||||
|
if (cid == null) {
|
||||||
|
return const Center(child: Text('맥락이 없습니다'));
|
||||||
|
}
|
||||||
|
|
||||||
|
final async = ref.watch(myProfileForContextProvider(cid));
|
||||||
|
|
||||||
|
ref.listen<AsyncValue<ProfileModel>>(myProfileForContextProvider(cid), (prev, next) {
|
||||||
|
next.whenData((p) {
|
||||||
|
if (!_dirty && mounted) {
|
||||||
|
_nameCtrl.text = p.displayName;
|
||||||
|
_statusCtrl.text = p.statusMessage ?? '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return async.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator(color: TossColors.blue)),
|
||||||
|
error: (e, _) => Center(child: Text('$e')),
|
||||||
|
data: (_) => RefreshIndicator(
|
||||||
|
color: TossColors.blue,
|
||||||
|
onRefresh: () async {
|
||||||
|
ref.invalidate(myProfileForContextProvider(cid));
|
||||||
|
await ref.read(myProfileForContextProvider(cid).future);
|
||||||
|
},
|
||||||
|
child: ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
|
||||||
|
children: [
|
||||||
|
if (personal != null && work != null) ...[
|
||||||
|
SegmentedButton<String>(
|
||||||
|
segments: [
|
||||||
|
ButtonSegment<String>(
|
||||||
|
value: personal.id,
|
||||||
|
label: Text(personal.name),
|
||||||
|
icon: const Icon(Icons.home_outlined, size: 18),
|
||||||
|
),
|
||||||
|
ButtonSegment<String>(
|
||||||
|
value: work.id,
|
||||||
|
label: Text(work.name),
|
||||||
|
icon: const Icon(Icons.business_outlined, size: 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {cid},
|
||||||
|
onSelectionChanged: (s) {
|
||||||
|
if (s.isEmpty) return;
|
||||||
|
_onSegmentChanged(s.first);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
'지금 편집: ${_contextName(contexts, cid)}',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'이 맥락에서만 보이는 이름과 상태입니다.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
TextField(
|
||||||
|
controller: _nameCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '표시 이름',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onChanged: (_) => setState(() => _dirty = true),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _statusCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '상태 메시지',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
|
onChanged: (_) => setState(() => _dirty = true),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _saving ? null : () => _save(cid),
|
||||||
|
child: _saving
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||||
|
)
|
||||||
|
: const Text('저장'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ContextModel? _firstOfKind(List<ContextModel> list, String kind) {
|
||||||
|
for (final c in list) {
|
||||||
|
if (c.kind == kind) return c;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _contextName(List<ContextModel> list, String id) {
|
||||||
|
for (final c in list) {
|
||||||
|
if (c.id == id) return c.name;
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
178
mobile/lib/features/profile/user_profile_sheet.dart
Normal file
178
mobile/lib/features/profile/user_profile_sheet.dart
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/msn_api.dart';
|
||||||
|
import '../../core/session_controller.dart';
|
||||||
|
import '../../models/profile_model.dart';
|
||||||
|
import '../../theme/toss_theme.dart';
|
||||||
|
|
||||||
|
/// Kakao-style profile sheet: large avatar, name, status, optional 1:1 chat.
|
||||||
|
Future<void> showUserProfileSheet(
|
||||||
|
BuildContext parentContext,
|
||||||
|
WidgetRef ref, {
|
||||||
|
required String contextId,
|
||||||
|
required String userId,
|
||||||
|
}) async {
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: parentContext,
|
||||||
|
isScrollControlled: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
backgroundColor: TossColors.surface,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (sheetContext) => _UserProfileSheetBody(
|
||||||
|
parentContext: parentContext,
|
||||||
|
contextId: contextId,
|
||||||
|
userId: userId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UserProfileSheetBody extends ConsumerStatefulWidget {
|
||||||
|
const _UserProfileSheetBody({
|
||||||
|
required this.parentContext,
|
||||||
|
required this.contextId,
|
||||||
|
required this.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
final BuildContext parentContext;
|
||||||
|
final String contextId;
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_UserProfileSheetBody> createState() => _UserProfileSheetBodyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UserProfileSheetBodyState extends ConsumerState<_UserProfileSheetBody> {
|
||||||
|
late Future<ProfileModel> _future;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final myId = ref.read(sessionProvider).value?.userId;
|
||||||
|
final isSelf = myId != null && widget.userId == myId;
|
||||||
|
_future = isSelf
|
||||||
|
? ref.read(msnApiProvider).getMyProfile(widget.contextId)
|
||||||
|
: ref.read(msnApiProvider).getUserProfile(widget.contextId, widget.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openDirect() async {
|
||||||
|
final roomId =
|
||||||
|
await ref.read(msnApiProvider).openDirectRoom(widget.contextId, widget.userId);
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
if (widget.parentContext.mounted) {
|
||||||
|
await widget.parentContext.push('/chat?roomId=$roomId&contextId=${widget.contextId}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final myId = ref.watch(sessionProvider).value?.userId;
|
||||||
|
final isSelf = myId != null && widget.userId == myId;
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 24,
|
||||||
|
right: 24,
|
||||||
|
top: 8,
|
||||||
|
bottom: 24 + MediaQuery.paddingOf(context).bottom,
|
||||||
|
),
|
||||||
|
child: FutureBuilder<ProfileModel>(
|
||||||
|
future: _future,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const SizedBox(
|
||||||
|
height: 220,
|
||||||
|
child: Center(child: CircularProgressIndicator(color: TossColors.blue)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 120,
|
||||||
|
child: Center(child: Text('${snapshot.error}')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final p = snapshot.data!;
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: _LargeAvatar(
|
||||||
|
displayName: p.displayName,
|
||||||
|
avatarUrl: p.avatarUrl,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
p.displayName,
|
||||||
|
style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (p.statusMessage != null && p.statusMessage!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
p.statusMessage!,
|
||||||
|
style: theme.textTheme.bodyLarge,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'이 맥락에서만 보이는 프로필이에요.',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (!isSelf) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _openDirect,
|
||||||
|
icon: const Icon(Icons.chat_bubble_outline),
|
||||||
|
label: const Text('1:1 채팅'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LargeAvatar extends StatelessWidget {
|
||||||
|
const _LargeAvatar({
|
||||||
|
required this.displayName,
|
||||||
|
this.avatarUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String displayName;
|
||||||
|
final String? avatarUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final hasUrl = avatarUrl != null && avatarUrl!.isNotEmpty;
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: 56,
|
||||||
|
backgroundColor: TossColors.blue.withValues(alpha: 0.12),
|
||||||
|
backgroundImage: hasUrl ? NetworkImage(avatarUrl!) : null,
|
||||||
|
child: hasUrl
|
||||||
|
? null
|
||||||
|
: Text(
|
||||||
|
displayName.isNotEmpty ? displayName[0].toUpperCase() : '?',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 40,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: TossColors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
338
mobile/lib/features/search/search_page.dart
Normal file
338
mobile/lib/features/search/search_page.dart
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/msn_api.dart';
|
||||||
|
import '../../core/session_controller.dart';
|
||||||
|
import '../../models/context_member_model.dart';
|
||||||
|
import '../../models/room_model.dart';
|
||||||
|
import '../../theme/toss_theme.dart';
|
||||||
|
import '../profile/user_profile_sheet.dart';
|
||||||
|
|
||||||
|
class SearchPage extends ConsumerStatefulWidget {
|
||||||
|
const SearchPage({super.key, required this.contextId});
|
||||||
|
|
||||||
|
final String contextId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<SearchPage> createState() => _SearchPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SearchPageState extends ConsumerState<SearchPage> {
|
||||||
|
final _q = TextEditingController();
|
||||||
|
List<Map<String, dynamic>> _messageResults = [];
|
||||||
|
List<RoomModel> _rooms = [];
|
||||||
|
List<ContextMember> _friends = [];
|
||||||
|
bool _busy = false;
|
||||||
|
bool _loadedLists = false;
|
||||||
|
int _segment = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _ensureLists());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_q.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _ensureLists() async {
|
||||||
|
if (_loadedLists || widget.contextId.isEmpty) return;
|
||||||
|
setState(() => _busy = true);
|
||||||
|
try {
|
||||||
|
final api = ref.read(msnApiProvider);
|
||||||
|
final rooms = await api.listRooms(widget.contextId);
|
||||||
|
final members = await api.listContextMembers(widget.contextId);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_rooms = rooms;
|
||||||
|
_friends = members;
|
||||||
|
_loadedLists = true;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runMessageSearch() async {
|
||||||
|
if (widget.contextId.isEmpty) return;
|
||||||
|
setState(() => _busy = true);
|
||||||
|
try {
|
||||||
|
final r = await ref.read(msnApiProvider).searchMessages(widget.contextId, _q.text);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _messageResults = r);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _needle => _q.text.trim().toLowerCase();
|
||||||
|
|
||||||
|
List<RoomModel> get _filteredRooms {
|
||||||
|
if (_needle.isEmpty) return _rooms;
|
||||||
|
return _rooms.where((r) {
|
||||||
|
final name = (r.name ?? (r.isGroup ? '그룹' : '1:1')).toLowerCase();
|
||||||
|
final last = (r.lastBody ?? '').toLowerCase();
|
||||||
|
return name.contains(_needle) || last.contains(_needle);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ContextMember> get _filteredFriends {
|
||||||
|
if (_needle.isEmpty) return _friends;
|
||||||
|
return _friends.where((m) {
|
||||||
|
final name = m.displayName.toLowerCase();
|
||||||
|
final st = (m.statusMessage ?? '').toLowerCase();
|
||||||
|
return name.contains(_needle) ||
|
||||||
|
m.userId.toLowerCase().contains(_needle) ||
|
||||||
|
st.contains(_needle);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final tt = Theme.of(context).textTheme;
|
||||||
|
final myId = ref.watch(sessionProvider).value?.userId;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: TossColors.bg,
|
||||||
|
appBar: AppBar(title: const Text('검색')),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||||
|
child: SegmentedButton<int>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(value: 0, label: Text('메시지'), icon: Icon(Icons.search, size: 18)),
|
||||||
|
ButtonSegment(value: 1, label: Text('방'), icon: Icon(Icons.chat_bubble_outline, size: 18)),
|
||||||
|
ButtonSegment(value: 2, label: Text('친구'), icon: Icon(Icons.people_outline, size: 18)),
|
||||||
|
],
|
||||||
|
selected: {_segment},
|
||||||
|
onSelectionChanged: (s) {
|
||||||
|
if (s.isEmpty) return;
|
||||||
|
setState(() => _segment = s.first);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _q,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: _segment == 0
|
||||||
|
? '이 맥락에서 메시지 검색'
|
||||||
|
: _segment == 1
|
||||||
|
? '방 이름 또는 미리보기로 필터'
|
||||||
|
: '이름·상태로 친구 필터',
|
||||||
|
prefixIcon: const Icon(Icons.search_rounded, color: TossColors.textSecondary),
|
||||||
|
),
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_segment != 0) setState(() {});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) {
|
||||||
|
if (_segment == 0) unawaited(_runMessageSearch());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (_segment == 0)
|
||||||
|
Material(
|
||||||
|
color: TossColors.blue,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: _busy ? null : () => unawaited(_runMessageSearch()),
|
||||||
|
icon: _busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.arrow_forward_rounded, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _busy && !_loadedLists && _segment != 0
|
||||||
|
? const Center(child: CircularProgressIndicator(color: TossColors.blue))
|
||||||
|
: _segment == 0
|
||||||
|
? _buildMessageList(tt)
|
||||||
|
: _segment == 1
|
||||||
|
? _buildRoomList(tt)
|
||||||
|
: _buildFriendList(tt, myId),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMessageList(TextTheme tt) {
|
||||||
|
return ListView.separated(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||||
|
itemCount: _messageResults.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final r = _messageResults[i];
|
||||||
|
return Material(
|
||||||
|
color: TossColors.surface,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
onTap: () {
|
||||||
|
final roomId = r['roomId'] as String?;
|
||||||
|
if (roomId == null) return;
|
||||||
|
context.push('/chat?roomId=$roomId&contextId=${widget.contextId}');
|
||||||
|
},
|
||||||
|
child: Ink(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: TossColors.line),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(r['body'] as String? ?? '', style: tt.bodyLarge),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text('방 ${r['roomId']}', style: tt.bodySmall),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRoomList(TextTheme tt) {
|
||||||
|
final list = _filteredRooms;
|
||||||
|
if (list.isEmpty) {
|
||||||
|
return Center(child: Text(_emptyHint('방')));
|
||||||
|
}
|
||||||
|
return ListView.separated(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||||
|
itemCount: list.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final r = list[i];
|
||||||
|
final title = r.name ?? (r.isGroup ? '그룹 채팅' : '1:1 채팅');
|
||||||
|
final sub = r.lastBody ?? '메시지 없음';
|
||||||
|
return Material(
|
||||||
|
color: TossColors.surface,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
onTap: () => context.push('/chat?roomId=${r.id}&contextId=${widget.contextId}'),
|
||||||
|
child: Ink(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: TossColors.line),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title, style: tt.titleMedium),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(sub, style: tt.bodySmall, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFriendList(TextTheme tt, String? myId) {
|
||||||
|
final list = _filteredFriends;
|
||||||
|
if (list.isEmpty) {
|
||||||
|
return Center(child: Text(_emptyHint('친구')));
|
||||||
|
}
|
||||||
|
return ListView.separated(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||||
|
itemCount: list.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final m = list[i];
|
||||||
|
final isSelf = myId != null && m.userId == myId;
|
||||||
|
return Material(
|
||||||
|
color: TossColors.surface,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
onTap: () async {
|
||||||
|
if (isSelf) {
|
||||||
|
await showUserProfileSheet(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
contextId: widget.contextId,
|
||||||
|
userId: m.userId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final roomId =
|
||||||
|
await ref.read(msnApiProvider).openDirectRoom(widget.contextId, m.userId);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await context.push('/chat?roomId=$roomId&contextId=${widget.contextId}');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Ink(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: TossColors.line),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
m.displayName.isNotEmpty ? m.displayName : m.userId,
|
||||||
|
style: tt.titleMedium,
|
||||||
|
),
|
||||||
|
if (m.statusMessage != null && m.statusMessage!.isNotEmpty)
|
||||||
|
Text(m.statusMessage!, style: tt.bodySmall, maxLines: 1),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.person_outline_rounded),
|
||||||
|
onPressed: () async {
|
||||||
|
await showUserProfileSheet(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
contextId: widget.contextId,
|
||||||
|
userId: m.userId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _emptyHint(String kind) {
|
||||||
|
if (_needle.isEmpty) return '목록을 불러오는 중이거나 $kind가 없습니다';
|
||||||
|
return '검색어와 일치하는 $kind가 없습니다';
|
||||||
|
}
|
||||||
|
}
|
||||||
93
mobile/lib/features/settings/settings_page.dart
Normal file
93
mobile/lib/features/settings/settings_page.dart
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
|
import '../../core/app_settings.dart';
|
||||||
|
import '../../core/chat_socket.dart';
|
||||||
|
import '../../core/fcm_service.dart';
|
||||||
|
import '../../core/msn_api.dart';
|
||||||
|
import '../../core/session_controller.dart';
|
||||||
|
class SettingsPage extends ConsumerStatefulWidget {
|
||||||
|
const SettingsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<SettingsPage> createState() => _SettingsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||||
|
String _version = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
PackageInfo.fromPlatform().then((p) {
|
||||||
|
if (mounted) setState(() => _version = '${p.version} (${p.buildNumber})');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _logout() async {
|
||||||
|
await ref.read(chatSocketProvider).disconnect();
|
||||||
|
await ref.read(sessionProvider.notifier).logout();
|
||||||
|
if (!mounted) return;
|
||||||
|
context.go('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final themeMode = ref.watch(themeModeProvider);
|
||||||
|
final notifOn = ref.watch(notificationsEnabledProvider);
|
||||||
|
final tt = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||||
|
appBar: AppBar(title: const Text('설정')),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||||
|
children: [
|
||||||
|
Text('IYKYKA', style: tt.titleLarge),
|
||||||
|
if (_version.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4, bottom: 16),
|
||||||
|
child: Text('버전 $_version', style: tt.bodySmall),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('화면', style: tt.titleSmall),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SegmentedButton<ThemeMode>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(value: ThemeMode.system, label: Text('시스템')),
|
||||||
|
ButtonSegment(value: ThemeMode.light, label: Text('라이트')),
|
||||||
|
],
|
||||||
|
selected: {themeMode},
|
||||||
|
onSelectionChanged: (s) {
|
||||||
|
if (s.isEmpty) return;
|
||||||
|
ref.read(themeModeProvider.notifier).setThemeMode(s.first);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text('알림', style: tt.titleSmall),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('푸시 알림 허용'),
|
||||||
|
subtitle: const Text('기기에서 알림을 허용한 경우에 적용됩니다'),
|
||||||
|
value: notifOn,
|
||||||
|
onChanged: (v) async {
|
||||||
|
await ref.read(notificationsEnabledProvider.notifier).setEnabled(v);
|
||||||
|
if (v) {
|
||||||
|
try {
|
||||||
|
await initializeFcmAndLocalNotifications(ref.read(msnApiProvider));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: _logout,
|
||||||
|
child: const Text('로그아웃'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
mobile/lib/firebase_options.dart
Normal file
49
mobile/lib/firebase_options.dart
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// File generated for build without `flutterfire configure`.
|
||||||
|
// Replace with `dart pub global run flutterfire_cli:flutterfire configure` for production.
|
||||||
|
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
||||||
|
import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||||
|
|
||||||
|
class DefaultFirebaseOptions {
|
||||||
|
static FirebaseOptions get currentPlatform {
|
||||||
|
if (kIsWeb) {
|
||||||
|
return web;
|
||||||
|
}
|
||||||
|
switch (defaultTargetPlatform) {
|
||||||
|
case TargetPlatform.android:
|
||||||
|
return android;
|
||||||
|
case TargetPlatform.iOS:
|
||||||
|
return ios;
|
||||||
|
case TargetPlatform.macOS:
|
||||||
|
return macos;
|
||||||
|
default:
|
||||||
|
return android;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static const FirebaseOptions android = FirebaseOptions(
|
||||||
|
apiKey: 'AIzaSyPlaceholderReplaceViaFlutterfireConfigure',
|
||||||
|
appId: '1:000000000000:android:0000000000000000000000',
|
||||||
|
messagingSenderId: '000000000000',
|
||||||
|
projectId: 'iykyka-placeholder',
|
||||||
|
storageBucket: 'iykyka-placeholder.appspot.com',
|
||||||
|
);
|
||||||
|
|
||||||
|
static const FirebaseOptions ios = FirebaseOptions(
|
||||||
|
apiKey: 'AIzaSyPlaceholderReplaceViaFlutterfireConfigure',
|
||||||
|
appId: '1:000000000000:ios:0000000000000000000000',
|
||||||
|
messagingSenderId: '000000000000',
|
||||||
|
projectId: 'iykyka-placeholder',
|
||||||
|
storageBucket: 'iykyka-placeholder.appspot.com',
|
||||||
|
iosBundleId: 'com.example.msnMobile',
|
||||||
|
);
|
||||||
|
|
||||||
|
static const FirebaseOptions macos = ios;
|
||||||
|
|
||||||
|
static const FirebaseOptions web = FirebaseOptions(
|
||||||
|
apiKey: 'AIzaSyPlaceholderReplaceViaFlutterfireConfigure',
|
||||||
|
appId: '1:000000000000:web:0000000000000000000000',
|
||||||
|
messagingSenderId: '000000000000',
|
||||||
|
projectId: 'iykyka-placeholder',
|
||||||
|
storageBucket: 'iykyka-placeholder.appspot.com',
|
||||||
|
);
|
||||||
|
}
|
||||||
36
mobile/lib/main.dart
Normal file
36
mobile/lib/main.dart
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'app/router.dart';
|
||||||
|
import 'core/app_settings.dart';
|
||||||
|
import 'firebase_options.dart';
|
||||||
|
import 'theme/toss_theme.dart';
|
||||||
|
|
||||||
|
Future<void> main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
try {
|
||||||
|
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Firebase.initializeApp skipped (configure firebase_options / google-services): $e');
|
||||||
|
}
|
||||||
|
runApp(const ProviderScope(child: MsnApp()));
|
||||||
|
}
|
||||||
|
|
||||||
|
class MsnApp extends ConsumerWidget {
|
||||||
|
const MsnApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final router = ref.watch(goRouterProvider);
|
||||||
|
final themeMode = ref.watch(themeModeProvider);
|
||||||
|
return MaterialApp.router(
|
||||||
|
title: 'IYKYKA',
|
||||||
|
theme: buildTossTheme(),
|
||||||
|
// Match light theme so OS dark mode does not switch to a separate dark palette.
|
||||||
|
darkTheme: buildTossTheme(),
|
||||||
|
themeMode: themeMode,
|
||||||
|
routerConfig: router,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
mobile/lib/models/context_member_model.dart
Normal file
25
mobile/lib/models/context_member_model.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
class ContextMember {
|
||||||
|
ContextMember({
|
||||||
|
required this.userId,
|
||||||
|
required this.role,
|
||||||
|
required this.displayName,
|
||||||
|
this.avatarUrl,
|
||||||
|
this.statusMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String userId;
|
||||||
|
final String role;
|
||||||
|
final String displayName;
|
||||||
|
final String? avatarUrl;
|
||||||
|
final String? statusMessage;
|
||||||
|
|
||||||
|
factory ContextMember.fromJson(Map<String, dynamic> j) {
|
||||||
|
return ContextMember(
|
||||||
|
userId: j['userId'] as String,
|
||||||
|
role: j['role'] as String? ?? 'member',
|
||||||
|
displayName: j['displayName'] as String? ?? '',
|
||||||
|
avatarUrl: j['avatarUrl'] as String?,
|
||||||
|
statusMessage: j['statusMessage'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
mobile/lib/models/context_model.dart
Normal file
26
mobile/lib/models/context_model.dart
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
class ContextModel {
|
||||||
|
ContextModel({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.kind,
|
||||||
|
this.retentionDays,
|
||||||
|
this.screenshotBlocked = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String kind;
|
||||||
|
final int? retentionDays;
|
||||||
|
final bool screenshotBlocked;
|
||||||
|
|
||||||
|
factory ContextModel.fromJson(Map<String, dynamic> j) {
|
||||||
|
final policy = j['policy'] as Map<String, dynamic>?;
|
||||||
|
return ContextModel(
|
||||||
|
id: j['id'] as String,
|
||||||
|
name: j['name'] as String,
|
||||||
|
kind: j['kind'] as String,
|
||||||
|
retentionDays: policy?['retentionDays'] as int?,
|
||||||
|
screenshotBlocked: policy?['screenshotBlocked'] == true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
mobile/lib/models/message_model.dart
Normal file
39
mobile/lib/models/message_model.dart
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
class MessageModel {
|
||||||
|
MessageModel({
|
||||||
|
required this.id,
|
||||||
|
required this.roomId,
|
||||||
|
required this.senderId,
|
||||||
|
required this.body,
|
||||||
|
required this.createdAt,
|
||||||
|
this.kind = 'text',
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String roomId;
|
||||||
|
final String senderId;
|
||||||
|
final String body;
|
||||||
|
final String createdAt;
|
||||||
|
final String kind;
|
||||||
|
|
||||||
|
factory MessageModel.fromJson(Map<String, dynamic> j) {
|
||||||
|
return MessageModel(
|
||||||
|
id: j['id'] as String,
|
||||||
|
roomId: j['roomId'] as String,
|
||||||
|
senderId: j['senderId'] as String,
|
||||||
|
body: j['body'] as String,
|
||||||
|
createdAt: j['createdAt'] as String,
|
||||||
|
kind: j['kind'] as String? ?? 'text',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory MessageModel.fromWs(Map<String, dynamic> j) {
|
||||||
|
return MessageModel(
|
||||||
|
id: j['id'] as String,
|
||||||
|
roomId: j['roomId'] as String,
|
||||||
|
senderId: j['senderId'] as String,
|
||||||
|
body: j['body'] as String,
|
||||||
|
createdAt: j['createdAt'] as String,
|
||||||
|
kind: j['kind'] as String? ?? 'text',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
mobile/lib/models/profile_model.dart
Normal file
28
mobile/lib/models/profile_model.dart
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
class ProfileModel {
|
||||||
|
ProfileModel({
|
||||||
|
required this.userId,
|
||||||
|
required this.contextId,
|
||||||
|
required this.displayName,
|
||||||
|
this.avatarUrl,
|
||||||
|
this.statusMessage,
|
||||||
|
this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String userId;
|
||||||
|
final String contextId;
|
||||||
|
final String displayName;
|
||||||
|
final String? avatarUrl;
|
||||||
|
final String? statusMessage;
|
||||||
|
final String? updatedAt;
|
||||||
|
|
||||||
|
factory ProfileModel.fromJson(Map<String, dynamic> j) {
|
||||||
|
return ProfileModel(
|
||||||
|
userId: j['userId'] as String,
|
||||||
|
contextId: j['contextId'] as String,
|
||||||
|
displayName: j['displayName'] as String,
|
||||||
|
avatarUrl: j['avatarUrl'] as String?,
|
||||||
|
statusMessage: j['statusMessage'] as String?,
|
||||||
|
updatedAt: j['updatedAt'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
mobile/lib/models/room_model.dart
Normal file
25
mobile/lib/models/room_model.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
class RoomModel {
|
||||||
|
RoomModel({
|
||||||
|
required this.id,
|
||||||
|
required this.isGroup,
|
||||||
|
this.name,
|
||||||
|
this.lastBody,
|
||||||
|
this.lastAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final bool isGroup;
|
||||||
|
final String? name;
|
||||||
|
final String? lastBody;
|
||||||
|
final String? lastAt;
|
||||||
|
|
||||||
|
factory RoomModel.fromJson(Map<String, dynamic> j) {
|
||||||
|
return RoomModel(
|
||||||
|
id: j['id'] as String,
|
||||||
|
isGroup: (j['is_group'] as int? ?? 0) == 1,
|
||||||
|
name: j['name'] as String?,
|
||||||
|
lastBody: j['last_body'] as String?,
|
||||||
|
lastAt: j['last_at'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
mobile/lib/models/ws_chat_side_event.dart
Normal file
23
mobile/lib/models/ws_chat_side_event.dart
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
class WsTypingEvent {
|
||||||
|
WsTypingEvent({
|
||||||
|
required this.roomId,
|
||||||
|
required this.userId,
|
||||||
|
required this.active,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String roomId;
|
||||||
|
final String userId;
|
||||||
|
final bool active;
|
||||||
|
}
|
||||||
|
|
||||||
|
class WsReadEvent {
|
||||||
|
WsReadEvent({
|
||||||
|
required this.roomId,
|
||||||
|
required this.userId,
|
||||||
|
required this.upToMessageId,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String roomId;
|
||||||
|
final String userId;
|
||||||
|
final String upToMessageId;
|
||||||
|
}
|
||||||
164
mobile/lib/theme/toss_theme.dart
Normal file
164
mobile/lib/theme/toss_theme.dart
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Toss-inspired palette (unofficial — evokes clean fintech / super-app UI).
|
||||||
|
abstract final class TossColors {
|
||||||
|
static const Color blue = Color(0xFF3182F6);
|
||||||
|
static const Color blueDark = Color(0xFF1B64DA);
|
||||||
|
static const Color bg = Color(0xFFF2F4F6);
|
||||||
|
static const Color surface = Color(0xFFFFFFFF);
|
||||||
|
static const Color textPrimary = Color(0xFF191F28);
|
||||||
|
static const Color textSecondary = Color(0xFF8B95A1);
|
||||||
|
static const Color line = Color(0xFFE5E8EB);
|
||||||
|
static const Color inputFill = Color(0xFFF2F4F6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global theme: light, high whitespace, blue CTAs.
|
||||||
|
ThemeData buildTossTheme() {
|
||||||
|
const primary = TossColors.blue;
|
||||||
|
const colorScheme = ColorScheme.light(
|
||||||
|
primary: primary,
|
||||||
|
onPrimary: Colors.white,
|
||||||
|
primaryContainer: Color(0xFFE8F3FF),
|
||||||
|
onPrimaryContainer: TossColors.blueDark,
|
||||||
|
surface: TossColors.surface,
|
||||||
|
onSurface: TossColors.textPrimary,
|
||||||
|
onSurfaceVariant: TossColors.textSecondary,
|
||||||
|
outline: TossColors.line,
|
||||||
|
error: Color(0xFFF04452),
|
||||||
|
onError: Colors.white,
|
||||||
|
);
|
||||||
|
|
||||||
|
final base = ThemeData.light();
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
scaffoldBackgroundColor: TossColors.bg,
|
||||||
|
splashFactory: InkRipple.splashFactory,
|
||||||
|
textTheme: base.textTheme.copyWith(
|
||||||
|
headlineLarge: const TextStyle(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.25,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
color: TossColors.textPrimary,
|
||||||
|
),
|
||||||
|
headlineMedium: const TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.3,
|
||||||
|
color: TossColors.textPrimary,
|
||||||
|
),
|
||||||
|
titleLarge: const TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: TossColors.textPrimary,
|
||||||
|
),
|
||||||
|
titleMedium: const TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: TossColors.textPrimary,
|
||||||
|
),
|
||||||
|
titleSmall: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: TossColors.textPrimary,
|
||||||
|
),
|
||||||
|
bodyLarge: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
height: 1.45,
|
||||||
|
color: TossColors.textPrimary,
|
||||||
|
),
|
||||||
|
bodyMedium: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
height: 1.45,
|
||||||
|
color: TossColors.textSecondary,
|
||||||
|
),
|
||||||
|
bodySmall: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.4,
|
||||||
|
color: TossColors.textSecondary,
|
||||||
|
),
|
||||||
|
labelLarge: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: TossColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
elevation: 0,
|
||||||
|
scrolledUnderElevation: 0,
|
||||||
|
centerTitle: false,
|
||||||
|
backgroundColor: TossColors.surface,
|
||||||
|
foregroundColor: TossColors.textPrimary,
|
||||||
|
titleTextStyle: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: TossColors.textPrimary,
|
||||||
|
),
|
||||||
|
iconTheme: IconThemeData(color: TossColors.textPrimary, size: 22),
|
||||||
|
),
|
||||||
|
cardTheme: CardThemeData(
|
||||||
|
color: TossColors.surface,
|
||||||
|
elevation: 0,
|
||||||
|
shadowColor: Colors.black.withValues(alpha: 0.06),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
filled: true,
|
||||||
|
fillColor: TossColors.inputFill,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
|
hintStyle: const TextStyle(color: TossColors.textSecondary, fontSize: 16),
|
||||||
|
labelStyle: const TextStyle(color: TossColors.textSecondary, fontSize: 14),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide: const BorderSide(color: TossColors.blue, width: 1.5),
|
||||||
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide: BorderSide(color: colorScheme.error.withValues(alpha: 0.8)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
filledButtonTheme: FilledButtonThemeData(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
disabledBackgroundColor: TossColors.line,
|
||||||
|
disabledForegroundColor: TossColors.textSecondary,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
textStyle: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: primary,
|
||||||
|
side: const BorderSide(color: TossColors.line),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 20),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textButtonTheme: TextButtonThemeData(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: TossColors.textSecondary,
|
||||||
|
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
||||||
|
backgroundColor: TossColors.blue,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
elevation: 2,
|
||||||
|
shape: CircleBorder(),
|
||||||
|
),
|
||||||
|
dividerTheme: const DividerThemeData(color: TossColors.line, thickness: 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
810
mobile/pubspec.lock
Normal file
810
mobile/pubspec.lock
Normal file
@@ -0,0 +1,810 @@
|
|||||||
|
# Generated by pub
|
||||||
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
|
packages:
|
||||||
|
_flutterfire_internals:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: _flutterfire_internals
|
||||||
|
sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.59"
|
||||||
|
args:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: args
|
||||||
|
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
|
async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: async
|
||||||
|
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.13.1"
|
||||||
|
boolean_selector:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: boolean_selector
|
||||||
|
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
characters:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: characters
|
||||||
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
|
clock:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: clock
|
||||||
|
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.2"
|
||||||
|
code_assets:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: code_assets
|
||||||
|
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
|
collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: collection
|
||||||
|
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.19.1"
|
||||||
|
crypto:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: crypto
|
||||||
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.7"
|
||||||
|
cupertino_icons:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: cupertino_icons
|
||||||
|
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.9"
|
||||||
|
dbus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dbus
|
||||||
|
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.12"
|
||||||
|
dio:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: dio
|
||||||
|
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.9.2"
|
||||||
|
dio_web_adapter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dio_web_adapter
|
||||||
|
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
fake_async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fake_async
|
||||||
|
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.3"
|
||||||
|
ffi:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ffi
|
||||||
|
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file
|
||||||
|
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.1"
|
||||||
|
firebase_core:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: firebase_core
|
||||||
|
sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.15.2"
|
||||||
|
firebase_core_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: firebase_core_platform_interface
|
||||||
|
sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.0.3"
|
||||||
|
firebase_core_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: firebase_core_web
|
||||||
|
sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.24.1"
|
||||||
|
firebase_messaging:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: firebase_messaging
|
||||||
|
sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "15.2.10"
|
||||||
|
firebase_messaging_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: firebase_messaging_platform_interface
|
||||||
|
sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.6.10"
|
||||||
|
firebase_messaging_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: firebase_messaging_web
|
||||||
|
sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.10.10"
|
||||||
|
flutter:
|
||||||
|
dependency: "direct main"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
flutter_lints:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: flutter_lints
|
||||||
|
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.0.0"
|
||||||
|
flutter_local_notifications:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications
|
||||||
|
sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "18.0.1"
|
||||||
|
flutter_local_notifications_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_linux
|
||||||
|
sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.0.0"
|
||||||
|
flutter_local_notifications_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_platform_interface
|
||||||
|
sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.0.0"
|
||||||
|
flutter_riverpod:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_riverpod
|
||||||
|
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.1"
|
||||||
|
flutter_secure_storage:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage
|
||||||
|
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.2.4"
|
||||||
|
flutter_secure_storage_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_linux
|
||||||
|
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.3"
|
||||||
|
flutter_secure_storage_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_macos
|
||||||
|
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.3"
|
||||||
|
flutter_secure_storage_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_platform_interface
|
||||||
|
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.2"
|
||||||
|
flutter_secure_storage_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_web
|
||||||
|
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
|
flutter_secure_storage_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_windows
|
||||||
|
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.2"
|
||||||
|
flutter_test:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
flutter_web_plugins:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
glob:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: glob
|
||||||
|
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
|
go_router:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: go_router
|
||||||
|
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "14.8.1"
|
||||||
|
hooks:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: hooks
|
||||||
|
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.2"
|
||||||
|
http:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http
|
||||||
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.0"
|
||||||
|
http_parser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_parser
|
||||||
|
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.2"
|
||||||
|
jni:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni
|
||||||
|
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
|
jni_flutter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni_flutter
|
||||||
|
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
|
js:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: js
|
||||||
|
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.6.7"
|
||||||
|
leak_tracker:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker
|
||||||
|
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.0.2"
|
||||||
|
leak_tracker_flutter_testing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker_flutter_testing
|
||||||
|
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.10"
|
||||||
|
leak_tracker_testing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker_testing
|
||||||
|
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.2"
|
||||||
|
lints:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: lints
|
||||||
|
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.1"
|
||||||
|
logging:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: logging
|
||||||
|
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
|
matcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: matcher
|
||||||
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.12.19"
|
||||||
|
material_color_utilities:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: material_color_utilities
|
||||||
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.13.0"
|
||||||
|
meta:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: meta
|
||||||
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.17.0"
|
||||||
|
mime:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: mime
|
||||||
|
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
|
native_toolchain_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: native_toolchain_c
|
||||||
|
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.17.6"
|
||||||
|
objective_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: objective_c
|
||||||
|
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.3.0"
|
||||||
|
package_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_config
|
||||||
|
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
package_info_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: package_info_plus
|
||||||
|
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.3.1"
|
||||||
|
package_info_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_info_plus_platform_interface
|
||||||
|
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.1"
|
||||||
|
path:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: path
|
||||||
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.9.1"
|
||||||
|
path_provider:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: path_provider
|
||||||
|
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.5"
|
||||||
|
path_provider_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_android
|
||||||
|
sha256: "914a07484c4380e572998d30486e77e0d9cd2faec72fee268086d07bf7f302c9"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
|
path_provider_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_foundation
|
||||||
|
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.0"
|
||||||
|
path_provider_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_linux
|
||||||
|
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.1"
|
||||||
|
path_provider_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_platform_interface
|
||||||
|
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
path_provider_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_windows
|
||||||
|
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
|
petitparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: petitparser
|
||||||
|
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.2"
|
||||||
|
platform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: platform
|
||||||
|
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.6"
|
||||||
|
plugin_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: plugin_platform_interface
|
||||||
|
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.8"
|
||||||
|
pub_semver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pub_semver
|
||||||
|
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
riverpod:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: riverpod
|
||||||
|
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.1"
|
||||||
|
shared_preferences:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: shared_preferences
|
||||||
|
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.5"
|
||||||
|
shared_preferences_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_android
|
||||||
|
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.23"
|
||||||
|
shared_preferences_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_foundation
|
||||||
|
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.6"
|
||||||
|
shared_preferences_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_linux
|
||||||
|
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
shared_preferences_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_platform_interface
|
||||||
|
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2"
|
||||||
|
shared_preferences_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_web
|
||||||
|
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.3"
|
||||||
|
shared_preferences_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_windows
|
||||||
|
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
sky_engine:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
source_span:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_span
|
||||||
|
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.10.2"
|
||||||
|
sqflite:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: sqflite
|
||||||
|
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2"
|
||||||
|
sqflite_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_android
|
||||||
|
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2+3"
|
||||||
|
sqflite_common:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_common
|
||||||
|
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.6"
|
||||||
|
sqflite_darwin:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_darwin
|
||||||
|
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2"
|
||||||
|
sqflite_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_platform_interface
|
||||||
|
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.0"
|
||||||
|
stack_trace:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stack_trace
|
||||||
|
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.12.1"
|
||||||
|
state_notifier:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: state_notifier
|
||||||
|
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
|
stream_channel:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_channel
|
||||||
|
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.4"
|
||||||
|
string_scanner:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: string_scanner
|
||||||
|
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
|
synchronized:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: synchronized
|
||||||
|
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.4.0"
|
||||||
|
term_glyph:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: term_glyph
|
||||||
|
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.2"
|
||||||
|
test_api:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: test_api
|
||||||
|
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.10"
|
||||||
|
timezone:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: timezone
|
||||||
|
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.10.1"
|
||||||
|
typed_data:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: typed_data
|
||||||
|
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
|
vector_math:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_math
|
||||||
|
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
vm_service:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vm_service
|
||||||
|
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "15.0.2"
|
||||||
|
web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web
|
||||||
|
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
web_socket:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web_socket
|
||||||
|
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
|
web_socket_channel:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: web_socket_channel
|
||||||
|
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.3"
|
||||||
|
win32:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32
|
||||||
|
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.15.0"
|
||||||
|
xdg_directories:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xdg_directories
|
||||||
|
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
xml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xml
|
||||||
|
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.6.1"
|
||||||
|
yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: yaml
|
||||||
|
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.3"
|
||||||
|
sdks:
|
||||||
|
dart: ">=3.10.3 <4.0.0"
|
||||||
|
flutter: ">=3.38.4"
|
||||||
33
mobile/pubspec.yaml
Normal file
33
mobile/pubspec.yaml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
name: msn_mobile
|
||||||
|
description: IYKYKA — multi-context messenger (Flutter client).
|
||||||
|
publish_to: "none"
|
||||||
|
version: 0.1.0+1
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ">=3.5.0 <4.0.0"
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
cupertino_icons: ^1.0.8
|
||||||
|
flutter_riverpod: ^2.6.1
|
||||||
|
dio: ^5.7.0
|
||||||
|
flutter_secure_storage: ^9.2.2
|
||||||
|
web_socket_channel: ^3.0.1
|
||||||
|
go_router: ^14.6.2
|
||||||
|
shared_preferences: ^2.3.3
|
||||||
|
sqflite: ^2.4.1
|
||||||
|
path: ^1.9.0
|
||||||
|
path_provider: ^2.1.4
|
||||||
|
package_info_plus: ^8.1.2
|
||||||
|
firebase_core: ^3.8.1
|
||||||
|
firebase_messaging: ^15.1.6
|
||||||
|
flutter_local_notifications: ^18.0.1
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_lints: ^5.0.0
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
18
mobile/test/widget_test.dart
Normal file
18
mobile/test/widget_test.dart
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:msn_mobile/features/auth/login_page.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('Login screen shows IYKYKA branding', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const ProviderScope(
|
||||||
|
child: MaterialApp(
|
||||||
|
home: LoginPage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.text('IYKYKA'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
BIN
mobile/web/favicon.png
Normal file
BIN
mobile/web/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 917 B |
BIN
mobile/web/icons/Icon-192.png
Normal file
BIN
mobile/web/icons/Icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
mobile/web/icons/Icon-512.png
Normal file
BIN
mobile/web/icons/Icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
BIN
mobile/web/icons/Icon-maskable-192.png
Normal file
BIN
mobile/web/icons/Icon-maskable-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
BIN
mobile/web/icons/Icon-maskable-512.png
Normal file
BIN
mobile/web/icons/Icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
46
mobile/web/index.html
Normal file
46
mobile/web/index.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<!--
|
||||||
|
If you are serving your web app in a path other than the root, change the
|
||||||
|
href value below to reflect the base path you are serving from.
|
||||||
|
|
||||||
|
The path provided below has to start and end with a slash "/" in order for
|
||||||
|
it to work correctly.
|
||||||
|
|
||||||
|
For more details:
|
||||||
|
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||||
|
|
||||||
|
This is a placeholder for base href that will be replaced by the value of
|
||||||
|
the `--base-href` argument provided to `flutter build`.
|
||||||
|
-->
|
||||||
|
<base href="$FLUTTER_BASE_HREF">
|
||||||
|
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||||
|
<meta name="description" content="A new Flutter project.">
|
||||||
|
|
||||||
|
<!-- iOS meta tags & icons -->
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="msn_mobile">
|
||||||
|
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||||
|
|
||||||
|
<title>msn_mobile</title>
|
||||||
|
<link rel="manifest" href="manifest.json">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!--
|
||||||
|
You can customize the "flutter_bootstrap.js" script.
|
||||||
|
This is useful to provide a custom configuration to the Flutter loader
|
||||||
|
or to give the user feedback during the initialization process.
|
||||||
|
|
||||||
|
For more details:
|
||||||
|
* https://docs.flutter.dev/platform-integration/web/initialization
|
||||||
|
-->
|
||||||
|
<script src="flutter_bootstrap.js" async></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
35
mobile/web/manifest.json
Normal file
35
mobile/web/manifest.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "msn_mobile",
|
||||||
|
"short_name": "msn_mobile",
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0175C2",
|
||||||
|
"theme_color": "#0175C2",
|
||||||
|
"description": "A new Flutter project.",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"prefer_related_applications": false,
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icons/Icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/Icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/Icon-maskable-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/Icon-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
server/data/msn.db
Normal file
BIN
server/data/msn.db
Normal file
Binary file not shown.
1738
server/package-lock.json
generated
Normal file
1738
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
server/package.json
Normal file
28
server/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "msn-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/ws": "^8.5.13",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
server/src/auth.ts
Normal file
14
server/src/auth.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET ?? "msn-dev-secret-change-me";
|
||||||
|
|
||||||
|
export type JwtPayload = { sub: string; email: string };
|
||||||
|
|
||||||
|
export function signToken(userId: string, email: string): string {
|
||||||
|
return jwt.sign({ sub: userId, email }, JWT_SECRET, { expiresIn: "30d" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyToken(token: string): JwtPayload {
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload;
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
17
server/src/contextAccess.ts
Normal file
17
server/src/contextAccess.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { db } from "./db.js";
|
||||||
|
|
||||||
|
export function isMemberOfContext(userId: string, contextId: string): boolean {
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT 1 FROM context_members WHERE user_id = ? AND context_id = ?`
|
||||||
|
)
|
||||||
|
.get(userId, contextId) as { 1: number } | undefined;
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireContextMember(
|
||||||
|
userId: string,
|
||||||
|
contextId: string
|
||||||
|
): boolean {
|
||||||
|
return isMemberOfContext(userId, contextId);
|
||||||
|
}
|
||||||
89
server/src/db.ts
Normal file
89
server/src/db.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const dataDir = path.join(__dirname, "..", "data");
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
const dbPath = process.env.SQLITE_PATH ?? path.join(dataDir, "msn.db");
|
||||||
|
|
||||||
|
export const db = new DatabaseSync(dbPath);
|
||||||
|
db.exec("PRAGMA foreign_keys = ON");
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS contexts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('personal','work','other')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
retention_days INTEGER,
|
||||||
|
screenshot_blocked INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS context_members (
|
||||||
|
context_id TEXT NOT NULL REFERENCES contexts(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL DEFAULT 'member',
|
||||||
|
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (context_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS profiles (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
context_id TEXT NOT NULL REFERENCES contexts(id) ON DELETE CASCADE,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
avatar_url TEXT,
|
||||||
|
status_message TEXT,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (user_id, context_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rooms (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
context_id TEXT NOT NULL REFERENCES contexts(id) ON DELETE CASCADE,
|
||||||
|
is_group INTEGER NOT NULL DEFAULT 0,
|
||||||
|
name TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS room_members (
|
||||||
|
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (room_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||||
|
sender_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
kind TEXT NOT NULL DEFAULT 'text'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS message_reads (
|
||||||
|
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
read_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (message_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS push_tokens (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
platform TEXT,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (user_id, token)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_room ON messages(room_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rooms_context ON rooms(context_id);
|
||||||
|
`);
|
||||||
42
server/src/index.ts
Normal file
42
server/src/index.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import express from "express";
|
||||||
|
import cors from "cors";
|
||||||
|
import http from "node:http";
|
||||||
|
import { authRouter } from "./routes/authRoutes.js";
|
||||||
|
import { contextRouter } from "./routes/contextRoutes.js";
|
||||||
|
import { profileRouter } from "./routes/profileRoutes.js";
|
||||||
|
import { roomRouter } from "./routes/roomRoutes.js";
|
||||||
|
import { messageRouter } from "./routes/messageRoutes.js";
|
||||||
|
import { pushRouter } from "./routes/pushRoutes.js";
|
||||||
|
import "./db.js";
|
||||||
|
import {
|
||||||
|
backfillDemoAvatarsIfNeeded,
|
||||||
|
extendDemoDataIfNeeded,
|
||||||
|
seedDemoIfNeeded,
|
||||||
|
} from "./seed.js";
|
||||||
|
import { attachWebSocket } from "./realtime.js";
|
||||||
|
|
||||||
|
seedDemoIfNeeded();
|
||||||
|
backfillDemoAvatarsIfNeeded();
|
||||||
|
extendDemoDataIfNeeded();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.get("/health", (_req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
|
app.use("/api/auth", authRouter);
|
||||||
|
app.use("/api/contexts", contextRouter);
|
||||||
|
app.use("/api/profiles", profileRouter);
|
||||||
|
app.use("/api/rooms", roomRouter);
|
||||||
|
app.use("/api/messages", messageRouter);
|
||||||
|
app.use("/api/push", pushRouter);
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT ?? 8787);
|
||||||
|
const server = http.createServer(app);
|
||||||
|
attachWebSocket(server);
|
||||||
|
|
||||||
|
server.listen(port, () => {
|
||||||
|
console.log(`msn-server listening on http://localhost:${port}`);
|
||||||
|
console.log(`WebSocket: ws://localhost:${port}/ws?token=JWT`);
|
||||||
|
});
|
||||||
21
server/src/middleware.ts
Normal file
21
server/src/middleware.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
import { verifyToken } from "./auth.js";
|
||||||
|
|
||||||
|
export type AuthedRequest = Request & { userId?: string; userEmail?: string };
|
||||||
|
|
||||||
|
export function requireAuth(req: AuthedRequest, res: Response, next: NextFunction) {
|
||||||
|
const header = req.headers.authorization;
|
||||||
|
const token = header?.startsWith("Bearer ") ? header.slice(7) : undefined;
|
||||||
|
if (!token) {
|
||||||
|
res.status(401).json({ error: "Unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const p = verifyToken(token);
|
||||||
|
req.userId = p.sub;
|
||||||
|
req.userEmail = p.email;
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ error: "Invalid token" });
|
||||||
|
}
|
||||||
|
}
|
||||||
197
server/src/realtime.ts
Normal file
197
server/src/realtime.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import type { Server } from "node:http";
|
||||||
|
import { WebSocketServer, WebSocket } from "ws";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { verifyToken } from "./auth.js";
|
||||||
|
import { db } from "./db.js";
|
||||||
|
|
||||||
|
export type ClientMessage =
|
||||||
|
| { type: "subscribe"; roomId: string }
|
||||||
|
| { type: "send"; roomId: string; body: string; kind?: string }
|
||||||
|
| { type: "typing"; roomId: string; active: boolean };
|
||||||
|
|
||||||
|
export type ServerMessage =
|
||||||
|
| { type: "message"; message: OutMessage }
|
||||||
|
| { type: "error"; message: string }
|
||||||
|
| { type: "typing"; roomId: string; userId: string; active: boolean }
|
||||||
|
| { type: "read"; roomId: string; userId: string; upToMessageId: string };
|
||||||
|
|
||||||
|
export type OutMessage = {
|
||||||
|
id: string;
|
||||||
|
roomId: string;
|
||||||
|
contextId: string;
|
||||||
|
senderId: string;
|
||||||
|
body: string;
|
||||||
|
createdAt: string;
|
||||||
|
kind: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const roomSubscribers = new Map<string, Set<WebSocket>>();
|
||||||
|
|
||||||
|
function userInRoom(userId: string, roomId: string): boolean {
|
||||||
|
const r = db
|
||||||
|
.prepare(`SELECT 1 FROM room_members WHERE user_id = ? AND room_id = ?`)
|
||||||
|
.get(userId, roomId) as { 1: number } | undefined;
|
||||||
|
return !!r;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoomContext(roomId: string): string | null {
|
||||||
|
const row = db
|
||||||
|
.prepare(`SELECT context_id FROM rooms WHERE id = ?`)
|
||||||
|
.get(roomId) as { context_id: string } | undefined;
|
||||||
|
return row?.context_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast(roomId: string, payload: ServerMessage) {
|
||||||
|
const set = roomSubscribers.get(roomId);
|
||||||
|
if (!set) return;
|
||||||
|
const data = JSON.stringify(payload);
|
||||||
|
for (const ws of set) {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) ws.send(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcastToRoom(roomId: string, payload: ServerMessage) {
|
||||||
|
broadcast(roomId, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stub: log push payload shape for FCM (contextId + roomId for deep link). */
|
||||||
|
function notifyPushStub(
|
||||||
|
targetUserIds: string[],
|
||||||
|
contextId: string,
|
||||||
|
roomId: string,
|
||||||
|
preview: string
|
||||||
|
) {
|
||||||
|
for (const uid of targetUserIds) {
|
||||||
|
console.log(
|
||||||
|
`[push stub] to=${uid} contextId=${contextId} roomId=${roomId} preview=${preview.slice(0, 40)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachWebSocket(httpServer: Server) {
|
||||||
|
const wss = new WebSocketServer({ server: httpServer, path: "/ws" });
|
||||||
|
|
||||||
|
wss.on("connection", (ws, req) => {
|
||||||
|
const url = new URL(req.url ?? "", "http://localhost");
|
||||||
|
const token = url.searchParams.get("token");
|
||||||
|
if (!token) {
|
||||||
|
ws.close(4001, "token required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let userId: string;
|
||||||
|
try {
|
||||||
|
userId = verifyToken(token).sub;
|
||||||
|
} catch {
|
||||||
|
ws.close(4002, "invalid token");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribed = new Set<string>();
|
||||||
|
|
||||||
|
ws.on("message", (raw) => {
|
||||||
|
let msg: ClientMessage;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(String(raw)) as ClientMessage;
|
||||||
|
} catch {
|
||||||
|
ws.send(JSON.stringify({ type: "error", message: "invalid json" } satisfies ServerMessage));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "typing") {
|
||||||
|
if (!userInRoom(userId, msg.roomId)) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({ type: "error", message: "forbidden" } satisfies ServerMessage)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload: ServerMessage = {
|
||||||
|
type: "typing",
|
||||||
|
roomId: msg.roomId,
|
||||||
|
userId,
|
||||||
|
active: !!msg.active,
|
||||||
|
};
|
||||||
|
const set = roomSubscribers.get(msg.roomId);
|
||||||
|
if (!set) return;
|
||||||
|
const data = JSON.stringify(payload);
|
||||||
|
for (const client of set) {
|
||||||
|
if (client === ws) continue;
|
||||||
|
if (client.readyState === WebSocket.OPEN) client.send(data);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "subscribe") {
|
||||||
|
if (!userInRoom(userId, msg.roomId)) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({ type: "error", message: "forbidden" } satisfies ServerMessage)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
subscribed.add(msg.roomId);
|
||||||
|
let set = roomSubscribers.get(msg.roomId);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
roomSubscribers.set(msg.roomId, set);
|
||||||
|
}
|
||||||
|
set.add(ws);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "send") {
|
||||||
|
if (!userInRoom(userId, msg.roomId)) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({ type: "error", message: "forbidden" } satisfies ServerMessage)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = randomUUID();
|
||||||
|
const kind = msg.kind && msg.kind !== "text" ? msg.kind : "text";
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO messages (id, room_id, sender_id, body, kind) VALUES (?, ?, ?, ?, ?)`
|
||||||
|
).run(id, msg.roomId, userId, msg.body, kind);
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, room_id, sender_id, body, created_at, kind FROM messages WHERE id = ?`
|
||||||
|
)
|
||||||
|
.get(id) as {
|
||||||
|
id: string;
|
||||||
|
room_id: string;
|
||||||
|
sender_id: string;
|
||||||
|
body: string;
|
||||||
|
created_at: string;
|
||||||
|
kind: string;
|
||||||
|
};
|
||||||
|
const contextId = getRoomContext(msg.roomId);
|
||||||
|
if (!contextId) return;
|
||||||
|
const out: OutMessage = {
|
||||||
|
id: row.id,
|
||||||
|
roomId: row.room_id,
|
||||||
|
contextId,
|
||||||
|
senderId: row.sender_id,
|
||||||
|
body: row.body,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
kind: row.kind,
|
||||||
|
};
|
||||||
|
broadcast(msg.roomId, { type: "message", message: out });
|
||||||
|
|
||||||
|
const others = db
|
||||||
|
.prepare(`SELECT user_id FROM room_members WHERE room_id = ? AND user_id != ?`)
|
||||||
|
.all(msg.roomId, userId) as { user_id: string }[];
|
||||||
|
notifyPushStub(
|
||||||
|
others.map((o) => o.user_id),
|
||||||
|
contextId,
|
||||||
|
msg.roomId,
|
||||||
|
msg.body
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("close", () => {
|
||||||
|
for (const roomId of subscribed) {
|
||||||
|
const set = roomSubscribers.get(roomId);
|
||||||
|
if (set) {
|
||||||
|
set.delete(ws);
|
||||||
|
if (set.size === 0) roomSubscribers.delete(roomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
85
server/src/routes/authRoutes.ts
Normal file
85
server/src/routes/authRoutes.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { db } from "../db.js";
|
||||||
|
import { signToken } from "../auth.js";
|
||||||
|
import type { AuthedRequest } from "../middleware.js";
|
||||||
|
import { requireAuth } from "../middleware.js";
|
||||||
|
|
||||||
|
export const authRouter = Router();
|
||||||
|
|
||||||
|
authRouter.post("/register", (req, res) => {
|
||||||
|
const { email, password, displayName } = req.body as {
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
displayName?: string;
|
||||||
|
};
|
||||||
|
if (!email || !password) {
|
||||||
|
res.status(400).json({ error: "email and password required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = db.prepare(`SELECT id FROM users WHERE email = ?`).get(email) as
|
||||||
|
| { id: string }
|
||||||
|
| undefined;
|
||||||
|
if (existing) {
|
||||||
|
res.status(409).json({ error: "Email already registered" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userId = randomUUID();
|
||||||
|
const hash = bcrypt.hashSync(password, 10);
|
||||||
|
db.prepare(`INSERT INTO users (id, email, password_hash) VALUES (?, ?, ?)`).run(
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
hash
|
||||||
|
);
|
||||||
|
|
||||||
|
const personalContextId = randomUUID();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO contexts (id, name, kind) VALUES (?, ?, 'personal')`
|
||||||
|
).run(personalContextId, "일상");
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO context_members (context_id, user_id, role) VALUES (?, ?, 'owner')`
|
||||||
|
).run(personalContextId, userId);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO profiles (user_id, context_id, display_name) VALUES (?, ?, ?)`
|
||||||
|
).run(userId, personalContextId, displayName ?? email.split("@")[0]);
|
||||||
|
|
||||||
|
const token = signToken(userId, email);
|
||||||
|
res.status(201).json({
|
||||||
|
token,
|
||||||
|
user: { id: userId, email },
|
||||||
|
defaultContextId: personalContextId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.post("/login", (req, res) => {
|
||||||
|
const { email, password } = req.body as { email?: string; password?: string };
|
||||||
|
if (!email || !password) {
|
||||||
|
res.status(400).json({ error: "email and password required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = db
|
||||||
|
.prepare(`SELECT id, email, password_hash FROM users WHERE email = ?`)
|
||||||
|
.get(email) as { id: string; email: string; password_hash: string } | undefined;
|
||||||
|
if (!row || !bcrypt.compareSync(password, row.password_hash)) {
|
||||||
|
res.status(401).json({ error: "Invalid credentials" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = signToken(row.id, row.email);
|
||||||
|
const personal = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT c.id FROM contexts c
|
||||||
|
JOIN context_members m ON m.context_id = c.id
|
||||||
|
WHERE m.user_id = ? AND c.kind = 'personal' LIMIT 1`
|
||||||
|
)
|
||||||
|
.get(row.id) as { id: string } | undefined;
|
||||||
|
res.json({
|
||||||
|
token,
|
||||||
|
user: { id: row.id, email: row.email },
|
||||||
|
defaultContextId: personal?.id ?? null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.get("/me", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
res.json({ id: req.userId, email: req.userEmail });
|
||||||
|
});
|
||||||
125
server/src/routes/contextRoutes.ts
Normal file
125
server/src/routes/contextRoutes.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { db } from "../db.js";
|
||||||
|
import type { AuthedRequest } from "../middleware.js";
|
||||||
|
import { requireAuth } from "../middleware.js";
|
||||||
|
import { isMemberOfContext } from "../contextAccess.js";
|
||||||
|
|
||||||
|
export const contextRouter = Router();
|
||||||
|
contextRouter.use(requireAuth);
|
||||||
|
|
||||||
|
contextRouter.get("/", (req: AuthedRequest, res) => {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT c.id, c.name, c.kind, c.retention_days, c.screenshot_blocked
|
||||||
|
FROM contexts c
|
||||||
|
JOIN context_members m ON m.context_id = c.id
|
||||||
|
WHERE m.user_id = ?
|
||||||
|
ORDER BY c.kind = 'personal' DESC, c.name`
|
||||||
|
)
|
||||||
|
.all(req.userId!) as Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
retention_days: number | null;
|
||||||
|
screenshot_blocked: number;
|
||||||
|
}>;
|
||||||
|
res.json({
|
||||||
|
contexts: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
kind: r.kind,
|
||||||
|
policy: {
|
||||||
|
retentionDays: r.retention_days,
|
||||||
|
screenshotBlocked: !!r.screenshot_blocked,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
contextRouter.post("/", (req: AuthedRequest, res) => {
|
||||||
|
const { name, kind } = req.body as { name?: string; kind?: string };
|
||||||
|
if (!name) {
|
||||||
|
res.status(400).json({ error: "name required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const k = kind === "work" || kind === "other" ? kind : "work";
|
||||||
|
const id = randomUUID();
|
||||||
|
db.prepare(`INSERT INTO contexts (id, name, kind) VALUES (?, ?, ?)`).run(id, name, k);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO context_members (context_id, user_id, role) VALUES (?, ?, 'owner')`
|
||||||
|
).run(id, req.userId!);
|
||||||
|
const email = req.userEmail!;
|
||||||
|
const display = email.split("@")[0];
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO profiles (user_id, context_id, display_name) VALUES (?, ?, ?)`
|
||||||
|
).run(req.userId!, id, display);
|
||||||
|
res.status(201).json({ id, name, kind: k });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** List members of a context with profile fields for this context only. */
|
||||||
|
contextRouter.get("/:contextId/members", (req: AuthedRequest, res) => {
|
||||||
|
const { contextId } = req.params;
|
||||||
|
if (!isMemberOfContext(req.userId!, contextId)) {
|
||||||
|
res.status(403).json({ error: "Forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT m.user_id, m.role, p.display_name, p.avatar_url, p.status_message
|
||||||
|
FROM context_members m
|
||||||
|
JOIN profiles p ON p.user_id = m.user_id AND p.context_id = m.context_id
|
||||||
|
WHERE m.context_id = ?
|
||||||
|
ORDER BY p.display_name COLLATE NOCASE`
|
||||||
|
)
|
||||||
|
.all(contextId) as Array<{
|
||||||
|
user_id: string;
|
||||||
|
role: string;
|
||||||
|
display_name: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
status_message: string | null;
|
||||||
|
}>;
|
||||||
|
res.json({
|
||||||
|
members: rows.map((r) => ({
|
||||||
|
userId: r.user_id,
|
||||||
|
role: r.role,
|
||||||
|
displayName: r.display_name,
|
||||||
|
avatarUrl: r.avatar_url,
|
||||||
|
statusMessage: r.status_message,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Add an existing user to this context by email (workplace registration). */
|
||||||
|
contextRouter.post("/:contextId/members", (req: AuthedRequest, res) => {
|
||||||
|
const { contextId } = req.params;
|
||||||
|
const { email } = req.body as { email?: string };
|
||||||
|
if (!email) {
|
||||||
|
res.status(400).json({ error: "email required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isMemberOfContext(req.userId!, contextId)) {
|
||||||
|
res.status(403).json({ error: "Forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = db.prepare(`SELECT id FROM users WHERE email = ?`).get(email) as
|
||||||
|
| { id: string }
|
||||||
|
| undefined;
|
||||||
|
if (!target) {
|
||||||
|
res.status(404).json({ error: "User not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO context_members (context_id, user_id, role) VALUES (?, ?, 'member')`
|
||||||
|
).run(contextId, target.id);
|
||||||
|
} catch {
|
||||||
|
res.status(409).json({ error: "Already a member" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const display = email.split("@")[0];
|
||||||
|
db.prepare(
|
||||||
|
`INSERT OR IGNORE INTO profiles (user_id, context_id, display_name) VALUES (?, ?, ?)`
|
||||||
|
).run(target.id, contextId, display);
|
||||||
|
res.status(201).json({ userId: target.id });
|
||||||
|
});
|
||||||
162
server/src/routes/messageRoutes.ts
Normal file
162
server/src/routes/messageRoutes.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { db } from "../db.js";
|
||||||
|
import type { AuthedRequest } from "../middleware.js";
|
||||||
|
import { requireAuth } from "../middleware.js";
|
||||||
|
import { broadcastToRoom } from "../realtime.js";
|
||||||
|
|
||||||
|
export const messageRouter = Router();
|
||||||
|
messageRouter.use(requireAuth);
|
||||||
|
|
||||||
|
function userInRoom(userId: string, roomId: string): boolean {
|
||||||
|
const r = db
|
||||||
|
.prepare(`SELECT 1 FROM room_members WHERE user_id = ? AND room_id = ?`)
|
||||||
|
.get(userId, roomId) as { 1: number } | undefined;
|
||||||
|
return !!r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Latest message id read by the other member in a 1:1 room (null if not 1:1 or none read). */
|
||||||
|
messageRouter.get("/read-state", (req: AuthedRequest, res) => {
|
||||||
|
const roomId = req.query.roomId as string | undefined;
|
||||||
|
if (!roomId) {
|
||||||
|
res.status(400).json({ error: "roomId required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!userInRoom(req.userId!, roomId)) {
|
||||||
|
res.status(403).json({ error: "Forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const members = db
|
||||||
|
.prepare(`SELECT user_id FROM room_members WHERE room_id = ?`)
|
||||||
|
.all(roomId) as { user_id: string }[];
|
||||||
|
const others = members.map((m) => m.user_id).filter((id) => id !== req.userId);
|
||||||
|
if (others.length !== 1) {
|
||||||
|
res.json({ lastReadMessageId: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const other = others[0];
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT m.id FROM messages m
|
||||||
|
INNER JOIN message_reads mr ON mr.message_id = m.id AND mr.user_id = ?
|
||||||
|
WHERE m.room_id = ?
|
||||||
|
ORDER BY m.created_at DESC LIMIT 1`
|
||||||
|
)
|
||||||
|
.get(other, roomId) as { id: string } | undefined;
|
||||||
|
res.json({ lastReadMessageId: row?.id ?? null });
|
||||||
|
});
|
||||||
|
|
||||||
|
messageRouter.get("/", (req: AuthedRequest, res) => {
|
||||||
|
const roomId = req.query.roomId as string | undefined;
|
||||||
|
const limit = Math.min(100, Math.max(1, parseInt(String(req.query.limit ?? "50"), 10)));
|
||||||
|
const before = req.query.before as string | undefined;
|
||||||
|
if (!roomId) {
|
||||||
|
res.status(400).json({ error: "roomId required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!userInRoom(req.userId!, roomId)) {
|
||||||
|
res.status(403).json({ error: "Forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = before
|
||||||
|
? (db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, room_id, sender_id, body, created_at, kind
|
||||||
|
FROM messages WHERE room_id = ? AND created_at < ?
|
||||||
|
ORDER BY created_at DESC LIMIT ?`
|
||||||
|
)
|
||||||
|
.all(roomId, before, limit) as Array<Record<string, unknown>>)
|
||||||
|
: (db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, room_id, sender_id, body, created_at, kind
|
||||||
|
FROM messages WHERE room_id = ?
|
||||||
|
ORDER BY created_at DESC LIMIT ?`
|
||||||
|
)
|
||||||
|
.all(roomId, limit) as Array<Record<string, unknown>>);
|
||||||
|
const messages = rows.reverse().map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
roomId: m.room_id,
|
||||||
|
senderId: m.sender_id,
|
||||||
|
body: m.body,
|
||||||
|
createdAt: m.created_at,
|
||||||
|
kind: m.kind,
|
||||||
|
}));
|
||||||
|
res.json({ messages });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Mark messages as read (extended). */
|
||||||
|
messageRouter.post("/read", (req: AuthedRequest, res) => {
|
||||||
|
const { roomId, upToMessageId } = req.body as {
|
||||||
|
roomId?: string;
|
||||||
|
upToMessageId?: string;
|
||||||
|
};
|
||||||
|
if (!roomId || !upToMessageId) {
|
||||||
|
res.status(400).json({ error: "roomId and upToMessageId required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!userInRoom(req.userId!, roomId)) {
|
||||||
|
res.status(403).json({ error: "Forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const msgs = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id FROM messages WHERE room_id = ? AND created_at <=
|
||||||
|
(SELECT created_at FROM messages WHERE id = ? AND room_id = ?)`
|
||||||
|
)
|
||||||
|
.all(roomId, upToMessageId, roomId) as { id: string }[];
|
||||||
|
const ins = db.prepare(
|
||||||
|
`INSERT OR IGNORE INTO message_reads (message_id, user_id) VALUES (?, ?)`
|
||||||
|
);
|
||||||
|
for (const m of msgs) {
|
||||||
|
ins.run(m.id, req.userId!);
|
||||||
|
}
|
||||||
|
broadcastToRoom(roomId, {
|
||||||
|
type: "read",
|
||||||
|
roomId,
|
||||||
|
userId: req.userId!,
|
||||||
|
upToMessageId,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, count: msgs.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Search within a context (messages in user's rooms). */
|
||||||
|
messageRouter.get("/search", (req: AuthedRequest, res) => {
|
||||||
|
const contextId = req.query.contextId as string | undefined;
|
||||||
|
const q = (req.query.q as string | undefined)?.trim();
|
||||||
|
if (!contextId || !q) {
|
||||||
|
res.status(400).json({ error: "contextId and q required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const member = db
|
||||||
|
.prepare(`SELECT 1 FROM context_members WHERE user_id = ? AND context_id = ?`)
|
||||||
|
.get(req.userId!, contextId);
|
||||||
|
if (!member) {
|
||||||
|
res.status(403).json({ error: "Forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const needle = `%${q.replace(/%/g, "")}%`;
|
||||||
|
const hits = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT m.id, m.room_id, m.body, m.created_at, m.sender_id
|
||||||
|
FROM messages m
|
||||||
|
JOIN rooms r ON r.id = m.room_id
|
||||||
|
JOIN room_members rm ON rm.room_id = m.room_id AND rm.user_id = ?
|
||||||
|
WHERE r.context_id = ? AND m.body LIKE ?
|
||||||
|
ORDER BY m.created_at DESC LIMIT 30`
|
||||||
|
)
|
||||||
|
.all(req.userId!, contextId, needle) as Array<{
|
||||||
|
id: string;
|
||||||
|
room_id: string;
|
||||||
|
body: string;
|
||||||
|
created_at: string;
|
||||||
|
sender_id: string;
|
||||||
|
}>;
|
||||||
|
res.json({
|
||||||
|
results: hits.map((h) => ({
|
||||||
|
messageId: h.id,
|
||||||
|
roomId: h.room_id,
|
||||||
|
body: h.body,
|
||||||
|
createdAt: h.created_at,
|
||||||
|
senderId: h.sender_id,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
146
server/src/routes/profileRoutes.ts
Normal file
146
server/src/routes/profileRoutes.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { db } from "../db.js";
|
||||||
|
import type { AuthedRequest } from "../middleware.js";
|
||||||
|
import { requireAuth } from "../middleware.js";
|
||||||
|
import { isMemberOfContext } from "../contextAccess.js";
|
||||||
|
|
||||||
|
export const profileRouter = Router();
|
||||||
|
profileRouter.use(requireAuth);
|
||||||
|
|
||||||
|
function parseContextId(req: AuthedRequest): string | null {
|
||||||
|
const q = req.query.contextId as string | undefined;
|
||||||
|
return q ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Own profile in a context — only if member. */
|
||||||
|
profileRouter.get("/me", (req: AuthedRequest, res) => {
|
||||||
|
const contextId = parseContextId(req);
|
||||||
|
if (!contextId) {
|
||||||
|
res.status(400).json({ error: "contextId query required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isMemberOfContext(req.userId!, contextId)) {
|
||||||
|
res.status(403).json({ error: "Forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT display_name, avatar_url, status_message, updated_at
|
||||||
|
FROM profiles WHERE user_id = ? AND context_id = ?`
|
||||||
|
)
|
||||||
|
.get(req.userId!, contextId) as
|
||||||
|
| {
|
||||||
|
display_name: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
status_message: string | null;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
if (!row) {
|
||||||
|
res.status(404).json({ error: "Profile not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
userId: req.userId,
|
||||||
|
contextId,
|
||||||
|
displayName: row.display_name,
|
||||||
|
avatarUrl: row.avatar_url,
|
||||||
|
statusMessage: row.status_message,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
profileRouter.patch("/me", (req: AuthedRequest, res) => {
|
||||||
|
const contextId = parseContextId(req);
|
||||||
|
if (!contextId) {
|
||||||
|
res.status(400).json({ error: "contextId query required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isMemberOfContext(req.userId!, contextId)) {
|
||||||
|
res.status(403).json({ error: "Forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { displayName, avatarUrl, statusMessage } = req.body as {
|
||||||
|
displayName?: string;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
statusMessage?: string | null;
|
||||||
|
};
|
||||||
|
const cur = db
|
||||||
|
.prepare(`SELECT display_name FROM profiles WHERE user_id = ? AND context_id = ?`)
|
||||||
|
.get(req.userId!, contextId) as { display_name: string } | undefined;
|
||||||
|
if (!cur) {
|
||||||
|
res.status(404).json({ error: "Profile not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sets: string[] = [];
|
||||||
|
const vals: unknown[] = [];
|
||||||
|
if (displayName !== undefined) {
|
||||||
|
sets.push("display_name = ?");
|
||||||
|
vals.push(displayName);
|
||||||
|
}
|
||||||
|
if (avatarUrl !== undefined) {
|
||||||
|
sets.push("avatar_url = ?");
|
||||||
|
vals.push(avatarUrl);
|
||||||
|
}
|
||||||
|
if (statusMessage !== undefined) {
|
||||||
|
sets.push("status_message = ?");
|
||||||
|
vals.push(statusMessage);
|
||||||
|
}
|
||||||
|
if (sets.length === 0) {
|
||||||
|
res.json({ ok: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sets.push("updated_at = datetime('now')");
|
||||||
|
vals.push(req.userId!, contextId);
|
||||||
|
const params = vals as string[];
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE profiles SET ${sets.join(", ")} WHERE user_id = ? AND context_id = ?`
|
||||||
|
).run(...params);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Other user's profile — only visible when both share the same context.
|
||||||
|
* No cross-context leakage: contextId is mandatory.
|
||||||
|
*/
|
||||||
|
profileRouter.get("/user/:targetUserId", (req: AuthedRequest, res) => {
|
||||||
|
const contextId = parseContextId(req);
|
||||||
|
const { targetUserId } = req.params;
|
||||||
|
if (!contextId) {
|
||||||
|
res.status(400).json({ error: "contextId query required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isMemberOfContext(req.userId!, contextId)) {
|
||||||
|
res.status(403).json({ error: "Forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isMemberOfContext(targetUserId, contextId)) {
|
||||||
|
res.status(404).json({ error: "Not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT display_name, avatar_url, status_message, updated_at
|
||||||
|
FROM profiles WHERE user_id = ? AND context_id = ?`
|
||||||
|
)
|
||||||
|
.get(targetUserId, contextId) as
|
||||||
|
| {
|
||||||
|
display_name: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
status_message: string | null;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
if (!row) {
|
||||||
|
res.status(404).json({ error: "Not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
userId: targetUserId,
|
||||||
|
contextId,
|
||||||
|
displayName: row.display_name,
|
||||||
|
avatarUrl: row.avatar_url,
|
||||||
|
statusMessage: row.status_message,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
});
|
||||||
|
});
|
||||||
24
server/src/routes/pushRoutes.ts
Normal file
24
server/src/routes/pushRoutes.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { db } from "../db.js";
|
||||||
|
import type { AuthedRequest } from "../middleware.js";
|
||||||
|
import { requireAuth } from "../middleware.js";
|
||||||
|
|
||||||
|
export const pushRouter = Router();
|
||||||
|
pushRouter.use(requireAuth);
|
||||||
|
|
||||||
|
/** Register FCM token for this user (payload routing uses contextId + roomId in app). */
|
||||||
|
pushRouter.post("/register", (req: AuthedRequest, res) => {
|
||||||
|
const { token, platform } = req.body as { token?: string; platform?: string };
|
||||||
|
if (!token) {
|
||||||
|
res.status(400).json({ error: "token required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO push_tokens (user_id, token, platform, updated_at)
|
||||||
|
VALUES (?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(user_id, token) DO UPDATE SET
|
||||||
|
platform = excluded.platform,
|
||||||
|
updated_at = datetime('now')`
|
||||||
|
).run(req.userId!, token, platform ?? "unknown");
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
139
server/src/routes/roomRoutes.ts
Normal file
139
server/src/routes/roomRoutes.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { db } from "../db.js";
|
||||||
|
import type { AuthedRequest } from "../middleware.js";
|
||||||
|
import { requireAuth } from "../middleware.js";
|
||||||
|
import { isMemberOfContext } from "../contextAccess.js";
|
||||||
|
|
||||||
|
export const roomRouter = Router();
|
||||||
|
roomRouter.use(requireAuth);
|
||||||
|
|
||||||
|
function userInRoom(userId: string, roomId: string): boolean {
|
||||||
|
const r = db
|
||||||
|
.prepare(`SELECT 1 FROM room_members WHERE user_id = ? AND room_id = ?`)
|
||||||
|
.get(userId, roomId) as { 1: number } | undefined;
|
||||||
|
return !!r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List rooms in a context the user belongs to. */
|
||||||
|
roomRouter.get("/", (req: AuthedRequest, res) => {
|
||||||
|
const contextId = req.query.contextId as string | undefined;
|
||||||
|
if (!contextId) {
|
||||||
|
res.status(400).json({ error: "contextId required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isMemberOfContext(req.userId!, contextId)) {
|
||||||
|
res.status(403).json({ error: "Forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rooms = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT r.id, r.is_group, r.name, r.created_at,
|
||||||
|
(SELECT body FROM messages m WHERE m.room_id = r.id ORDER BY m.created_at DESC LIMIT 1) AS last_body,
|
||||||
|
(SELECT created_at FROM messages m WHERE m.room_id = r.id ORDER BY m.created_at DESC LIMIT 1) AS last_at
|
||||||
|
FROM rooms r
|
||||||
|
JOIN room_members rm ON rm.room_id = r.id AND rm.user_id = ?
|
||||||
|
WHERE r.context_id = ?
|
||||||
|
ORDER BY last_at IS NULL, last_at DESC`
|
||||||
|
)
|
||||||
|
.all(req.userId!, contextId) as Array<{
|
||||||
|
id: string;
|
||||||
|
is_group: number;
|
||||||
|
name: string | null;
|
||||||
|
created_at: string;
|
||||||
|
last_body: string | null;
|
||||||
|
last_at: string | null;
|
||||||
|
}>;
|
||||||
|
res.json({ rooms });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Open or create a 1:1 room in context. */
|
||||||
|
roomRouter.post("/direct", (req: AuthedRequest, res) => {
|
||||||
|
const { contextId, otherUserId } = req.body as {
|
||||||
|
contextId?: string;
|
||||||
|
otherUserId?: string;
|
||||||
|
};
|
||||||
|
if (!contextId || !otherUserId) {
|
||||||
|
res.status(400).json({ error: "contextId and otherUserId required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isMemberOfContext(req.userId!, contextId)) {
|
||||||
|
res.status(403).json({ error: "Forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isMemberOfContext(otherUserId, contextId)) {
|
||||||
|
res.status(400).json({ error: "Other user is not in this context" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const u1 = req.userId!;
|
||||||
|
const u2 = otherUserId;
|
||||||
|
const pair = [u1, u2].sort();
|
||||||
|
const existing = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT r.id FROM rooms r
|
||||||
|
JOIN room_members a ON a.room_id = r.id AND a.user_id = ?
|
||||||
|
JOIN room_members b ON b.room_id = r.id AND b.user_id = ?
|
||||||
|
WHERE r.context_id = ? AND r.is_group = 0`
|
||||||
|
)
|
||||||
|
.get(pair[0], pair[1], contextId) as { id: string } | undefined;
|
||||||
|
if (existing) {
|
||||||
|
res.json({ roomId: existing.id, created: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const roomId = randomUUID();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO rooms (id, context_id, is_group, name) VALUES (?, ?, 0, NULL)`
|
||||||
|
).run(roomId, contextId);
|
||||||
|
db.prepare(`INSERT INTO room_members (room_id, user_id) VALUES (?, ?)`).run(roomId, u1);
|
||||||
|
db.prepare(`INSERT INTO room_members (room_id, user_id) VALUES (?, ?)`).run(roomId, u2);
|
||||||
|
res.status(201).json({ roomId, created: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Create group room (extended feature). */
|
||||||
|
roomRouter.post("/group", (req: AuthedRequest, res) => {
|
||||||
|
const { contextId, name, memberIds } = req.body as {
|
||||||
|
contextId?: string;
|
||||||
|
name?: string;
|
||||||
|
memberIds?: string[];
|
||||||
|
};
|
||||||
|
if (!contextId || !name) {
|
||||||
|
res.status(400).json({ error: "contextId and name required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isMemberOfContext(req.userId!, contextId)) {
|
||||||
|
res.status(403).json({ error: "Forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const roomId = randomUUID();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO rooms (id, context_id, is_group, name) VALUES (?, ?, 1, ?)`
|
||||||
|
).run(roomId, contextId, name);
|
||||||
|
const members = new Set<string>([req.userId!, ...(memberIds ?? [])]);
|
||||||
|
for (const uid of members) {
|
||||||
|
if (!isMemberOfContext(uid, contextId)) continue;
|
||||||
|
db.prepare(`INSERT INTO room_members (room_id, user_id) VALUES (?, ?)`).run(
|
||||||
|
roomId,
|
||||||
|
uid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.status(201).json({ roomId });
|
||||||
|
});
|
||||||
|
|
||||||
|
roomRouter.get("/:roomId/participants", (req: AuthedRequest, res) => {
|
||||||
|
const { roomId } = req.params;
|
||||||
|
if (!userInRoom(req.userId!, roomId)) {
|
||||||
|
res.status(403).json({ error: "Forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctx = db
|
||||||
|
.prepare(`SELECT context_id FROM rooms WHERE id = ?`)
|
||||||
|
.get(roomId) as { context_id: string } | undefined;
|
||||||
|
if (!ctx) {
|
||||||
|
res.status(404).json({ error: "Not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userIds = db
|
||||||
|
.prepare(`SELECT user_id FROM room_members WHERE room_id = ?`)
|
||||||
|
.all(roomId) as { user_id: string }[];
|
||||||
|
res.json({ contextId: ctx.context_id, userIds: userIds.map((u) => u.user_id) });
|
||||||
|
});
|
||||||
230
server/src/seed.ts
Normal file
230
server/src/seed.ts
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { db } from "./db.js";
|
||||||
|
|
||||||
|
const DEMO_EMAIL_ALICE = "alice@demo.msn";
|
||||||
|
const DEMO_EMAIL_BOB = "bob@demo.msn";
|
||||||
|
const DEMO_PASSWORD = "demo1234";
|
||||||
|
|
||||||
|
/** Stable UUIDs for docs and debugging. */
|
||||||
|
export const DemoIds = {
|
||||||
|
alice: "a1111111-1111-4111-8111-111111111111",
|
||||||
|
bob: "b2222222-2222-4222-8222-222222222222",
|
||||||
|
ctxAlicePersonal: "c1111111-1111-4111-8111-111111111111",
|
||||||
|
ctxBobPersonal: "c2222222-2222-4222-8222-222222222222",
|
||||||
|
ctxWork: "d3333333-3333-4333-8333-333333333333",
|
||||||
|
roomWorkDm: "e4444444-4444-4444-8444-444444444444",
|
||||||
|
/** Alice–Bob DM in Alice's personal context */
|
||||||
|
roomPersonalDm: "f1111111-1111-4111-8111-111111111111",
|
||||||
|
/** Group room in work context */
|
||||||
|
roomWorkGroup: "g1111111-1111-4111-8111-111111111111",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Stable portrait URLs for Flutter NetworkImage (HTTPS, JPG). */
|
||||||
|
const DEMO_AVATAR_ALICE_PERSONAL = "https://i.pravatar.cc/400?img=47";
|
||||||
|
const DEMO_AVATAR_BOB_PERSONAL = "https://i.pravatar.cc/400?img=33";
|
||||||
|
const DEMO_AVATAR_ALICE_WORK = "https://i.pravatar.cc/400?img=12";
|
||||||
|
const DEMO_AVATAR_BOB_WORK = "https://i.pravatar.cc/400?img=59";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demo users, personal + work contexts, profiles, DM + sample messages.
|
||||||
|
* Skips if alice@demo.msn already exists (idempotent).
|
||||||
|
*/
|
||||||
|
export function seedDemoIfNeeded(): void {
|
||||||
|
const exists = db.prepare(`SELECT id FROM users WHERE email = ?`).get(DEMO_EMAIL_ALICE) as
|
||||||
|
| { id: string }
|
||||||
|
| undefined;
|
||||||
|
if (exists) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = bcrypt.hashSync(DEMO_PASSWORD, 10);
|
||||||
|
const { alice, bob, ctxAlicePersonal, ctxBobPersonal, ctxWork, roomWorkDm } = DemoIds;
|
||||||
|
|
||||||
|
db.prepare(`INSERT INTO users (id, email, password_hash) VALUES (?, ?, ?)`).run(
|
||||||
|
alice,
|
||||||
|
DEMO_EMAIL_ALICE,
|
||||||
|
hash
|
||||||
|
);
|
||||||
|
db.prepare(`INSERT INTO users (id, email, password_hash) VALUES (?, ?, ?)`).run(
|
||||||
|
bob,
|
||||||
|
DEMO_EMAIL_BOB,
|
||||||
|
hash
|
||||||
|
);
|
||||||
|
|
||||||
|
db.prepare(`INSERT INTO contexts (id, name, kind) VALUES (?, ?, 'personal')`).run(
|
||||||
|
ctxAlicePersonal,
|
||||||
|
"일상"
|
||||||
|
);
|
||||||
|
db.prepare(`INSERT INTO contexts (id, name, kind) VALUES (?, ?, 'personal')`).run(
|
||||||
|
ctxBobPersonal,
|
||||||
|
"일상"
|
||||||
|
);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO contexts (id, name, kind, retention_days, screenshot_blocked) VALUES (?, ?, 'work', 365, 0)`
|
||||||
|
).run(ctxWork, "데모 회사");
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO context_members (context_id, user_id, role) VALUES (?, ?, 'owner')`
|
||||||
|
).run(ctxAlicePersonal, alice);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO context_members (context_id, user_id, role) VALUES (?, ?, 'owner')`
|
||||||
|
).run(ctxBobPersonal, bob);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO context_members (context_id, user_id, role) VALUES (?, ?, 'owner')`
|
||||||
|
).run(ctxWork, alice);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO context_members (context_id, user_id, role) VALUES (?, ?, 'member')`
|
||||||
|
).run(ctxWork, bob);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO profiles (user_id, context_id, display_name, avatar_url, status_message) VALUES (?, ?, ?, ?, ?)`
|
||||||
|
).run(
|
||||||
|
alice,
|
||||||
|
ctxAlicePersonal,
|
||||||
|
"앨리스",
|
||||||
|
DEMO_AVATAR_ALICE_PERSONAL,
|
||||||
|
"일상 프로필 · 친구들과 수다"
|
||||||
|
);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO profiles (user_id, context_id, display_name, avatar_url, status_message) VALUES (?, ?, ?, ?, ?)`
|
||||||
|
).run(bob, ctxBobPersonal, "밥", DEMO_AVATAR_BOB_PERSONAL, "일상 상태 메시지");
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO profiles (user_id, context_id, display_name, avatar_url, status_message) VALUES (?, ?, ?, ?, ?)`
|
||||||
|
).run(alice, ctxWork, "김앨리스 (기획)", DEMO_AVATAR_ALICE_WORK, "회사 맥락 전용 표시명");
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO profiles (user_id, context_id, display_name, avatar_url, status_message) VALUES (?, ?, ?, ?, ?)`
|
||||||
|
).run(bob, ctxWork, "박밥 (백엔드)", DEMO_AVATAR_BOB_WORK, "동료에게만 보이는 이름");
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO rooms (id, context_id, is_group, name) VALUES (?, ?, 0, NULL)`
|
||||||
|
).run(roomWorkDm, ctxWork);
|
||||||
|
db.prepare(`INSERT INTO room_members (room_id, user_id) VALUES (?, ?)`).run(roomWorkDm, alice);
|
||||||
|
db.prepare(`INSERT INTO room_members (room_id, user_id) VALUES (?, ?)`).run(roomWorkDm, bob);
|
||||||
|
|
||||||
|
const m1 = randomUUID();
|
||||||
|
const m2 = randomUUID();
|
||||||
|
const m3 = randomUUID();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO messages (id, room_id, sender_id, body, kind) VALUES (?, ?, ?, ?, 'text')`
|
||||||
|
).run(m1, roomWorkDm, bob, "안녕하세요, 회의 자료 공유드립니다.");
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO messages (id, room_id, sender_id, body, kind) VALUES (?, ?, ?, ?, 'text')`
|
||||||
|
).run(m2, roomWorkDm, alice, "확인했습니다. 오후에 뵐게요.");
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO messages (id, room_id, sender_id, body, kind) VALUES (?, ?, ?, ?, 'text')`
|
||||||
|
).run(m3, roomWorkDm, bob, "네, 감사합니다.");
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
console.log("========== DEMO DATA SEEDED ==========");
|
||||||
|
console.log(` ${DEMO_EMAIL_ALICE} / ${DEMO_PASSWORD} (Alice — switch to \"데모 회사\")`);
|
||||||
|
console.log(` ${DEMO_EMAIL_BOB} / ${DEMO_PASSWORD} (Bob — second account)`);
|
||||||
|
console.log("======================================");
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills demo avatar_url for existing DBs seeded before avatars were added.
|
||||||
|
* Safe to run on every startup (only updates NULL/empty for known demo rows).
|
||||||
|
*/
|
||||||
|
export function backfillDemoAvatarsIfNeeded(): void {
|
||||||
|
const { alice, bob, ctxAlicePersonal, ctxBobPersonal, ctxWork } = DemoIds;
|
||||||
|
const rows: Array<[string, string, string]> = [
|
||||||
|
[alice, ctxAlicePersonal, DEMO_AVATAR_ALICE_PERSONAL],
|
||||||
|
[bob, ctxBobPersonal, DEMO_AVATAR_BOB_PERSONAL],
|
||||||
|
[alice, ctxWork, DEMO_AVATAR_ALICE_WORK],
|
||||||
|
[bob, ctxWork, DEMO_AVATAR_BOB_WORK],
|
||||||
|
];
|
||||||
|
const stmt = db.prepare(
|
||||||
|
`UPDATE profiles SET avatar_url = ? WHERE user_id = ? AND context_id = ? AND (avatar_url IS NULL OR avatar_url = '')`
|
||||||
|
);
|
||||||
|
for (const [uid, cid, url] of rows) {
|
||||||
|
stmt.run(url, uid, cid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extra demo: Bob in Alice's personal context, personal DM, work group room + messages.
|
||||||
|
* Idempotent — safe on every startup (uses INSERT OR IGNORE / message count checks).
|
||||||
|
*/
|
||||||
|
export function extendDemoDataIfNeeded(): void {
|
||||||
|
const exists = db.prepare(`SELECT id FROM users WHERE email = ?`).get(DEMO_EMAIL_ALICE) as
|
||||||
|
| { id: string }
|
||||||
|
| undefined;
|
||||||
|
if (!exists) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { alice, bob, ctxAlicePersonal, ctxWork, roomPersonalDm, roomWorkGroup } = DemoIds;
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT OR IGNORE INTO context_members (context_id, user_id, role) VALUES (?, ?, 'member')`
|
||||||
|
).run(ctxAlicePersonal, bob);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT OR IGNORE INTO profiles (user_id, context_id, display_name, avatar_url, status_message) VALUES (?, ?, ?, ?, ?)`
|
||||||
|
).run(
|
||||||
|
bob,
|
||||||
|
ctxAlicePersonal,
|
||||||
|
"밥",
|
||||||
|
DEMO_AVATAR_BOB_PERSONAL,
|
||||||
|
"앨리스 일상에서 보이는 상태"
|
||||||
|
);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT OR IGNORE INTO rooms (id, context_id, is_group, name) VALUES (?, ?, 0, NULL)`
|
||||||
|
).run(roomPersonalDm, ctxAlicePersonal);
|
||||||
|
db.prepare(`INSERT OR IGNORE INTO room_members (room_id, user_id) VALUES (?, ?)`).run(
|
||||||
|
roomPersonalDm,
|
||||||
|
alice
|
||||||
|
);
|
||||||
|
db.prepare(`INSERT OR IGNORE INTO room_members (room_id, user_id) VALUES (?, ?)`).run(
|
||||||
|
roomPersonalDm,
|
||||||
|
bob
|
||||||
|
);
|
||||||
|
|
||||||
|
const personalMsgN = db
|
||||||
|
.prepare(`SELECT COUNT(*) as n FROM messages WHERE room_id = ?`)
|
||||||
|
.get(roomPersonalDm) as { n: number };
|
||||||
|
if (personalMsgN.n === 0) {
|
||||||
|
const a = randomUUID();
|
||||||
|
const b = randomUUID();
|
||||||
|
const c = randomUUID();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO messages (id, room_id, sender_id, body, kind) VALUES (?, ?, ?, ?, 'text')`
|
||||||
|
).run(a, roomPersonalDm, alice, "주말에 카페 갈래?");
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO messages (id, room_id, sender_id, body, kind) VALUES (?, ?, ?, ?, 'text')`
|
||||||
|
).run(b, roomPersonalDm, bob, "좋아, 몇 시에 볼까?");
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO messages (id, room_id, sender_id, body, kind) VALUES (?, ?, ?, ?, 'text')`
|
||||||
|
).run(c, roomPersonalDm, alice, "2시 어때?");
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT OR IGNORE INTO rooms (id, context_id, is_group, name) VALUES (?, ?, 1, ?)`
|
||||||
|
).run(roomWorkGroup, ctxWork, "프로젝트 A");
|
||||||
|
db.prepare(`INSERT OR IGNORE INTO room_members (room_id, user_id) VALUES (?, ?)`).run(
|
||||||
|
roomWorkGroup,
|
||||||
|
alice
|
||||||
|
);
|
||||||
|
db.prepare(`INSERT OR IGNORE INTO room_members (room_id, user_id) VALUES (?, ?)`).run(
|
||||||
|
roomWorkGroup,
|
||||||
|
bob
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupMsgN = db
|
||||||
|
.prepare(`SELECT COUNT(*) as n FROM messages WHERE room_id = ?`)
|
||||||
|
.get(roomWorkGroup) as { n: number };
|
||||||
|
if (groupMsgN.n === 0) {
|
||||||
|
const m1 = randomUUID();
|
||||||
|
const m2 = randomUUID();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO messages (id, room_id, sender_id, body, kind) VALUES (?, ?, ?, ?, 'text')`
|
||||||
|
).run(m1, roomWorkGroup, alice, "[프로젝트 A] 킥오프 슬라이드 올려뒀어요.");
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO messages (id, room_id, sender_id, body, kind) VALUES (?, ?, ?, ?, 'text')`
|
||||||
|
).run(m2, roomWorkGroup, bob, "확인했습니다. 내일 정리해서 공유할게요.");
|
||||||
|
}
|
||||||
|
}
|
||||||
13
server/tsconfig.json
Normal file
13
server/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user