513 lines
18 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|