오대리ㅣㅣㅣㅣ
This commit is contained in:
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('로그아웃'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user