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 createState() => _HomePageState(); } class _HomePageState extends ConsumerState { 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 _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 _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 _addWorkContext() async { final name = TextEditingController(); final ok = await showDialog( 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 _inviteEmail() async { final session = ref.read(sessionProvider).value; final cid = session?.effectiveContextId; if (cid == null) return; final email = TextEditingController(); final ok = await showDialog( 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 _openDirect() async { final session = ref.read(sessionProvider).value; final cid = session?.effectiveContextId; if (cid == null) return; final uid = TextEditingController(); final ok = await showDialog( 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 _createGroup() async { final session = ref.read(sessionProvider).value; final cid = session?.effectiveContextId; if (cid == null) return; final name = TextEditingController(); final ok = await showDialog( 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( 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 _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, ), ), ], ), ), ), ); } }