diff --git a/client_app/android/app/src/main/res/drawable-v21/launch_background.xml b/client_app/android/app/src/main/res/drawable-v21/launch_background.xml index f74085f..8fc7c52 100644 --- a/client_app/android/app/src/main/res/drawable-v21/launch_background.xml +++ b/client_app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -1,12 +1,12 @@ - - - - - + android:gravity="fill" + android:src="@drawable/splash_chat_hebat" /> + diff --git a/client_app/android/app/src/main/res/drawable/launch_background.xml b/client_app/android/app/src/main/res/drawable/launch_background.xml index 304732f..8fc7c52 100644 --- a/client_app/android/app/src/main/res/drawable/launch_background.xml +++ b/client_app/android/app/src/main/res/drawable/launch_background.xml @@ -1,12 +1,12 @@ - - - - + android:gravity="fill" + android:src="@drawable/splash_chat_hebat" /> + diff --git a/client_app/android/app/src/main/res/drawable/splash_chat_hebat.png b/client_app/android/app/src/main/res/drawable/splash_chat_hebat.png new file mode 100644 index 0000000..d3034f7 Binary files /dev/null and b/client_app/android/app/src/main/res/drawable/splash_chat_hebat.png differ diff --git a/client_app/android/app/src/main/res/values-v31/styles.xml b/client_app/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000..9e8ba73 --- /dev/null +++ b/client_app/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/client_app/assets/images/chat_pattern.png b/client_app/assets/images/chat_pattern.png new file mode 100644 index 0000000..3a23b1b Binary files /dev/null and b/client_app/assets/images/chat_pattern.png differ diff --git a/client_app/assets/images/splash/splash_1.png b/client_app/assets/images/splash/splash_1.png new file mode 100644 index 0000000..29c303d Binary files /dev/null and b/client_app/assets/images/splash/splash_1.png differ diff --git a/client_app/assets/images/splash/splash_2.png b/client_app/assets/images/splash/splash_2.png new file mode 100644 index 0000000..2bd9d7b Binary files /dev/null and b/client_app/assets/images/splash/splash_2.png differ diff --git a/client_app/assets/images/splash/splash_3.png b/client_app/assets/images/splash/splash_3.png new file mode 100644 index 0000000..412c4cd Binary files /dev/null and b/client_app/assets/images/splash/splash_3.png differ diff --git a/client_app/assets/images/splash_chat_hebat.png b/client_app/assets/images/splash_chat_hebat.png new file mode 100644 index 0000000..d3034f7 Binary files /dev/null and b/client_app/assets/images/splash_chat_hebat.png differ diff --git a/client_app/lib/features/chat/screens/chat_screen.dart b/client_app/lib/features/chat/screens/chat_screen.dart index 416a630..504d1eb 100644 --- a/client_app/lib/features/chat/screens/chat_screen.dart +++ b/client_app/lib/features/chat/screens/chat_screen.dart @@ -7,6 +7,12 @@ import '../../../core/chat/session_closure_notifier.dart'; import '../../../core/constants.dart'; import '../widgets/pricing_bottom_sheet.dart'; +// Chat theme colors +const _kUserBubbleColor = Color(0xFFD4929A); +const _kBgTint = Color(0xFFF5D0D6); +const _kBannerColor = Color(0xFFC4868F); +const _kAccentPink = Color(0xFFBE7C8A); + class ChatScreen extends ConsumerStatefulWidget { final String sessionId; final String mitraName; @@ -21,6 +27,8 @@ class _ChatScreenState extends ConsumerState { final _messageController = TextEditingController(); final _scrollController = ScrollController(); Timer? _typingThrottle; + bool _showBestieBanner = true; + bool _showUserBanner = true; @override void initState() { @@ -106,8 +114,15 @@ class _ChatScreenState extends ConsumerState { return Scaffold( appBar: AppBar( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + elevation: 0.5, + centerTitle: true, + leading: IconButton( + icon: const Icon(Icons.chevron_left, size: 28), + onPressed: () => context.pop(), + ), title: Text(widget.mitraName), - automaticallyImplyLeading: true, actions: [ if (chatState is ChatConnectedData && chatState.remainingSeconds != null) Padding( @@ -116,7 +131,7 @@ class _ChatScreenState extends ConsumerState { child: Text( '${chatState.remainingSeconds}s', style: TextStyle( - color: chatState.remainingSeconds! < 30 ? Colors.red : null, + color: chatState.remainingSeconds! < 30 ? Colors.red : Colors.black, fontWeight: FontWeight.bold, ), ), @@ -146,41 +161,90 @@ class _ChatScreenState extends ConsumerState { return _buildGoodbyeView(closureState); } - if (state.sessionExpired) { - return _buildExpiredView(); - } - if (state.sessionPaused) { return _buildPausedView(); } - return Column( + return Stack( children: [ - Expanded( - child: ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.all(16), - itemCount: state.messages.length, - itemBuilder: (context, index) { - final msg = state.messages[index]; - final isMe = msg.senderType == UserType.customer; - return _buildMessageBubble(msg, isMe); - }, - ), - ), - if (state.isOtherTyping) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Align( - alignment: Alignment.centerLeft, - child: Text('Bestie sedang mengetik...', style: TextStyle(color: Colors.grey, fontSize: 12)), + // Background pattern + Positioned.fill( + child: Container( + color: _kBgTint, + child: Image.asset( + 'assets/images/chat_pattern.png', + repeat: ImageRepeat.repeat, + fit: BoxFit.none, ), ), - _buildInputBar(), + ), + // Content + Column( + children: [ + // Entry banners + if (_showBestieBanner) + _buildEntryBanner( + '[Bestie] Sudah Memasuki Ruangan', + () => setState(() => _showBestieBanner = false), + ), + if (_showUserBanner) + _buildEntryBanner( + '[User] Sudah Memasuki Ruangan', + () => setState(() => _showUserBanner = false), + ), + // Messages + Expanded( + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(16), + itemCount: state.messages.length, + itemBuilder: (context, index) { + final msg = state.messages[index]; + final isMe = msg.senderType == UserType.customer; + return _buildMessageBubble(msg, isMe); + }, + ), + ), + // Typing indicator + if (state.isOtherTyping) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Align( + alignment: Alignment.centerLeft, + child: Text('Bestie sedang mengetik...', style: TextStyle(color: Colors.grey, fontSize: 12)), + ), + ), + // Input bar or session ended bar + if (state.sessionExpired) + _buildSessionEndedBar() + else + _buildInputBar(), + ], + ), ], ); } + Widget _buildEntryBanner(String text, VoidCallback onDismiss) { + return Container( + color: _kBannerColor, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + const Icon(Icons.volume_up, color: Colors.white, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 13)), + ), + GestureDetector( + onTap: onDismiss, + child: const Icon(Icons.close, color: Colors.white, size: 18), + ), + ], + ), + ); + } + Widget _buildMessageBubble(ChatMessage msg, bool isMe) { return Align( alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, @@ -189,7 +253,7 @@ class _ChatScreenState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75), decoration: BoxDecoration( - color: isMe ? Colors.blue.shade100 : Colors.grey.shade200, + color: isMe ? _kUserBubbleColor : Colors.white, borderRadius: BorderRadius.circular(16), ), child: Column( @@ -202,7 +266,7 @@ class _ChatScreenState extends ConsumerState { children: [ Text( '${msg.createdAt.hour.toString().padLeft(2, '0')}:${msg.createdAt.minute.toString().padLeft(2, '0')}', - style: const TextStyle(fontSize: 10, color: Colors.grey), + style: TextStyle(fontSize: 10, color: isMe ? Colors.white70 : Colors.grey), ), if (isMe) ...[ const SizedBox(width: 4), @@ -219,13 +283,13 @@ class _ChatScreenState extends ConsumerState { Widget _buildStatusIcon(String status) { switch (status) { case 'sending': - return const Icon(Icons.access_time, size: 14, color: Colors.grey); + return const Icon(Icons.access_time, size: 14, color: Colors.white70); case MessageStatus.sent: - return const Icon(Icons.check, size: 14, color: Colors.grey); + return const Icon(Icons.check, size: 14, color: Colors.white70); case MessageStatus.delivered: - return const Icon(Icons.done_all, size: 14, color: Colors.grey); + return const Icon(Icons.done_all, size: 14, color: Colors.white70); case MessageStatus.read: - return const Icon(Icons.done_all, size: 14, color: Colors.blue); + return const Icon(Icons.done_all, size: 14, color: Colors.white); default: return const SizedBox.shrink(); } @@ -233,8 +297,9 @@ class _ChatScreenState extends ConsumerState { Widget _buildInputBar() { return SafeArea( - child: Padding( + child: Container( padding: const EdgeInsets.all(8), + color: Colors.white, child: Row( children: [ Expanded( @@ -244,16 +309,28 @@ class _ChatScreenState extends ConsumerState { textInputAction: TextInputAction.send, onSubmitted: (_) => _sendMessage(), decoration: InputDecoration( - hintText: 'Ketik pesan...', - border: OutlineInputBorder(borderRadius: BorderRadius.circular(24)), + hintText: 'Ketik Pesan', + hintStyle: TextStyle(color: Colors.grey.shade400), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), ), ), ), const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.send, color: Colors.blue), - onPressed: _sendMessage, + Container( + decoration: const BoxDecoration( + color: _kAccentPink, + shape: BoxShape.circle, + ), + child: IconButton( + icon: const Icon(Icons.send, color: Colors.white, size: 20), + onPressed: _sendMessage, + ), ), ], ), @@ -261,51 +338,32 @@ class _ChatScreenState extends ConsumerState { ); } - Widget _buildExpiredView() { - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: TweenAnimationBuilder( - tween: IntTween(begin: 300, end: 0), - duration: const Duration(seconds: 300), - builder: (context, remaining, _) { - if (remaining <= 0) { - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(sessionClosureProvider.notifier).declineExtension(); - }); - } - final minutes = remaining ~/ 60; - final seconds = remaining % 60; - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.timer_off, size: 64, color: Colors.orange), - const SizedBox(height: 16), - const Text('Waktu sesi habis', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - const Text('Apakah kamu ingin memperpanjang sesi?', textAlign: TextAlign.center), - const SizedBox(height: 12), - Text( - '$minutes:${seconds.toString().padLeft(2, '0')}', - style: TextStyle( - fontSize: 32, - fontWeight: FontWeight.bold, - color: remaining < 60 ? Colors.red : Colors.orange, - ), - ), - const SizedBox(height: 24), - ElevatedButton( - onPressed: () => PricingBottomSheet.showForExtension(context, sessionId: widget.sessionId), - child: const Text('Perpanjang Sesi'), - ), - const SizedBox(height: 12), - TextButton( - onPressed: () => ref.read(sessionClosureProvider.notifier).declineExtension(), - child: const Text('Tidak, akhiri sesi'), - ), - ], - ); - }, + Widget _buildSessionEndedBar() { + return SafeArea( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: _kBgTint, + border: Border(top: BorderSide(color: Colors.grey.shade300)), + ), + child: Row( + children: [ + const Icon(Icons.access_time, color: Colors.red, size: 18), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'Waktu Curhat Berakhir', + style: TextStyle(color: Colors.red, fontWeight: FontWeight.w500), + ), + ), + GestureDetector( + onTap: () => PricingBottomSheet.showForExtension(context, sessionId: widget.sessionId), + child: const Text( + 'Curhat Lagi', + style: TextStyle(color: _kAccentPink, fontWeight: FontWeight.bold), + ), + ), + ], ), ), ); diff --git a/client_app/lib/features/onboarding/onboarding_screen.dart b/client_app/lib/features/onboarding/onboarding_screen.dart new file mode 100644 index 0000000..c9870db --- /dev/null +++ b/client_app/lib/features/onboarding/onboarding_screen.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../router.dart'; + +const _kOnboardingDone = 'onboarding_done'; +const _kPink = Color(0xFFBE7C8A); + +class _OnboardingPage { + final String title; + final String text; + final String image; + + const _OnboardingPage({ + required this.title, + required this.text, + required this.image, + }); +} + +const _pages = [ + _OnboardingPage( + title: 'Langsung Curhat', + text: 'Tidak perlu form panjang atau janji. Masuk dan langsung ngobrol.', + image: 'assets/images/splash/splash_1.png', + ), + _OnboardingPage( + title: '100% Anonim', + text: 'Identitas kamu tidak akan ditampilkan. Cerita dengan tenang, tanpa khawatir.', + image: 'assets/images/splash/splash_2.png', + ), + _OnboardingPage( + title: 'Bestie yang Relevan', + text: 'Kamu akan dipasangkan dengan bestie berdasarkan topik & kondisi kamu saat ini.', + image: 'assets/images/splash/splash_3.png', + ), +]; + +class OnboardingScreen extends ConsumerStatefulWidget { + const OnboardingScreen({super.key}); + + @override + ConsumerState createState() => _OnboardingScreenState(); +} + +class _OnboardingScreenState extends ConsumerState { + final _controller = PageController(); + int _currentPage = 0; + + @override + void initState() { + super.initState(); + // Auto-advance: page 0 → 1 after 500ms + _scheduleAutoAdvance(0); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _scheduleAutoAdvance(int fromPage) { + // Only auto-advance for pages 0 and 1 + if (fromPage >= 2) return; + Future.delayed(const Duration(seconds: 1), () { + if (mounted && _currentPage == fromPage) { + _controller.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }); + } + + void _onPageChanged(int index) { + setState(() => _currentPage = index); + _scheduleAutoAdvance(index); + } + + Future _finish() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kOnboardingDone, true); + ref.invalidate(onboardingDoneProvider); + if (mounted) { + context.go('/welcome'); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Column( + children: [ + Expanded( + child: PageView.builder( + controller: _controller, + itemCount: _pages.length, + onPageChanged: _onPageChanged, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + final page = _pages[index]; + return _buildPage(page); + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: Row( + children: [ + // Page indicators + Row( + children: List.generate(_pages.length, (index) { + final isActive = index == _currentPage; + return Container( + margin: const EdgeInsets.only(right: 8), + width: isActive ? 32 : 12, + height: 6, + decoration: BoxDecoration( + color: isActive ? _kPink : _kPink.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(3), + ), + ); + }), + ), + const Spacer(), + // CTA button — only show "Mulai" on last page + if (_currentPage == _pages.length - 1) + GestureDetector( + onTap: _finish, + child: Container( + height: 56, + padding: const EdgeInsets.symmetric(horizontal: 32), + decoration: BoxDecoration( + color: _kPink, + borderRadius: BorderRadius.circular(16), + ), + child: const Center( + child: Text( + 'Mulai', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildPage(_OnboardingPage page) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + const Spacer(flex: 1), + // Image + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Image.asset( + page.image, + height: 280, + fit: BoxFit.contain, + ), + ), + const Spacer(flex: 1), + // Title + Text( + page.title, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: _kPink, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + // Description + Text( + page.text, + style: TextStyle( + fontSize: 16, + color: Colors.pink.shade300, + height: 1.5, + ), + textAlign: TextAlign.center, + ), + const Spacer(flex: 1), + ], + ), + ); + } +} + +/// Check if onboarding has been completed +Future isOnboardingDone() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_kOnboardingDone) ?? false; +} diff --git a/client_app/lib/features/splash/splash_screen.dart b/client_app/lib/features/splash/splash_screen.dart index 335021f..33bcc7b 100644 --- a/client_app/lib/features/splash/splash_screen.dart +++ b/client_app/lib/features/splash/splash_screen.dart @@ -5,20 +5,12 @@ class SplashScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return const Scaffold( + return Scaffold( + backgroundColor: Colors.white, body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.favorite, size: 80, color: Colors.blue), - SizedBox(height: 24), - Text( - 'Halo Bestie', - style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold), - ), - SizedBox(height: 32), - CircularProgressIndicator(), - ], + child: Image.asset( + 'assets/images/splash_chat_hebat.png', + width: 200, ), ), ); diff --git a/client_app/lib/router.dart b/client_app/lib/router.dart index 38f0121..ffd00ec 100644 --- a/client_app/lib/router.dart +++ b/client_app/lib/router.dart @@ -8,6 +8,7 @@ import 'features/auth/screens/register_screen.dart'; import 'features/auth/screens/otp_screen.dart'; import 'features/auth/screens/force_register_screen.dart'; import 'features/auth/screens/set_display_name_screen.dart'; +import 'features/onboarding/onboarding_screen.dart'; import 'features/splash/splash_screen.dart'; import 'features/home/home_screen.dart'; import 'features/chat/screens/searching_screen.dart'; @@ -25,6 +26,9 @@ class RouterNotifier extends ChangeNotifier { } } +/// Cached onboarding status — loaded once at startup, invalidated after onboarding completes +final onboardingDoneProvider = FutureProvider((ref) => isOnboardingDone()); + final routerProvider = Provider((ref) => buildRouter(ref)); GoRouter buildRouter(Ref ref) { @@ -36,15 +40,25 @@ GoRouter buildRouter(Ref ref) { redirect: (context, state) { final authState = ref.read(authProvider); final isSplash = state.matchedLocation == '/splash'; + final isOnboarding = state.matchedLocation == '/onboarding'; final isAuthRoute = state.matchedLocation.startsWith('/auth') || state.matchedLocation == '/welcome'; - // Show splash only during initial load — don't redirect away from auth routes + // Show splash only during initial load if (authState is AsyncLoading) { - if (isSplash || isAuthRoute) return null; + if (isSplash || isAuthRoute || isOnboarding) return null; return '/splash'; } + // Check onboarding status — must complete before anything else + final onboardingDone = ref.read(onboardingDoneProvider).valueOrNull ?? false; + if (!onboardingDone) { + return isOnboarding ? null : '/onboarding'; + } + if (isOnboarding) { + return '/welcome'; + } + final data = authState.valueOrNull; if (data == null) { // Error state — show login @@ -64,6 +78,7 @@ GoRouter buildRouter(Ref ref) { }, routes: [ GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()), + GoRoute(path: '/onboarding', builder: (_, __) => const OnboardingScreen()), GoRoute(path: '/welcome', builder: (_, __) => const WelcomeScreen()), GoRoute(path: '/auth/display-name', builder: (_, __) => const DisplayNameScreen()), GoRoute(path: '/auth/register', builder: (_, __) => const RegisterScreen()), diff --git a/client_app/pubspec.yaml b/client_app/pubspec.yaml index 0c58d86..9757464 100644 --- a/client_app/pubspec.yaml +++ b/client_app/pubspec.yaml @@ -48,3 +48,6 @@ dev_dependencies: flutter: uses-material-design: true + assets: + - assets/images/ + - assets/images/splash/ diff --git a/mitra_app/android/app/src/main/res/drawable-v21/launch_background.xml b/mitra_app/android/app/src/main/res/drawable-v21/launch_background.xml index f74085f..8fc7c52 100644 --- a/mitra_app/android/app/src/main/res/drawable-v21/launch_background.xml +++ b/mitra_app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -1,12 +1,12 @@ - - - - - + android:gravity="fill" + android:src="@drawable/splash_chat_hebat" /> + diff --git a/mitra_app/android/app/src/main/res/drawable/launch_background.xml b/mitra_app/android/app/src/main/res/drawable/launch_background.xml index 304732f..8fc7c52 100644 --- a/mitra_app/android/app/src/main/res/drawable/launch_background.xml +++ b/mitra_app/android/app/src/main/res/drawable/launch_background.xml @@ -1,12 +1,12 @@ - - - - + android:gravity="fill" + android:src="@drawable/splash_chat_hebat" /> + diff --git a/mitra_app/android/app/src/main/res/drawable/splash_chat_hebat.png b/mitra_app/android/app/src/main/res/drawable/splash_chat_hebat.png new file mode 100644 index 0000000..d3034f7 Binary files /dev/null and b/mitra_app/android/app/src/main/res/drawable/splash_chat_hebat.png differ diff --git a/mitra_app/android/app/src/main/res/values-v31/styles.xml b/mitra_app/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000..9e8ba73 --- /dev/null +++ b/mitra_app/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/mitra_app/assets/images/chat_pattern.png b/mitra_app/assets/images/chat_pattern.png new file mode 100644 index 0000000..3a23b1b Binary files /dev/null and b/mitra_app/assets/images/chat_pattern.png differ diff --git a/mitra_app/assets/images/splash/splash_1.png b/mitra_app/assets/images/splash/splash_1.png new file mode 100644 index 0000000..29c303d Binary files /dev/null and b/mitra_app/assets/images/splash/splash_1.png differ diff --git a/mitra_app/assets/images/splash/splash_2.png b/mitra_app/assets/images/splash/splash_2.png new file mode 100644 index 0000000..2bd9d7b Binary files /dev/null and b/mitra_app/assets/images/splash/splash_2.png differ diff --git a/mitra_app/assets/images/splash/splash_3.png b/mitra_app/assets/images/splash/splash_3.png new file mode 100644 index 0000000..412c4cd Binary files /dev/null and b/mitra_app/assets/images/splash/splash_3.png differ diff --git a/mitra_app/assets/images/splash_chat_hebat.png b/mitra_app/assets/images/splash_chat_hebat.png new file mode 100644 index 0000000..d3034f7 Binary files /dev/null and b/mitra_app/assets/images/splash_chat_hebat.png differ diff --git a/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart b/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart index 793fbcc..5a0c792 100644 --- a/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart +++ b/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart @@ -6,6 +6,12 @@ import '../../../core/chat/mitra_chat_notifier.dart'; import '../../../core/chat/extension_notifier.dart'; import '../../../core/constants.dart'; +// Chat theme colors +const _kUserBubbleColor = Color(0xFFD4929A); +const _kBgTint = Color(0xFFF5D0D6); +const _kBannerColor = Color(0xFFC4868F); +const _kAccentPink = Color(0xFFBE7C8A); + class MitraChatScreen extends ConsumerStatefulWidget { final String sessionId; final String customerName; @@ -20,6 +26,8 @@ class _MitraChatScreenState extends ConsumerState { final _messageController = TextEditingController(); final _scrollController = ScrollController(); Timer? _typingThrottle; + bool _showBestieBanner = true; + bool _showUserBanner = true; @override void initState() { @@ -70,7 +78,7 @@ class _MitraChatScreenState extends ConsumerState { final chatState = ref.watch(mitraChatProvider); final extState = ref.watch(mitraExtensionProvider); - // Listen for extension complete → navigate home + // Listen for extension complete -> navigate home ref.listen(mitraExtensionProvider, (prev, next) { if (next is ExtensionCompleteData) { context.go('/home'); @@ -93,6 +101,14 @@ class _MitraChatScreenState extends ConsumerState { return Scaffold( appBar: AppBar( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + elevation: 0.5, + centerTitle: true, + leading: IconButton( + icon: const Icon(Icons.chevron_left, size: 28), + onPressed: () => context.pop(), + ), title: Text(widget.customerName), actions: [ if (chatState is MitraChatConnectedData && chatState.remainingSeconds != null) @@ -102,7 +118,7 @@ class _MitraChatScreenState extends ConsumerState { child: Text( '${chatState.remainingSeconds}s', style: TextStyle( - color: chatState.remainingSeconds! < 30 ? Colors.red : null, + color: chatState.remainingSeconds! < 30 ? Colors.red : Colors.black, fontWeight: FontWeight.bold, ), ), @@ -138,33 +154,83 @@ class _MitraChatScreenState extends ConsumerState { return _buildGoodbyeView(extState); } - return Column( + return Stack( children: [ - Expanded( - child: ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.all(16), - itemCount: state.messages.length, - itemBuilder: (context, index) { - final msg = state.messages[index]; - final isMe = msg.senderType == UserType.mitra; - return _buildMessageBubble(msg, isMe); - }, - ), - ), - if (state.isOtherTyping) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Align( - alignment: Alignment.centerLeft, - child: Text('Customer sedang mengetik...', style: TextStyle(color: Colors.grey, fontSize: 12)), + // Background pattern + Positioned.fill( + child: Container( + color: _kBgTint, + child: Image.asset( + 'assets/images/chat_pattern.png', + repeat: ImageRepeat.repeat, + fit: BoxFit.none, ), ), - _buildInputBar(), + ), + // Content + Column( + children: [ + // Entry banners + if (_showBestieBanner) + _buildEntryBanner( + '[Bestie] Sudah Memasuki Ruangan', + () => setState(() => _showBestieBanner = false), + ), + if (_showUserBanner) + _buildEntryBanner( + '[User] Sudah Memasuki Ruangan', + () => setState(() => _showUserBanner = false), + ), + // Messages + Expanded( + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(16), + itemCount: state.messages.length, + itemBuilder: (context, index) { + final msg = state.messages[index]; + final isMe = msg.senderType == UserType.mitra; + return _buildMessageBubble(msg, isMe); + }, + ), + ), + // Typing indicator + if (state.isOtherTyping) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Align( + alignment: Alignment.centerLeft, + child: Text('Customer sedang mengetik...', style: TextStyle(color: Colors.grey, fontSize: 12)), + ), + ), + // Input bar + _buildInputBar(), + ], + ), ], ); } + Widget _buildEntryBanner(String text, VoidCallback onDismiss) { + return Container( + color: _kBannerColor, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + const Icon(Icons.volume_up, color: Colors.white, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 13)), + ), + GestureDetector( + onTap: onDismiss, + child: const Icon(Icons.close, color: Colors.white, size: 18), + ), + ], + ), + ); + } + Widget _buildMessageBubble(MitraChatMessage msg, bool isMe) { return Align( alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, @@ -173,7 +239,7 @@ class _MitraChatScreenState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75), decoration: BoxDecoration( - color: isMe ? Colors.green.shade100 : Colors.grey.shade200, + color: isMe ? _kUserBubbleColor : Colors.white, borderRadius: BorderRadius.circular(16), ), child: Column( @@ -186,7 +252,7 @@ class _MitraChatScreenState extends ConsumerState { children: [ Text( '${msg.createdAt.hour.toString().padLeft(2, '0')}:${msg.createdAt.minute.toString().padLeft(2, '0')}', - style: const TextStyle(fontSize: 10, color: Colors.grey), + style: TextStyle(fontSize: 10, color: isMe ? Colors.white70 : Colors.grey), ), if (isMe) ...[ const SizedBox(width: 4), @@ -203,13 +269,13 @@ class _MitraChatScreenState extends ConsumerState { Widget _buildStatusIcon(String status) { switch (status) { case 'sending': - return const Icon(Icons.access_time, size: 14, color: Colors.grey); + return const Icon(Icons.access_time, size: 14, color: Colors.white70); case MessageStatus.sent: - return const Icon(Icons.check, size: 14, color: Colors.grey); + return const Icon(Icons.check, size: 14, color: Colors.white70); case MessageStatus.delivered: - return const Icon(Icons.done_all, size: 14, color: Colors.grey); + return const Icon(Icons.done_all, size: 14, color: Colors.white70); case MessageStatus.read: - return const Icon(Icons.done_all, size: 14, color: Colors.blue); + return const Icon(Icons.done_all, size: 14, color: Colors.white); default: return const SizedBox.shrink(); } @@ -217,8 +283,9 @@ class _MitraChatScreenState extends ConsumerState { Widget _buildInputBar() { return SafeArea( - child: Padding( + child: Container( padding: const EdgeInsets.all(8), + color: Colors.white, child: Row( children: [ Expanded( @@ -228,16 +295,28 @@ class _MitraChatScreenState extends ConsumerState { textInputAction: TextInputAction.send, onSubmitted: (_) => _sendMessage(), decoration: InputDecoration( - hintText: 'Ketik pesan...', - border: OutlineInputBorder(borderRadius: BorderRadius.circular(24)), + hintText: 'Ketik Pesan', + hintStyle: TextStyle(color: Colors.grey.shade400), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), ), ), ), const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.send, color: Colors.green), - onPressed: _sendMessage, + Container( + decoration: const BoxDecoration( + color: _kAccentPink, + shape: BoxShape.circle, + ), + child: IconButton( + icon: const Icon(Icons.send, color: Colors.white, size: 20), + onPressed: _sendMessage, + ), ), ], ), diff --git a/mitra_app/lib/features/splash/splash_screen.dart b/mitra_app/lib/features/splash/splash_screen.dart index 653111d..33bcc7b 100644 --- a/mitra_app/lib/features/splash/splash_screen.dart +++ b/mitra_app/lib/features/splash/splash_screen.dart @@ -5,20 +5,12 @@ class SplashScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return const Scaffold( + return Scaffold( + backgroundColor: Colors.white, body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.favorite, size: 80, color: Colors.blue), - SizedBox(height: 24), - Text( - 'Halo Bestie Mitra', - style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold), - ), - SizedBox(height: 32), - CircularProgressIndicator(), - ], + child: Image.asset( + 'assets/images/splash_chat_hebat.png', + width: 200, ), ), ); diff --git a/mitra_app/pubspec.yaml b/mitra_app/pubspec.yaml index 73b0ff2..5bf7dad 100644 --- a/mitra_app/pubspec.yaml +++ b/mitra_app/pubspec.yaml @@ -41,3 +41,6 @@ dev_dependencies: flutter: uses-material-design: true + assets: + - assets/images/ + - assets/images/splash/