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