오대리ㅣㅣㅣㅣ

This commit is contained in:
송원형
2026-04-07 16:17:03 +09:00
commit 5bb54fdefe
63 changed files with 7897 additions and 0 deletions

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

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

View 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);
});

View 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;
}
}