오대리ㅣㅣㅣㅣ

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

View File

@@ -0,0 +1,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(),
),
],
);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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();
}
}

View 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('계정이 없어요'),
),
],
),
),
),
);
}
}

View 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('로그인으로 돌아가기'),
),
],
),
),
),
);
}
}

View 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;
}
}
}

View 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();
},
),
],
),
),
),
);
},
),
);
},
);
}
}

View 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,
),
),
],
),
),
),
);
}
}

View 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);
});

View 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;
}
}

View 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,
),
),
);
}
}

View 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가 없습니다';
}
}

View 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('로그아웃'),
),
],
),
);
}
}

View 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
View 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,
);
}
}

View 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?,
);
}
}

View 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,
);
}
}

View 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',
);
}
}

View 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?,
);
}
}

View 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?,
);
}
}

View 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;
}

View 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),
);
}