diff --git a/mobile/lib/pages/chat_page.dart b/mobile/lib/pages/chat_page.dart index 8c7b123..1a541b1 100644 --- a/mobile/lib/pages/chat_page.dart +++ b/mobile/lib/pages/chat_page.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:io'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_auth/firebase_auth.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; @@ -56,6 +55,11 @@ class _ChatPageState extends State { bool _isVerified = false; bool _searchOpen = false; + // Reset on every mount, which is exactly what we want: BottomBar rebuilds + // ChatPage from scratch on each tab switch, so the notice reappears every + // time the user navigates back to the chat. + bool _hoursNoticeDismissed = false; + @override void initState() { super.initState(); @@ -129,31 +133,71 @@ class _ChatPageState extends State { /// Non-blocking notice telling patients when the Social Worker team is /// reachable, so an after-hours message doesn't read as being ignored. - /// Patients can still send messages at any time. + /// Patients can still send messages at any time. Swipe it up to dismiss it + /// for the rest of this visit to the chat. + /// + /// AnimatedSize collapses the banner rather than popping it out, matching the + /// 220ms easeOut used elsewhere in the app. A plain GestureDetector is enough + /// here — Dismissible wants a fixed-extent child in a list and brings its own + /// horizontal-swipe defaults we'd only have to switch off. Widget _hoursBanner() { - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - color: const Color(0xFFFFF8E1), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(Icons.schedule, size: 20, color: Color(0xFF8D6E63)), - const SizedBox(width: 8), - Expanded( - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - const Text( - '$socialWorkerHours Messages sent outside those hours will ' - 'be answered the next working day. If this is urgent, call using the button above.', - style: TextStyle(fontSize: 13, color: Color(0xFF5D4037)), + return AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOut, + alignment: Alignment.topCenter, + child: _hoursNoticeDismissed + ? const SizedBox(width: double.infinity) + : GestureDetector( + onVerticalDragEnd: (details) { + // Upward flings only, so a downward drag over the banner + // doesn't dismiss what the user was trying to read. + if ((details.primaryVelocity ?? 0) < 0) { + AppHaptics.tap(); + setState(() => _hoursNoticeDismissed = true); + } + }, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + color: const Color(0xFFFFF8E1), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.schedule, + size: 20, + color: Color(0xFF8D6E63), + ), + const SizedBox(width: 8), + Expanded( + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + const Text( + '$socialWorkerHours Messages sent outside those hours will ' + 'be answered the next working day. If this is urgent, call using the button above.', + style: TextStyle( + fontSize: 13, + color: Color(0xFF5D4037), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + // Affordance: the swipe is otherwise invisible. + const Icon( + Icons.keyboard_arrow_up, + size: 20, + color: Color(0xFF8D6E63), + ), + ], ), - ], + ), ), - ), - ], - ), ); } @@ -323,101 +367,6 @@ class _HeaderIconButton extends StatelessWidget { } -// ── Notification bell button ────────────────────────────────────────────────── - -class _NotificationIconButton extends StatefulWidget { - const _NotificationIconButton(); - - @override - State<_NotificationIconButton> createState() => - _NotificationIconButtonState(); -} - -class _NotificationIconButtonState extends State<_NotificationIconButton> { - AuthorizationStatus _status = AuthorizationStatus.notDetermined; - - @override - void initState() { - super.initState(); - // check current status on load so icon shows correct state - _loadStatus(); - } - - Future _loadStatus() async { - final status = await NotificationService().getPermissionStatus(); - if (mounted) setState(() => _status = status); - } - - Future _onTap() async { - final isGranted = _status == AuthorizationStatus.authorized; - - await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Message Notifications'), - content: Text( - isGranted - ? 'Notifications are enabled. You\'ll be alerted when ' - 'your social worker sends you a message.' - : 'Enable notifications to be alerted when your social ' - 'worker sends you a message.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Close'), - ), - // Only show Enable button if not already granted - if (!isGranted) - TextButton( - onPressed: () async { - Navigator.pop(ctx); - final granted = await NotificationService() - .requestPermissionAndInit(context); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - granted - ? 'Notifications enabled!' - : 'Permission denied. Enable in device Settings.', - ), - ), - ); - _loadStatus(); // refresh icon after permission decision - } - }, - child: const Text('Enable'), - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - final isEnabled = _status == AuthorizationStatus.authorized; - - return GestureDetector( - onTap: _onTap, - child: Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - isEnabled ? Icons.notifications_active : Icons.notifications_off, - // Amber when on so it's visually distinct, white when off - color: isEnabled ? Colors.amber : Colors.white, - size: 24, - ), - ), - ); - } -} - // ── Messages list ───────────────────────────────────────────────────────────── class _MessagesList extends StatefulWidget { @@ -466,6 +415,13 @@ class _MessagesListState extends State<_MessagesList> { Timer? _highlightTimer; int _lastDocCount = 0; + // BottomBar rebuilds ChatPage from scratch on every tab switch, so the very + // first snapshot always has to move the list from the top to the newest + // message. Animating that is what read as a wild scroll through the whole + // conversation; the initial placement is now instant and only genuinely new + // messages animate. + bool _didInitialScroll = false; + @override void initState() { super.initState(); @@ -480,6 +436,7 @@ class _MessagesListState extends State<_MessagesList> { // chat document - not on every incidental rebuild. _messagesStream = widget.chatService.streamMessages(widget.chatId); _lastDocCount = 0; + _didInitialScroll = false; } } @@ -489,13 +446,15 @@ class _MessagesListState extends State<_MessagesList> { super.dispose(); } - void _scrollToBottom(int groupCount) { + void _scrollToBottom(int groupCount, {bool animate = true}) { if (groupCount == 0) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (_itemScrollController.isAttached) { _itemScrollController.scrollTo( index: groupCount - 1, - duration: const Duration(milliseconds: 200), + duration: animate + ? const Duration(milliseconds: 200) + : Duration.zero, curve: Curves.easeOut, ); } @@ -593,7 +552,8 @@ class _MessagesListState extends State<_MessagesList> { // Don't yank the list to the bottom while we're mid-way through // jumping to a searched message. if (_highlightedMessageId == null) { - _scrollToBottom(groups.length); + _scrollToBottom(groups.length, animate: _didInitialScroll); + _didInitialScroll = true; } } @@ -864,13 +824,17 @@ class _ChatInputState extends State<_ChatInput> { if (text.isEmpty || _sending) return; setState(() => _sending = true); - _controller.clear(); try { String chatId = widget.chatId ?? await widget.chatService.findOrCreateUserChat(); if (widget.chatId == null) widget.onChatCreated(chatId); await widget.chatService.sendMessage(chatId, text); + // Only clear once the message is actually in Firestore. sendChatMessage + // is a callable that returns after its batch commits, so awaiting it is + // the right line to draw. Clearing up front used to lose whatever the + // patient typed whenever the send failed. + _controller.clear(); } catch (e) { if (mounted) { ScaffoldMessenger.of( @@ -954,7 +918,11 @@ class _ChatInputState extends State<_ChatInput> { onTap: _sending ? null : _pickAndSendImage, ), const SizedBox(width: 12), - _InputButton(icon: Icons.send, onTap: _sending ? null : _send), + _InputButton( + icon: Icons.send, + onTap: _sending ? null : _send, + loading: _sending, + ), ], ), ); @@ -965,7 +933,15 @@ class _InputButton extends StatelessWidget { final IconData icon; final VoidCallback? onTap; - const _InputButton({required this.icon, required this.onTap}); + /// Swaps the icon for a spinner while the message is in flight, so the wait + /// between tapping send and the bubble appearing reads as progress. + final bool loading; + + const _InputButton({ + required this.icon, + required this.onTap, + this.loading = false, + }); @override Widget build(BuildContext context) { @@ -983,7 +959,18 @@ class _InputButton extends StatelessWidget { color: onTap != null ? Colors.black : Colors.grey, borderRadius: BorderRadius.circular(12), ), - child: Icon(icon, color: Colors.white, size: 24), + child: loading + ? const Center( + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + ) + : Icon(icon, color: Colors.white, size: 24), ), ); }