diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 3b35f5c..81a65cf 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -5,6 +5,7 @@ import 'screens/conversation_list_screen.dart'; import 'screens/onboarding_tone_screen.dart'; import 'screens/signup_screen.dart'; import 'state/session_state.dart'; +import 'theme/app_theme.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -24,13 +25,10 @@ class BunsinApp extends StatelessWidget { value: session, child: MaterialApp( title: '분신', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF1F6F5B), - brightness: Brightness.light, - ), - useMaterial3: true, - ), + debugShowCheckedModeBanner: false, + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + themeMode: ThemeMode.system, home: Consumer( builder: (context, s, _) { if (s.user == null) return const SignupScreen(); diff --git a/mobile/lib/screens/autonomy_settings_screen.dart b/mobile/lib/screens/autonomy_settings_screen.dart index 9954a05..d153824 100644 --- a/mobile/lib/screens/autonomy_settings_screen.dart +++ b/mobile/lib/screens/autonomy_settings_screen.dart @@ -49,36 +49,50 @@ class _AutonomySettingsScreenState extends State { super.dispose(); } + static const _levelDescriptions = { + AutonomyLevel.L0: '분신이 초안만 만들고, 발송은 항상 직접 합니다.', + AutonomyLevel.L1: '분신이 초안을 만들면 검토·수정 후 승인해야 보내집니다.', + AutonomyLevel.L2: '아래 화이트리스트 주제는 승인 없이 자동으로 보내집니다.', + }; + @override Widget build(BuildContext context) { final session = context.watch(); + final theme = Theme.of(context); return Scaffold( appBar: AppBar(title: const Text('자율성 설정')), body: ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), children: [ - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.privacy_tip_outlined), - title: const Text('데이터 흐름'), - subtitle: const Text('무엇이 기기에 남고 서버로 가는지'), - onTap: () { - Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DataFlowScreen())); - }, + Card( + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.privacy_tip_outlined), + title: const Text('데이터 흐름'), + subtitle: const Text('무엇이 기기에 남고 서버로 가는지'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DataFlowScreen())); + }, + ), + const Divider(height: 1, indent: 16, endIndent: 16), + ListTile( + leading: const Icon(Icons.devices), + title: const Text('로그인 세션'), + subtitle: const Text('멀티 디바이스 세션 목록'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SessionsScreen())); + }, + ), + ], + ), ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.devices), - title: const Text('로그인 세션'), - subtitle: const Text('멀티 디바이스 세션 목록'), - onTap: () { - Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SessionsScreen())); - }, - ), - const Divider(height: 32), - Text('전역 레벨', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), + const SizedBox(height: 24), + Text('전역 자율성 레벨', style: theme.textTheme.titleMedium), + const SizedBox(height: 12), SegmentedButton( segments: const [ ButtonSegment(value: AutonomyLevel.L0, label: Text('L0'), tooltip: '초안만'), @@ -88,23 +102,37 @@ class _AutonomySettingsScreenState extends State { selected: {session.autonomyLevel}, onSelectionChanged: (s) => session.setAutonomy(s.first), ), - const SizedBox(height: 8), - Text( - '기본값은 L0입니다. L2는 아래 화이트리스트 주제에만 자동 발송됩니다.', - style: Theme.of(context).textTheme.bodySmall, + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, size: 18, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + _levelDescriptions[session.autonomyLevel] ?? '', + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ), + ], + ), ), - const Divider(height: 32), - Text('L2 화이트리스트 주제', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), + const SizedBox(height: 28), + Text('L2 화이트리스트 주제', style: theme.textTheme.titleMedium), + const SizedBox(height: 12), Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: TextField( controller: _keyword, - decoration: const InputDecoration( - labelText: '주제 키워드', - border: OutlineInputBorder(), - ), + decoration: const InputDecoration(labelText: '주제 키워드'), ), ), const SizedBox(width: 8), @@ -128,24 +156,37 @@ class _AutonomySettingsScreenState extends State { ), if (_error != null) ...[ const SizedBox(height: 8), - Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + Text(_error!, style: TextStyle(color: theme.colorScheme.error)), ], const SizedBox(height: 12), if (_loading) - const Center(child: CircularProgressIndicator()) - else - ..._rules.map( - (r) => ListTile( - title: Text(r.topicKeyword), - trailing: IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: () async { - if (session.user == null) return; - await session.api.deleteWhitelist(session.user!.id, r.id); - setState(() => _rules = _rules.where((x) => x.id != r.id).toList()); - }, - ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ) + else if (_rules.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text( + '아직 화이트리스트 주제가 없습니다.', + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), + ) + else + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final r in _rules) + InputChip( + label: Text(r.topicKeyword), + onDeleted: () async { + if (session.user == null) return; + await session.api.deleteWhitelist(session.user!.id, r.id); + setState(() => _rules = _rules.where((x) => x.id != r.id).toList()); + }, + ), + ], ), ], ), diff --git a/mobile/lib/screens/chat_screen.dart b/mobile/lib/screens/chat_screen.dart index 0e62e99..52cba85 100644 --- a/mobile/lib/screens/chat_screen.dart +++ b/mobile/lib/screens/chat_screen.dart @@ -205,6 +205,18 @@ class _ChatScreenState extends State { } Future _veto() async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('거부권을 쓸까요?'), + content: const Text('이 대화방에서 분신 자동응대가 즉시 중단됩니다. 이후에는 다시 켤 수 없습니다.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')), + FilledButton.tonal(onPressed: () => Navigator.pop(ctx, true), child: const Text('거부권 사용')), + ], + ), + ); + if (ok != true || !mounted) return; final session = context.read(); try { await session.api.vetoConversation(widget.conversationId); @@ -229,74 +241,86 @@ class _ChatScreenState extends State { if (draft == null) return const SizedBox.shrink(); if (draft.isEscalate) { - return Material( - color: theme.colorScheme.errorContainer, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text('직접 확인 필요', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700)), - const SizedBox(height: 4), - Text(draft.text.isEmpty ? '민감·확정성 내용으로 분신 발송이 보류되었습니다.' : draft.text), - const SizedBox(height: 8), - Align( - alignment: Alignment.centerRight, - child: TextButton(onPressed: _rejectDraft, child: const Text('닫기')), - ), - ], - ), + return Container( + margin: const EdgeInsets.fromLTRB(12, 0, 12, 8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer, + borderRadius: BorderRadius.circular(16), ), - ); - } - - return Material( - elevation: 2, - color: theme.colorScheme.secondaryContainer, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ - Icon(Icons.auto_awesome, size: 18, color: theme.colorScheme.onSecondaryContainer), + Icon(Icons.warning_amber_rounded, size: 18, color: theme.colorScheme.onErrorContainer), const SizedBox(width: 6), Text( - 'L1 승인 — 수정 후 보내기', - style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), + '직접 확인 필요', + style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onErrorContainer), ), ], ), - const SizedBox(height: 8), - TextField( - controller: _draftEdit, - minLines: 2, - maxLines: 5, - decoration: const InputDecoration( - border: OutlineInputBorder(), - filled: true, - fillColor: Colors.white70, - ), + const SizedBox(height: 6), + Text( + draft.text.isEmpty ? '민감·확정성 내용으로 분신 발송이 보류되었습니다.' : draft.text, + style: TextStyle(color: theme.colorScheme.onErrorContainer), ), - const SizedBox(height: 8), - Row( - children: [ - TextButton(onPressed: _busy ? null : _rejectDraft, child: const Text('버리기')), - const Spacer(), - FilledButton.tonal( - onPressed: _busy ? null : _requestDraft, - child: const Text('다시 초안'), - ), - const SizedBox(width: 8), - FilledButton( - onPressed: _busy ? null : _sendTwinApproved, - child: const Text('승인하고 보내기'), - ), - ], + const SizedBox(height: 4), + Align( + alignment: Alignment.centerRight, + child: TextButton(onPressed: _rejectDraft, child: const Text('닫기')), ), ], ), + ); + } + + return Container( + margin: const EdgeInsets.fromLTRB(12, 0, 12, 8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon(Icons.auto_awesome, size: 18, color: theme.colorScheme.onSecondaryContainer), + const SizedBox(width: 6), + Text( + 'L1 승인 — 수정 후 보내기', + style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onSecondaryContainer), + ), + ], + ), + const SizedBox(height: 10), + TextField( + controller: _draftEdit, + minLines: 2, + maxLines: 5, + style: TextStyle(color: theme.colorScheme.onSurface), + decoration: InputDecoration(fillColor: theme.colorScheme.surface), + ), + const SizedBox(height: 10), + Row( + children: [ + TextButton(onPressed: _busy ? null : _rejectDraft, child: const Text('버리기')), + const Spacer(), + FilledButton.tonal( + onPressed: _busy ? null : _requestDraft, + child: const Text('다시 초안'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _busy ? null : _sendTwinApproved, + child: const Text('승인하고 보내기'), + ), + ], + ), + ], ), ); } @@ -306,17 +330,23 @@ class _ChatScreenState extends State { final session = context.watch(); final me = session.user?.id; + final theme = Theme.of(context); return Scaffold( appBar: AppBar( title: Text(widget.title ?? '대화방 #${widget.conversationId}'), actions: [ - TextButton(onPressed: _busy ? null : _veto, child: const Text('거부권')), + IconButton( + tooltip: '거부권 (분신 자동응대 중단)', + onPressed: _busy ? null : _veto, + icon: const Icon(Icons.block), + ), ], ), body: Column( children: [ if (_banner != null) MaterialBanner( + leading: Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant), content: Text(_banner!), actions: [ TextButton(onPressed: () => setState(() => _banner = null), child: const Text('닫기')), @@ -325,35 +355,40 @@ class _ChatScreenState extends State { Expanded( child: _loadingHistory ? const Center(child: CircularProgressIndicator()) - : ListView.builder( - controller: _scroll, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _messages.length, - itemBuilder: (context, i) { - final m = _messages[i]; - return MessageBubble( - message: m, - isMine: me != null && m.senderId == me, - onRetract: m.isTwin ? () => _retract(m) : null, - ); - }, - ), + : _messages.isEmpty + ? Center( + child: Text( + '아직 메시지가 없습니다. 첫 메시지를 보내 보세요.', + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ) + : ListView.builder( + controller: _scroll, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _messages.length, + itemBuilder: (context, i) { + final m = _messages[i]; + return MessageBubble( + message: m, + isMine: me != null && m.senderId == me, + onRetract: m.isTwin ? () => _retract(m) : null, + ); + }, + ), ), _buildL1Panel(context), SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), child: Row( + crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: TextField( controller: _input, minLines: 1, maxLines: 4, - decoration: const InputDecoration( - hintText: '메시지', - border: OutlineInputBorder(), - ), + decoration: const InputDecoration(hintText: '메시지'), ), ), const SizedBox(width: 8), diff --git a/mobile/lib/screens/contacts_screen.dart b/mobile/lib/screens/contacts_screen.dart index ec86ac3..7bc5ffc 100644 --- a/mobile/lib/screens/contacts_screen.dart +++ b/mobile/lib/screens/contacts_screen.dart @@ -54,7 +54,7 @@ class _ContactsScreenState extends State { children: [ TextField( controller: nameCtrl, - decoration: const InputDecoration(labelText: '표시 이름', border: OutlineInputBorder()), + decoration: const InputDecoration(labelText: '표시 이름'), ), const SizedBox(height: 12), TextField( @@ -63,13 +63,12 @@ class _ContactsScreenState extends State { decoration: const InputDecoration( labelText: '상대 사용자 ID (선택)', helperText: '대화 시작에 필요', - border: OutlineInputBorder(), ), ), const SizedBox(height: 12), TextField( controller: noteCtrl, - decoration: const InputDecoration(labelText: '관계 메모 (선택)', border: OutlineInputBorder()), + decoration: const InputDecoration(labelText: '관계 메모 (선택)'), ), ], ), @@ -118,54 +117,102 @@ class _ContactsScreenState extends State { } } + Future _delete(Contact c) async { + final session = context.read(); + if (session.user == null) return; + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('연락처 삭제'), + content: Text('${c.displayName}을(를) 삭제할까요?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')), + FilledButton.tonal(onPressed: () => Navigator.pop(ctx, true), child: const Text('삭제')), + ], + ), + ); + if (ok != true) return; + await session.api.deleteContact(session.user!.id, c.id); + if (!mounted) return; + setState(() => _contacts = _contacts.where((x) => x.id != c.id).toList()); + } + @override Widget build(BuildContext context) { + final theme = Theme.of(context); return Scaffold( appBar: AppBar(title: const Text('연락처')), floatingActionButton: FloatingActionButton( onPressed: _showAddDialog, + tooltip: '연락처 추가', child: const Icon(Icons.person_add_alt_1), ), body: RefreshIndicator( onRefresh: _load, child: _loading - ? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())]) + ? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())]) : ListView( + padding: const EdgeInsets.symmetric(vertical: 4), children: [ if (_error != null) Padding( - padding: const EdgeInsets.all(16), - child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)), ), if (_contacts.isEmpty) - const Padding( - padding: EdgeInsets.all(24), - child: Text('연락처가 없습니다. + 버튼으로 추가하세요.'), + Padding( + padding: const EdgeInsets.symmetric(vertical: 64, horizontal: 32), + child: Column( + children: [ + Icon(Icons.person_add_outlined, size: 40, color: theme.colorScheme.outline), + const SizedBox(height: 12), + Text('연락처가 없습니다', style: theme.textTheme.titleMedium), + const SizedBox(height: 4), + Text( + '오른쪽 아래 버튼으로 첫 연락처를 추가해 보세요.', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ], + ), ), for (final c in _contacts) - ListTile( - leading: const CircleAvatar(child: Icon(Icons.person_outline)), - title: Text(c.displayName), - subtitle: Text( - [ - if (c.contactUserId != null) '사용자 #${c.contactUserId}', - if (c.relationshipNote.isNotEmpty) c.relationshipNote, - ].join(' · '), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + child: ListTile( + leading: CircleAvatar( + backgroundColor: theme.colorScheme.secondaryContainer, + child: Text( + c.displayName.isEmpty ? '?' : c.displayName.substring(0, 1), + style: TextStyle( + color: theme.colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w700, + ), + ), + ), + title: Text(c.displayName, style: theme.textTheme.titleSmall), + subtitle: Text( + [ + if (c.contactUserId != null) '사용자 #${c.contactUserId}', + if (c.relationshipNote.isNotEmpty) c.relationshipNote, + ].join(' · '), + style: theme.textTheme.bodySmall, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (c.contactUserId != null) + TextButton(onPressed: () => _startChat(c), child: const Text('대화')), + IconButton( + tooltip: '삭제', + icon: const Icon(Icons.delete_outline, size: 20), + onPressed: () => _delete(c), + ), + ], + ), ), - trailing: c.contactUserId == null - ? null - : TextButton(onPressed: () => _startChat(c), child: const Text('대화')), - onLongPress: () async { - final session = context.read(); - if (session.user == null) return; - await session.api.deleteContact(session.user!.id, c.id); - setState(() => _contacts = _contacts.where((x) => x.id != c.id).toList()); - }, ), - const Padding( - padding: EdgeInsets.all(16), - child: Text('길게 누르면 삭제됩니다.', textAlign: TextAlign.center), - ), + const SizedBox(height: 72), ], ), ), diff --git a/mobile/lib/screens/conversation_list_screen.dart b/mobile/lib/screens/conversation_list_screen.dart index 2003b4e..7430603 100644 --- a/mobile/lib/screens/conversation_list_screen.dart +++ b/mobile/lib/screens/conversation_list_screen.dart @@ -58,7 +58,6 @@ class _ConversationListScreenState extends State { decoration: const InputDecoration( labelText: '상대 사용자 ID', helperText: '연락처에 등록된 상대면 연락처 화면에서 시작하는 편이 낫습니다.', - border: OutlineInputBorder(), ), ), actions: [ @@ -84,14 +83,27 @@ class _ConversationListScreenState extends State { } } + Color _avatarColor(BuildContext context, int seed) { + final scheme = Theme.of(context).colorScheme; + final palette = [scheme.primaryContainer, scheme.tertiaryContainer, scheme.secondaryContainer]; + return palette[seed % palette.length]; + } + + Color _onAvatarColor(BuildContext context, int seed) { + final scheme = Theme.of(context).colorScheme; + final palette = [scheme.onPrimaryContainer, scheme.onTertiaryContainer, scheme.onSecondaryContainer]; + return palette[seed % palette.length]; + } + @override Widget build(BuildContext context) { final session = context.watch(); + final theme = Theme.of(context); final me = session.user?.id; return Scaffold( appBar: AppBar( - title: Text('분신 · ${session.user?.displayName ?? ''}'), + title: const Text('분신'), actions: [ IconButton( tooltip: '사후 알림', @@ -108,68 +120,120 @@ class _ConversationListScreenState extends State { }, icon: const Icon(Icons.contacts_outlined), ), - IconButton( - tooltip: '말투 샘플', - onPressed: () { - Navigator.of(context).push(MaterialPageRoute(builder: (_) => const OnboardingToneScreen())); - }, - icon: const Icon(Icons.record_voice_over_outlined), - ), - IconButton( - tooltip: '자율성 설정', - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const AutonomySettingsScreen()), - ); - }, - icon: const Icon(Icons.tune), + PopupMenuButton( + tooltip: '더보기', + icon: const Icon(Icons.more_vert), + onSelected: (action) => action(), + itemBuilder: (context) => [ + PopupMenuItem( + value: () => Navigator.of(context) + .push(MaterialPageRoute(builder: (_) => const OnboardingToneScreen())), + child: const ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.record_voice_over_outlined), + title: Text('말투 샘플'), + ), + ), + PopupMenuItem( + value: () => Navigator.of(context) + .push(MaterialPageRoute(builder: (_) => const AutonomySettingsScreen())), + child: const ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.tune), + title: Text('자율성 설정'), + ), + ), + ], ), + const SizedBox(width: 4), ], ), - floatingActionButton: FloatingActionButton( + floatingActionButton: FloatingActionButton.extended( onPressed: _createConversation, - child: const Icon(Icons.chat), + icon: const Icon(Icons.chat), + label: const Text('새 대화'), ), body: RefreshIndicator( onRefresh: _load, child: _loading - ? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())]) + ? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())]) : ListView( + padding: const EdgeInsets.symmetric(vertical: 4), children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: Text( + session.user == null ? '' : '안녕하세요, ${session.user!.displayName}님', + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ), if (_error != null) Padding( - padding: const EdgeInsets.all(16), - child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)), ), if (_rooms.isEmpty) - const Padding( - padding: EdgeInsets.all(24), - child: Text('대화방이 없습니다. + 버튼이나 연락처에서 대화를 시작하세요.'), + Padding( + padding: const EdgeInsets.symmetric(vertical: 64, horizontal: 32), + child: Column( + children: [ + Icon(Icons.chat_bubble_outline, size: 40, color: theme.colorScheme.outline), + const SizedBox(height: 12), + Text( + '대화방이 없습니다', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + '연락처나 "새 대화" 버튼으로 첫 대화를 시작해 보세요.', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ], + ), ), for (final room in _rooms) - ListTile( - leading: CircleAvatar( - child: Icon(room.isGroup ? Icons.groups_outlined : Icons.chat_bubble_outline), - ), - title: Text(me == null ? '대화방 #${room.id}' : room.titleFor(me)), - subtitle: Text( - [ - 'ID ${room.id}', - if (room.twinDisabledByPeer) '상대가 분신 거부', - ].join(' · '), - ), - onTap: () async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ChatScreen( - conversationId: room.id, - title: me == null ? null : room.titleFor(me), - ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + child: ListTile( + leading: CircleAvatar( + backgroundColor: _avatarColor(context, room.id), + child: Icon( + room.isGroup ? Icons.groups_outlined : Icons.person_outline, + color: _onAvatarColor(context, room.id), ), - ); - await _load(); - }, + ), + title: Text( + me == null ? '대화방 #${room.id}' : room.titleFor(me), + style: theme.textTheme.titleSmall, + ), + subtitle: room.twinDisabledByPeer + ? Row( + children: [ + Icon(Icons.block, size: 13, color: theme.colorScheme.error), + const SizedBox(width: 4), + Text( + '상대가 분신을 거부함', + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error), + ), + ], + ) + : Text('대화방 ID ${room.id}', style: theme.textTheme.bodySmall), + trailing: const Icon(Icons.chevron_right, size: 20), + onTap: () async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChatScreen( + conversationId: room.id, + title: me == null ? null : room.titleFor(me), + ), + ), + ); + await _load(); + }, + ), ), + const SizedBox(height: 72), ], ), ), diff --git a/mobile/lib/screens/data_flow_screen.dart b/mobile/lib/screens/data_flow_screen.dart index f8b4fa2..a07c1ed 100644 --- a/mobile/lib/screens/data_flow_screen.dart +++ b/mobile/lib/screens/data_flow_screen.dart @@ -7,6 +7,50 @@ import '../state/session_state.dart'; class DataFlowScreen extends StatelessWidget { const DataFlowScreen({super.key}); + Widget _section( + BuildContext context, { + required IconData icon, + required Color tint, + required String title, + required String body, + }) { + final theme = Theme.of(context); + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 36, + height: 36, + alignment: Alignment.center, + decoration: BoxDecoration(color: tint.withOpacity(0.15), shape: BoxShape.circle), + child: Icon(icon, size: 18, color: tint), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleSmall), + const SizedBox(height: 4), + Text( + body, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { final session = context.watch(); @@ -16,18 +60,20 @@ class DataFlowScreen extends StatelessWidget { return Scaffold( appBar: AppBar(title: const Text('데이터 흐름')), body: ListView( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(16), children: [ - Text('기기 안에만 둡니다', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), - const SizedBox(height: 8), - Text( - '말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. ' - '원문 대화 전체를 서버로 올리지 않는 것이 기본 원칙입니다.', - style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + _section( + context, + icon: Icons.lock_outline, + tint: Colors.green.shade600, + title: '기기 안에만 둡니다', + body: '말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. ' + '원문 대화 전체를 서버로 올리지 않는 것이 기본 원칙입니다.', ), - const SizedBox(height: 12), Card( + margin: const EdgeInsets.only(bottom: 16), child: ListTile( + leading: Icon(Icons.record_voice_over_outlined, color: theme.colorScheme.primary), title: Text('말투 샘플 ${samples.length}개'), subtitle: Text( [ @@ -40,23 +86,23 @@ class DataFlowScreen extends StatelessWidget { isThreeLine: true, ), ), - const SizedBox(height: 24), - Text('서버로 보낼 수 있는 것', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), - const SizedBox(height: 8), - Text( - '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트).\n' - '채팅 릴레이: 보낸 메시지 본문.\n' - '계정: 표시 이름·초대 코드·세션 토큰.\n' - '푸시(준비 중): 디바이스 토큰만 등록, 실제 전송은 FCM 프로젝트 연결 후.', - style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + _section( + context, + icon: Icons.cloud_upload_outlined, + tint: Colors.amber.shade800, + title: '서버로 보낼 수 있는 것', + body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n' + '채팅 릴레이: 보낸 메시지 본문\n' + '계정: 표시 이름·초대 코드·세션 토큰\n' + '푸시(준비 중): 디바이스 토큰만 등록, 실제 전송은 FCM 프로젝트 연결 후', ), - const SizedBox(height: 24), - Text('보내지 않는 것', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), - const SizedBox(height: 8), - Text( - '전체 채팅 백업 업로드, 온디바이스 말투 학습 원문 코퍼스, ' - '관계 메모의 자동 클라우드 분석.', - style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + _section( + context, + icon: Icons.block, + tint: theme.colorScheme.error, + title: '보내지 않는 것', + body: '전체 채팅 백업 업로드, 온디바이스 말투 학습 원문 코퍼스, ' + '관계 메모의 자동 클라우드 분석', ), ], ), diff --git a/mobile/lib/screens/inbox_screen.dart b/mobile/lib/screens/inbox_screen.dart index 7c1e34c..45f24cc 100644 --- a/mobile/lib/screens/inbox_screen.dart +++ b/mobile/lib/screens/inbox_screen.dart @@ -51,11 +51,12 @@ class _InboxScreenState extends State { body: RefreshIndicator( onRefresh: _load, child: _loading - ? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())]) + ? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())]) : ListView( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 24), children: [ Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + padding: const EdgeInsets.fromLTRB(4, 0, 4, 12), child: Text( '분신이 보류·차단한 내용과 에스컬레이션 기록입니다. ' '이미 보낸 분신 메시지는 해당 대화방에서 되돌릴 수 있습니다.', @@ -64,36 +65,89 @@ class _InboxScreenState extends State { ), if (_error != null) Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)), ), if (_logs.isEmpty) - const Padding( - padding: EdgeInsets.all(24), - child: Text('새 알림이 없습니다.'), + Padding( + padding: const EdgeInsets.symmetric(vertical: 64, horizontal: 32), + child: Column( + children: [ + Icon(Icons.notifications_none, size: 40, color: theme.colorScheme.outline), + const SizedBox(height: 12), + Text('새 알림이 없습니다', style: theme.textTheme.titleMedium), + ], + ), ), for (final log in _logs) - ListTile( - leading: Icon( - log.resolved ? Icons.check_circle_outline : Icons.warning_amber_rounded, - color: log.resolved ? theme.colorScheme.primary : theme.colorScheme.error, - ), - title: Text(log.reason.isEmpty ? '에스컬레이션' : log.reason), - subtitle: Text( - [ - '대화방 #${log.conversationId}', - if (log.messageSnippet.isNotEmpty) log.messageSnippet, - log.createdAt.toLocal().toString().split('.').first, - ].join('\n'), - ), - isThreeLine: true, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ChatScreen(conversationId: log.conversationId), + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Card( + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChatScreen(conversationId: log.conversationId), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 18, + backgroundColor: log.resolved + ? theme.colorScheme.primaryContainer + : theme.colorScheme.errorContainer, + child: Icon( + log.resolved ? Icons.check_circle_outline : Icons.warning_amber_rounded, + size: 18, + color: log.resolved + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onErrorContainer, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + log.reason.isEmpty ? '에스컬레이션' : log.reason, + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 2), + Text( + '대화방 #${log.conversationId}', + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + if (log.messageSnippet.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + log.messageSnippet, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium, + ), + ], + const SizedBox(height: 6), + Text( + log.createdAt.toLocal().toString().split('.').first, + style: theme.textTheme.labelSmall + ?.copyWith(color: theme.colorScheme.outline), + ), + ], + ), + ), + ], + ), ), - ); - }, + ), + ), ), ], ), diff --git a/mobile/lib/screens/onboarding_tone_screen.dart b/mobile/lib/screens/onboarding_tone_screen.dart index 6983f10..3d7776e 100644 --- a/mobile/lib/screens/onboarding_tone_screen.dart +++ b/mobile/lib/screens/onboarding_tone_screen.dart @@ -46,6 +46,7 @@ class _OnboardingToneScreenState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final scheme = theme.colorScheme; return Scaffold( appBar: AppBar( title: const Text('말투 샘플'), @@ -54,20 +55,28 @@ class _OnboardingToneScreenState extends State { onPressed: () => context.read().skipToneOnboarding(), child: const Text('나중에'), ), + const SizedBox(width: 8), ], ), body: SafeArea( child: ListView( padding: const EdgeInsets.all(24), children: [ - Text('분신이 따라 쓸 말투', style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700)), + Container( + width: 56, + height: 56, + decoration: BoxDecoration(color: scheme.primaryContainer, borderRadius: BorderRadius.circular(16)), + child: Icon(Icons.record_voice_over_outlined, size: 28, color: scheme.onPrimaryContainer), + ), + const SizedBox(height: 16), + Text('분신이 따라 쓸 말투', style: theme.textTheme.headlineSmall), const SizedBox(height: 8), Text( '자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다. ' '최종 문구·수집 방식은 사람 PoC 이후에 다듬습니다.', - style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), ), - const SizedBox(height: 24), + const SizedBox(height: 28), for (var i = 0; i < _samples.length; i++) ...[ TextField( controller: _samples[i], @@ -75,12 +84,26 @@ class _OnboardingToneScreenState extends State { decoration: InputDecoration( labelText: '샘플 ${i + 1}', hintText: i == 0 ? '예: ㅇㅇ 알겠음' : null, - border: const OutlineInputBorder(), + prefixIcon: Padding( + padding: const EdgeInsets.only(left: 4, right: 4, top: 4), + child: CircleAvatar( + radius: 12, + backgroundColor: scheme.secondaryContainer, + child: Text( + '${i + 1}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: scheme.onSecondaryContainer, + ), + ), + ), + ), ), ), const SizedBox(height: 12), ], - const SizedBox(height: 8), + const SizedBox(height: 12), FilledButton( onPressed: () => _save(markDone: true), child: const Text('저장하고 시작'), diff --git a/mobile/lib/screens/sessions_screen.dart b/mobile/lib/screens/sessions_screen.dart index 097a1b0..64355fe 100644 --- a/mobile/lib/screens/sessions_screen.dart +++ b/mobile/lib/screens/sessions_screen.dart @@ -72,32 +72,53 @@ class _SessionsScreenState extends State { @override Widget build(BuildContext context) { + final theme = Theme.of(context); return Scaffold( appBar: AppBar(title: const Text('로그인 세션')), body: RefreshIndicator( onRefresh: _load, child: _loading - ? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())]) + ? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())]) : ListView( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), children: [ - const Padding( - padding: EdgeInsets.all(16), - child: Text('이 계정에 연결된 활성 세션입니다. 다른 기기를 종료하면 해당 토큰이 즉시 무효화됩니다.'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Text( + '이 계정에 연결된 활성 세션입니다. 다른 기기를 종료하면 해당 토큰이 즉시 무효화됩니다.', + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), ), if (_error != null) Padding( - padding: const EdgeInsets.all(16), - child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)), ), for (final s in _sessions) - ListTile( - leading: Icon(s['is_current'] == true ? Icons.smartphone : Icons.devices_other), - title: Text(s['is_current'] == true ? '이 기기 (현재)' : '세션 #${s['id']}'), - subtitle: Text('만료: ${s['expires_at'] ?? ''}'), - trailing: IconButton( - tooltip: '세션 종료', - icon: const Icon(Icons.logout), - onPressed: () => _revoke(s), + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: ListTile( + leading: CircleAvatar( + backgroundColor: s['is_current'] == true + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceContainerHighest, + child: Icon( + s['is_current'] == true ? Icons.smartphone : Icons.devices_other, + color: s['is_current'] == true + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurfaceVariant, + ), + ), + title: Text( + s['is_current'] == true ? '이 기기 (현재)' : '세션 #${s['id']}', + style: theme.textTheme.titleSmall, + ), + subtitle: Text('만료: ${s['expires_at'] ?? ''}', style: theme.textTheme.bodySmall), + trailing: IconButton( + tooltip: '세션 종료', + icon: const Icon(Icons.logout), + onPressed: () => _revoke(s), + ), ), ), ], diff --git a/mobile/lib/screens/signup_screen.dart b/mobile/lib/screens/signup_screen.dart index 89652a9..173f747 100644 --- a/mobile/lib/screens/signup_screen.dart +++ b/mobile/lib/screens/signup_screen.dart @@ -24,28 +24,47 @@ class _SignupScreenState extends State { @override Widget build(BuildContext context) { final session = context.watch(); + final theme = Theme.of(context); + final scheme = theme.colorScheme; return Scaffold( body: SafeArea( child: Padding( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const SizedBox(height: 48), - Text('분신', style: Theme.of(context).textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w800)), + const Spacer(flex: 3), + Center( + child: Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: scheme.primaryContainer, + borderRadius: BorderRadius.circular(22), + ), + child: Icon(Icons.auto_awesome, size: 34, color: scheme.onPrimaryContainer), + ), + ), + const SizedBox(height: 20), + Text( + '분신', + textAlign: TextAlign.center, + style: theme.textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w800), + ), const SizedBox(height: 8), Text( - '초대 코드로 클로즈드 베타에 참여합니다.', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + '나를 대신해 답하는, 나만의 분신', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge?.copyWith(color: scheme.onSurfaceVariant), ), - const SizedBox(height: 32), + const Spacer(flex: 3), + Text('초대 코드로 클로즈드 베타에 참여합니다', style: theme.textTheme.labelLarge), + const SizedBox(height: 12), TextField( controller: _invite, decoration: const InputDecoration( labelText: '초대 코드', - border: OutlineInputBorder(), + prefixIcon: Icon(Icons.vpn_key), ), textInputAction: TextInputAction.next, ), @@ -54,15 +73,15 @@ class _SignupScreenState extends State { controller: _name, decoration: const InputDecoration( labelText: '표시 이름', - border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), ), textInputAction: TextInputAction.done, ), if (session.error != null) ...[ const SizedBox(height: 12), - Text(session.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + Text(session.error!, style: TextStyle(color: scheme.error)), ], - const Spacer(), + const SizedBox(height: 20), FilledButton( onPressed: session.loading ? null @@ -71,6 +90,7 @@ class _SignupScreenState extends State { ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('시작하기'), ), + const SizedBox(height: 32), ], ), ), diff --git a/mobile/lib/theme/app_theme.dart b/mobile/lib/theme/app_theme.dart new file mode 100644 index 0000000..d185243 --- /dev/null +++ b/mobile/lib/theme/app_theme.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; + +/// Central design tokens for 분신 (Bunsin). One seed color drives the whole +/// Material 3 palette; `twinAccent` is a separate warm accent used only for +/// the AI-authored badge/border so a twin-written bubble never reads as +/// something the human actually typed (PRD §3.1 분신 뱃지). +class AppTheme { + AppTheme._(); + + static const _seed = Color(0xFF1F6F5B); + + static Color twinAccent(Brightness brightness) => + brightness == Brightness.dark ? Colors.amber.shade300 : Colors.amber.shade800; + + static ThemeData light() => _build(Brightness.light); + static ThemeData dark() => _build(Brightness.dark); + + static ThemeData _build(Brightness brightness) { + final scheme = ColorScheme.fromSeed(seedColor: _seed, brightness: brightness); + + return ThemeData( + useMaterial3: true, + brightness: brightness, + colorScheme: scheme, + scaffoldBackgroundColor: scheme.surface, + visualDensity: VisualDensity.comfortable, + textTheme: _textTheme(), + appBarTheme: AppBarTheme( + backgroundColor: scheme.surface, + foregroundColor: scheme.onSurface, + surfaceTintColor: scheme.surfaceTint, + elevation: 0, + scrolledUnderElevation: 1, + centerTitle: false, + titleTextStyle: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w800, + color: scheme.onSurface, + letterSpacing: -0.2, + ), + ), + cardTheme: CardThemeData( + elevation: 0, + color: scheme.surfaceContainerHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + margin: EdgeInsets.zero, + ), + listTileTheme: ListTileThemeData( + iconColor: scheme.onSurfaceVariant, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + ), + dividerTheme: DividerThemeData(color: scheme.outlineVariant, space: 32, thickness: 1), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: scheme.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: scheme.primary, width: 1.6), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: scheme.error, width: 1.2), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: scheme.error, width: 1.6), + ), + hintStyle: TextStyle(color: scheme.onSurfaceVariant.withOpacity(0.7)), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + textStyle: const TextStyle(fontWeight: FontWeight.w700), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + segmentedButtonTheme: SegmentedButtonThemeData( + style: SegmentedButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + ), + chipTheme: ChipThemeData( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + side: BorderSide.none, + backgroundColor: scheme.surfaceContainerHighest, + labelStyle: TextStyle(color: scheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + ), + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + elevation: 1, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + bannerTheme: MaterialBannerThemeData( + backgroundColor: scheme.surfaceContainerHigh, + padding: const EdgeInsets.all(16), + ), + dialogTheme: DialogThemeData( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + ), + progressIndicatorTheme: ProgressIndicatorThemeData(color: scheme.primary), + ); + } + + static TextTheme _textTheme() { + return const TextTheme( + displaySmall: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.5), + headlineSmall: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.3), + titleLarge: TextStyle(fontWeight: FontWeight.w700), + titleMedium: TextStyle(fontWeight: FontWeight.w700), + titleSmall: TextStyle(fontWeight: FontWeight.w700), + bodyLarge: TextStyle(height: 1.4), + bodyMedium: TextStyle(height: 1.4), + labelLarge: TextStyle(fontWeight: FontWeight.w700), + ); + } +} diff --git a/mobile/lib/widgets/message_bubble.dart b/mobile/lib/widgets/message_bubble.dart index 4d79980..3b73031 100644 --- a/mobile/lib/widgets/message_bubble.dart +++ b/mobile/lib/widgets/message_bubble.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import '../models/models.dart'; +import '../theme/app_theme.dart'; -/// Twin messages use a dashed border + badge (PRD §3.1 분신 뱃지). +/// Twin messages get a dashed border + badge (PRD §3.1 분신 뱃지) so an +/// auto-sent bubble never reads as something the human actually typed. class MessageBubble extends StatelessWidget { const MessageBubble({ super.key, @@ -15,90 +17,146 @@ class MessageBubble extends StatelessWidget { final bool isMine; final VoidCallback? onRetract; + static String _time(DateTime dt) { + final local = dt.toLocal(); + final h = local.hour.toString().padLeft(2, '0'); + final m = local.minute.toString().padLeft(2, '0'); + return '$h:$m'; + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); + final scheme = theme.colorScheme; final twin = message.isTwin; - final bg = isMine - ? theme.colorScheme.primaryContainer - : theme.colorScheme.surfaceContainerHighest; + final retracted = message.retracted; + final accent = AppTheme.twinAccent(theme.brightness); + + final Color bg; + final Color fg; + if (retracted) { + bg = scheme.surfaceContainerHigh; + fg = scheme.onSurfaceVariant; + } else if (isMine) { + bg = scheme.primaryContainer; + fg = scheme.onPrimaryContainer; + } else { + bg = scheme.surfaceContainerHighest; + fg = scheme.onSurface; + } + + final radius = BorderRadius.only( + topLeft: const Radius.circular(18), + topRight: const Radius.circular(18), + bottomLeft: Radius.circular(isMine ? 18 : 4), + bottomRight: Radius.circular(isMine ? 4 : 18), + ); final bubble = Container( - constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.78), - margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), - padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(14), - border: twin - ? Border.all(color: theme.colorScheme.tertiary, width: 1.5, strokeAlign: BorderSide.strokeAlignOutside) - : null, - ), + constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.76), + padding: const EdgeInsets.fromLTRB(14, 10, 14, 8), + decoration: BoxDecoration(color: bg, borderRadius: radius), child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ if (twin) Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Text( - '분신', - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.tertiary, - fontWeight: FontWeight.w700, + padding: const EdgeInsets.only(bottom: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.auto_awesome, size: 13, color: accent), + const SizedBox(width: 4), + Text( + '분신', + style: theme.textTheme.labelSmall?.copyWith( + color: accent, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + ], + ), + ), + if (retracted) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.replay, size: 14, color: fg), + const SizedBox(width: 4), + Text( + '되돌린 메시지', + style: theme.textTheme.bodyMedium?.copyWith(color: fg, fontStyle: FontStyle.italic), ), - ), + ], + ) + else + Text( + message.text, + style: theme.textTheme.bodyMedium?.copyWith(color: fg, height: 1.35), ), + const SizedBox(height: 4), Text( - message.retracted ? '(되돌린 메시지)' : message.text, - style: theme.textTheme.bodyMedium?.copyWith( - fontStyle: message.retracted ? FontStyle.italic : FontStyle.normal, - color: message.retracted ? theme.disabledColor : null, - ), + _time(message.createdAt), + style: theme.textTheme.labelSmall?.copyWith(color: fg.withOpacity(0.55), fontSize: 10), ), - if (twin && isMine && !message.retracted && onRetract != null) - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: onRetract, - child: const Text('되돌리기'), - ), - ), ], ), ); - // Dashed look for twin: overlay a custom painter border when twin. - if (!twin) { - return Align( - alignment: isMine ? Alignment.centerRight : Alignment.centerLeft, - child: bubble, - ); - } + final content = Column( + crossAxisAlignment: isMine ? CrossAxisAlignment.end : CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + twin + ? CustomPaint( + painter: _DashedRRectPainter(color: accent, radius: radius), + child: bubble, + ) + : bubble, + if (twin && isMine && !retracted && onRetract != null) + Padding( + padding: const EdgeInsets.only(top: 2, right: 4, left: 4), + child: TextButton.icon( + onPressed: onRetract, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + foregroundColor: scheme.onSurfaceVariant, + textStyle: const TextStyle(fontSize: 12), + ), + icon: const Icon(Icons.undo, size: 14), + label: const Text('되돌리기'), + ), + ), + ], + ); - return Align( - alignment: isMine ? Alignment.centerRight : Alignment.centerLeft, - child: CustomPaint( - painter: _DashedRRectPainter(color: theme.colorScheme.tertiary), - child: bubble, + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), + child: Align( + alignment: isMine ? Alignment.centerRight : Alignment.centerLeft, + child: content, ), ); } } class _DashedRRectPainter extends CustomPainter { - _DashedRRectPainter({required this.color}); + _DashedRRectPainter({required this.color, required this.radius}); + final Color color; + final BorderRadius radius; @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = color ..style = PaintingStyle.stroke - ..strokeWidth = 1.5; - final rrect = RRect.fromRectAndRadius( - Rect.fromLTWH(1, 1, size.width - 2, size.height - 2), - const Radius.circular(14), - ); + ..strokeWidth = 1.4; + final rrect = radius.toRRect(Rect.fromLTWH(0.7, 0.7, size.width - 1.4, size.height - 1.4)); final path = Path()..addRRect(rrect); final dashed = _dashPath(path, dashLength: 5, gapLength: 4); canvas.drawPath(dashed, paint); @@ -119,5 +177,6 @@ class _DashedRRectPainter extends CustomPainter { } @override - bool shouldRepaint(covariant _DashedRRectPainter oldDelegate) => oldDelegate.color != color; + bool shouldRepaint(covariant _DashedRRectPainter oldDelegate) => + oldDelegate.color != color || oldDelegate.radius != radius; }