Files
iykyk_msn/mobile/lib/features/home/home_page.dart
2026-04-07 16:17:03 +09:00

513 lines
18 KiB
Dart

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